From e2a4078199cb582e55520107dfdce203e9da9142 Mon Sep 17 00:00:00 2001
From: Dao <comnuoc@users.noreply.gitlab.utu.fi>
Date: Sat, 29 Jun 2024 05:15:17 +0300
Subject: [PATCH] feat(part2): Exercise 4.

---
 part_2/exercise_4/.gitignore                  |  30 +++
 part_2/exercise_4/exercise_4.iml              |  11 ++
 part_2/exercise_4/src/Card.java               | 110 +++++++++++
 part_2/exercise_4/src/CardId.java             |  20 ++
 part_2/exercise_4/src/CommandLineApp.java     | 175 ++++++++++++++++++
 part_2/exercise_4/src/CommandLineHelper.java  | 137 ++++++++++++++
 part_2/exercise_4/src/Main.java               |   6 +
 part_2/exercise_4/src/MoneyHelper.java        |  14 ++
 part_2/exercise_4/src/TicketType.java         |  37 ++++
 .../exercise_4/src/TicketTypeRepository.java  |  56 ++++++
 10 files changed, 596 insertions(+)
 create mode 100644 part_2/exercise_4/.gitignore
 create mode 100644 part_2/exercise_4/exercise_4.iml
 create mode 100644 part_2/exercise_4/src/Card.java
 create mode 100644 part_2/exercise_4/src/CardId.java
 create mode 100644 part_2/exercise_4/src/CommandLineApp.java
 create mode 100644 part_2/exercise_4/src/CommandLineHelper.java
 create mode 100644 part_2/exercise_4/src/Main.java
 create mode 100644 part_2/exercise_4/src/MoneyHelper.java
 create mode 100644 part_2/exercise_4/src/TicketType.java
 create mode 100644 part_2/exercise_4/src/TicketTypeRepository.java

diff --git a/part_2/exercise_4/.gitignore b/part_2/exercise_4/.gitignore
new file mode 100644
index 0000000..d578053
--- /dev/null
+++ b/part_2/exercise_4/.gitignore
@@ -0,0 +1,30 @@
+### IntelliJ IDEA ###
+/.idea/
+out/
+!**/src/main/**/out/
+!**/src/test/**/out/
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+bin/
+!**/src/main/**/bin/
+!**/src/test/**/bin/
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
\ No newline at end of file
diff --git a/part_2/exercise_4/exercise_4.iml b/part_2/exercise_4/exercise_4.iml
new file mode 100644
index 0000000..c90834f
--- /dev/null
+++ b/part_2/exercise_4/exercise_4.iml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<module type="JAVA_MODULE" version="4">
+  <component name="NewModuleRootManager" inherit-compiler-output="true">
+    <exclude-output />
+    <content url="file://$MODULE_DIR$">
+      <sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
+    </content>
+    <orderEntry type="inheritedJdk" />
+    <orderEntry type="sourceFolder" forTests="false" />
+  </component>
+</module>
\ No newline at end of file
diff --git a/part_2/exercise_4/src/Card.java b/part_2/exercise_4/src/Card.java
new file mode 100644
index 0000000..0e831df
--- /dev/null
+++ b/part_2/exercise_4/src/Card.java
@@ -0,0 +1,110 @@
+import java.util.Objects;
+import java.util.Optional;
+
+/*
+ * @.classInvariant: getId() != null && getBalanceInCents() >= 0
+ */
+public class Card {
+    private final CardId id;
+    private long balanceInCents = 0;
+    private TicketType lastPurchasedTicketType = null;
+    private long ticketExpiredAt = 0; // number of milliseconds passed since 1970
+
+    /*
+     * @.pre: true
+     * @.post: Object is constructed
+     *      throw NullPointerException if id == null
+     */
+    public Card(CardId id) {
+        this.id = Objects.requireNonNull(id, "Id should not be null");
+    }
+
+    /*
+     * @.pre: true
+     * @.post: RESULT != null
+     */
+    public CardId getId() {
+        return id;
+    }
+
+    /*
+     * @.pre: true
+     * @.post: getBalanceInCents() = OLD(getBalanceInCents()) + amountInCents
+     *      throw ZeroOrNegativeAmountException if amountInCents <= 0
+     */
+    public void topUp(long amountInCents) throws ZeroOrNegativeAmountException {
+        if (amountInCents <= 0) {
+            throw new ZeroOrNegativeAmountException("Top up amount should be greater than zero");
+        }
+
+        balanceInCents += amountInCents;
+    }
+
+    /*
+     * @.pre: true
+     * @.post: RESULT >= 0
+     */
+    public long getBalanceInCents() {
+        return balanceInCents;
+    }
+
+    /*
+     * @.pre: true
+     * @.post: RESULT == true if a ticket is purchased and is not expired, otherwise RESULT == false
+     */
+    public boolean isTicketValid() {
+        return isTicketPurchased() && !isTicketExpired();
+    }
+
+    /*
+     * @.pre: true
+     * @.post: RESULT == null if there is no ticket
+     */
+    public Optional<TicketType> getLastPurchasedTicketType() {
+        return Optional.ofNullable(lastPurchasedTicketType);
+    }
+
+    /*
+     * @.pre: ticketType != null
+     * @.post: isTicketValid() == true && getBalanceInCents() == OLD(getBalanceInCents()) - ticketType.priceInCents()
+     *      throw NotEnoughMoneyForTicketException if getBalanceInCents() < ticketType.priceInCents()
+     */
+    public void buyTicket(TicketType ticketType) throws NotEnoughMoneyForTicketException {
+        if (balanceInCents < ticketType.priceInCents()) {
+            throw new NotEnoughMoneyForTicketException("Your balance is not sufficient for the ticket");
+        }
+
+        balanceInCents -= ticketType.priceInCents();
+        lastPurchasedTicketType = ticketType;
+        ticketExpiredAt = getTimestamp() + ticketType.durationInMilliseconds();
+    }
+
+    @Override
+    public String toString() {
+        return id.toString();
+    }
+
+    private boolean isTicketPurchased() {
+        return null != lastPurchasedTicketType;
+    }
+
+    private boolean isTicketExpired() {
+        return getTimestamp() > ticketExpiredAt;
+    }
+
+    private long getTimestamp() {
+        return System.currentTimeMillis();
+    }
+}
+
+class ZeroOrNegativeAmountException extends RuntimeException {
+    public ZeroOrNegativeAmountException(String message) {
+        super(message);
+    }
+}
+
+class NotEnoughMoneyForTicketException extends RuntimeException {
+    public NotEnoughMoneyForTicketException(String message) {
+        super(message);
+    }
+}
\ No newline at end of file
diff --git a/part_2/exercise_4/src/CardId.java b/part_2/exercise_4/src/CardId.java
new file mode 100644
index 0000000..bc4e957
--- /dev/null
+++ b/part_2/exercise_4/src/CardId.java
@@ -0,0 +1,20 @@
+import java.util.Objects;
+
+/*
+ * @.classInvariant: id() != null
+ */
+public record CardId(Object id) {
+    /*
+     * @.pre: true
+     * @.post: Object is constructed
+     *      throw NullPointerException if id == null
+     */
+    public CardId {
+        Objects.requireNonNull(id, "Id should not be null");
+    }
+
+    @Override
+    public String toString() {
+        return id.toString();
+    }
+}
diff --git a/part_2/exercise_4/src/CommandLineApp.java b/part_2/exercise_4/src/CommandLineApp.java
new file mode 100644
index 0000000..22538e9
--- /dev/null
+++ b/part_2/exercise_4/src/CommandLineApp.java
@@ -0,0 +1,175 @@
+import java.util.*;
+
+public class CommandLineApp {
+    private final Card card;
+    private final CommandLineHelper commandLineHelper;
+    private final TicketTypeRepository ticketTypeRepository;
+    private final MoneyHelper moneyHelper;
+
+    public CommandLineApp() {
+        card = createCard();
+        commandLineHelper = new CommandLineHelper();
+        ticketTypeRepository = new TicketTypeRepository();
+        moneyHelper = new MoneyHelper();
+    }
+
+    public void run() {
+        int choice = getMenuChoice();
+        commandLineHelper.clearScreen();
+
+        switch (choice) {
+            case 1:
+                showBalance();
+                break;
+
+            case 2:
+                topUp();
+                break;
+
+            case 3:
+                checkTicketValidity();
+                break;
+        }
+    }
+
+    private int getMenuChoice() {
+        List<String> menu = new ArrayList<>();
+        menu.add("Your card ID: " + card.toString());
+        menu.add(" ");
+        menu.add("1. Show balance");
+        menu.add("2. Top-up");
+        menu.add("3. Buy ticket");
+        menu.add("4. Exit");
+        commandLineHelper.printMenu(menu);
+
+        return commandLineHelper.getIntInput(
+                "Please choose",
+                "Error: please select a number between 1 and 4",
+                (number) -> number >= 1 && number <= 4
+        );
+    }
+
+    private void showBalance() {
+        commandLineHelper.printSuccessMenu("Your balance is: " + moneyHelper.formatCents(card.getBalanceInCents()));
+        System.out.println();
+        run();
+    }
+
+    private void topUp() {
+        boolean isSucceed = false;
+
+        while (true) {
+            try {
+                long euro = commandLineHelper.getLongInput("Please enter amount in Euro", (number) -> number >= 0);
+                long cents = commandLineHelper.getLongInput("Please enter amount in cents", (number) -> number >= 0);
+                long amount = moneyHelper.euroToCent(euro) + cents;
+
+                card.topUp(amount);
+                isSucceed = true;
+
+                break;
+            } catch (ZeroOrNegativeAmountException e) {
+                commandLineHelper.printErrorMessage("Error: " + e.getMessage());
+                String isContinue = commandLineHelper.getStringInput("Do you want to continue? (y/n)");
+
+                if (isContinue.equals("n")) {
+                    break;
+                }
+            }
+        }
+
+        if (isSucceed) {
+            commandLineHelper.printSuccessMessage("Top-up successfully");
+            showBalance();
+            return;
+        }
+
+        System.out.println();
+        run();
+    }
+
+    private void checkTicketValidity() {
+        boolean isBuyNewTicket = !card.isTicketValid();
+
+        if (!isBuyNewTicket) {
+            commandLineHelper.printSuccessMessage("Your ticket is still valid. Current ticket type:");
+            commandLineHelper.printSuccessMessage(formatTicketType(card.getLastPurchasedTicketType().get()));
+            String isContinue = commandLineHelper.getStringInput("Do you want to buy another? (y/n)");
+            isBuyNewTicket = isContinue.equals("y");
+        }
+
+        if (isBuyNewTicket) {
+            System.out.println();
+            buyTicket();
+        } else {
+            System.out.println();
+            run();
+        }
+    }
+
+    private void buyTicket() {
+        Optional<TicketType> optChoseType = getChoseTicketType();
+
+        if (optChoseType.isPresent()) {
+            try {
+                card.buyTicket(optChoseType.get());
+                commandLineHelper.printSuccessMessage("Buy ticket successfully");
+            } catch (NotEnoughMoneyForTicketException e) {
+                commandLineHelper.printErrorMessage("Error: " + e.getMessage());
+            }
+        }
+
+        System.out.println();
+        run();
+    }
+
+    private Optional<TicketType> getChoseTicketType() {
+        TicketType choseType = null;
+
+        List<String> menu = new ArrayList<>();
+        menu.add("Ticket types: ");
+        menu.add(" ");
+
+        for (TicketType type: ticketTypeRepository.getAllTypes()) {
+            menu.add(formatTicketType(type));
+        }
+
+        while (true) {
+            try {
+                commandLineHelper.printMenu(menu);
+
+                String typeName = commandLineHelper.getStringInput("Please enter the ticket type name or zero to cancel");
+
+                if (typeName.equals("0")) {
+                    break;
+                }
+
+                choseType = ticketTypeRepository.getByName(typeName);
+
+                break;
+            } catch (TicketTypeNotFoundException e) {
+                commandLineHelper.printErrorMessage("Error: " + e.getMessage());
+                String isContinue = commandLineHelper.getStringInput("Do you want to continue? (y/n)");
+
+                if (isContinue.equals("n")) {
+                    break;
+                }
+            }
+        }
+
+        return Optional.ofNullable(choseType);
+    }
+
+    private String formatTicketType(TicketType type) {
+        return "Name: '" + type.name()
+                + "'. Description: " + type.description()
+                + ". Price: " + moneyHelper.formatCents(type.priceInCents()) + ".";
+    }
+
+    private Card createCard() {
+        UUID uuid = UUID.randomUUID();
+        CardId cardId = new CardId(uuid);
+
+        return new Card(cardId);
+    }
+}
\ No newline at end of file
diff --git a/part_2/exercise_4/src/CommandLineHelper.java b/part_2/exercise_4/src/CommandLineHelper.java
new file mode 100644
index 0000000..77a0405
--- /dev/null
+++ b/part_2/exercise_4/src/CommandLineHelper.java
@@ -0,0 +1,137 @@
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Scanner;
+import java.util.function.Predicate;
+
+public class CommandLineHelper {
+    private final String ANSI_RESET = "\u001B[0m";
+    private final String ANSI_GREEN = "\u001B[32m";
+    private final String ANSI_RED = "\u001B[31m";
+
+    private final Scanner reader;
+
+    public CommandLineHelper() {
+        reader = new Scanner(System.in);
+    }
+
+    public void printMenu(List<String> strings) {
+        this.printMenu(strings, '*');
+    }
+
+    public void printMenu(List<String> strings, char character) {
+        int maxLen = strings.stream()
+                .mapToInt(String::length)
+                .max()
+                .orElse(0);
+
+        System.out.println(repeatCharacter(character, maxLen + 4));
+
+        for (String str: strings) {
+            System.out.println(
+                    character + " " + str
+                            + repeatCharacter(' ', maxLen - str.length())
+                            + " " + character
+            );
+        }
+
+        System.out.println(repeatCharacter(character, maxLen + 4));
+    }
+
+    public void printMenu(List<String> strings, char character, String color) {
+        System.out.print(color);
+        printMenu(strings, character);
+        System.out.print(ANSI_RESET);
+    }
+
+    public void printMenu(List<String> strings, String color) {
+        this.printMenu(strings, '*', color);
+    }
+
+    public void printMenu(String string, String color) {
+        List<String> messages = new ArrayList<>();
+        messages.add(string);
+
+        printMenu(messages, color);
+    }
+
+    public void printSuccessMenu(String string) {
+        printMenu(string, ANSI_GREEN);
+    }
+
+    public void printSuccessMessage(String string) {
+        System.out.println(ANSI_GREEN + string + ANSI_RESET);
+    }
+
+    public void printErrorMessage(String string) {
+        System.out.println(ANSI_RED + string + ANSI_RESET);
+    }
+
+    public String repeatCharacter(char character, int number) {
+        if (number <= 0) {
+            return "";
+        }
+
+        char[] repeat = new char[number];
+        Arrays.fill(repeat, character);
+
+        return new String(repeat);
+    }
+
+    public void clearScreen() {
+        System.out.print("\033[H\033[2J");
+        System.out.flush();
+    }
+
+    public String getStringInput(String message) {
+        System.out.print(message + ": ");
+
+        return reader.nextLine();
+    }
+
+    public int getIntInput(String message, String errorMessage, Predicate<Integer> tester) {
+        int input;
+
+        while (true) {
+            try {
+                System.out.print(message + ": ");
+                input = Integer.parseInt(reader.nextLine());
+
+                if (null == tester || tester.test(input)) {
+                    break;
+                }
+
+                printErrorMessage(errorMessage);
+            } catch (NumberFormatException e) {
+                printErrorMessage(errorMessage);
+            }
+        }
+
+        return input;
+    }
+
+    public long getLongInput(String message, Predicate<Long> tester) {
+        return getLongInput(message, "Error: please enter a number", tester);
+    }
+
+    public long getLongInput(String message, String errorMessage, Predicate<Long> tester) {
+        long input;
+
+        while (true) {
+            try {
+                System.out.print(message + ": ");
+                input = Long.parseLong(reader.nextLine());
+
+                if (null == tester || tester.test(input)) {
+                    break;
+                }
+
+                printErrorMessage(errorMessage);
+            } catch (NumberFormatException e) {
+                printErrorMessage(errorMessage);
+            }
+        }
+
+        return input;
+    }
+}
\ No newline at end of file
diff --git a/part_2/exercise_4/src/Main.java b/part_2/exercise_4/src/Main.java
new file mode 100644
index 0000000..df4ad8d
--- /dev/null
+++ b/part_2/exercise_4/src/Main.java
@@ -0,0 +1,6 @@
+public class Main {
+    public static void main(String[] args) {
+        CommandLineApp app = new CommandLineApp();
+        app.run();
+    }
+}
\ No newline at end of file
diff --git a/part_2/exercise_4/src/MoneyHelper.java b/part_2/exercise_4/src/MoneyHelper.java
new file mode 100644
index 0000000..d6674f0
--- /dev/null
+++ b/part_2/exercise_4/src/MoneyHelper.java
@@ -0,0 +1,14 @@
+import java.text.NumberFormat;
+import java.util.Locale;
+
+public class MoneyHelper {
+    public long euroToCent(long euro) {
+        return euro * 100;
+    }
+
+    public String formatCents(long cents) {
+        NumberFormat nf = NumberFormat.getCurrencyInstance(Locale.FRANCE);
+
+        return nf.format(cents / 100.0);
+    }
+}
diff --git a/part_2/exercise_4/src/TicketType.java b/part_2/exercise_4/src/TicketType.java
new file mode 100644
index 0000000..f77e3a1
--- /dev/null
+++ b/part_2/exercise_4/src/TicketType.java
@@ -0,0 +1,37 @@
+import java.util.Objects;
+
+/*
+ * @.classInvariant: name() != null && durationInMilliseconds() > 0 && priceInCents() >= 0
+ */
+public record TicketType(String name, String description, long durationInMilliseconds, long priceInCents) {
+    /*
+     * @.pre: true
+     * @.post: Object is constructed
+     *      throw NullPointerException if name == null
+     *      throw ZeroOrNegativeDurationException if durationInMilliseconds <= 0
+     *      throw NegativePriceException if priceInCents < 0
+     */
+    public TicketType {
+        Objects.requireNonNull(name, "Id should not be null");
+
+        if (durationInMilliseconds <= 0) {
+            throw new ZeroOrNegativeDurationException("Duration should be greater than zero");
+        }
+
+        if (priceInCents < 0) {
+            throw new NegativePriceException("Price should be greater than or equal to zero");
+        }
+    }
+}
+
+class ZeroOrNegativeDurationException extends RuntimeException {
+    public ZeroOrNegativeDurationException(String message) {
+        super(message);
+    }
+}
+
+class NegativePriceException extends RuntimeException {
+    public NegativePriceException(String message) {
+        super(message);
+    }
+}
\ No newline at end of file
diff --git a/part_2/exercise_4/src/TicketTypeRepository.java b/part_2/exercise_4/src/TicketTypeRepository.java
new file mode 100644
index 0000000..570dca3
--- /dev/null
+++ b/part_2/exercise_4/src/TicketTypeRepository.java
@@ -0,0 +1,56 @@
+import java.util.ArrayList;
+import java.util.List;
+
+public class TicketTypeRepository {
+    private final List<TicketType> types;
+
+    public TicketTypeRepository() {
+        types = new ArrayList<>();
+        types.add(new TicketType(
+                "single",
+                "Single ticket. Valid for 2 hours",
+                (long) 2 * 60 * 60 * 1000,
+                3 * 100
+        ));
+        types.add(new TicketType(
+                "day",
+                "Day ticket. Valid for 24 hours",
+                (long) 24 * 60 * 60 * 1000,
+                8 * 100
+        ));
+        types.add(new TicketType(
+                "monthly",
+                "Monthly ticket. Valid for 30 days",
+                (long) 30 * 24 * 60 * 60 * 1000,
+                55 * 100
+        ));
+    }
+
+    public List<TicketType> getAllTypes() {
+        return types;
+    }
+
+    /*
+     * @.pre: name != null
+     * @.post: RESULT != null
+     *      throw TicketTypeNotFoundException if there is no ticket type with this name.
+     */
+    public TicketType getByName(String name) {
+        List<TicketType> result = types.stream()
+                .filter((type) -> type.name().equals(name))
+                .limit(2)
+                .toList();
+
+        if (result.isEmpty()) {
+            throw new TicketTypeNotFoundException("Ticket type '" + name + "' is not found");
+        }
+
+        return result.getFirst();
+    }
+}
+
+class TicketTypeNotFoundException extends RuntimeException {
+    public TicketTypeNotFoundException(String message) {
+        super(message);
+    }
+}
-- 
GitLab