diff --git a/.gitignore b/.gitignore index effd886..a5fea7f 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ desktop.ini # ──────────────────────────────────────────────── # Snapcraft -snap/.snapcraft/ + parts/ prime/ stage/ @@ -59,6 +59,9 @@ build/ # ──────────────────────────────────────────────── tasks.json *.log +*.json +*.bak +*.db # ──────────────────────────────────────────────── # Ecosystem Standards diff --git a/assets/icons/icon-128.png b/assets/icons/icon-128.png new file mode 100644 index 0000000..a6b421f Binary files /dev/null and b/assets/icons/icon-128.png differ diff --git a/assets/icons/icon-16.png b/assets/icons/icon-16.png new file mode 100644 index 0000000..e81d3d2 Binary files /dev/null and b/assets/icons/icon-16.png differ diff --git a/assets/icons/icon-24.png b/assets/icons/icon-24.png new file mode 100644 index 0000000..d8d3d08 Binary files /dev/null and b/assets/icons/icon-24.png differ diff --git a/assets/icons/icon-256.png b/assets/icons/icon-256.png new file mode 100644 index 0000000..0405c0e Binary files /dev/null and b/assets/icons/icon-256.png differ diff --git a/assets/icons/icon-32.png b/assets/icons/icon-32.png new file mode 100644 index 0000000..e964111 Binary files /dev/null and b/assets/icons/icon-32.png differ diff --git a/assets/icons/icon-48.png b/assets/icons/icon-48.png new file mode 100644 index 0000000..260649b Binary files /dev/null and b/assets/icons/icon-48.png differ diff --git a/assets/icons/icon-512.png b/assets/icons/icon-512.png new file mode 100644 index 0000000..0c292c9 Binary files /dev/null and b/assets/icons/icon-512.png differ diff --git a/assets/icons/icon-64.png b/assets/icons/icon-64.png new file mode 100644 index 0000000..b6c1fb0 Binary files /dev/null and b/assets/icons/icon-64.png differ diff --git a/assets/icons/icon.png b/assets/icons/icon.png new file mode 100644 index 0000000..0c292c9 Binary files /dev/null and b/assets/icons/icon.png differ diff --git a/config/README.md b/config/README.md deleted file mode 100644 index 8da6cfa..0000000 --- a/config/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Config Directory - -This folder stores user‑specific configuration files for SchedPlus. - -- These files are **local to your machine**. -- They are **not tracked by Git** and are safe to modify. -- The contents here act as **templates** for personal settings, saved tasks, and future features like authentication or database configuration. - -As SchedPlus evolves, this directory will remain the home for all user‑level config and state files. diff --git a/config/tasks.json b/config/tasks.json deleted file mode 100644 index e09d31f..0000000 --- a/config/tasks.json +++ /dev/null @@ -1,20 +0,0 @@ -[ - { - "id": "task1", - "name": "Task 1", - "description": "This is the first task.", - "status": "pending" - }, - { - "id": "task2", - "name": "Task 2", - "description": "This is the second task.", - "status": "in_progress" - }, - { - "id": "task3", - "name": "Task 3", - "description": "This is the third task.", - "status": "completed" - } -] \ No newline at end of file diff --git a/docs/schema.md b/docs/schema.md deleted file mode 100644 index 8b87297..0000000 --- a/docs/schema.md +++ /dev/null @@ -1,40 +0,0 @@ -# Task JSON Schema - -SchedPlus stores scheduled tasks in JSON using a simple versioned schema. - -## Task Object Structure - -Each task is represented as an object with the following fields: - -- `id` — unique task identifier (string, `uuid4` recommended) -- `date` — ISO date in `YYYY-MM-DD` format -- `time` — 24-hour time in `HH:MM` format -- `text` — task description -- `createdAt` — creation timestamp in epoch milliseconds -- `updatedAt` — last update timestamp in epoch milliseconds - -## Full File Layout - -The top-level file contains a `version` field and a `tasks` array. - -```json -{ - "version": 1, - "tasks": [ - { - "id": "uuid", - "date": "2026-05-04", - "time": "14:30", - "text": "Example task", - "createdAt": 1714820000000, - "updatedAt": 1714820000000 - } - ] -} -``` - -## Notes - -- The `version` field supports future migrations. -- The schema uses ISO date/time formats for clarity. -- The file is intentionally simple and beginner-friendly. diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..10f358b --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +from setuptools import setup, find_packages + +setup( + name="schedplus", + version="0.7.3", + package_dir={"": "src"}, + packages=find_packages(where="src"), + + install_requires=[ + "PyQt6>=6.11.0", + "babel>=2.18.0", + "PyQt6-Qt6>=6.11.1", + "PyQt6_sip>=13.11.1", + "tkcalendar>=1.6.1" + ], + + entry_points={ + "console_scripts": [ + "schedplus = schedplus.__main__:boot" + ] + }, + + include_package_data=True, + python_requires=">=3.10", +) diff --git a/snap/gui/icon.png b/snap/gui/icon.png new file mode 100644 index 0000000..0c292c9 Binary files /dev/null and b/snap/gui/icon.png differ diff --git a/snap/gui/icon.svg b/snap/gui/icon.svg new file mode 100644 index 0000000..595b6b2 --- /dev/null +++ b/snap/gui/icon.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/snap/gui/schedplus.desktop b/snap/gui/schedplus.desktop new file mode 100644 index 0000000..281d731 --- /dev/null +++ b/snap/gui/schedplus.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=SchedPlus +Comment=Scheduling Application +TryExec=schedplus +Exec=schedplus %F +Icon=${SNAP}/meta/gui/icon.png +Categories=Utility; \ No newline at end of file diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml new file mode 100644 index 0000000..796539a --- /dev/null +++ b/snap/snapcraft.yaml @@ -0,0 +1,60 @@ +name: schedplus +title: SchedPlus +summary: Local-first scheduling engine with multiple UIs +base: core22 +version: '0.7.4' +source-code: + - https://github.com/ZFordDev/SchedPlus +license: MIT +contact: + - zforddev@gmail.com +issues: + - zforddev@gmail.com + - https://github.com/ZFordDev/SchedPlus/issues +donation: + - zforddev@gmail.com + - https://ko-fi.com/zforddev +website: + - https://zford.dev/ +icon: snap/gui/icon.png +type: app +compression: xz + +description: | + SchedPlus is a modular, local-first scheduling engine with three interchangeable + interfaces: PyQt, Tkinter, and RAW CLI. It stores data in a simple, durable + SQLite schema and is designed to be UI-agnostic, extensible, and portable. + + This snap packages the PyQt interface for development and testing. Future + variants may include Dev, SysAdmin, and Admin editions built on the same core. + +grade: devel +confinement: devmode + +parts: + schedplus: + plugin: python + source: . + python-packages: [] + build-environment: + - PIP_USE_PEP517: "false" + build-packages: + - python3 + - python3-venv + - python3-setuptools + stage-packages: + - python3 + - python3-venv + + +apps: + schedplus: + command: bin/schedplus + +platforms: + amd64: + build-on: [amd64] + build-for: [amd64] + arm64: + build-on: [amd64] + build-for: [arm64] diff --git a/src/cli/__init__.py b/src/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cli/cli_main.py b/src/cli/cli_main.py new file mode 100644 index 0000000..ad8919d --- /dev/null +++ b/src/cli/cli_main.py @@ -0,0 +1,18 @@ +""" +cli_main.py +----------- +Main Entry point to CLI (RAW) +""" + +import sys +from .raw_mode import run_raw_mode + +def run_cli(scheduler): + # Entry point for RAW CLI mode. + args = sys.argv[1:] + + # Remove the --raw flag itself + if "--raw" in args: + args.remove("--raw") + + run_raw_mode(args, scheduler) diff --git a/src/cli/help.py b/src/cli/help.py new file mode 100644 index 0000000..fdfdf64 --- /dev/null +++ b/src/cli/help.py @@ -0,0 +1,76 @@ +""" +help.py +------- +Centralized help system for SchedPlus. +NEW: colored CLI help output. +""" + +# ANSI colors +C_RESET = "\033[0m" +C_HEADER = "\033[95m" +C_CMD = "\033[96m" +C_DESC = "\033[90m" +C_WARN = "\033[91m" + +def _fmt(cmd, desc): # Format a command/description pair with alignment. + return f" {C_CMD}{cmd:<18}{C_RESET} {C_DESC}{desc}{C_RESET}" + +# --------------------------------------------------------- +# STARTUP HELP +# --------------------------------------------------------- + +def show_startup_help(): + print(f""" +{C_HEADER}SchedPlus Startup Flags{C_RESET} + +{_fmt('--tk', 'Launch Tkinter UI')} +{_fmt('--py', 'Launch PyQt UI')} +{_fmt('--dev', 'Developer mode')} +{_fmt('--raw', 'Use RAW CLI mode')} + +(no flags) Show GUI startup selector +""".rstrip()) + +# --------------------------------------------------------- +# RAW CLI HELP +# --------------------------------------------------------- + +def show_raw_help(): + print(f""" +{C_HEADER}SchedPlus RAW CLI Commands{C_RESET} + +{_fmt('schedplus --raw add', 'Add a task (interactive prompts)')} +{_fmt('schedplus --raw list', 'List all tasks')} +{_fmt('schedplus --raw --wipe', 'Wipe ALL tasks (3 confirmations)')} +{_fmt('schedplus --raw help', 'Show this help message')} + +{C_DESC}Notes:{C_RESET} + - Type 'cancel' during add to abort + - RAW mode is one-shot: command → action → exit +""".rstrip()) + +# --------------------------------------------------------- +# GENERAL HELP +# --------------------------------------------------------- + +def show_general_help(): + print(f""" +{C_HEADER}SchedPlus Help{C_RESET} + +{C_HEADER}Startup Modes:{C_RESET} +{_fmt('--tk', 'Launch Tkinter UI')} +{_fmt('--py', 'Launch PyQt UI')} +{_fmt('--dev', 'Developer mode')} +{_fmt('--raw', 'Use RAW CLI mode')} +(no flags) Show GUI startup selector + +{C_HEADER}RAW CLI Commands:{C_RESET} +{_fmt('schedplus --raw add', 'Add a task')} +{_fmt('schedplus --raw list', 'List all tasks')} +{_fmt('schedplus --raw --wipe', 'Wipe all tasks')} +{_fmt('schedplus --raw help', 'Show RAW help')} + +Tip: + Use {C_CMD}schedplus --raw help{C_RESET} for CLI-specific commands. +""".rstrip()) +# we can expand this more later \ No newline at end of file diff --git a/src/cli/raw_mode.py b/src/cli/raw_mode.py new file mode 100644 index 0000000..37488df --- /dev/null +++ b/src/cli/raw_mode.py @@ -0,0 +1,145 @@ +""" +raw_mode.py +----------- +Basic one-shot CLI for SchedPlus. +(Might expand into a run time later) +""" + +import sys +from datetime import datetime + +# ANSI colors +C_RESET = "\033[0m" +C_OK = "\033[92m" +C_WARN = "\033[91m" +C_INFO = "\033[96m" + + +def run_raw_mode(args, scheduler): + # If no arguments provided, display help docs + if not args: + from cli.help import show_raw_help + return show_raw_help() + + # Extract the first arg + cmd = args[0].lower() + + # Route to the 'add' task + if cmd == "add": + return cmd_add(scheduler) + + # Route to the 'list' tasks display + if cmd == "list": + return cmd_list(scheduler) + + # Route to the 'wipe' database clearing function + if cmd in ("--wipe", "--wipe!", "wipe"): + return cmd_wipe(scheduler) + + # Route to help docs + if cmd in ("help", "--help", "-h"): + from cli.help import show_raw_help + return show_raw_help() + + # Fallback: Unknown command triggers help display + from cli.help import show_raw_help + show_raw_help() + + +# --------------------------------------------------------- +# ADD +# --------------------------------------------------------- + +def cmd_add(scheduler): + # Prompt user to start adding a task or cancel + print(f"{C_INFO}Add new task (type 'cancel' to abort){C_RESET}") + + # Prompt for date input with valid loop + while True: + date = input("Date (DD-MM-YYYY): ").strip() + if date.lower() == "cancel": + print("Cancelled.") + return + + try: + # Validate date and convert to ISO + d = datetime.strptime(date, "%d-%m-%Y") + date_iso = d.strftime("%Y-%m-%d") + break + except ValueError: + print(f"{C_WARN}Invalid date. Try again.{C_RESET}") + + # Prompt for time input with validation loop + # Add fuzzy logic later + while True: + time = input("Time (HH:MM): ").strip() + if time.lower() == "cancel": + print("Cancelled.") + return + + try: + # Validate time format (HH:MM) + datetime.strptime(time, "%H:%M") + break + except ValueError: + print(f"{C_WARN}Invalid time. Try again.{C_RESET}") + + # Prompt for task note + text = input("Note: ").strip() + if text.lower() == "cancel": + print("Cancelled.") + return + + # Add the validated task to the scheduler + scheduler.add_task(date=date_iso, time=time, text=text) + # Confirm successful addition + print(f"{C_OK}[OK]{C_RESET} Added: {date_iso} {time} — {text}") + + +# --------------------------------------------------------- +# LIST +# --------------------------------------------------------- + +def cmd_list(scheduler): + # Retrieve all tasks from the scheduler + tasks = scheduler.get_tasks() + + # Handle case where no tasks exist + if not tasks: + print("No tasks found.") + return + + # Display header and list each task + print(f"{C_INFO}Tasks:{C_RESET}") + for t in tasks: + print(f" {t.date} {t.time} — {t.text}") + + +# --------------------------------------------------------- +# WIPE +# --------------------------------------------------------- + +def cmd_wipe(scheduler): + # Warn user about deleting + print(f"{C_WARN}WARNING: This will erase ALL tasks from the database.{C_RESET}") + + # Require triple confirmation ('YES') before proceeding + # This seems safe enough for now. + for i in range(3): + confirm = input(f"Type 'YES' ({3-i} left): ").strip() + if confirm != "YES": + print("Cancelled.") + return + + # Import necessary modules for database file manipulation + from logic.storage.sqlite_storage import DB_FILE, init_db + import os + + # Remove the database file if it exists + if os.path.exists(DB_FILE): + os.remove(DB_FILE) + + # Re-initialize the database schema + init_db() + # Confirm successful wipe + print(f"{C_OK}[OK]{C_RESET} Database wiped.") \ No newline at end of file diff --git a/src/data/README.md b/src/data/README.md new file mode 100644 index 0000000..866259d --- /dev/null +++ b/src/data/README.md @@ -0,0 +1,96 @@ +# Storage Overview +SchedPlus uses a **local‑first**, **user‑owned** storage model. +Older versions stored tasks in a JSON file, while newer versions use a SQLite database. Both formats share the same conceptual schema, ensuring compatibility across all UIs (Tkinter, PyQt, RAW CLI, and future Pro interfaces). + +The storage folder may contain: + +- legacy `tasks.json` files +- the new `tasks.db` SQLite database +- future extension tables linked by UUID + +After the first successful migration, the JSON file is usually safe to delete — but backing it up is recommended. + +--- + +# Core Schema (V1 – Stable, Minimal, Future‑Proof) + +``` +{ + "version": 1, + "tasks": [ + { + "id": "UUID", + "date": "Date input", + "time": "Time input", + "text": "Text input", + "createdAt": "unique at time of creation", + "updatedAt": "updated if entry changed" + } + ] +} +``` + +The SQLite version mirrors this structure: + +``` +entries ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + time TEXT NOT NULL, + text TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL +) +``` + +This schema is intentionally minimal: + +- **UUID** → universal linking key +- **ISO timestamps** → perfect for sorting, syncing, merging +- **date/time/text** → UI‑agnostic, human‑readable +- **no foreign keys** → safe for all UIs, including legacy ones + +This is the **SchedPlus Core**, and it will not change. + +--- + +# Why We Don’t Modify the Core Table +SchedPlus is designed as a **platform**, not a single app. +Multiple UIs (Tkinter, PyQt, RAW, Dev, SysAdmin, Admin, Pro) all rely on the same core logic. + +Changing the core schema would break: + +- older UIs +- third‑party tools +- CLI scripts +- user data portability + +Instead, **all future features attach to the UUID** using additional tables. + +This keeps SchedPlus: + +- backward compatible +- forward expandable +- UI‑agnostic +- safe for open‑source users +- powerful for Pro users + +--- + +# The Future: Extension Tables (SchedPlus‑Pro, Dev, SysAdmin, Admin) + +Instead of modifying `entries`, new features will use **linked tables**: + +- **entry_meta** -> priority, status, color +- **entry_links** -> file paths, git commits, servers, calendar events +- **entry_tags** -> tagging system +- **entry_comments** -> threaded notes +- **entry_sync** -> cloud/team sync metadata + +All linked by: + +``` +entry_id TEXT REFERENCES entries(id) +``` + +--- diff --git a/src/data/__init__.py b/src/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/data/tasks.db b/src/data/tasks.db new file mode 100644 index 0000000..f526300 Binary files /dev/null and b/src/data/tasks.db differ diff --git a/src/data/tasks.json.bak b/src/data/tasks.json.bak new file mode 100644 index 0000000..fe77005 --- /dev/null +++ b/src/data/tasks.json.bak @@ -0,0 +1,13 @@ +{ + "version": 1, + "tasks": [ + { + "id": "07d9f012-979b-4ec7-859c-c15f976d9e04", + "date": "2026-06-20", + "time": "01:01", + "text": "hh", + "createdAt": "2026-06-20T11:10:01.601441", + "updatedAt": "2026-06-20T11:10:01.601459" + } + ] +} \ No newline at end of file diff --git a/src/logic/__init__.py b/src/logic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logic/scheduler.py b/src/logic/scheduler.py index bcb682d..dfee77d 100644 --- a/src/logic/scheduler.py +++ b/src/logic/scheduler.py @@ -1,12 +1,12 @@ """ -scheduler.py (v0.4) -------------------- -Core scheduler logic for SchedPlus (v0.4). - -This module provides the `Task` dataclass and the `Scheduler` class. -The `Scheduler` is responsible for in-memory task management and -exposes small wrapper methods to persist/load tasks via the storage -layer so UIs (Tkinter/PyQt) do not need to know storage paths. +scheduler.py +------------- +SQLite-backed scheduler logic for SchedPlus. + +The Scheduler class now delegates all persistence to +logic.storage.sqlite_storage instead of the legacy JSON storage. + +UIs remain fully decoupled from storage details. """ import uuid @@ -14,6 +14,7 @@ from typing import List from datetime import datetime + @dataclass class Task: id: str = field(default_factory=lambda: str(uuid.uuid4())) @@ -43,54 +44,66 @@ def from_dict(cls, data: dict): createdAt=data.get("createdAt", datetime.utcnow().isoformat()), updatedAt=data.get("updatedAt", datetime.utcnow().isoformat()), ) + + class Scheduler: """ - The Scheduler class manages a list of tasks. - In v0.4, tasks are stored in memory and the class exposes - simple `save_tasks`/`load_tasks` helpers that delegate to the - storage layer. This keeps UIs decoupled from storage details. + SQLite-backed scheduler. + + Responsibilities: + - Maintain in-memory list of Task objects + - Provide add/update/delete operations + - Delegate persistence to sqlite_storage """ def __init__(self): self.tasks: List[Task] = [] + # --------------------------------------------------------- + # Create + # --------------------------------------------------------- + def add_task(self, date: str, time: str, text: str): - """ - Add a new task to the scheduler. - No validation yet — this will be added in v0.2+. - """ + from .storage import sqlite_storage as db + task = Task(date=date, time=time, text=text) + db.create_entry(task) self.tasks.append(task) - def save_tasks(self, filepath: str = None): - """ - Persist current tasks using the storage layer. - - This method performs a local import to avoid import cycles - between `logic.storage` and `logic.scheduler`. - """ - try: - from . import storage as _storage - - _storage.save_tasks(self.tasks, filepath) if filepath else _storage.save_tasks(self.tasks) - except Exception: - # Intentionally swallow errors here; storage will log on failure. - pass - - def load_tasks(self, filepath: str = None): - """ - Load tasks from the storage layer into `self.tasks`. - Returns the loaded list of tasks. - """ - try: - from . import storage as _storage - - self.tasks = _storage.load_tasks(filepath or _storage.DATA_FILE) - except Exception: - self.tasks = [] + # --------------------------------------------------------- + # Read + # --------------------------------------------------------- + def load_tasks(self): + from .storage import sqlite_storage as db + + self.tasks = db.list_entries() return self.tasks def get_tasks(self) -> List[Task]: - """Return the list of tasks.""" return self.tasks + + # --------------------------------------------------------- + # Update + # --------------------------------------------------------- + + def update_task(self, task: Task): + from .storage import sqlite_storage as db + + db.update_entry(task) + + # Update in-memory list + for i, t in enumerate(self.tasks): + if t.id == task.id: + self.tasks[i] = task + break + + # --------------------------------------------------------- + # Delete + # --------------------------------------------------------- + + def delete_task(self, task_id: str): + from .storage import sqlite_storage as db + + db.delete_entry(task_id) + self.tasks = [t for t in self.tasks if t.id != task_id] diff --git a/src/logic/storage.py b/src/logic/storage.py index 8a0ff7a..ac5ba4d 100644 --- a/src/logic/storage.py +++ b/src/logic/storage.py @@ -2,7 +2,7 @@ import os from .scheduler import Task -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 DATA_FILE = os.path.abspath( os.path.join(os.path.dirname(__file__), "..", "data", "tasks.json") diff --git a/src/logic/storage/__init__.py b/src/logic/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/logic/storage/migration.py b/src/logic/storage/migration.py new file mode 100644 index 0000000..09dff42 --- /dev/null +++ b/src/logic/storage/migration.py @@ -0,0 +1,94 @@ +""" +migration.py +------------ +Handles migration from legacy JSON storage -> SQLite storage. +- JSON logic remains untouched +- SQLite logic remains clean +- Scheduler and UIs do not need to know about migration +""" + +import os +import json +from . import sqlite_storage +from ..scheduler import Task + +# Legacy JSON path (from old storage.py) +LEGACY_JSON = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "data", "tasks.json") +) + +# New SQLite DB path +SQLITE_DB = sqlite_storage.DB_FILE + + +# --------------------------------------------------------- +# Migration checks +# --------------------------------------------------------- + +def needs_migration(): + """ + Returns True if: + - SQLite DB does NOT exist + - JSON file DOES exist + """ + json_exists = os.path.exists(LEGACY_JSON) + db_exists = os.path.exists(SQLITE_DB) + + return json_exists and not db_exists + + +# --------------------------------------------------------- +# Migration runner +# --------------------------------------------------------- + +def run_migration(): + """ + Migrates tasks.json -> tasks.db + + Steps: + 1. Load JSON tasks + 2. Initialize SQLite DB + 3. Insert tasks + 4. Rename JSON → tasks.json.bak + """ + + print("[MIGRATION] Starting migration from JSON → SQLite") + + # 1. Load JSON tasks + if not os.path.exists(LEGACY_JSON): + print("[MIGRATION] No JSON file found — nothing to migrate") + return + + try: + with open(LEGACY_JSON, "r", encoding="utf-8") as f: + raw = f.read() + data = json.loads(raw) + tasks_data = data.get("tasks", []) + except Exception as e: + print(f"[MIGRATION] ERROR reading JSON: {e}") + return + + print(f"[MIGRATION] Found {len(tasks_data)} tasks in JSON") + + # Convert dicts -> Task objects + tasks = [Task.from_dict(t) for t in tasks_data] + + # 2. Initialize SQLite DB + sqlite_storage.init_db() + + # 3. Insert tasks into SQLite + for task in tasks: + sqlite_storage.create_entry(task) + + print("[MIGRATION] Inserted tasks into SQLite") + + # 4. Rename JSON -> backup + backup_path = LEGACY_JSON + ".bak" + + try: + os.rename(LEGACY_JSON, backup_path) + print(f"[MIGRATION] JSON file renamed to {backup_path}") + except Exception as e: + print(f"[MIGRATION] WARNING: Could not rename JSON file: {e}") + + print("[MIGRATION] Migration complete") diff --git a/src/logic/storage/sqlite_storage.py b/src/logic/storage/sqlite_storage.py new file mode 100644 index 0000000..cebd827 --- /dev/null +++ b/src/logic/storage/sqlite_storage.py @@ -0,0 +1,166 @@ +import os +import sqlite3 +from datetime import datetime +from ..scheduler import Task + +# --------------------------------------------------------- +# Database location +# --------------------------------------------------------- + +DB_FILE = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "data", "tasks.db") +) +os.makedirs(os.path.dirname(DB_FILE), exist_ok=True) + +# --------------------------------------------------------- +# Connection helper +# --------------------------------------------------------- + +def _get_conn(): + return sqlite3.connect(DB_FILE) + +# --------------------------------------------------------- +# Schema initialization +# --------------------------------------------------------- + +def init_db(): + conn = _get_conn() + cur = conn.cursor() + + cur.execute( + """ + CREATE TABLE IF NOT EXISTS entries ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + time TEXT NOT NULL, + text TEXT NOT NULL, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL + ); + """ + ) + + conn.commit() + conn.close() + +# --------------------------------------------------------- +# Create +# --------------------------------------------------------- + +def create_entry(task: Task): + conn = _get_conn() + cur = conn.cursor() + + cur.execute( + """ + INSERT INTO entries (id, date, time, text, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + task.id, + task.date, + task.time, + task.text, + task.createdAt, + task.updatedAt, + ), + ) + + conn.commit() + conn.close() + +# --------------------------------------------------------- +# Update +# --------------------------------------------------------- + +def update_entry(task: Task): + conn = _get_conn() + cur = conn.cursor() + + task.updatedAt = datetime.utcnow().isoformat() + + cur.execute( + """ + UPDATE entries + SET date = ?, time = ?, text = ?, updatedAt = ? + WHERE id = ? + """, + (task.date, task.time, task.text, task.updatedAt, task.id), + ) + + conn.commit() + conn.close() + +# --------------------------------------------------------- +# Delete +# --------------------------------------------------------- + +def delete_entry(task_id: str): + conn = _get_conn() + cur = conn.cursor() + + cur.execute("DELETE FROM entries WHERE id = ?", (task_id,)) + + conn.commit() + conn.close() + +# --------------------------------------------------------- +# Read (single) +# --------------------------------------------------------- + +def get_entry(task_id: str): + conn = _get_conn() + cur = conn.cursor() + + cur.execute( + "SELECT id, date, time, text, createdAt, updatedAt FROM entries WHERE id = ?", + (task_id,), + ) + row = cur.fetchone() + conn.close() + + if not row: + return None + + return Task( + id=row[0], + date=row[1], + time=row[2], + text=row[3], + createdAt=row[4], + updatedAt=row[5], + ) + +# --------------------------------------------------------- +# Read (all) +# --------------------------------------------------------- + +def list_entries(): + conn = _get_conn() + cur = conn.cursor() + + cur.execute( + """ + SELECT id, date, time, text, createdAt, updatedAt + FROM entries + ORDER BY date ASC, time ASC + """ + ) + + rows = cur.fetchall() + conn.close() + + tasks = [] + for row in rows: + tasks.append( + Task( + id=row[0], + date=row[1], + time=row[2], + text=row[3], + createdAt=row[4], + updatedAt=row[5], + ) + ) + + return tasks \ No newline at end of file diff --git a/src/main.py b/src/main.py index 5770b55..2bd7ca0 100644 --- a/src/main.py +++ b/src/main.py @@ -1,10 +1,12 @@ """ -main.py (v0.4) --------------- -Entry point for SchedPlus (v0.4). +main.py +-------- +Entry point for SchedPlus This module creates the `Scheduler`, asks it to load persisted tasks, and launches the selected UI. UIs interact only with the scheduler API. + +Note: No Logic in here, keep it simple! """ from startup.controller import boot diff --git a/src/schedplus/__init__.py b/src/schedplus/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/schedplus/__main__.py b/src/schedplus/__main__.py new file mode 100644 index 0000000..2bd7ca0 --- /dev/null +++ b/src/schedplus/__main__.py @@ -0,0 +1,16 @@ +""" +main.py +-------- +Entry point for SchedPlus + +This module creates the `Scheduler`, asks it to load persisted tasks, +and launches the selected UI. UIs interact only with the scheduler API. + +Note: No Logic in here, keep it simple! +""" + +from startup.controller import boot + + +if __name__ == "__main__": + boot() diff --git a/src/startup/controller.py b/src/startup/controller.py index a825076..c2aef9e 100644 --- a/src/startup/controller.py +++ b/src/startup/controller.py @@ -2,19 +2,12 @@ controller.py ------------- Startup controller for SchedPlus. - -This module orchestrates the boot process: -- Reads CLI flags -- Shows popup selector if needed -- Launches the correct UI -- Handles missing dependencies gracefully - -This file must not contain UI logic or storage logic. """ import sys from .flags import determine_startup_mode from .modes import StartupMode +from logic.storage.migration import needs_migration, run_migration def boot(): @@ -23,20 +16,23 @@ def boot(): Determines startup mode and launches the correct UI. """ + # 0. Run migration BEFORE anything else + if needs_migration(): + run_migration() + # 1. Determine mode from CLI flags mode = determine_startup_mode(sys.argv[1:]) - # 2. If invalid flag → stop + # 2. If invalid flag -> stop if mode == StartupMode.INVALID: return - # 3. If no flags → show popup selector + # 3. If no flags -> show popup selector if mode == StartupMode.POPUP: from .selector import StartupSelector selector = StartupSelector() mode = selector.show() - # User closed popup if mode is None: print("Startup cancelled.") return @@ -51,7 +47,6 @@ def _launch_mode(mode: StartupMode): Lazy imports ensure no heavy UI frameworks load unless needed. """ - # Create scheduler lazily (only if a UI is actually launching) from logic.scheduler import Scheduler scheduler = Scheduler() scheduler.load_tasks() @@ -76,12 +71,14 @@ def _launch_mode(mode: StartupMode): print("[Startup] Launching PyQt UI...") run_pyqt_ui(scheduler) + # going to add this soon to avoid constant wiping. elif mode == StartupMode.DEV: print("[Startup] Developer mode not implemented yet.") return elif mode == StartupMode.RAW: - print("[Startup] RAW mode not implemented yet.") + from cli.cli_main import run_cli + run_cli(scheduler) return else: diff --git a/src/startup/flags.py b/src/startup/flags.py index 791f687..020b4f8 100644 --- a/src/startup/flags.py +++ b/src/startup/flags.py @@ -3,11 +3,12 @@ -------- CLI flag parser for SchedPlus startup logic. -This module is UI‑agnostic and must not import any UI frameworks. -It simply interprets command‑line arguments and returns a StartupMode. +This module is UI-agnostic and must not import any UI frameworks. +It simply interprets command-line arguments and returns a StartupMode. """ from .modes import StartupMode +from cli.help import show_startup_help VALID_FLAGS = { @@ -18,45 +19,21 @@ } -def print_help(): - """Print help text for invalid or unknown flags.""" - help_text = """ -SchedPlus Startup Flags: - --dev Use terminal selector - --py Launch PyQt UI - --tk Launch Tkinter UI - --raw Reserved for future RAW mode - (no flags) Show GUI startup selector -""" - print(help_text.strip()) - - def determine_startup_mode(args) -> StartupMode: """ Determine the startup mode based on CLI arguments. - - Parameters - ---------- - args : list[str] - Command-line arguments (excluding the script name). - - Returns - ------- - StartupMode - The resolved startup mode. """ - # No flags → show popup selector + # No args -> popup selector if not args: return StartupMode.POPUP - # Only one flag is supported for now flag = args[0].strip().lower() # Valid flag if flag in VALID_FLAGS: return VALID_FLAGS[flag] - # Invalid flag - print_help() + # Invalid flag -> show help + show_startup_help() return StartupMode.INVALID diff --git a/src/startup/selector.py b/src/startup/selector.py index f4c36cc..7f67392 100644 --- a/src/startup/selector.py +++ b/src/startup/selector.py @@ -3,9 +3,7 @@ ----------- Popup selector window for choosing the UI mode. -This module must remain lightweight and must not import or initialize -the full Tkinter or PyQt UI systems. It only provides a small modal -window that returns a StartupMode value. +Bypass this with direct commands see modes.py """ from .modes import StartupMode @@ -31,7 +29,7 @@ def __init__(self): self.result = None self.root = tk.Tk() self.root.title("Select Mode") - self.root.geometry("300x200") + self.root.geometry("300x240") self.root.resizable(False, False) # Center window @@ -54,7 +52,8 @@ def _build_ui(self): ttk.Button(frame, text="Advanced | PyQt", command=lambda: self._select(StartupMode.PYQT)).pack(fill="x", pady=4) - ttk.Button(frame, text="RAW (coming soon)", state="disabled").pack(fill="x", pady=4) + ttk.Button(frame, text="RAW | CLI Mode", + command=lambda: self._select(StartupMode.RAW)).pack(fill="x", pady=4) ttk.Button(frame, text="Close", command=self._close).pack(fill="x", pady=(10, 0)) diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/pyqt/__init__.py b/src/ui/pyqt/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/pyqt/add_dialog.py b/src/ui/pyqt/add_dialog.py new file mode 100644 index 0000000..229801e --- /dev/null +++ b/src/ui/pyqt/add_dialog.py @@ -0,0 +1,152 @@ +""" +pyqt/add_dialog.py +------------------ + +This is the popup window to add a new entry. + +We will also add edit entry soon. + +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLineEdit, + QPushButton, QDateEdit, QTimeEdit, QLabel +) +from PyQt6.QtCore import QDate, QTime, Qt + + +class AddTaskDialog(QDialog): + def __init__(self): + super().__init__() + self.setWindowTitle("Create New Task") + self.setFixedSize(400, 360) + + # Make the dialog background match + self.setStyleSheet(""" + QDialog { + background-color: #FFFFFF; + } + QLabel { + font-size: 12px; + font-weight: bold; + color: #555555; + margin-bottom: 2px; + } + QLineEdit, QDateEdit, QTimeEdit { + background-color: #F8F9FA; + border: 1px solid #E0E0E0; + border-radius: 6px; + padding: 8px 12px; + font-size: 14px; + color: #1A1A1A; + } + QLineEdit:focus, QDateEdit:focus, QTimeEdit:focus { + border: 1px solid #007ACC; + background-color: #FFFFFF; + } + /* Clean up the native dropdown arrow for date picker */ + QDateEdit::drop-down, QTimeEdit::up-button, QTimeEdit::down-button { + border: none; + background: transparent; + } + """) + + # Main Layout + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(24, 24, 24, 24) + main_layout.setSpacing(16) + + # --- Task Input Field --- + task_block = QVBoxLayout() + task_block.setSpacing(4) + task_label = QLabel("WHAT NEEDS TO BE DONE?") + self.text_input = QLineEdit() + self.text_input.setPlaceholderText("e.g., Review project proposal") + task_block.addWidget(task_label) + task_block.addWidget(self.text_input) + main_layout.addLayout(task_block) + + # --- Date & Time Fields (Side by Side) --- + dt_layout = QHBoxLayout() + dt_layout.setSpacing(16) + + # Date Field + date_block = QVBoxLayout() + date_block.setSpacing(4) + date_label = QLabel("DATE") + self.date_input = QDateEdit() + self.date_input.setCalendarPopup(True) + self.date_input.setDisplayFormat("yyyy-MM-dd") + self.date_input.setDate(QDate.currentDate()) + date_block.addWidget(date_label) + date_block.addWidget(self.date_input) + dt_layout.addLayout(date_block) + + # Time Field + time_block = QVBoxLayout() + time_block.setSpacing(4) + time_label = QLabel("TIME") + self.time_input = QTimeEdit() + self.time_input.setDisplayFormat("HH:mm") + self.time_input.setTime(QTime.currentTime()) + time_block.addWidget(time_label) + time_block.addWidget(self.time_input) + dt_layout.addLayout(time_block) + + main_layout.addLayout(dt_layout) + + # Spacer to push layout cleanly + main_layout.addStretch() + + # --------------------------------------------------------- + # CUSTOM ACTION BUTTONS + # --------------------------------------------------------- + button_layout = QHBoxLayout() + button_layout.setSpacing(12) + + self.cancel_btn = QPushButton("Cancel") + self.cancel_btn.setFixedHeight(38) + self.cancel_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.cancel_btn.setStyleSheet(""" + QPushButton { + background-color: #F0F0F0; + color: #444444; + border-radius: 6px; + font-weight: bold; + font-size: 13px; + border: none; + } + QPushButton:hover { background-color: #E5E5E5; } + """) + + self.save_btn = QPushButton("Save Task") + self.save_btn.setFixedHeight(38) + self.save_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.save_btn.setStyleSheet(""" + QPushButton { + background-color: #007ACC; + color: white; + border-radius: 6px; + font-weight: bold; + font-size: 13px; + border: none; + } + QPushButton:hover { background-color: #0062A3; } + QPushButton:pressed { background-color: #004C80; } + """) + + # Connect slots + self.cancel_btn.clicked.connect(self.reject) + self.save_btn.clicked.connect(self.accept) + + button_layout.addWidget(self.cancel_btn) + button_layout.addWidget(self.save_btn) + + main_layout.addLayout(button_layout) + + def get_values(self): + date_str = self.date_input.date().toString("yyyy-MM-dd") + time_str = self.time_input.time().toString("HH:mm") + text_str = self.text_input.text().strip() + + return date_str, time_str, text_str \ No newline at end of file diff --git a/src/ui/pyqt/task_card.py b/src/ui/pyqt/task_card.py new file mode 100644 index 0000000..f99dffe --- /dev/null +++ b/src/ui/pyqt/task_card.py @@ -0,0 +1,68 @@ +""" +pyqt/task_card.py +------------------ +Shows the individual cards in the list. +""" + +from PyQt6.QtWidgets import QWidget, QLabel, QVBoxLayout, QFrame, QHBoxLayout +from PyQt6.QtCore import Qt + + +class TaskCard(QWidget): + def __init__(self, task): + super().__init__() + self.task = task + + # Outer layout provides breathing room between individual cards + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 4, 0, 4) + outer_layout.setSpacing(0) + + # Inner Card Body Container + self.card_body = QFrame() + self.card_body.setObjectName("CardBody") + + # Inner layout defines padding inside the white card boundary + inner_layout = QVBoxLayout(self.card_body) + inner_layout.setContentsMargins(16, 12, 16, 12) + inner_layout.setSpacing(6) + + # Meta row: Time (Date is removed here because the TaskList already groups by Date Header!) + meta = QLabel(task.time) + meta.setStyleSheet(""" + QLabel { + font-size: 11px; + font-weight: 600; + color: #007ACC; + text-transform: uppercase; + } + """) + + # Task content (larger, readable line space feel) + text = QLabel(task.text) + text.setWordWrap(True) + text.setStyleSheet(""" + QLabel { + font-size: 14px; + color: #2D3142; + line-height: 1.3; + } + """) + + inner_layout.addWidget(meta) + inner_layout.addWidget(text) + + outer_layout.addWidget(self.card_body) + + # Explicitly styling the ObjectName prevents any inheritance weirdness + self.setStyleSheet(""" + QFrame#CardBody { + background: #FFFFFF; + border: 1px solid #EAEAEA; + border-radius: 8px; + } + QFrame#CardBody:hover { + border: 1px solid #007ACC; + background: #FDFDFD; + } + """) \ No newline at end of file diff --git a/src/ui/pyqt/task_list.py b/src/ui/pyqt/task_list.py new file mode 100644 index 0000000..d71f1ac --- /dev/null +++ b/src/ui/pyqt/task_list.py @@ -0,0 +1,145 @@ +""" +pyqt/task_list.py +----------------- +The list window for the cards. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QPushButton, QScrollArea, QLabel, QFrame +) +from PyQt6.QtCore import Qt +from ui.pyqt.task_card import TaskCard + + +class TaskListWidget(QWidget): + def __init__(self, scheduler): + super().__init__() + self.scheduler = scheduler + + # Main Layout + layout = QVBoxLayout(self) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) + + # Scroll Area Setup + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + # Remove default harsh QScrollArea border + scroll.setStyleSheet("QScrollArea { border: none; background: transparent; }") + + # Scroll Content Container + self.container = QWidget() + self.container.setStyleSheet("background: transparent;") + self.container_layout = QVBoxLayout(self.container) + self.container_layout.setSpacing(12) + self.container_layout.setContentsMargins(0, 0, 0, 0) + + scroll.setWidget(self.container) + layout.addWidget(scroll) + + # Add Button (Styled & separated) + self.add_button = QPushButton("+ Add Task") + self.add_button.setFixedHeight(42) + self.add_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_button.setStyleSheet(""" + QPushButton { + background-color: #007ACC; + color: white; + border-radius: 6px; + font-weight: bold; + font-size: 13px; + border: none; + } + QPushButton:hover { + background-color: #0062A3; + } + QPushButton:pressed { + background-color: #004C80; + } + """) + layout.addWidget(self.add_button) + + self.refresh() + + # --------------------------------------------------------- + # RENDER & CLEANUP + # --------------------------------------------------------- + + def _clear_layout(self): + """Safely clears all widgets, layouts, and spacers from the container.""" + while self.container_layout.count() > 0: + item = self.container_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + def refresh(self): + self._clear_layout() + + tasks = self.scheduler.get_tasks() + + # Dynamic Auto-Hide UI Logic + if not tasks: + self._render_empty_state() + self.add_button.show() # Bring back the button if the list becomes empty + return + + self.add_button.hide() # Auto-hide the button to avoid duplication with the header + + # Sort and group tasks by date + grouped = {} + for t in sorted(tasks, key=lambda x: x.date): + grouped.setdefault(t.date, []).append(t) + + # Render groups + for date, items in grouped.items(): + self._render_date_header(date) + + for task in items: + card = TaskCard(task) + self.container_layout.addWidget(card) + + # Push everything to the top safely + self.container_layout.addStretch(1) + + # --------------------------------------------------------- + # HELPERS + # --------------------------------------------------------- + + def _render_date_header(self, date): + # Header container to hold label and line together neatly + header_widget = QWidget() + header_layout = QVBoxLayout(header_widget) + header_layout.setContentsMargins(0, 8, 0, 4) + header_layout.setSpacing(4) + + label = QLabel(date) + label.setStyleSheet(""" + QLabel { + font-size: 13px; + font-weight: bold; + color: #555555; + } + """) + header_layout.addWidget(label) + + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setStyleSheet("border: none; background-color: #E0E0E0; max-height: 1px;") + header_layout.addWidget(line) + + self.container_layout.addWidget(header_widget) + + def _render_empty_state(self): + label = QLabel("No tasks yet.\nClick 'Add Task' to create one.") + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet(""" + QLabel { + font-size: 14px; + color: #888888; + line-height: 1.4; + padding: 60px 20px; + } + """) + self.container_layout.addWidget(label) \ No newline at end of file diff --git a/src/ui/pyqt/theme.py b/src/ui/pyqt/theme.py new file mode 100644 index 0000000..7a07d37 --- /dev/null +++ b/src/ui/pyqt/theme.py @@ -0,0 +1,17 @@ +""" +pyqt/theme.py +------------- +Helper themes global +""" + +BASE_QSS = """ +QWidget { + font-family: Segoe UI; + font-size: 11pt; +} +""" +self.setStyleSheet(""" + QMainWindow { + background: #fafafa; + } +""") diff --git a/src/ui/pyqt/window.py b/src/ui/pyqt/window.py new file mode 100644 index 0000000..1e00a9d --- /dev/null +++ b/src/ui/pyqt/window.py @@ -0,0 +1,126 @@ +""" +pyqt/window.py +-------------- +Main Frame for the PyQt UI + +""" + +from PyQt6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QStatusBar +) +from PyQt6.QtCore import QTimer, Qt +from PyQt6.QtGui import QColor + +from ui.pyqt.task_list import TaskListWidget +from ui.pyqt.add_dialog import AddTaskDialog + + +class SchedPlusWindow(QMainWindow): + def __init__(self, scheduler): + super().__init__() + self.scheduler = scheduler + + self.setWindowTitle("SchedPlus") + self.resize(520, 680) # Slightly taller for better task distribution + self.setStyleSheet("background-color: #FAFAFA;") # Soft global background + + # Status Bar Styling + status = self.statusBar() + status.setStyleSheet(""" + QStatusBar { + background: #F0F0F0; + color: #555555; + font-size: 11px; + border-top: 1px solid #E0E0E0; + } + QStatusBar::item { border: none; } + """) + + self.status_timer = QTimer() + self.status_timer.setSingleShot(True) + self.status_timer.timeout.connect(self.clear_status_message) + + # Central Layout Setup + central = QWidget() + layout = QVBoxLayout(central) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) # Tighten the gap between header and content + + # --------------------------------------------------------- + # HEADER BAR + # --------------------------------------------------------- + header_widget = QWidget() + header_widget.setStyleSheet(""" + QWidget { + background-color: #FFFFFF; + border-bottom: 1px solid #EAEAEA; + } + """) + header_layout = QHBoxLayout(header_widget) + header_layout.setContentsMargins(20, 16, 20, 16) + + title = QLabel("SchedPlus") + title.setStyleSheet(""" + QLabel { + font-size: 20px; + font-weight: bold; + color: #1A1A1A; + border: none; + } + """) + + # Cleaned up header button to match the app style + self.add_btn = QPushButton("+ Add Task") + self.add_btn.setFixedHeight(34) + self.add_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.add_btn.setStyleSheet(""" + QPushButton { + background: #007ACC; + color: white; + border-radius: 6px; + padding: 0px 16px; + font-weight: bold; + font-size: 12px; + border: none; + } + QPushButton:hover { background: #0062A3; } + QPushButton:pressed { background: #004C80; } + """) + self.add_btn.clicked.connect(self.open_add_dialog) + + header_layout.addWidget(title) + header_layout.addStretch() + header_layout.addWidget(self.add_btn) + + layout.addWidget(header_widget) + + # --------------------------------------------------------- + # TASK LIST + # --------------------------------------------------------- + self.task_list = TaskListWidget(scheduler) + layout.addWidget(self.task_list) + + # CONNECT THE TASK LIST BUTTON! + self.task_list.add_button.clicked.connect(self.open_add_dialog) + + self.setCentralWidget(central) + self.show_status_message("Tasks loaded") + + def open_add_dialog(self): + dialog = AddTaskDialog() + if dialog.exec(): + # Destructuring layout values safely + result = dialog.get_values() + if len(result) == 3: + date, time, text = result + if date and time and text: + self.scheduler.add_task(date, time, text) + self.task_list.refresh() + self.show_status_message("Task added successfully") + + def show_status_message(self, msg, duration=3000): + self.statusBar().showMessage(f" {msg}") # Tiny padding spacer + self.status_timer.start(duration) + + def clear_status_message(self): + self.statusBar().clearMessage() \ No newline at end of file diff --git a/src/ui/pyqt_ui.py b/src/ui/pyqt_ui.py index 5ae2a87..5c9ccc1 100644 --- a/src/ui/pyqt_ui.py +++ b/src/ui/pyqt_ui.py @@ -1,119 +1,15 @@ -# pyqt_ui.py (v0.1) -# ----------------- -# Teaching PyQt UI for SchedPlus. -# Mirrors the Tkinter UI but with a cleaner layout and modern widgets. +""" +pyqt_ui.py +---------- +This is now just a entry point for pyqt/ +""" -from PyQt6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem, - QDialog, QDialogButtonBox, QFormLayout -) -from PyQt6.QtCore import Qt, QTimer - - -class AddTaskDialog(QDialog): - def __init__(self): - super().__init__() - self.setWindowTitle("Add Task") - - layout = QFormLayout() - - self.date_input = QLineEdit() - self.time_input = QLineEdit() - self.text_input = QLineEdit() - - layout.addRow("Date (YYYY-MM-DD):", self.date_input) - layout.addRow("Time (HH:MM):", self.time_input) - layout.addRow("Task:", self.text_input) - - buttons = QDialogButtonBox( - QDialogButtonBox.StandardButton.Ok | - QDialogButtonBox.StandardButton.Cancel - ) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - - layout.addWidget(buttons) - self.setLayout(layout) - - def get_values(self): - return ( - self.date_input.text(), - self.time_input.text(), - self.text_input.text() - ) - - -class SchedPlusWindow(QMainWindow): - def __init__(self, scheduler): - super().__init__() - self.scheduler = scheduler - - self.setWindowTitle("SchedPlus v0.5 (PyQt)") - - # --- Status Bar (initialize first) --- - self.statusBar().showMessage("") - self.status_timer = QTimer() - self.status_timer.setSingleShot(True) - self.status_timer.timeout.connect(self.clear_status_message) - - # Create central widget and main layout - central_widget = QWidget() - main_layout = QVBoxLayout(central_widget) - - # --- Task List --- - self.task_list = QListWidget() - main_layout.addWidget(self.task_list) - - for task in scheduler.get_tasks(): - self.task_list.addItem( - f"{task.date} {task.time} - {task.text}" - ) - - # Display tasks loaded feedback - self.show_status_message("Tasks loaded") - - # --- Add Task Button --- - add_btn = QPushButton("Add Task") - add_btn.clicked.connect(self.open_add_dialog) - main_layout.addWidget(add_btn) - - central_widget.setLayout(main_layout) - self.setCentralWidget(central_widget) - - def show_status_message(self, message, duration_ms=3000): - """Display a status message that auto-clears after the specified duration.""" - self.statusBar().showMessage(message) - self.status_timer.stop() - self.status_timer.start(duration_ms) - - def clear_status_message(self): - """Clear the status bar message.""" - self.statusBar().clearMessage() - - def open_add_dialog(self): - dialog = AddTaskDialog() - if dialog.exec() == QDialog.DialogCode.Accepted: - date, time, text = dialog.get_values() - - if date and time and text: - self.scheduler.add_task(date, time, text) - - new_task = self.scheduler.get_tasks()[-1] - self.task_list.addItem( - f"{new_task.date} {new_task.time} - {new_task.text}" - ) - - try: - self.scheduler.save_tasks() - self.show_status_message("Task saved") - except Exception as e: - self.show_status_message(f"Error saving task: {str(e)}") +from PyQt6.QtWidgets import QApplication +from ui.pyqt.window import SchedPlusWindow def run_pyqt_ui(scheduler): app = QApplication([]) window = SchedPlusWindow(scheduler) - window.resize(400, 500) window.show() app.exec() diff --git a/src/ui/shortcuts.py b/src/ui/shortcuts.py index 2f52198..8d042f5 100644 --- a/src/ui/shortcuts.py +++ b/src/ui/shortcuts.py @@ -1,15 +1,11 @@ """ -shortcuts.py (v0.1) +shortcuts.py ------------------- Keyboard shortcuts and event bindings for SchedPlus UI. This module handles: -- Enter key binding for quick task addition -- Cross-platform compatibility (Windows, Linux, macOS) - -Later versions will: -- Add more keyboard shortcuts -- Add customizable keybindings +while Tkinter uses these PyQt currently does not. +this will likely not be expanded on to keep UIs independant """ @@ -21,7 +17,6 @@ def bind_enter_key(fields, event_callback): fields: List of Entry widgets to bind Enter to event_callback: The callback function to execute on Enter - Works on Windows, Linux, and macOS. """ for field in fields: field.bind("", lambda event: event_callback()) diff --git a/src/ui/tkinter_ui.py b/src/ui/tkinter_ui.py index 70a75a9..acd7036 100644 --- a/src/ui/tkinter_ui.py +++ b/src/ui/tkinter_ui.py @@ -1,10 +1,10 @@ """ tkinter_ui.py -------------------- -Improved teaching Tkinter UI for SchedPlus. - Still intentionally simple, but with cleaner layout, better spacing, and more readable structure. + +This is now User frendly :) """ import tkinter as tk @@ -42,7 +42,7 @@ def run_ui(scheduler: Scheduler) -> None: date_entry = ttk.Entry(container) date_entry.grid(row=1, column=1, sticky="ew", pady=3) - def open_calendar(): + def open_calendar(): # new: still not forcing users to use it top = tk.Toplevel(root) top.title("Select Date") @@ -65,7 +65,7 @@ def choose(): time_entry = ttk.Entry(container) time_entry.grid(row=2, column=1, sticky="ew", pady=3) - def open_time_picker(): + def open_time_picker(): # new: still not forcing users to use it top = tk.Toplevel(root) top.title("Select Time")