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")