refactor(services): enhance TodoService with callbacks, UUID IDs, and UTF‑8 file handling
This commit is contained in:
6 files changed
+440
-44
No files matched your search
+43
-36
@@ -1,30 +1,42 @@
|
||||
# Standard library imports
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from fabric.core.service import Service
|
||||
from fabric.utils import logger
|
||||
# Fabric imports
|
||||
from fabric.core.service import Property, Service
|
||||
|
||||
# Local imports
|
||||
import shared.data as data
|
||||
|
||||
|
||||
class TodoService(Service):
|
||||
"""Service for managing persistent todo list with JSON storage"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._todos = []
|
||||
self._file_path = self._get_todos_file_path()
|
||||
self._load_todos()
|
||||
self._callbacks = []
|
||||
|
||||
def _notify_callbacks(self, event: str, data=None):
|
||||
def add_callback(self, callback):
|
||||
"""Add a callback function to be notified of changes"""
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback):
|
||||
"""Remove a callback function"""
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
def _notify_callbacks(self, event_type, data=None):
|
||||
"""Notify all registered callbacks of changes"""
|
||||
for callback in self._callbacks:
|
||||
try:
|
||||
callback(event, data)
|
||||
callback(event_type, data)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in todo callback: {e}")
|
||||
print(f"Error in todo callback: {e}")
|
||||
|
||||
def _get_todos_file_path(self):
|
||||
"""Returns the path to the todos JSON file"""
|
||||
@@ -36,23 +48,23 @@ class TodoService(Service):
|
||||
"""Load todos from JSON file"""
|
||||
try:
|
||||
if self._file_path.exists():
|
||||
with open(self._file_path, "r") as f:
|
||||
with open(self._file_path, "r", encoding="utf-8") as f:
|
||||
self._todos = json.load(f)
|
||||
else:
|
||||
self._todos = []
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading todos: {e}")
|
||||
print(f"Error loading todos: {e}")
|
||||
self._todos = []
|
||||
|
||||
def _save_todos(self):
|
||||
"""Save todos to JSON file"""
|
||||
try:
|
||||
with open(self._file_path, "w") as f:
|
||||
with open(self._file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(self._todos, f, indent=2, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving todos: {e}")
|
||||
print(f"Error saving todos: {e}")
|
||||
|
||||
@property
|
||||
@Property(list, "readable")
|
||||
def todos(self):
|
||||
"""Get all todos"""
|
||||
return self._todos.copy()
|
||||
@@ -60,10 +72,10 @@ class TodoService(Service):
|
||||
def add_todo(self, text: str, priority: str = "medium") -> dict:
|
||||
"""Add a new todo item"""
|
||||
todo = {
|
||||
"id": os.urandom(8).hex(),
|
||||
"id": str(uuid.uuid4()),
|
||||
"text": text,
|
||||
"completed": False,
|
||||
"priority": priority,
|
||||
"priority": priority, # low, medium, high
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"updated_at": datetime.now().isoformat(),
|
||||
}
|
||||
@@ -110,11 +122,7 @@ class TodoService(Service):
|
||||
|
||||
def set_priority(self, todo_id: str, priority: str) -> bool:
|
||||
"""Set the priority of a todo item"""
|
||||
valid_priorities = ["low", "medium", "high"]
|
||||
if priority not in valid_priorities:
|
||||
logger.warning(
|
||||
f"Invalid priority '{priority}'. Must be one of {valid_priorities}"
|
||||
)
|
||||
if priority not in ["low", "medium", "high"]:
|
||||
return False
|
||||
|
||||
for todo in self._todos:
|
||||
@@ -127,6 +135,13 @@ class TodoService(Service):
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_todo(self, todo_id: str) -> dict | None:
|
||||
"""Get a specific todo by ID"""
|
||||
for todo in self._todos:
|
||||
if todo["id"] == todo_id:
|
||||
return todo.copy()
|
||||
return None
|
||||
|
||||
def clear_completed(self):
|
||||
"""Remove all completed todos"""
|
||||
initial_count = len(self._todos)
|
||||
@@ -139,28 +154,20 @@ class TodoService(Service):
|
||||
"""Get todo statistics"""
|
||||
total = len(self._todos)
|
||||
completed = sum(1 for todo in self._todos if todo["completed"])
|
||||
pending = total - completed
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"active": total - completed,
|
||||
"pending": pending,
|
||||
"completion_rate": (completed / total * 100) if total > 0 else 0,
|
||||
}
|
||||
|
||||
def add_callback(self, callback):
|
||||
"""Add a callback for todo events"""
|
||||
if callback not in self._callbacks:
|
||||
self._callbacks.append(callback)
|
||||
|
||||
def remove_callback(self, callback):
|
||||
"""Remove a callback"""
|
||||
if callback in self._callbacks:
|
||||
self._callbacks.remove(callback)
|
||||
|
||||
|
||||
_todo_service = None
|
||||
# Global service instance
|
||||
todo_service = TodoService()
|
||||
|
||||
|
||||
def get_todo_service() -> TodoService:
|
||||
global _todo_service
|
||||
if _todo_service is None:
|
||||
_todo_service = TodoService()
|
||||
return _todo_service
|
||||
"""Get the global todo service instance"""
|
||||
return todo_service
|
||||
@@ -24,12 +24,12 @@ class DismissLayer(Window):
|
||||
class AppletWindow(PopupWindow):
|
||||
_active_popup = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
def __init__(self, edge_margin: int = 0, **kwargs):
|
||||
self._is_open = False
|
||||
self._hide_timeout_id = None
|
||||
self.dismiss_layer = DismissLayer(on_dismiss=self.toggle)
|
||||
kwargs["keyboard_mode"] = "on-demand"
|
||||
super().__init__(**kwargs)
|
||||
super().__init__(edge_margin=edge_margin, **kwargs)
|
||||
self.add_keybinding("escape", lambda *_: self.toggle())
|
||||
|
||||
def toggle(self, *_):
|
||||
|
||||
@@ -2,8 +2,6 @@ from fabric.utils import Gdk, GLib, Gtk, logger
|
||||
from fabric.widgets.wayland import WaylandWindow
|
||||
from gi.repository import GtkLayerShell
|
||||
|
||||
EDGE_MARGIN = 0
|
||||
|
||||
|
||||
def _get_monitor_geometry(widget: Gtk.Widget) -> tuple[int, int]:
|
||||
screen = Gdk.Screen.get_default()
|
||||
@@ -24,6 +22,7 @@ class PopupWindow(WaylandWindow):
|
||||
parent: WaylandWindow | None = None,
|
||||
pointing_to: Gtk.Widget | None = None,
|
||||
margin: tuple[int, ...] | str = "0px 0px 0px 0px",
|
||||
edge_margin: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(**kwargs)
|
||||
@@ -33,6 +32,7 @@ class PopupWindow(WaylandWindow):
|
||||
self._pointing_widget = pointing_to
|
||||
self._base_margin = self.extract_margin(margin)
|
||||
self.margin = self._base_margin.values()
|
||||
self.edge_margin = edge_margin
|
||||
self.connect("notify::visible", self.do_update_handlers)
|
||||
|
||||
def get_coords_for_widget(self, widget: Gtk.Widget) -> tuple[int, int]:
|
||||
@@ -163,15 +163,15 @@ class PopupWindow(WaylandWindow):
|
||||
raw_margin = round((bar_left + coords_centered[0]) - (width / 2))
|
||||
else:
|
||||
raw_margin = round((parent_x_margin + coords_centered[0]) - (width / 2))
|
||||
min_margin = EDGE_MARGIN
|
||||
max_margin = monitor_width - width - EDGE_MARGIN
|
||||
min_margin = self.edge_margin
|
||||
max_margin = monitor_width - width - self.edge_margin
|
||||
clamped = max(min_margin, min(raw_margin, max_margin))
|
||||
position_margins = (0, 0, 0, clamped)
|
||||
else:
|
||||
raw_margin = round((parent_y_margin + coords_centered[1]) - (height / 2))
|
||||
monitor_height = Gdk.Screen.get_default().get_height()
|
||||
min_margin = EDGE_MARGIN
|
||||
max_margin = monitor_height - height - EDGE_MARGIN
|
||||
min_margin = self.edge_margin
|
||||
max_margin = monitor_height - height - self.edge_margin
|
||||
clamped = max(min_margin, min(raw_margin, max_margin))
|
||||
position_margins = (clamped, 0, 0, 0)
|
||||
|
||||
|
||||
@@ -15,3 +15,5 @@
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
@import url("widgets/pomodoro.css");
|
||||
@import url("widgets/todo.css");
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
#pomodoro-popup {
|
||||
background-color: @bg-a50;
|
||||
border-radius: apply(radius-md);
|
||||
box-shadow: inset 0 0 0 1px alpha(@text-dim, 0.4);
|
||||
padding: 15px;
|
||||
margin-right: 20px;
|
||||
border: 1px solid @border-strong;
|
||||
}
|
||||
|
||||
#pomodoro-container {
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#pomodoro-time {
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
#pomodoro-cycle {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: @fg-a60;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
#pomodoro-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
/* Base progress bar color */
|
||||
#pomodoro-progress {
|
||||
background-color: transparent;
|
||||
color: alpha(@primary, 0.3);
|
||||
border-color: @primary;
|
||||
}
|
||||
|
||||
/* Short break color */
|
||||
#pomodoro-progress-short {
|
||||
background-color: transparent;
|
||||
color: alpha(@success, 0.3);
|
||||
border-color: @success;
|
||||
}
|
||||
|
||||
/* Long break color */
|
||||
#pomodoro-progress-long {
|
||||
background-color: transparent;
|
||||
color: alpha(@error, 0.3);
|
||||
border-color: @error;
|
||||
}
|
||||
|
||||
#pomodoro-phase-btn {
|
||||
background-color: @fg-a18;
|
||||
color: alpha(@theme_fg_color, 0.7);
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 5px 14px;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#pomodoro-phase-btn:hover {
|
||||
background-color: @fg-a25;
|
||||
color: @theme_fg_color;
|
||||
}
|
||||
|
||||
#pomodoro-phase-btn.active {
|
||||
background-color: @primary;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#pomodoro-start-btn {
|
||||
background-color: @primary;
|
||||
color: white;
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 6px 20px;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#pomodoro-start-btn:hover {
|
||||
background-color: alpha(@primary, 0.8);
|
||||
}
|
||||
|
||||
#pomodoro-reset-btn, #pomodoro-secondary-btn {
|
||||
background-color: @fg-a18;
|
||||
color: @theme_fg_color;
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 6px 15px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#pomodoro-reset-btn:hover, #pomodoro-secondary-btn:hover {
|
||||
background-color: @fg-a25;
|
||||
}
|
||||
|
||||
#pomodoro-spin-box {
|
||||
background-color: @fg-a18;
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#pomodoro-spin-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 2px 4px;
|
||||
min-height: 16px;
|
||||
color: @theme_fg_color;
|
||||
border-radius: apply(radius-sm);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#pomodoro-spin-btn:hover {
|
||||
background-color: @fg-a25;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
|
||||
/* === Main Todo List === */
|
||||
#todo-list-menu {
|
||||
background-color: @bg-a50;
|
||||
border-radius: apply(radius-md);
|
||||
box-shadow: inset 0 0 0 1px alpha(@text-dim, 0.4);
|
||||
padding: 15px;
|
||||
margin-right: 20px;
|
||||
border: 1px solid @border-strong;
|
||||
}
|
||||
|
||||
#todo-main-container {
|
||||
padding: 5px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#todo-header {
|
||||
padding: 0;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#todo-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
#todo-stats {
|
||||
font-size: 12px;
|
||||
color: @fg-a60;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
/* === Add Task Section === */
|
||||
#todo-add-section {
|
||||
background-color: @fg-a18;
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
#new-todo-entry {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: @foreground;
|
||||
font-size: 13px;
|
||||
font-family: "SF Pro Rounded";
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
#new-todo-entry placeholder {
|
||||
color: @fg-a40;
|
||||
}
|
||||
|
||||
#add-todo-button {
|
||||
background-color: @primary;
|
||||
border-radius: apply(radius-full);
|
||||
padding: 6px;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
#add-todo-button:hover {
|
||||
background-color: alpha(@primary, 0.8);
|
||||
}
|
||||
|
||||
/* === Todo Items === */
|
||||
#todos-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#todo-item {
|
||||
background-color: transparent;
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 8px 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#todo-item:hover {
|
||||
background-color: alpha(@fg-a10, 0.3);
|
||||
}
|
||||
|
||||
#todo-divider {
|
||||
min-height: 1px;
|
||||
background-color: alpha(@fg-a20, 0.5);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* === Progress Ring === */
|
||||
#todo-progress-ring {
|
||||
background-color: transparent;
|
||||
color: alpha(@foreground, 0.15);
|
||||
border-color: @foreground;
|
||||
}
|
||||
|
||||
/* === Checkbox === */
|
||||
#todo-checkbox {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
#todo-checkbox:hover {
|
||||
background-color: alpha(@fg-a18, 0.5);
|
||||
border-radius: apply(radius-sm);
|
||||
}
|
||||
|
||||
/* === Task Text === */
|
||||
#todo-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
/* === Options Button === */
|
||||
#todo-options-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 4px 8px;
|
||||
min-width: 24px;
|
||||
min-height: 24px;
|
||||
font-size: 16px;
|
||||
color: @fg-a40;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
#todo-item:hover #todo-options-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#todo-options-btn:hover {
|
||||
color: @foreground;
|
||||
background-color: alpha(@fg-a18, 0.5);
|
||||
border-radius: apply(radius-sm);
|
||||
}
|
||||
|
||||
/* === Clear + Footer === */
|
||||
#clear-completed-button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
color: @fg-a40;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
#clear-completed-button:hover {
|
||||
color: @foreground;
|
||||
}
|
||||
|
||||
#todo-footer {
|
||||
font-size: 11px;
|
||||
color: @fg-a40;
|
||||
font-family: "SF Pro Rounded";
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* === Options Popover === */
|
||||
#todo-options-popover {
|
||||
background-color: @bg-a50;
|
||||
border-radius: apply(radius-md);
|
||||
box-shadow: inset 0 0 0 1px alpha(@text-dim, 0.4);
|
||||
padding: 10px;
|
||||
margin-right: 20px;
|
||||
border: 1px solid @border-strong;
|
||||
min-width: 340px;
|
||||
}
|
||||
|
||||
#todo-options-container {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#todo-opt-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
#todo-opt-subtitle {
|
||||
font-size: 11px;
|
||||
color: @fg-a60;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
/* === Color Swatches === */
|
||||
#todo-color-swatch {
|
||||
background-color: @fg-a18;
|
||||
border-radius: apply(radius-full);
|
||||
padding: 0;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
#todo-color-swatch:hover {
|
||||
border-color: alpha(@foreground, 0.3);
|
||||
}
|
||||
|
||||
#todo-color-swatch.selected {
|
||||
border-color: @foreground;
|
||||
box-shadow: 0 0 0 2px alpha(@foreground, 0.2);
|
||||
}
|
||||
|
||||
/* === Deadline Pill === */
|
||||
#todo-deadline-pill {
|
||||
background-color: @fg-a18;
|
||||
border-radius: apply(radius-full);
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
#todo-deadline-value {
|
||||
font-size: 12px;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
/* === Stepper (reuse pomodoro style) === */
|
||||
#todo-spin-box {
|
||||
background-color: @fg-a10;
|
||||
border-radius: apply(radius-sm);
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
#todo-spin-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 2px 4px;
|
||||
min-height: 14px;
|
||||
color: @theme_fg_color;
|
||||
border-radius: apply(radius-sm);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
#todo-spin-btn:hover {
|
||||
background-color: @fg-a25;
|
||||
}
|
||||
|
||||
#todo-spin-value {
|
||||
font-size: 12px;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
/* === Preset Buttons === */
|
||||
#todo-preset-btn {
|
||||
background-color: @fg-a18;
|
||||
border-radius: apply(radius-full);
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: @foreground;
|
||||
font-family: "SF Pro Rounded";
|
||||
}
|
||||
|
||||
#todo-preset-btn:hover {
|
||||
background-color: @fg-a25;
|
||||
}
|
||||
Reference in new issue
Block a user