The actual Python behind two of my projects — read straight through, no build step required.
A Tkinter desktop companion app: mood check-ins, an encrypted local journal, grounding and breathing exercises, goal tracking, and automatic detection of crisis language that surfaces Canada's 988 Suicide Crisis Helpline. Originally built with multi-language support.
import base64
import hashlib
import hmac
import json
import random
import re
import secrets
import time
import tkinter as tk
import webbrowser
from pathlib import Path
from tkinter import messagebox, scrolledtext
APP_NAME = "SÕBRAD"
DATA_FILE = Path(__file__).with_name("sobrad_data.json")
TITLE = "#00A3E0"
BACKGROUND = "#FFFFFF"
OPTION_BUTTON = "#DDEB47"
NAVY = "#0E3A5F"
TEXT = "#173047"
MUTED = "#62798B"
LINE = "#DCEAF0"
SOFT = "#F5FBFD"
DANGER = "#B93645"
SUBTITLES = [
"If you've experienced bullying, violence, or trauma, or you just don't have anyone to talk to right now, I'm here.",
"For anyone carrying something heavy. Talk to me.",
"Feeling overwhelmed, panicked, or alone? Let's get through this moment together.",
]
CRISIS_PATTERNS = [
r"\bkill myself\b",
r"\bsuicide\b",
r"\bend my life\b",
r"\bi want to die\b",
r"\bwant to die\b",
r"\bcan't go on\b",
r"\bcannot go on\b",
r"\bno reason to live\b",
r"\bhurt myself\b",
r"\bself[-\s]?harm\b",
r"\boverdose\b",
r"\bhang myself\b",
r"\bjump off\b",
]
HOME_OPTIONS = [
("mood", "home_mood"),
("chat", "home_chat"),
("emergency", "home_emergency"),
("journal", "home_journal"),
("exercises", "home_exercises"),
("goals", "home_goals"),
("dashboard", "home_dashboard"),
("privacy", "home_privacy"),
]
LANGUAGES = [
("en", "🇬🇧 English (UK)"),
("fr", "🇫🇷 Français"),
("nl", "🇳🇱 Nederlands"),
("et", "🇪🇪 Eesti"),
("es", "🇪🇸 Español"),
("it", "🇮🇹 Italiano"),
("pt", "🇵🇹 Portuguese"),
("de", "🇩🇪 Deutsch"),
("fi", "🇫🇮 Suomi"),
("sv", "🇸🇪 Svenska"),
("ja", "🇯🇵 日本語"),
]
LANGUAGE_LABELS = {code: label for code, label in LANGUAGES}
LANGUAGE_CODES = {label: code for code, label in LANGUAGES}
TRANSLATIONS = {
"en": {
"language": "Language",
"username": "Username",
"password": "Password",
"confirm_password": "Confirm password",
"login": "Log in",
"create_account": "Create account",
"anonymous_mode": "Anonymous mode",
"home": "Home",
"home_mood": "Mood Check-In",
"home_chat": "Talk to AI Companion",
"home_emergency": "Emergency Help",
"home_journal": "Journal",
"home_exercises": "Recovery Exercises",
"home_goals": "Goals and Future Planning",
"home_dashboard": "Progress Dashboard",
"home_privacy": "Privacy and Security",
"today": "Today",
"home_title": "A quieter place to begin again.",
"home_hint": "Choose the smallest useful next step.",
"home_hint_new": "This can be a steady place for the next minute.",
"companion": "Companion",
"talk_title": "Talk with SÕBRAD",
"settings": "Settings",
"privacy_title": "Privacy and access",
"save_language": "Language changes save automatically.",
},
"fr": {
"language": "Langue", "username": "Nom d'utilisateur", "password": "Mot de passe",
"confirm_password": "Confirmer le mot de passe", "login": "Connexion", "create_account": "Créer un compte",
"anonymous_mode": "Mode anonyme", "home": "Accueil", "home_mood": "Humeur", "home_chat": "Parler au compagnon IA",
"home_emergency": "Aide d'urgence", "home_journal": "Journal", "home_exercises": "Exercices de récupération",
"home_goals": "Objectifs et avenir", "home_dashboard": "Tableau de progrès", "home_privacy": "Confidentialité",
"today": "Aujourd'hui", "home_title": "Un endroit plus calme pour recommencer.", "companion": "Compagnon",
"talk_title": "Parler avec SÕBRAD", "settings": "Réglages", "privacy_title": "Confidentialité et accès",
},
"nl": {
"language": "Taal", "username": "Gebruikersnaam", "password": "Wachtwoord",
"confirm_password": "Bevestig wachtwoord", "login": "Inloggen", "create_account": "Account maken",
"anonymous_mode": "Anonieme modus", "home": "Start", "home_mood": "Stemming", "home_chat": "Praat met AI-maatje",
"home_emergency": "Noodhulp", "home_journal": "Dagboek", "home_exercises": "Hersteloefeningen",
"home_goals": "Doelen en toekomst", "home_dashboard": "Voortgang", "home_privacy": "Privacy",
"today": "Vandaag", "home_title": "Een stillere plek om opnieuw te beginnen.", "companion": "Maatje",
"talk_title": "Praat met SÕBRAD", "settings": "Instellingen", "privacy_title": "Privacy en toegang",
},
"et": {
"language": "Keel", "username": "Kasutajanimi", "password": "Parool",
"confirm_password": "Kinnita parool", "login": "Logi sisse", "create_account": "Loo konto",
"anonymous_mode": "Anonüümne režiim", "home": "Avaleht", "home_mood": "Meeleolu", "home_chat": "Räägi AI-kaaslasega",
"home_emergency": "Hädaabi", "home_journal": "Päevik", "home_exercises": "Taastumise harjutused",
"home_goals": "Eesmärgid ja tulevik", "home_dashboard": "Edusammud", "home_privacy": "Privaatsus",
"today": "Täna", "home_title": "Rahulikum koht, kust uuesti alustada.", "companion": "Kaaslane",
"talk_title": "Räägi SÕBRADiga", "settings": "Seaded", "privacy_title": "Privaatsus ja ligipääs",
},
"es": {
"language": "Idioma", "username": "Usuario", "password": "Contraseña",
"confirm_password": "Confirmar contraseña", "login": "Entrar", "create_account": "Crear cuenta",
"anonymous_mode": "Modo anónimo", "home": "Inicio", "home_mood": "Estado de ánimo", "home_chat": "Hablar con IA",
"home_emergency": "Ayuda de emergencia", "home_journal": "Diario", "home_exercises": "Ejercicios de recuperación",
"home_goals": "Metas y futuro", "home_dashboard": "Progreso", "home_privacy": "Privacidad",
"today": "Hoy", "home_title": "Un lugar más tranquilo para empezar otra vez.", "companion": "Compañía",
"talk_title": "Habla con SÕBRAD", "settings": "Ajustes", "privacy_title": "Privacidad y acceso",
},
"it": {
"language": "Lingua", "username": "Nome utente", "password": "Password",
"confirm_password": "Conferma password", "login": "Accedi", "create_account": "Crea account",
"anonymous_mode": "Modalità anonima", "home": "Home", "home_mood": "Umore", "home_chat": "Parla con IA",
"home_emergency": "Aiuto d'emergenza", "home_journal": "Diario", "home_exercises": "Esercizi di recupero",
"home_goals": "Obiettivi e futuro", "home_dashboard": "Progresso", "home_privacy": "Privacy",
"today": "Oggi", "home_title": "Un posto più quieto per ricominciare.", "companion": "Compagno",
"talk_title": "Parla con SÕBRAD", "settings": "Impostazioni", "privacy_title": "Privacy e accesso",
},
"pt": {
"language": "Idioma", "username": "Nome de utilizador", "password": "Palavra-passe",
"confirm_password": "Confirmar palavra-passe", "login": "Entrar", "create_account": "Criar conta",
"anonymous_mode": "Modo anónimo", "home": "Início", "home_mood": "Humor", "home_chat": "Falar com IA",
"home_emergency": "Ajuda de emergência", "home_journal": "Diário", "home_exercises": "Exercícios de recuperação",
"home_goals": "Metas e futuro", "home_dashboard": "Progresso", "home_privacy": "Privacidade",
"today": "Hoje", "home_title": "Um lugar mais calmo para recomeçar.", "companion": "Companhia",
"talk_title": "Fale com SÕBRAD", "settings": "Definições", "privacy_title": "Privacidade e acesso",
},
"de": {
"language": "Sprache", "username": "Benutzername", "password": "Passwort",
"confirm_password": "Passwort bestätigen", "login": "Anmelden", "create_account": "Konto erstellen",
"anonymous_mode": "Anonymer Modus", "home": "Start", "home_mood": "Stimmung", "home_chat": "Mit KI sprechen",
"home_emergency": "Notfallhilfe", "home_journal": "Journal", "home_exercises": "Übungen zur Stabilisierung",
"home_goals": "Ziele und Zukunft", "home_dashboard": "Fortschritt", "home_privacy": "Privatsphäre",
"today": "Heute", "home_title": "Ein ruhigerer Ort für einen neuen Anfang.", "companion": "Begleitung",
"talk_title": "Mit SÕBRAD sprechen", "settings": "Einstellungen", "privacy_title": "Privatsphäre und Zugang",
},
"fi": {
"language": "Kieli", "username": "Käyttäjänimi", "password": "Salasana",
"confirm_password": "Vahvista salasana", "login": "Kirjaudu", "create_account": "Luo tili",
"anonymous_mode": "Anonyymi tila", "home": "Koti", "home_mood": "Mieli", "home_chat": "Puhu AI-kumppanille",
"home_emergency": "Hätäapu", "home_journal": "Päiväkirja", "home_exercises": "Toipumisharjoitukset",
"home_goals": "Tavoitteet ja tulevaisuus", "home_dashboard": "Edistyminen", "home_privacy": "Yksityisyys",
"today": "Tänään", "home_title": "Rauhallisempi paikka aloittaa uudelleen.", "companion": "Kumppani",
"talk_title": "Puhu SÕBRADille", "settings": "Asetukset", "privacy_title": "Yksityisyys ja pääsy",
},
"sv": {
"language": "Språk", "username": "Användarnamn", "password": "Lösenord",
"confirm_password": "Bekräfta lösenord", "login": "Logga in", "create_account": "Skapa konto",
"anonymous_mode": "Anonymt läge", "home": "Hem", "home_mood": "Mående", "home_chat": "Prata med AI",
"home_emergency": "Akut hjälp", "home_journal": "Journal", "home_exercises": "Återhämtningsövningar",
"home_goals": "Mål och framtid", "home_dashboard": "Framsteg", "home_privacy": "Integritet",
"today": "Idag", "home_title": "En lugnare plats att börja om.", "companion": "Följeslagare",
"talk_title": "Prata med SÕBRAD", "settings": "Inställningar", "privacy_title": "Integritet och åtkomst",
},
"ja": {
"language": "言語", "username": "ユーザー名", "password": "パスワード",
"confirm_password": "パスワード確認", "login": "ログイン", "create_account": "アカウント作成",
"anonymous_mode": "匿名モード", "home": "ホーム", "home_mood": "気分チェック", "home_chat": "AIコンパニオンと話す",
"home_emergency": "緊急ヘルプ", "home_journal": "日記", "home_exercises": "回復エクササイズ",
"home_goals": "目標と未来", "home_dashboard": "進捗", "home_privacy": "プライバシー",
"today": "今日", "home_title": "もう一度始めるための静かな場所。", "companion": "コンパニオン",
"talk_title": "SÕBRAD と話す", "settings": "設定", "privacy_title": "プライバシーとアクセス",
},
}
GROUNDING_PROMPTS = [
"Name five things you can see. Let your eyes move slowly.",
"Name four things you can feel. Notice texture, pressure, and temperature.",
"Name three things you can hear. Let the sounds arrive without chasing them.",
"Name two things you can smell. If nothing is there, choose two scents you like.",
"Name one thing you can taste. Then take one slower breath.",
]
def default_state():
return {
"accepted_disclaimer": False,
"language": "en",
"user": None,
"anonymous": False,
"moods": [],
"chat": [],
"journal": [],
"goals": [],
"completed_goals": 0,
"triggers": [],
"safety": {"trusted": "", "place": "", "action": ""},
"skills": {"breathing": 0, "grounding": 0},
}
def load_state():
if not DATA_FILE.exists():
return default_state()
try:
loaded = json.loads(DATA_FILE.read_text(encoding="utf-8"))
state = default_state()
state.update(loaded)
return state
except Exception:
return default_state()
def save_state(state):
DATA_FILE.write_text(json.dumps(state, indent=2), encoding="utf-8")
def password_hash(username, password):
return hashlib.sha256(f"{username}:{password}".encode("utf-8")).hexdigest()
def b64(data):
return base64.b64encode(data).decode("ascii")
def from_b64(data):
return base64.b64decode(data.encode("ascii"))
def derive_key(pin, salt):
return hashlib.pbkdf2_hmac("sha256", pin.encode("utf-8"), salt, 210_000, dklen=32)
def stream_bytes(key, nonce, length):
chunks = []
counter = 0
while sum(len(chunk) for chunk in chunks) < length:
counter_bytes = counter.to_bytes(8, "big")
chunks.append(hmac.new(key, nonce + counter_bytes, hashlib.sha256).digest())
counter += 1
return b"".join(chunks)[:length]
def encrypt_note(pin, text):
salt = secrets.token_bytes(16)
nonce = secrets.token_bytes(16)
key = derive_key(pin, salt)
plain = text.encode("utf-8")
mask = stream_bytes(key, nonce, len(plain))
cipher = bytes(a ^ b for a, b in zip(plain, mask))
tag = hmac.new(key, nonce + cipher, hashlib.sha256).digest()
return {"salt": b64(salt), "nonce": b64(nonce), "data": b64(cipher), "tag": b64(tag)}
def decrypt_note(pin, entry):
salt = from_b64(entry["salt"])
nonce = from_b64(entry["nonce"])
cipher = from_b64(entry["data"])
tag = from_b64(entry["tag"])
key = derive_key(pin, salt)
expected = hmac.new(key, nonce + cipher, hashlib.sha256).digest()
if not hmac.compare_digest(tag, expected):
raise ValueError("Wrong PIN or damaged journal entry.")
mask = stream_bytes(key, nonce, len(cipher))
plain = bytes(a ^ b for a, b in zip(cipher, mask))
return plain.decode("utf-8")
class CircleOption(tk.Canvas):
def __init__(self, parent, text, command):
super().__init__(
parent,
width=160,
height=160,
bg=BACKGROUND,
highlightthickness=0,
cursor="hand2",
)
self.command = command
self.create_oval(8, 8, 152, 152, fill=OPTION_BUTTON, outline=NAVY, width=2)
self.create_text(
80,
80,
text=text,
fill=NAVY,
font=("Google Sans", 11, "bold"),
width=118,
justify="center",
)
self.bind("<Button-1>", lambda event: self.command())
self.bind("<Enter>", lambda event: self.configure(bg=SOFT))
self.bind("<Leave>", lambda event: self.configure(bg=BACKGROUND))
class SobradApp:
def __init__(self, root):
self.root = root
self.state = load_state()
self.content = None
self.breathing = False
self.breath_phase = 0
self.grounding_index = 0
self.root.title(APP_NAME)
self.root.geometry("1120x760")
self.root.minsize(900, 620)
self.root.configure(bg=BACKGROUND)
self.show_login()
def clear(self):
for child in self.root.winfo_children():
child.destroy()
def tr(self, key):
language = self.state.get("language", "en")
return TRANSLATIONS.get(language, {}).get(key, TRANSLATIONS["en"].get(key, key))
def language_display(self):
return LANGUAGE_LABELS.get(self.state.get("language", "en"), LANGUAGE_LABELS["en"])
def set_language_from_label(self, label, refresh=None):
self.state["language"] = LANGUAGE_CODES.get(label, "en")
save_state(self.state)
if refresh == "login":
self.show_login()
elif refresh:
self.show_shell(refresh)
def language_selector(self, parent, refresh=None):
frame = tk.Frame(parent, bg=parent["bg"])
tk.Label(frame, text=self.tr("language"), fg=NAVY, bg=parent["bg"], font=("Google Sans", 10, "bold")).pack(side="left", padx=(0, 8))
value = tk.StringVar(value=self.language_display())
menu = tk.OptionMenu(frame, value, *[label for _, label in LANGUAGES], command=lambda label: self.set_language_from_label(label, refresh))
menu.configure(bg=BACKGROUND, fg=NAVY, activebackground=SOFT, relief="flat", highlightbackground=LINE)
menu["menu"].configure(bg=BACKGROUND, fg=NAVY)
menu.pack(side="left")
return frame
def label(self, parent, text, size=12, color=TEXT, bold=False, **grid):
font = ("Google Sans", size, "bold" if bold else "normal")
widget = tk.Label(parent, text=text, fg=color, bg=parent["bg"], font=font, wraplength=760, justify="left")
widget.grid(**grid)
return widget
def button(self, parent, text, command, bg=TITLE, fg="white"):
return tk.Button(
parent,
text=text,
command=command,
bg=bg,
fg=fg,
activebackground=bg,
activeforeground=fg,
relief="flat",
padx=16,
pady=10,
bd=0,
font=("Google Sans", 10, "bold"),
cursor="hand2",
)
def show_login(self):
self.clear()
self.root.configure(bg=BACKGROUND)
outer = tk.Frame(self.root, bg=BACKGROUND, padx=42, pady=42)
outer.pack(fill="both", expand=True)
outer.columnconfigure(0, weight=1)
outer.columnconfigure(1, weight=1)
left = tk.Frame(outer, bg=BACKGROUND)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 30))
tk.Label(left, text="G'day... Tsau...", fg=NAVY, bg=BACKGROUND, font=("Inter", 12, "bold")).pack(anchor="w")
tk.Label(left, text=APP_NAME, fg=TITLE, bg=BACKGROUND, font=("Avenir Next", 54, "bold")).pack(anchor="w", pady=(8, 12))
tk.Label(
left,
text=random.choice(SUBTITLES),
fg=MUTED,
bg=BACKGROUND,
font=("Inter", 14),
wraplength=520,
justify="left",
).pack(anchor="w")
form = tk.Frame(outer, bg=SOFT, padx=24, pady=24, highlightbackground=LINE, highlightthickness=1)
form.grid(row=0, column=1, sticky="nsew")
form.columnconfigure(0, weight=1)
tk.Label(form, text=self.tr("username"), fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).grid(row=0, column=0, sticky="w")
username = tk.Entry(form, font=("Google Sans", 12), relief="flat", highlightbackground=LINE, highlightthickness=1)
username.grid(row=1, column=0, sticky="ew", pady=(6, 16), ipady=8)
tk.Label(form, text=self.tr("password"), fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).grid(row=2, column=0, sticky="w")
password = tk.Entry(form, show="*", font=("Google Sans", 12), relief="flat", highlightbackground=LINE, highlightthickness=1)
password.grid(row=3, column=0, sticky="ew", pady=(6, 18), ipady=8)
def login():
name = username.get().strip()
pwd = password.get()
if not name or not pwd:
messagebox.showwarning(APP_NAME, "Enter a username and password.")
return
stored = self.state.get("user")
if not stored:
messagebox.showinfo(APP_NAME, "No account exists yet. Choose Create account first.")
return
pwd_hash = password_hash(name, pwd)
if stored.get("username") != name:
messagebox.showerror(APP_NAME, "This local app already has a different username.")
return
if stored.get("password_hash") != pwd_hash:
messagebox.showerror(APP_NAME, "That password does not match this local profile.")
return
self.state["anonymous"] = False
save_state(self.state)
self.show_shell("home")
def anonymous():
self.state["anonymous"] = True
save_state(self.state)
self.show_shell("home")
self.button(form, self.tr("login"), login).grid(row=4, column=0, sticky="ew", pady=(0, 10))
self.button(form, self.tr("create_account"), self.show_create_account, bg=OPTION_BUTTON, fg=NAVY).grid(row=5, column=0, sticky="ew", pady=(0, 10))
self.button(form, self.tr("anonymous_mode"), anonymous, bg=OPTION_BUTTON, fg=NAVY).grid(row=6, column=0, sticky="ew")
self.language_selector(form, refresh="login").grid(row=7, column=0, sticky="w", pady=(18, 0))
def show_create_account(self):
if self.state.get("user"):
messagebox.showinfo(APP_NAME, "A local account already exists. Use Privacy to delete all data before creating a new one.")
return
modal = tk.Toplevel(self.root)
modal.title(self.tr("create_account"))
modal.configure(bg=BACKGROUND)
modal.transient(self.root)
modal.grab_set()
modal.geometry("460x360")
modal.columnconfigure(0, weight=1)
tk.Label(modal, text=self.tr("create_account"), fg=TITLE, bg=BACKGROUND, font=("Avenir Next", 24, "bold")).grid(row=0, column=0, sticky="w", padx=24, pady=(24, 14))
fields = tk.Frame(modal, bg=BACKGROUND, padx=24)
fields.grid(row=1, column=0, sticky="ew")
fields.columnconfigure(0, weight=1)
tk.Label(fields, text=self.tr("username"), fg=NAVY, bg=BACKGROUND, font=("Google Sans", 11, "bold")).grid(row=0, column=0, sticky="w")
username = tk.Entry(fields, font=("Google Sans", 12), relief="flat", highlightbackground=LINE, highlightthickness=1)
username.grid(row=1, column=0, sticky="ew", pady=(6, 14), ipady=7)
tk.Label(fields, text=self.tr("password"), fg=NAVY, bg=BACKGROUND, font=("Google Sans", 11, "bold")).grid(row=2, column=0, sticky="w")
password = tk.Entry(fields, show="*", font=("Google Sans", 12), relief="flat", highlightbackground=LINE, highlightthickness=1)
password.grid(row=3, column=0, sticky="ew", pady=(6, 14), ipady=7)
tk.Label(fields, text=self.tr("confirm_password"), fg=NAVY, bg=BACKGROUND, font=("Google Sans", 11, "bold")).grid(row=4, column=0, sticky="w")
confirm = tk.Entry(fields, show="*", font=("Google Sans", 12), relief="flat", highlightbackground=LINE, highlightthickness=1)
confirm.grid(row=5, column=0, sticky="ew", pady=(6, 18), ipady=7)
def create():
name = username.get().strip()
pwd = password.get()
confirm_pwd = confirm.get()
if not name or not pwd:
messagebox.showwarning(APP_NAME, "Enter a username and password.")
return
if len(pwd) < 6:
messagebox.showwarning(APP_NAME, "Use at least 6 characters for the password.")
return
if pwd != confirm_pwd:
messagebox.showerror(APP_NAME, "The passwords do not match.")
return
self.state["user"] = {"username": name, "password_hash": password_hash(name, pwd), "created": time.time()}
self.state["anonymous"] = False
save_state(self.state)
modal.destroy()
self.show_shell("home")
self.button(modal, self.tr("create_account"), create).grid(row=2, column=0, sticky="e", padx=24, pady=20)
def show_disclaimer_if_needed(self):
if self.state.get("accepted_disclaimer"):
return
modal = tk.Toplevel(self.root)
modal.title("Before you begin")
modal.configure(bg=BACKGROUND)
modal.transient(self.root)
modal.grab_set()
modal.geometry("520x300")
tk.Label(modal, text="Before you begin", fg=TITLE, bg=BACKGROUND, font=("Avenir Next", 24, "bold")).pack(anchor="w", padx=24, pady=(24, 8))
tk.Label(
modal,
text=(
"SÕBRAD is not a replacement for professional medical, psychological, or emergency care. "
"It can help you slow down, write privately, and choose a next step."
),
fg=TEXT,
bg=BACKGROUND,
font=("Google Sans", 12),
wraplength=460,
justify="left",
).pack(anchor="w", padx=24, pady=(0, 16))
agreed = tk.BooleanVar(value=False)
tk.Checkbutton(modal, text="I understand.", variable=agreed, fg=NAVY, bg=BACKGROUND, activebackground=BACKGROUND).pack(anchor="w", padx=24)
def accept():
if not agreed.get():
messagebox.showwarning(APP_NAME, "Please check 'I understand' first.")
return
self.state["accepted_disclaimer"] = True
save_state(self.state)
modal.destroy()
self.button(modal, "Continue", accept).pack(anchor="e", padx=24, pady=20)
def show_shell(self, view):
self.current_view = view
self.clear()
header = tk.Frame(self.root, bg=BACKGROUND, padx=18, pady=12, highlightbackground=LINE, highlightthickness=1)
header.pack(fill="x")
tk.Label(header, text=APP_NAME, fg=NAVY, bg=BACKGROUND, font=("Avenir Next", 18, "bold")).pack(side="left", padx=(0, 18))
self.language_selector(header, refresh=view).pack(side="right")
for key, label_key in [("home", "home")] + HOME_OPTIONS:
label = self.tr(label_key)
tk.Button(
header,
text=label.split(" and ")[0],
command=lambda key=key: self.show_shell(key),
fg=NAVY,
bg=BACKGROUND,
activebackground=SOFT,
relief="flat",
padx=8,
pady=6,
font=("Google Sans", 9, "bold"),
cursor="hand2",
).pack(side="left")
self.content = tk.Frame(self.root, bg=BACKGROUND, padx=26, pady=24)
self.content.pack(fill="both", expand=True)
getattr(self, f"page_{view}")()
self.show_disclaimer_if_needed()
def page_title(self, eyebrow, title, subtitle=None):
tk.Label(self.content, text=eyebrow.upper(), fg=NAVY, bg=BACKGROUND, font=("Inter", 10, "bold")).pack(anchor="w")
tk.Label(self.content, text=title, fg=TITLE, bg=BACKGROUND, font=("Avenir Next", 32, "bold")).pack(anchor="w", pady=(4, 8))
if subtitle:
tk.Label(self.content, text=subtitle, fg=MUTED, bg=BACKGROUND, font=("Inter", 12), wraplength=860, justify="left").pack(anchor="w", pady=(0, 18))
def page_home(self):
name = "Anonymous" if self.state.get("anonymous") else self.state.get("user", {}).get("username", "")
greeting = self.tr("home_hint") if name else self.tr("home_hint_new")
self.page_title(self.tr("today"), self.tr("home_title"), greeting)
grid = tk.Frame(self.content, bg=BACKGROUND)
grid.pack(fill="both", expand=True, pady=8)
for index, (key, label_key) in enumerate(HOME_OPTIONS):
row, col = divmod(index, 4)
cell = tk.Frame(grid, bg=BACKGROUND, padx=12, pady=12)
cell.grid(row=row, column=col, sticky="nsew")
grid.columnconfigure(col, weight=1)
grid.rowconfigure(row, weight=1)
CircleOption(cell, self.tr(label_key), lambda key=key: self.show_shell(key)).pack()
def page_mood(self):
self.page_title("Check-in", "How is this moment?")
body = tk.Frame(self.content, bg=BACKGROUND)
body.pack(fill="both", expand=True)
left = tk.Frame(body, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
left.pack(side="left", fill="both", expand=True, padx=(0, 12))
right = tk.Frame(body, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
right.pack(side="left", fill="both", expand=True)
tk.Label(left, text="Mood level", fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).pack(anchor="w")
mood_value = tk.IntVar(value=5)
tk.Scale(left, from_=1, to=10, orient="horizontal", variable=mood_value, bg=SOFT, fg=NAVY, highlightthickness=0).pack(fill="x")
tk.Label(left, text="What is present?", fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).pack(anchor="w", pady=(12, 4))
words = ["numb", "afraid", "angry", "sad", "tired", "hopeful", "steady", "alone"]
selected = {word: tk.BooleanVar(value=False) for word in words}
chips = tk.Frame(left, bg=SOFT)
chips.pack(anchor="w")
for word in words:
tk.Checkbutton(chips, text=word, variable=selected[word], bg=SOFT, fg=NAVY, activebackground=SOFT).pack(side="left", padx=(0, 6))
tk.Label(left, text="Note", fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).pack(anchor="w", pady=(12, 4))
note = tk.Text(left, height=5, wrap="word", relief="flat", highlightbackground=LINE, highlightthickness=1)
note.pack(fill="x")
def save_mood():
self.state["moods"].insert(0, {
"value": mood_value.get(),
"words": [word for word in words if selected[word].get()],
"note": note.get("1.0", "end").strip(),
"time": time.time(),
})
self.state["moods"] = self.state["moods"][:60]
save_state(self.state)
self.show_shell("mood")
self.button(left, "Save check-in", save_mood).pack(anchor="e", pady=12)
tk.Label(right, text="Recent moods", fg=NAVY, bg=SOFT, font=("Google Sans", 13, "bold")).pack(anchor="w")
self.draw_mood_chart(right)
for entry in self.state["moods"][:6]:
words_text = ", ".join(entry.get("words", []))
note_text = entry.get("note", "")
tk.Label(right, text=f"{entry['value']}/10 {words_text} {note_text}", fg=TEXT, bg=SOFT, wraplength=430, justify="left").pack(anchor="w", pady=3)
def draw_mood_chart(self, parent):
canvas = tk.Canvas(parent, width=430, height=170, bg=BACKGROUND, highlightbackground=LINE, highlightthickness=1)
canvas.pack(fill="x", pady=12)
moods = list(reversed(self.state["moods"][:12]))
for i in range(1, 6):
y = 20 + i * 25
canvas.create_line(24, y, 400, y, fill=LINE)
if len(moods) < 2:
canvas.create_text(210, 85, text="No mood check-ins yet.", fill=MUTED)
return
points = []
for index, item in enumerate(moods):
x = 28 + index * (360 / max(len(moods) - 1, 1))
y = 145 - ((item["value"] - 1) / 9) * 120
points.append((x, y))
for start, end in zip(points, points[1:]):
canvas.create_line(*start, *end, fill=TITLE, width=3)
for x, y in points:
canvas.create_oval(x - 4, y - 4, x + 4, y + 4, fill=TITLE, outline=TITLE)
def page_chat(self):
self.page_title(self.tr("companion"), self.tr("talk_title"))
if not self.state["chat"]:
self.state["chat"].append({"role": "bot", "text": "I am here. We can go slowly. What is the smallest thing you want help carrying right now?"})
save_state(self.state)
chat_outer = tk.Frame(self.content, bg=SOFT, highlightbackground=LINE, highlightthickness=1)
chat_outer.pack(fill="both", expand=True, pady=(0, 12))
chat_canvas = tk.Canvas(chat_outer, bg=SOFT, highlightthickness=0)
chat_scrollbar = tk.Scrollbar(chat_outer, orient="vertical", command=chat_canvas.yview)
chat_messages = tk.Frame(chat_canvas, bg=SOFT)
chat_window = chat_canvas.create_window((0, 0), window=chat_messages, anchor="nw")
chat_canvas.configure(yscrollcommand=chat_scrollbar.set)
chat_canvas.pack(side="left", fill="both", expand=True)
chat_scrollbar.pack(side="right", fill="y")
def resize_messages(event):
chat_canvas.itemconfigure(chat_window, width=event.width)
def update_scroll_region(event=None):
chat_canvas.configure(scrollregion=chat_canvas.bbox("all"))
chat_canvas.bind("<Configure>", resize_messages)
chat_messages.bind("<Configure>", update_scroll_region)
for message in self.state["chat"]:
self.chat_bubble(chat_messages, message)
self.root.after(80, lambda: chat_canvas.yview_moveto(1.0))
bottom = tk.Frame(self.content, bg=BACKGROUND)
bottom.pack(fill="x")
entry = tk.Text(bottom, height=3, wrap="word", relief="flat", highlightbackground=LINE, highlightthickness=1)
entry.pack(side="left", fill="x", expand=True, padx=(0, 10))
def send():
text = entry.get("1.0", "end").strip()
if not text:
return
crisis = any(re.search(pattern, text, re.IGNORECASE) for pattern in CRISIS_PATTERNS)
self.state["chat"].append({"role": "user", "text": text})
self.state["chat"].append({"role": "bot", "text": self.companion_reply(text, crisis)})
self.state["chat"] = self.state["chat"][-80:]
save_state(self.state)
if crisis:
self.show_crisis_dialog()
self.show_shell("chat")
self.button(bottom, "Send", send).pack(side="right")
def chat_bubble(self, parent, message):
role = message.get("role", "bot")
is_user = role == "user"
row = tk.Frame(parent, bg=SOFT)
row.pack(fill="x", padx=14, pady=7)
bubble_bg = TITLE if is_user else BACKGROUND
bubble_fg = "white" if is_user else TEXT
name = "You" if is_user else APP_NAME
side = "right" if is_user else "left"
anchor = "e" if is_user else "w"
bubble = tk.Frame(row, bg=bubble_bg, padx=14, pady=10, highlightbackground=LINE, highlightthickness=0)
bubble.pack(side=side, anchor=anchor)
tk.Label(
bubble,
text=name,
fg=bubble_fg,
bg=bubble_bg,
font=("Google Sans", 9, "bold"),
justify="left",
).pack(anchor="w")
tk.Label(
bubble,
text=message.get("text", ""),
fg=bubble_fg,
bg=bubble_bg,
font=("Google Sans", 11),
wraplength=560,
justify="left",
).pack(anchor="w")
def companion_reply(self, text, crisis):
lower = text.lower()
if crisis:
return "I am taking this seriously. Move near another person if you can and contact 9-8-8 now. I can stay with you for one grounding step too."
if any(word in lower for word in ["panic", "anxious", "anxiety", "overwhelmed", "flashback", "scared"]):
return "Let us shrink the moment. Name five things you can see, then press your feet into the floor and exhale longer than you inhale."
if any(word in lower for word in ["sad", "alone", "lonely", "worthless", "ashamed", "cry", "tired"]):
return "That sounds heavy. You do not have to turn it into a perfect sentence. Tell me one detail of what hurts most right now."
if any(word in lower for word in ["study", "school", "work", "career", "goal", "future", "job"]):
return "A future can start very small. Choose one step that takes less than ten minutes, then let that count."
return "I hear you. Let us make this moment smaller: what is one thing your body needs in the next five minutes?"
def show_crisis_dialog(self):
modal = tk.Toplevel(self.root)
modal.title("Emergency help")
modal.configure(bg=BACKGROUND)
modal.geometry("560x330")
modal.transient(self.root)
modal.grab_set()
tk.Label(modal, text="You do not have to handle this alone.", fg=TITLE, bg=BACKGROUND, font=("Avenir Next", 22, "bold")).pack(anchor="w", padx=24, pady=(24, 8))
tk.Label(modal, text="If there is immediate danger, call emergency services now. For suicide crisis support in Canada, contact 9-8-8.", fg=TEXT, bg=BACKGROUND, wraplength=500, justify="left").pack(anchor="w", padx=24)
row = tk.Frame(modal, bg=BACKGROUND)
row.pack(anchor="w", padx=24, pady=18)
self.button(row, "Call 988", lambda: webbrowser.open("tel:988")).pack(side="left", padx=(0, 8))
self.button(row, "Text 988", lambda: webbrowser.open("sms:988"), bg=OPTION_BUTTON, fg=NAVY).pack(side="left", padx=(0, 8))
self.button(row, "988.ca", lambda: webbrowser.open("https://988.ca/"), bg=OPTION_BUTTON, fg=NAVY).pack(side="left")
self.button(modal, "Ground for one minute", lambda: [modal.destroy(), self.show_shell("exercises")], bg=OPTION_BUTTON, fg=NAVY).pack(anchor="w", padx=24)
self.button(modal, "I am safe for now", modal.destroy, bg=BACKGROUND, fg=NAVY).pack(anchor="e", padx=24, pady=16)
def page_emergency(self):
self.page_title("Urgent care", "Emergency help")
box = tk.Frame(self.content, bg=SOFT, padx=20, pady=20, highlightbackground=LINE, highlightthickness=1)
box.pack(fill="x", pady=(0, 14))
tk.Label(box, text="9-8-8: Suicide Crisis Helpline", fg=NAVY, bg=SOFT, font=("Google Sans", 14, "bold")).pack(anchor="w")
tk.Label(box, text="Live support by phone and text across Canada, in English and French, 24 hours a day.", fg=TEXT, bg=SOFT, wraplength=780, justify="left").pack(anchor="w", pady=8)
row = tk.Frame(box, bg=SOFT)
row.pack(anchor="w")
self.button(row, "Call 988", lambda: webbrowser.open("tel:988")).pack(side="left", padx=(0, 8))
self.button(row, "Text 988", lambda: webbrowser.open("sms:988"), bg=OPTION_BUTTON, fg=NAVY).pack(side="left", padx=(0, 8))
self.button(row, "Open 988.ca", lambda: webbrowser.open("https://988.ca/"), bg=OPTION_BUTTON, fg=NAVY).pack(side="left")
plan = tk.Frame(self.content, bg=SOFT, padx=20, pady=20, highlightbackground=LINE, highlightthickness=1)
plan.pack(fill="x")
tk.Label(plan, text="Safety plan", fg=NAVY, bg=SOFT, font=("Google Sans", 14, "bold")).grid(row=0, column=0, sticky="w", columnspan=2)
fields = [("trusted", "Trusted person"), ("place", "Safe place"), ("action", "Calming action")]
entries = {}
for row_index, (key, label) in enumerate(fields, start=1):
tk.Label(plan, text=label, fg=NAVY, bg=SOFT).grid(row=row_index, column=0, sticky="w", pady=8)
entry = tk.Entry(plan, relief="flat", highlightbackground=LINE, highlightthickness=1)
entry.insert(0, self.state["safety"].get(key, ""))
entry.grid(row=row_index, column=1, sticky="ew", pady=8, ipady=6)
entries[key] = entry
plan.columnconfigure(1, weight=1)
def save_plan():
self.state["safety"] = {key: entry.get().strip() for key, entry in entries.items()}
save_state(self.state)
messagebox.showinfo(APP_NAME, "Saved.")
self.button(plan, "Save plan", save_plan).grid(row=4, column=1, sticky="e", pady=8)
def page_journal(self):
self.page_title("Private space", "Encrypted journal")
body = tk.Frame(self.content, bg=BACKGROUND)
body.pack(fill="both", expand=True)
left = tk.Frame(body, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
left.pack(side="left", fill="both", expand=True, padx=(0, 12))
right = tk.Frame(body, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
right.pack(side="left", fill="both", expand=True)
tk.Label(left, text="Journal PIN", fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).pack(anchor="w")
pin = tk.Entry(left, show="*", relief="flat", highlightbackground=LINE, highlightthickness=1)
pin.pack(fill="x", ipady=7, pady=(4, 12))
tk.Label(left, text="Entry", fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).pack(anchor="w")
entry = tk.Text(left, height=12, wrap="word", relief="flat", highlightbackground=LINE, highlightthickness=1)
entry.pack(fill="both", expand=True, pady=(4, 12))
def save_entry():
value = entry.get("1.0", "end").strip()
if len(pin.get()) < 4 or not value:
messagebox.showwarning(APP_NAME, "Enter a PIN with at least 4 digits and a journal entry.")
return
encrypted = encrypt_note(pin.get(), value)
encrypted["time"] = time.time()
self.state["journal"].insert(0, encrypted)
save_state(self.state)
self.show_shell("journal")
self.button(left, "Save encrypted entry", save_entry).pack(anchor="e")
tk.Label(right, text="Entries", fg=NAVY, bg=SOFT, font=("Google Sans", 14, "bold")).pack(anchor="w")
output = scrolledtext.ScrolledText(right, height=18, wrap="word", relief="flat", highlightbackground=LINE, highlightthickness=1)
output.pack(fill="both", expand=True, pady=8)
output.insert("end", "Entries are encrypted. Enter your PIN on the left and unlock them here.\n")
output.configure(state="disabled")
def unlock():
output.configure(state="normal")
output.delete("1.0", "end")
try:
for item in self.state["journal"]:
text = decrypt_note(pin.get(), item)
stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(item["time"]))
output.insert("end", f"{stamp}\n{text}\n\n")
if not self.state["journal"]:
output.insert("end", "No entries yet.")
except Exception:
output.insert("end", "That PIN did not unlock the entries.")
output.configure(state="disabled")
self.button(right, "Unlock", unlock, bg=OPTION_BUTTON, fg=NAVY).pack(anchor="e")
def page_exercises(self):
self.page_title("Body and mind", "Recovery exercises")
body = tk.Frame(self.content, bg=BACKGROUND)
body.pack(fill="both", expand=True)
for col in range(3):
body.columnconfigure(col, weight=1)
self.exercise_breathing(body, 0)
self.exercise_grounding(body, 1)
self.exercise_triggers(body, 2)
def exercise_breathing(self, parent, col):
frame = tk.Frame(parent, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
frame.grid(row=0, column=col, sticky="nsew", padx=8)
tk.Label(frame, text="Breathing", fg=NAVY, bg=SOFT, font=("Google Sans", 14, "bold")).pack(anchor="w")
canvas = tk.Canvas(frame, width=190, height=190, bg=SOFT, highlightthickness=0)
canvas.pack(pady=18)
circle = canvas.create_oval(25, 25, 165, 165, outline=TITLE, width=3, fill=BACKGROUND)
label = canvas.create_text(95, 95, text="Inhale", fill=NAVY, font=("Avenir Next", 17, "bold"))
def step():
if not self.breathing:
return
phases = ["Inhale", "Hold", "Exhale", "Rest"]
self.breath_phase = (self.breath_phase + 1) % len(phases)
canvas.itemconfigure(label, text=phases[self.breath_phase])
radius = 70 if phases[self.breath_phase] in ["Inhale", "Hold"] else 55
canvas.coords(circle, 95 - radius, 95 - radius, 95 + radius, 95 + radius)
self.root.after(2200, step)
def start_stop():
self.breathing = not self.breathing
if self.breathing:
self.state["skills"]["breathing"] += 1
save_state(self.state)
step()
else:
canvas.itemconfigure(label, text="Inhale")
self.button(frame, "Start / Stop", start_stop).pack()
def exercise_grounding(self, parent, col):
frame = tk.Frame(parent, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
frame.grid(row=0, column=col, sticky="nsew", padx=8)
tk.Label(frame, text="5-4-3-2-1", fg=NAVY, bg=SOFT, font=("Google Sans", 14, "bold")).pack(anchor="w")
prompt = tk.Label(frame, text=GROUNDING_PROMPTS[self.grounding_index], fg=TEXT, bg=BACKGROUND, wraplength=270, justify="left", padx=12, pady=18)
prompt.pack(fill="x", pady=18)
def next_prompt():
self.grounding_index = (self.grounding_index + 1) % len(GROUNDING_PROMPTS)
self.state["skills"]["grounding"] += 1
save_state(self.state)
prompt.configure(text=GROUNDING_PROMPTS[self.grounding_index])
self.button(frame, "Next", next_prompt, bg=OPTION_BUTTON, fg=NAVY).pack()
def exercise_triggers(self, parent, col):
frame = tk.Frame(parent, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
frame.grid(row=0, column=col, sticky="nsew", padx=8)
tk.Label(frame, text="Trigger map", fg=NAVY, bg=SOFT, font=("Google Sans", 14, "bold")).pack(anchor="w")
entry = tk.Entry(frame, relief="flat", highlightbackground=LINE, highlightthickness=1)
entry.pack(fill="x", ipady=7, pady=(12, 8))
def add_trigger():
text = entry.get().strip()
if text:
self.state["triggers"].insert(0, text)
self.state["triggers"] = self.state["triggers"][:24]
save_state(self.state)
self.show_shell("exercises")
self.button(frame, "Add", add_trigger, bg=OPTION_BUTTON, fg=NAVY).pack(anchor="e")
for item in self.state["triggers"][:10]:
tk.Label(frame, text=item, fg=NAVY, bg=OPTION_BUTTON, padx=10, pady=5).pack(anchor="w", pady=3)
def page_goals(self):
self.page_title("Future", "Goals and planning")
body = tk.Frame(self.content, bg=BACKGROUND)
body.pack(fill="both", expand=True)
form = tk.Frame(body, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
form.pack(side="left", fill="both", expand=True, padx=(0, 12))
listing = tk.Frame(body, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
listing.pack(side="left", fill="both", expand=True)
fields = {}
for label in ["Goal", "Next step", "Strength used"]:
tk.Label(form, text=label, fg=NAVY, bg=SOFT, font=("Google Sans", 11, "bold")).pack(anchor="w")
field = tk.Entry(form, relief="flat", highlightbackground=LINE, highlightthickness=1)
field.pack(fill="x", ipady=7, pady=(4, 12))
fields[label] = field
def save_goal():
goal = fields["Goal"].get().strip()
step = fields["Next step"].get().strip()
strength = fields["Strength used"].get().strip()
if not goal or not step:
messagebox.showwarning(APP_NAME, "Add a goal and next step.")
return
self.state["goals"].insert(0, {"goal": goal, "step": step, "strength": strength, "time": time.time()})
save_state(self.state)
self.show_shell("goals")
self.button(form, "Save goal", save_goal).pack(anchor="e")
tk.Label(listing, text="Active goals", fg=NAVY, bg=SOFT, font=("Google Sans", 14, "bold")).pack(anchor="w")
if not self.state["goals"]:
tk.Label(listing, text="No goals yet.", fg=MUTED, bg=SOFT).pack(anchor="w", pady=8)
for index, goal in enumerate(self.state["goals"]):
box = tk.Frame(listing, bg=BACKGROUND, padx=10, pady=10, highlightbackground=LINE, highlightthickness=1)
box.pack(fill="x", pady=6)
tk.Label(box, text=goal["goal"], fg=NAVY, bg=BACKGROUND, font=("Google Sans", 11, "bold")).pack(anchor="w")
tk.Label(box, text=goal["step"], fg=TEXT, bg=BACKGROUND, wraplength=420, justify="left").pack(anchor="w")
if goal.get("strength"):
tk.Label(box, text=goal["strength"], fg=MUTED, bg=BACKGROUND).pack(anchor="w")
row = tk.Frame(box, bg=BACKGROUND)
row.pack(anchor="e")
self.button(row, "Complete", lambda index=index: self.complete_goal(index), bg=OPTION_BUTTON, fg=NAVY).pack(side="left", padx=4)
self.button(row, "Remove", lambda index=index: self.remove_goal(index), bg=BACKGROUND, fg=NAVY).pack(side="left", padx=4)
def complete_goal(self, index):
if 0 <= index < len(self.state["goals"]):
self.state["goals"].pop(index)
self.state["completed_goals"] += 1
save_state(self.state)
self.show_shell("goals")
def remove_goal(self, index):
if 0 <= index < len(self.state["goals"]):
self.state["goals"].pop(index)
save_state(self.state)
self.show_shell("goals")
def page_dashboard(self):
self.page_title("Progress", "Your dashboard", "Progress is cumulative here. Nothing is lost when a week is difficult.")
stats = [
("Mood check-ins", len(self.state["moods"])),
("Journal entries", len(self.state["journal"])),
("Goals achieved", self.state["completed_goals"]),
("Exercises completed", self.state["skills"]["breathing"] + self.state["skills"]["grounding"]),
("Triggers mapped", len(self.state["triggers"])),
]
grid = tk.Frame(self.content, bg=BACKGROUND)
grid.pack(fill="x")
for index, (label, value) in enumerate(stats):
card = tk.Frame(grid, bg=SOFT, padx=18, pady=18, highlightbackground=LINE, highlightthickness=1)
card.grid(row=0, column=index, sticky="nsew", padx=6)
grid.columnconfigure(index, weight=1)
tk.Label(card, text=str(value), fg=TITLE, bg=SOFT, font=("Avenir Next", 30, "bold")).pack(anchor="w")
tk.Label(card, text=label, fg=NAVY, bg=SOFT, font=("Google Sans", 10, "bold"), wraplength=150).pack(anchor="w")
def page_privacy(self):
self.page_title(self.tr("settings"), self.tr("privacy_title"))
box = tk.Frame(self.content, bg=SOFT, padx=20, pady=20, highlightbackground=LINE, highlightthickness=1)
box.pack(fill="x")
self.language_selector(box, refresh="privacy").pack(anchor="w", pady=(0, 8))
tk.Label(box, text=self.tr("save_language"), fg=MUTED, bg=SOFT, wraplength=820, justify="left").pack(anchor="w", pady=(0, 12))
tk.Label(box, text="Data stays on this computer in sobrad_data.json beside the Python app.", fg=TEXT, bg=SOFT, wraplength=820, justify="left").pack(anchor="w", pady=(0, 12))
anonymous = tk.BooleanVar(value=self.state.get("anonymous", False))
tk.Checkbutton(box, text="Anonymous mode", variable=anonymous, bg=SOFT, fg=NAVY, activebackground=SOFT).pack(anchor="w")
def save_privacy():
self.state["anonymous"] = anonymous.get()
save_state(self.state)
messagebox.showinfo(APP_NAME, "Saved.")
def delete_all():
if messagebox.askyesno(APP_NAME, "Delete all local SÕBRAD data from this computer?"):
self.state = default_state()
if DATA_FILE.exists():
DATA_FILE.unlink()
self.show_login()
row = tk.Frame(box, bg=SOFT)
row.pack(anchor="w", pady=16)
self.button(row, "Save privacy settings", save_privacy).pack(side="left", padx=(0, 8))
self.button(row, "Delete all data", delete_all, bg=DANGER, fg="white").pack(side="left")
def main():
root = tk.Tk()
SobradApp(root)
root.mainloop()
if __name__ == "__main__":
main()
The Tkinter interface module for TIJDMANAGER, an ADHD-friendly study and time-planning app — Pomodoro-style focus timer, a draggable time-blocking planner with day/week/month views, task tracking, mood check-ins, and an AI coaching layer. This file is the app's main window and screens; it's part of a larger package (assistant, i18n, privacy and storage modules included) rather than a single-file script.
"""Tkinter desktop interface for TIJDMANAGER."""
from __future__ import annotations
import calendar
import ctypes
import math
import textwrap
import uuid
from collections import Counter, defaultdict
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any, Callable
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog
from tkinter import font as tkfont
from . import __version__
from .assistant import (
adaptive_goals,
break_task_into_steps,
coach_reply,
completion_rate,
deadline_recommendation,
estimate_minutes,
gentle_reminder,
smart_reschedule,
weekly_review,
wellbeing_insights,
)
from .i18n import LANGUAGES, language_code, translate
from .privacy import PrivacyError
from .storage import Store, app_data_dir
ASSET_DIR = Path(__file__).resolve().parent / "assets"
LOGO_PATH = ASSET_DIR / "logo.png"
FONT_DIR = ASSET_DIR / "fonts"
OVERALL_COLOR = "#02B2E4"
ACCENT_COLOR = "#CED200"
LOGO_CYAN = OVERALL_COLOR
LOGO_LIME = ACCENT_COLOR
LOGO_WHITE = "#FFFFFF"
LOGO_CHARCOAL = "#24313A"
LOGO_INK = "#071116"
LOGO_CYAN_SOFT = "#7BD9EE"
LOGO_LIME_SOFT = "#EEF3A6"
CATEGORY_COLORS = {
"Study": LOGO_CYAN,
"Work": LOGO_CHARCOAL,
"Break": LOGO_LIME,
"Exercise": LOGO_CYAN_SOFT,
"Rest": LOGO_LIME_SOFT,
}
PRIORITIES = ["🔴 Urgent", "🟡 Important", "🟢 Optional"]
BLOCK_KINDS = ["Study", "Work", "Break", "Exercise", "Rest"]
MOODS = ["😀 Great", "🙂 Good", "😐 Okay", "😞 Difficult"]
RECURRENCE = ["None", "Daily", "Weekly", "Monthly"]
def parse_clock(value: str) -> int:
hour, minute = value.split(":", 1)
return int(hour) * 60 + int(minute)
def format_clock(minutes: int) -> str:
minutes = max(0, min(23 * 60 + 59, minutes))
return f"{minutes // 60:02d}:{minutes % 60:02d}"
def today_iso() -> str:
return date.today().isoformat()
def parse_date(value: str | None) -> date | None:
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except ValueError:
return None
class ScrollFrame(tk.Frame):
def __init__(self, parent: tk.Misc, bg: str) -> None:
super().__init__(parent, bg=bg)
self.canvas = tk.Canvas(self, bg=bg, highlightthickness=0)
self.scrollbar = tk.Scrollbar(self, orient="vertical", command=self.canvas.yview)
self.inner = tk.Frame(self.canvas, bg=bg)
self.window_id = self.canvas.create_window((0, 0), window=self.inner, anchor="nw")
self.canvas.configure(yscrollcommand=self.scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
self.scrollbar.pack(side="right", fill="y")
self.inner.bind("<Configure>", self._on_inner_configure)
self.canvas.bind("<Configure>", self._on_canvas_configure)
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
def _on_inner_configure(self, _event: tk.Event) -> None:
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
def _on_canvas_configure(self, event: tk.Event) -> None:
self.canvas.itemconfigure(self.window_id, width=event.width)
def _on_mousewheel(self, event: tk.Event) -> None:
if self.winfo_ismapped():
self.canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
class TijdManagerApp(tk.Tk):
def __init__(self, store: Store) -> None:
super().__init__()
self.store = store
self.data = store.data
self.ensure_runtime_defaults()
self.current_screen = "Today"
self.timer_remaining = 0
self.timer_duration = 25 * 60
self.timer_running = False
self.timer_after: str | None = None
self.timer_started_at: datetime | None = None
self.timer_label: tk.Label | None = None
self.timer_canvas: tk.Canvas | None = None
self.timer_mode_var = tk.StringVar(value="25/5")
self.custom_focus_var = tk.StringVar(value="25")
self.custom_break_var = tk.StringVar(value="5")
self.focus_task_var = tk.StringVar(value="Essay research")
self.calendar_view_var = tk.StringVar(value=self.data.get("settings", {}).get("calendar_view", "Day"))
self.drag_block_id: str | None = None
self.drag_start_y = 0
self.drag_original_start = 0
self.drag_total_pixels = 0
self.logo_image: tk.PhotoImage | None = None
self.title_family = "Segoe UI"
self.body_family = "Segoe UI"
self.japanese_family = "Segoe UI"
self._register_fonts()
self.colors = self._colors()
self.title("TIJDMANAGER")
self.geometry("1200x780")
self.minsize(1040, 680)
self.configure(bg=self.colors["bg"])
if self.data.get("profile", {}).get("logged_in"):
self.build_shell()
self.show_today()
else:
self.show_login()
self.protocol("WM_DELETE_WINDOW", self.on_close)
def ensure_runtime_defaults(self) -> None:
profile = self.data.setdefault("profile", {})
profile.setdefault("name", "Alex")
profile.setdefault("email", "")
profile.setdefault("logged_in", False)
profile.setdefault("account_provider", "Local")
profile.setdefault("google_connected", False)
profile.setdefault("family_sharing_enabled", False)
profile.setdefault(
"share_consent",
{
"tasks": False,
"deadlines": False,
"focus_totals": False,
"schedule": False,
"mood_trend": False,
"journal": False,
},
)
profile["share_consent"].setdefault("schedule", False)
profile.setdefault("share_account_name", f"{profile.get('name', 'Alex')}'s TIJDMANAGER account")
profile.setdefault("share_account_id", f"TM-{uuid.uuid4().hex[:8].upper()}")
self.data.setdefault("family_members", [])
settings = self.data.setdefault("settings", {})
settings.setdefault("calendar_view", "Day")
settings.setdefault("google_calendar_credentials_path", "")
settings.setdefault("google_calendar_connected", False)
settings.setdefault("google_calendar_status", "Not connected")
def show_login(self) -> None:
for child in self.winfo_children():
child.destroy()
self.colors = self._colors()
self.configure(bg=OVERALL_COLOR)
wrap = tk.Frame(self, bg=OVERALL_COLOR)
wrap.pack(fill="both", expand=True)
card = tk.Frame(
wrap,
bg=LOGO_WHITE,
highlightbackground=ACCENT_COLOR,
highlightthickness=3,
bd=0,
)
card.place(relx=0.5, rely=0.5, anchor="center", width=520, height=560)
if LOGO_PATH.exists():
self.logo_image = tk.PhotoImage(file=str(LOGO_PATH)).subsample(3, 3)
tk.Label(card, image=self.logo_image, bg=LOGO_WHITE).pack(pady=(28, 10))
tk.Label(
card,
text="TIJDMANAGER",
bg=LOGO_WHITE,
fg=LOGO_CHARCOAL,
font=self.brand_font(26),
).pack()
tk.Label(
card,
text="Sign in to start your calm day plan.",
bg=LOGO_WHITE,
fg="#315763",
font=self.body_font(11),
).pack(pady=(6, 24))
fields = tk.Frame(card, bg=LOGO_WHITE)
fields.pack(fill="x", padx=52)
name_var = tk.StringVar(value=self.data.get("profile", {}).get("name", ""))
email_var = tk.StringVar(value=self.data.get("profile", {}).get("email", ""))
tk.Label(fields, text="Name", bg=LOGO_WHITE, fg=LOGO_CHARCOAL, font=self.body_font(10, "bold")).pack(anchor="w")
self.entry(fields, name_var, 34).pack(fill="x", pady=(4, 12))
tk.Label(fields, text="Email", bg=LOGO_WHITE, fg=LOGO_CHARCOAL, font=self.body_font(10, "bold")).pack(anchor="w")
self.entry(fields, email_var, 34).pack(fill="x", pady=(4, 16))
self.button(
fields,
"Continue with Google",
lambda: self.complete_login(name_var.get(), email_var.get(), "Google"),
primary=True,
fill="x",
pady=(0, 8),
)
self.button(
fields,
"Continue with local account",
lambda: self.complete_login(name_var.get(), email_var.get(), "Local"),
fill="x",
)
tk.Label(
card,
text="Google sign-in is prepared as a local demo until OAuth credentials are added.",
bg=LOGO_WHITE,
fg="#315763",
font=self.body_font(9),
wraplength=390,
justify="center",
).pack(side="bottom", pady=22)
def complete_login(self, name: str, email: str, provider: str) -> None:
profile = self.data.setdefault("profile", {})
profile["name"] = name.strip() or "Alex"
profile["email"] = email.strip()
profile["logged_in"] = True
profile["account_provider"] = provider
profile["google_connected"] = provider == "Google"
self.save()
self.build_shell()
self.show_today()
def _register_fonts(self) -> None:
if hasattr(ctypes, "windll"):
for font_path in FONT_DIR.glob("*.*tf"):
try:
ctypes.windll.gdi32.AddFontResourceExW(str(font_path), 0x10, 0)
except OSError:
pass
families = {family.lower(): family for family in tkfont.families(self)}
for key, family in families.items():
if "mokoto" in key:
self.title_family = family
inter_family = families.get("inter")
if not inter_family:
inter_family = next((family for key, family in families.items() if key.startswith("inter")), None)
if inter_family:
self.body_family = inter_family
noto_family = families.get("noto sans jp")
if not noto_family:
noto_family = next(
(
family
for key, family in families.items()
if "noto sans jp" in key or key.startswith("notosansjp")
),
None,
)
self.japanese_family = noto_family or self.body_family
def _colors(self) -> dict[str, str]:
settings = self.data.get("settings", {})
if settings.get("high_contrast"):
return {
"bg": LOGO_WHITE,
"panel": LOGO_WHITE,
"sidebar": "#000000",
"text": "#000000",
"muted": "#262626",
"line": "#000000",
"primary": OVERALL_COLOR,
"accent": LOGO_LIME,
"button": "#000000",
"button_text": "#FFFFFF",
"soft": "#F2F2F2",
}
if settings.get("dark_mode"):
return {
"bg": "#171C22",
"panel": "#222A31",
"sidebar": "#10151B",
"text": "#F5F8F9",
"muted": "#AAB7BF",
"line": "#3D4852",
"primary": LOGO_CYAN,
"accent": LOGO_LIME,
"button": LOGO_CYAN,
"button_text": LOGO_INK,
"soft": "#2B343C",
}
return {
"bg": OVERALL_COLOR,
"panel": LOGO_WHITE,
"sidebar": OVERALL_COLOR,
"text": LOGO_CHARCOAL,
"muted": "#315763",
"line": "#009FD0",
"primary": LOGO_CYAN,
"accent": LOGO_LIME,
"button": ACCENT_COLOR,
"button_text": LOGO_INK,
"soft": LOGO_LIME_SOFT,
}
def title_font(self, size: int = 22, weight: str = "bold") -> tuple[str, int, str]:
if self.uses_japanese_font():
return (self.japanese_family, size, weight)
return (self.title_family, size, weight)
def brand_font(self, size: int = 22, weight: str = "bold") -> tuple[str, int, str]:
return (self.title_family, size, weight)
def body_font(self, size: int = 11, weight: str = "normal") -> tuple[str, int, str]:
if self.uses_japanese_font():
return (self.japanese_family, size, weight)
return (self.body_family, size, weight)
def uses_japanese_font(self) -> bool:
language = self.data.get("profile", {}).get("language", LANGUAGES[0])
return language_code(language) == "ja"
def build_shell(self) -> None:
for child in self.winfo_children():
child.destroy()
self.colors = self._colors()
self.configure(bg=self.colors["bg"])
self.body = tk.Frame(self, bg=self.colors["bg"])
self.body.pack(fill="both", expand=True)
self.topbar = tk.Frame(self.body, bg=self.colors["sidebar"], height=138)
self.topbar.pack(side="top", fill="x")
self.topbar.pack_propagate(False)
self.main = tk.Frame(self.body, bg=self.colors["bg"])
self.main.pack(side="top", fill="both", expand=True)
self._build_sidebar()
self.apply_translations(self.topbar)
def _build_sidebar(self) -> None:
top = tk.Frame(self.topbar, bg=self.colors["sidebar"])
top.pack(side="left", fill="y", padx=20, pady=16)
if LOGO_PATH.exists():
self.logo_image = tk.PhotoImage(file=str(LOGO_PATH)).subsample(5, 5)
logo = tk.Label(
top,
image=self.logo_image,
bg=self.colors["sidebar"],
width=56,
height=56,
)
logo.pack(side="left", padx=(0, 12))
brand = tk.Frame(top, bg=self.colors["sidebar"])
brand.pack(side="left")
tk.Label(
brand,
text="TIJDMANAGER",
bg=self.colors["sidebar"],
fg=self.colors["text"],
font=self.brand_font(18),
).pack(anchor="w", pady=(10, 2))
tk.Label(
brand,
text="low-stimulation day planning",
bg=self.colors["sidebar"],
fg=self.colors["muted"],
font=self.body_font(9),
).pack(anchor="w")
privacy = "Local only"
if self.data.get("settings", {}).get("privacy_lock_enabled"):
privacy = "Local privacy lock on"
tk.Label(
brand,
text=privacy,
bg=self.colors["sidebar"],
fg=self.colors["muted"],
font=self.body_font(8),
).pack(anchor="w", pady=(6, 0))
self.nav_buttons: dict[str, tk.Canvas] = {}
nav_items: list[tuple[str, Callable[[], None]]] = [
("Today", self.show_today),
("Focus", self.show_focus),
("Planner", self.show_planner),
("Tasks", self.show_tasks),
("Mood", self.show_mood),
("Habits", self.show_habits),
("Analytics", self.show_analytics),
("Coach", self.show_coach),
("Account", self.show_family),
("Settings", self.show_settings),
]
nav = tk.Frame(self.topbar, bg=self.colors["sidebar"])
nav.pack(side="right", fill="y", padx=(8, 14), pady=14)
for label, command in nav_items:
circle = self.nav_circle(nav, label, command)
circle.pack(side="left", padx=3)
self.nav_buttons[label] = circle
def update_nav(self) -> None:
for label, button in self.nav_buttons.items():
active = label == self.current_screen
self.draw_nav_circle(button, label, active)
def screen(self, name: str, builder: Callable[[tk.Frame], None]) -> None:
self.current_screen = name
self.update_nav()
self.timer_label = None
self.timer_canvas = None
for child in self.main.winfo_children():
child.destroy()
scroll = ScrollFrame(self.main, self.colors["bg"])
scroll.pack(fill="both", expand=True)
frame = scroll.inner
frame.configure(padx=28, pady=24)
builder(frame)
self.apply_translations(frame)
def tr(self, text: str) -> str:
language = self.data.get("profile", {}).get("language", LANGUAGES[0])
return translate(text, language)
def apply_translations(self, root: tk.Misc) -> None:
for widget in self._walk_widgets(root):
try:
current = widget.cget("text")
except tk.TclError:
continue
if isinstance(current, str) and current:
translated = self.tr(current)
if translated != current:
try:
widget.configure(text=translated)
except tk.TclError:
pass
def _walk_widgets(self, root: tk.Misc) -> list[tk.Misc]:
widgets: list[tk.Misc] = [root]
for child in root.winfo_children():
widgets.extend(self._walk_widgets(child))
return widgets
def panel(self, parent: tk.Misc, **grid: Any) -> tk.Frame:
frame = tk.Frame(
parent,
bg=self.colors["panel"],
highlightbackground=self.colors["line"],
highlightthickness=1,
bd=0,
)
if grid:
frame.grid(**grid)
return frame
def label(
self,
parent: tk.Misc,
text: str,
size: int = 11,
weight: str = "normal",
muted: bool = False,
**pack: Any,
) -> tk.Label:
widget = tk.Label(
parent,
text=text,
bg=parent.cget("bg") if hasattr(parent, "cget") else self.colors["bg"],
fg=self.colors["muted"] if muted else self.colors["text"],
font=self.body_font(size, weight),
justify="left",
anchor="w",
wraplength=720,
)
widget.pack(**pack)
return widget
def button(
self,
parent: tk.Misc,
text: str,
command: Callable[[], None],
primary: bool = False,
**pack: Any,
) -> tk.Button:
bg = self.colors["button"] if primary else self.colors["soft"]
fg = self.colors["button_text"] if primary else self.colors["text"]
widget = tk.Button(
parent,
text=text,
command=command,
relief="flat",
bd=0,
padx=14,
pady=9,
bg=bg,
fg=fg,
activebackground=self.colors["accent"] if primary else self.colors["line"],
activeforeground=self.colors["text"],
font=self.body_font(10, "bold" if primary else "normal"),
)
widget.pack(**pack)
return widget
def nav_circle(self, parent: tk.Misc, label: str, command: Callable[[], None]) -> tk.Canvas:
canvas = tk.Canvas(
parent,
width=62,
height=62,
bg=self.colors["sidebar"],
highlightthickness=0,
cursor="hand2",
)
canvas.command = command # type: ignore[attr-defined]
canvas.bind("<Button-1>", lambda _event: command())
self.draw_nav_circle(canvas, label, label == self.current_screen)
return canvas
def draw_nav_circle(self, canvas: tk.Canvas, label: str, active: bool) -> None:
canvas.delete("all")
fill = self.colors["accent"] if active else self.colors["panel"]
outline = LOGO_CHARCOAL if active else self.colors["accent"]
text_color = LOGO_INK if active else self.colors["text"]
canvas.create_oval(
4,
4,
58,
58,
fill=fill,
outline=outline,
width=3,
tags=("click",),
)
canvas.create_text(
31,
31,
text=self.tr(label),
fill=text_color,
font=self.body_font(7, "bold"),
width=44,
justify="center",
tags=("click",),
)
canvas.tag_bind("click", "<Button-1>", lambda _event: getattr(canvas, "command")())
def circle_option(
self,
parent: tk.Misc,
title: str,
subtitle: str,
command: Callable[[], None],
fill: str,
outline: str | None = None,
) -> tk.Canvas:
canvas = tk.Canvas(
parent,
width=126,
height=126,
bg=parent.cget("bg") if hasattr(parent, "cget") else self.colors["panel"],
highlightthickness=0,
cursor="hand2",
)
canvas.create_oval(
7,
7,
119,
119,
fill=fill,
outline=outline or self.colors["accent"],
width=3,
tags=("click",),
)
canvas.create_text(
63,
50,
text=title,
fill=LOGO_INK if fill != LOGO_CHARCOAL else LOGO_WHITE,
font=self.body_font(11, "bold"),
width=92,
justify="center",
tags=("click",),
)
canvas.create_text(
63,
80,
text=subtitle,
fill=LOGO_INK if fill != LOGO_CHARCOAL else LOGO_WHITE,
font=self.body_font(8),
width=92,
justify="center",
tags=("click",),
)
canvas.tag_bind("click", "<Button-1>", lambda _event: command())
canvas.bind("<Button-1>", lambda _event: command())
return canvas
def entry(self, parent: tk.Misc, textvariable: tk.StringVar | None = None, width: int = 20) -> tk.Entry:
return tk.Entry(
parent,
textvariable=textvariable,
width=width,
bg=self.colors["panel"],
fg=self.colors["text"],
insertbackground=self.colors["text"],
relief="solid",
bd=1,
highlightthickness=1,
highlightbackground=self.colors["line"],
font=self.body_font(10),
)
def on_close(self) -> None:
self.store.save()
self.destroy()
def save(self) -> None:
self.store.save()
# Today Mode
def show_today(self) -> None:
self.screen("Today", self.render_today)
def render_today(self, frame: tk.Frame) -> None:
name = self.data.get("profile", {}).get("name", "there")
now = datetime.now()
greeting = "Good morning" if now.hour < 12 else "Good afternoon" if now.hour < 18 else "Good evening"
tk.Label(
frame,
text=f"{self.tr(greeting)}, {name}",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(26),
).pack(anchor="w")
self.label(
frame,
"Today Mode shows only the next useful things, so the day stays readable.",
muted=True,
pady=(4, 18),
)
hero = self.panel(frame)
hero.pack(fill="x", pady=(0, 16))
hero.configure(padx=18, pady=16)
hero_top = tk.Frame(hero, bg=self.colors["panel"])
hero_top.pack(fill="x")
tk.Label(
hero_top,
text="Your calm control centre",
bg=self.colors["panel"],
fg=self.colors["text"],
font=self.title_font(18),
anchor="w",
).pack(side="left", fill="x", expand=True)
tk.Label(
hero_top,
text="Choose one option below",
bg=self.colors["panel"],
fg=self.colors["muted"],
font=self.body_font(10, "bold"),
).pack(side="right")
option_row = tk.Frame(hero, bg=self.colors["panel"])
option_row.pack(fill="x", pady=(16, 2))
options = [
("Focus", "start timer", self.show_focus, ACCENT_COLOR, ACCENT_COLOR),
("Plan", "time blocks", self.show_planner, LOGO_CYAN_SOFT, ACCENT_COLOR),
("Tasks", "to-do list", self.show_tasks, LOGO_WHITE, ACCENT_COLOR),
("Mood", "check-in", self.show_mood, LOGO_LIME_SOFT, ACCENT_COLOR),
("Habits", "streaks", self.show_habits, LOGO_CYAN_SOFT, ACCENT_COLOR),
("Analytics", "progress", self.show_analytics, LOGO_WHITE, ACCENT_COLOR),
("Coach", "AI help", self.show_coach, LOGO_LIME_SOFT, ACCENT_COLOR),
("Account", "sharing", self.show_family, LOGO_CYAN_SOFT, ACCENT_COLOR),
("Settings", "access", self.show_settings, LOGO_WHITE, ACCENT_COLOR),
("Emergency", "confirm first", self.confirm_emergency_focus, ACCENT_COLOR, LOGO_CHARCOAL),
]
for column in range(5):
option_row.columnconfigure(column, weight=1)
for index, (title, subtitle, command, fill, outline) in enumerate(options):
option = self.circle_option(option_row, title, subtitle, command, fill, outline)
option.grid(row=index // 5, column=index % 5, padx=8, pady=8)
stats_row = tk.Frame(hero, bg=self.colors["panel"])
stats_row.pack(fill="x", pady=(14, 0))
today_focus = sum(
session.get("minutes", 0)
for session in self.data.get("focus_sessions", [])
if session.get("date") == today_iso()
)
latest_mood = next((item.get("mood") for item in reversed(self.data.get("moods", []))), "No check-in")
stats = [
("Focus today", f"{today_focus} min"),
("Task completion", f"{completion_rate(self.data.get('tasks', [])):.0%}"),
("Account sharing", "On" if self.data.get("profile", {}).get("family_sharing_enabled") else "Off"),
("Latest mood", latest_mood),
]
for label_text, value in stats:
item = tk.Frame(
stats_row,
bg=self.colors["soft"],
highlightbackground=self.colors["accent"],
highlightthickness=1,
padx=12,
pady=10,
)
item.pack(side="left", fill="x", expand=True, padx=(0, 10))
tk.Label(
item,
text=label_text,
bg=self.colors["soft"],
fg=self.colors["muted"],
font=self.body_font(9, "bold"),
anchor="w",
).pack(anchor="w")
tk.Label(
item,
text=value,
bg=self.colors["soft"],
fg=self.colors["text"],
font=self.body_font(13, "bold"),
anchor="w",
).pack(anchor="w", pady=(4, 0))
grid = tk.Frame(frame, bg=self.colors["bg"])
grid.pack(fill="both", expand=True)
grid.columnconfigure(0, weight=3)
grid.columnconfigure(1, weight=2)
left = self.panel(grid, row=0, column=0, sticky="nsew", padx=(0, 14))
right = self.panel(grid, row=0, column=1, sticky="nsew", padx=(0, 0))
for container in (left, right):
container.configure(padx=18, pady=18)
self.label(left, "Today", size=15, weight="bold", pady=(0, 10))
tasks = self._today_tasks()
if not tasks:
self.label(left, "No required tasks for today.", muted=True)
for idx, task in enumerate(tasks[:5], start=1):
row = tk.Frame(left, bg=self.colors["panel"])
row.pack(fill="x", pady=5)
var = tk.IntVar(value=1 if task.get("completed") else 0)
check = tk.Checkbutton(
row,
variable=var,
bg=self.colors["panel"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
command=lambda task_id=task["id"], state=var: self.toggle_task(task_id, bool(state.get()), refresh="Today"),
)
check.pack(side="left")
title = f"{idx}. {task.get('title', 'Untitled')}"
tk.Label(
row,
text=title,
bg=self.colors["panel"],
fg=self.colors["text"],
font=self.body_font(11, "bold"),
anchor="w",
).pack(side="left", fill="x", expand=True)
tk.Label(
row,
text=task.get("priority", ""),
bg=self.colors["panel"],
fg=self.colors["muted"],
font=self.body_font(10),
).pack(side="right")
action_row = tk.Frame(left, bg=self.colors["panel"])
action_row.pack(fill="x", pady=(18, 0))
self.button(action_row, "Start Focus", self.show_focus, primary=True, side="left")
self.button(action_row, "Open Planner", self.show_planner, side="left", padx=8)
moved = tk.Frame(left, bg=self.colors["panel"])
moved.pack(fill="x", pady=(12, 0))
self.button(moved, "Smart reschedule skipped tasks", self.reschedule_and_refresh, side="left")
self.label(right, "Next", size=15, weight="bold", pady=(0, 10))
next_block = self._next_block()
if next_block:
block_text = (
f"{next_block['start']} {next_block['title']}\n"
f"{next_block['kind']} for {next_block['duration']} minutes"
)
else:
block_text = "No block planned. A 10 minute starter block is enough."
self.label(right, block_text, size=12, pady=(0, 14))
planned = sum(
block.get("duration", 0)
for block in self.data.get("blocks", [])
if block.get("date") == today_iso() and block.get("kind") in {"Study", "Work"}
)
goals = adaptive_goals(planned or 60)
self.label(right, "Adaptive goal", size=13, weight="bold", pady=(6, 6))
for key, minutes in goals.items():
self.label(right, f"{key}: {minutes} min", muted=key == "Stretch Goal")
self.label(right, "Gentle reminder", size=13, weight="bold", pady=(16, 6))
self.label(right, gentle_reminder(goals["Minimum Goal"]), muted=True)
self.label(right, "Wellbeing insight", size=13, weight="bold", pady=(16, 6))
for insight in wellbeing_insights(self.data)[:2]:
self.label(right, insight, muted=True)
deadlines = self.data.get("deadlines", [])
if deadlines:
self.label(right, "Upcoming deadline", size=13, weight="bold", pady=(16, 6))
soonest = min(deadlines, key=lambda item: item.get("date", "9999-12-31"))
self.label(right, f"{soonest['title']} · {deadline_recommendation(soonest)}", muted=True)
def _today_tasks(self) -> list[dict[str, Any]]:
today = date.today()
tasks = []
for task in self.data.get("tasks", []):
due = parse_date(task.get("due"))
if task.get("archived"):
continue
if due == today or (not task.get("completed") and due and due < today):
tasks.append(task)
if not tasks:
tasks = [task for task in self.data.get("tasks", []) if not task.get("completed") and not task.get("archived")]
return tasks
def _next_block(self) -> dict[str, Any] | None:
now_minutes = datetime.now().hour * 60 + datetime.now().minute
blocks = [
block
for block in self.data.get("blocks", [])
if block.get("date") == today_iso()
]
blocks.sort(key=lambda block: parse_clock(block.get("start", "23:59")))
for block in blocks:
if parse_clock(block.get("start", "00:00")) + block.get("duration", 0) >= now_minutes:
return block
return blocks[0] if blocks else None
def toggle_task(self, task_id: str, completed: bool, refresh: str | None = None) -> None:
for task in self.data.get("tasks", []):
if task.get("id") == task_id:
task["completed"] = completed
task["progress"] = 100 if completed else min(task.get("progress", 0), 90)
break
self.save()
if refresh == "Today":
self.show_today()
elif refresh == "Tasks":
self.show_tasks()
def reschedule_and_refresh(self) -> None:
moved = smart_reschedule(self.data)
self.save()
messagebox.showinfo("Smart rescheduling", f"Moved {moved} skipped task(s) to tomorrow.")
self.show_today()
# Focus timer
def show_focus(self) -> None:
self.screen("Focus", lambda frame: self.render_focus(frame, emergency=False))
def render_focus(self, frame: tk.Frame, emergency: bool = False) -> None:
title = "Emergency Focus Mode" if emergency else "Focus Session Timer"
tk.Label(
frame,
text=title,
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
subtitle = "Only the current task and timer are visible." if emergency else "Pomodoro, custom sessions, and gentle completion notifications."
self.label(frame, subtitle, muted=True, pady=(4, 18))
panel = self.panel(frame)
panel.pack(fill="x")
panel.configure(padx=22, pady=22)
task_row = tk.Frame(panel, bg=self.colors["panel"])
task_row.pack(fill="x")
self.label(task_row, "Current task", size=11, weight="bold", side="left", padx=(0, 10))
task_entry = self.entry(task_row, self.focus_task_var, width=42)
task_entry.pack(side="left", fill="x", expand=True)
if not emergency:
controls = tk.Frame(panel, bg=self.colors["panel"])
controls.pack(fill="x", pady=(16, 0))
self.label(controls, "Mode", side="left", padx=(0, 8))
mode = tk.OptionMenu(controls, self.timer_mode_var, "25/5", "50/10", "Custom")
mode.configure(bg=self.colors["soft"], fg=self.colors["text"], relief="flat", highlightthickness=0)
mode.pack(side="left")
self.label(controls, "Focus min", side="left", padx=(16, 6))
self.entry(controls, self.custom_focus_var, width=6).pack(side="left")
self.label(controls, "Break min", side="left", padx=(12, 6))
self.entry(controls, self.custom_break_var, width=6).pack(side="left")
timer_area = tk.Frame(panel, bg=self.colors["panel"])
timer_area.pack(fill="x", pady=(22, 8))
self.timer_canvas = tk.Canvas(timer_area, width=220, height=220, bg=self.colors["panel"], highlightthickness=0)
self.timer_canvas.pack(side="left", padx=(0, 26))
self.timer_label = tk.Label(
timer_area,
text=self._timer_text(),
bg=self.colors["panel"],
fg=self.colors["text"],
font=self.title_font(44),
)
self.timer_label.pack(side="left", anchor="center")
actions = tk.Frame(panel, bg=self.colors["panel"])
actions.pack(fill="x", pady=(18, 0))
self.button(actions, "Start", self.start_timer, primary=True, side="left")
self.button(actions, "Pause", self.pause_timer, side="left", padx=8)
self.button(actions, "Continue Session", self.continue_session, side="left")
self.button(actions, "Finish early", self.finish_timer_early, side="left", padx=8)
if emergency:
self.button(actions, "Exit emergency mode", self.exit_emergency_focus, side="right")
else:
self.button(actions, "Back to Today", self.show_today, side="right")
self.update_timer_ui()
def _selected_focus_minutes(self) -> int:
mode = self.timer_mode_var.get()
if mode == "50/10":
return 50
if mode == "Custom":
try:
return max(1, min(240, int(self.custom_focus_var.get())))
except ValueError:
return 25
return 25
def _timer_text(self) -> str:
seconds = max(0, self.timer_remaining)
return f"{seconds // 60:02d}:{seconds % 60:02d}"
def start_timer(self) -> None:
if self.timer_running:
return
if self.timer_remaining <= 0:
self.timer_duration = self._selected_focus_minutes() * 60
self.timer_remaining = self.timer_duration
self.timer_started_at = datetime.now()
self.timer_running = True
self._tick()
def pause_timer(self) -> None:
self.timer_running = False
if self.timer_after:
self.after_cancel(self.timer_after)
self.timer_after = None
self.update_timer_ui()
def continue_session(self) -> None:
self.timer_remaining = self.timer_duration or self._selected_focus_minutes() * 60
self.timer_started_at = datetime.now()
self.timer_running = False
self.start_timer()
def finish_timer_early(self) -> None:
if self.timer_duration > self.timer_remaining:
self._log_focus_session(self.timer_duration - self.timer_remaining)
self.timer_remaining = 0
self.timer_running = False
self.update_timer_ui()
self.save()
def _tick(self) -> None:
if not self.timer_running:
return
self.update_timer_ui()
if self.timer_remaining <= 0:
self.timer_running = False
self._log_focus_session(self.timer_duration)
self.save()
if self.data.get("settings", {}).get("sound"):
self.bell()
messagebox.showinfo("Session complete", "Focus session complete. Take a gentle break.")
self.update_timer_ui()
return
self.timer_remaining -= 1
self.timer_after = self.after(1000, self._tick)
def update_timer_ui(self) -> None:
if self.timer_label and self.timer_label.winfo_exists():
self.timer_label.configure(text=self._timer_text())
if self.timer_canvas and self.timer_canvas.winfo_exists():
self.timer_canvas.delete("all")
size = 190
x0 = y0 = 15
self.timer_canvas.create_oval(
x0,
y0,
x0 + size,
y0 + size,
outline=self.colors["line"],
width=12,
)
progress = 0 if not self.timer_duration else self.timer_remaining / self.timer_duration
angle = max(0, min(360, 360 * progress))
self.timer_canvas.create_arc(
x0,
y0,
x0 + size,
y0 + size,
start=90,
extent=-angle,
outline=self.colors["primary"],
width=12,
style="arc",
)
self.timer_canvas.create_text(
110,
110,
text="focus" if self.timer_running else "ready",
fill=self.colors["muted"],
font=self.body_font(11, "bold"),
)
def _log_focus_session(self, elapsed_seconds: int) -> None:
minutes = max(1, math.ceil(elapsed_seconds / 60))
task = self.focus_task_var.get().strip() or "Focus session"
subject = "Study"
for existing in self.data.get("tasks", []):
if existing.get("title", "").lower() in task.lower() or task.lower() in existing.get("title", "").lower():
subject = existing.get("tag", "Study")
existing["actual_minutes"] = existing.get("actual_minutes", 0) + minutes
break
self.data.setdefault("focus_sessions", []).append(
{
"date": today_iso(),
"task": task,
"subject": subject,
"minutes": minutes,
"started_at": (self.timer_started_at or datetime.now()).strftime("%H:%M"),
}
)
def confirm_emergency_focus(self) -> None:
if not messagebox.askyesno(
"Emergency Focus Mode",
"Start Emergency Focus Mode?\n\nThis hides the normal app layout and starts a focus timer.",
):
return
self.enter_emergency_focus()
def enter_emergency_focus(self) -> None:
self.current_screen = "Focus"
for child in self.winfo_children():
child.destroy()
self.colors = self._colors()
self.configure(bg=self.colors["bg"])
self.body = tk.Frame(self, bg=self.colors["bg"])
self.body.pack(fill="both", expand=True)
self.main = tk.Frame(self.body, bg=self.colors["bg"])
self.main.pack(fill="both", expand=True)
scroll = ScrollFrame(self.main, self.colors["bg"])
scroll.pack(fill="both", expand=True)
scroll.inner.configure(padx=42, pady=36)
self.render_focus(scroll.inner, emergency=True)
self.apply_translations(scroll.inner)
self.start_timer()
def exit_emergency_focus(self) -> None:
self.build_shell()
self.show_today()
# Planner and calendar
def show_planner(self) -> None:
self.screen("Planner", self.render_planner)
def render_planner(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Time Blocking Planner",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(frame, "Drag blocks vertically to reschedule. Blocks snap to 15 minute intervals.", muted=True, pady=(4, 18))
controls = self.panel(frame)
controls.pack(fill="x", pady=(0, 14))
controls.configure(padx=18, pady=14)
kind_var = tk.StringVar(value="Study")
title_var = tk.StringVar(value="New block")
start_var = tk.StringVar(value="09:00")
duration_var = tk.StringVar(value="30")
for label_text, widget in [
("Type", tk.OptionMenu(controls, kind_var, *BLOCK_KINDS)),
("Title", self.entry(controls, title_var, 18)),
("Start", self.entry(controls, start_var, 8)),
("Minutes", self.entry(controls, duration_var, 6)),
]:
self.label(controls, label_text, side="left", padx=(0, 6))
widget.configure(bg=self.colors["soft"] if isinstance(widget, tk.OptionMenu) else self.colors["panel"], fg=self.colors["text"])
widget.pack(side="left", padx=(0, 12))
self.button(
controls,
"Add block",
lambda: self.add_block(kind_var.get(), title_var.get(), start_var.get(), duration_var.get()),
primary=True,
side="left",
)
self.label(controls, "View", side="left", padx=(18, 6))
for view_name in ("Day", "Week", "Month"):
option = tk.Radiobutton(
controls,
text=view_name,
variable=self.calendar_view_var,
value=view_name,
indicatoron=False,
command=lambda name=view_name: self.set_calendar_view(name),
bg=self.colors["soft"],
fg=self.colors["text"],
selectcolor=self.colors["accent"],
activebackground=self.colors["accent"],
activeforeground=self.colors["text"],
relief="flat",
padx=12,
pady=7,
font=self.body_font(9, "bold"),
)
option.pack(side="left", padx=(6, 0))
body = tk.Frame(frame, bg=self.colors["bg"])
body.pack(fill="both", expand=True)
body.columnconfigure(0, weight=3)
body.columnconfigure(1, weight=2)
timeline_panel = self.panel(body, row=0, column=0, sticky="nsew", padx=(0, 14))
timeline_panel.configure(padx=16, pady=16)
self.planner_canvas = tk.Canvas(timeline_panel, height=710, bg=self.colors["panel"], highlightthickness=0)
self.planner_canvas.pack(fill="both", expand=True)
self.draw_timeline()
side = self.panel(body, row=0, column=1, sticky="nsew")
side.configure(padx=18, pady=18)
self.label(side, "Calendar", size=15, weight="bold", pady=(0, 10))
cal_text = calendar.month(date.today().year, date.today().month)
tk.Label(
side,
text=cal_text,
bg=self.colors["panel"],
fg=self.colors["text"],
font=("Consolas", 10),
justify="left",
anchor="w",
).pack(anchor="w")
self.label(side, "Sync", size=13, weight="bold", pady=(14, 6))
self.render_google_calendar_controls(side)
self.label(side, "Conflict detection", size=13, weight="bold", pady=(16, 6))
conflicts = self.detect_conflicts()
if conflicts:
for conflict in conflicts:
self.label(side, conflict, muted=True)
else:
self.label(side, "No overlaps found for today.", muted=True)
def add_block(self, kind: str, title: str, start: str, duration: str) -> None:
try:
parse_clock(start)
minutes = max(5, min(360, int(duration)))
except (ValueError, TypeError):
messagebox.showerror("Block not added", "Use a start time like 09:30 and minutes as a number.")
return
self.data.setdefault("blocks", []).append(
{
"id": str(uuid.uuid4()),
"date": today_iso(),
"kind": kind,
"title": title.strip() or kind,
"start": start,
"duration": minutes,
}
)
self.save()
self.show_planner()
def set_calendar_view(self, view_name: str) -> None:
self.data.setdefault("settings", {})["calendar_view"] = view_name
self.save()
self.draw_timeline()
def blocks_for_date(self, target: date) -> list[dict[str, Any]]:
blocks = [
block
for block in self.data.get("blocks", [])
if block.get("date") == target.isoformat()
]
blocks.sort(key=lambda block: block.get("start", "99:99"))
return blocks
def draw_timeline(self) -> None:
canvas = self.planner_canvas
canvas.delete("all")
view_name = self.calendar_view_var.get() or "Day"
if view_name == "Week":
self.draw_week_view(canvas)
return
if view_name == "Month":
self.draw_month_view(canvas)
return
start_hour = 6
end_hour = 22
pixels_per_hour = 40
top = 28
left = 76
width = 680
for hour in range(start_hour, end_hour + 1):
y = top + (hour - start_hour) * pixels_per_hour
canvas.create_line(left, y, width, y, fill=self.colors["line"])
canvas.create_text(34, y, text=f"{hour:02d}:00", fill=self.colors["muted"], font=self.body_font(9))
blocks = self.blocks_for_date(date.today())
blocks.sort(key=lambda block: parse_clock(block.get("start", "00:00")))
for block in blocks:
start = parse_clock(block.get("start", "06:00"))
y = top + ((start - start_hour * 60) / 60) * pixels_per_hour
height = max(24, (block.get("duration", 30) / 60) * pixels_per_hour)
fill = CATEGORY_COLORS.get(block.get("kind"), self.colors["primary"])
tag = f"block_{block['id']}"
canvas.create_rectangle(left, y, width - 20, y + height, fill=fill, outline="", tags=(tag,))
canvas.create_text(
left + 12,
y + 10,
text=f"{block['start']} {block['title']}",
fill=LOGO_INK,
anchor="nw",
font=self.body_font(10, "bold"),
tags=(tag,),
)
canvas.create_text(
left + 12,
y + 30,
text=f"{block['kind']} · {block['duration']} min",
fill=LOGO_CHARCOAL,
anchor="nw",
font=self.body_font(9),
tags=(tag,),
)
canvas.tag_bind(tag, "<ButtonPress-1>", lambda event, block_id=block["id"]: self.block_press(event, block_id))
canvas.tag_bind(tag, "<B1-Motion>", self.block_drag)
canvas.tag_bind(tag, "<ButtonRelease-1>", self.block_release)
def draw_week_view(self, canvas: tk.Canvas) -> None:
today = date.today()
week_start = today - timedelta(days=today.weekday())
width = max(canvas.winfo_width(), 760)
left = 24
top = 38
day_width = (width - 48) / 7
canvas.create_text(
left,
16,
text=f"Week of {week_start.strftime('%d %b %Y')}",
anchor="w",
fill=self.colors["text"],
font=self.body_font(13, "bold"),
)
for index in range(7):
day = week_start + timedelta(days=index)
x = left + index * day_width
fill = self.colors["soft"] if day == today else self.colors["panel"]
canvas.create_rectangle(x, top, x + day_width - 8, 665, fill=fill, outline=self.colors["line"])
canvas.create_text(
x + 10,
top + 14,
text=day.strftime("%a %d"),
anchor="w",
fill=self.colors["text"],
font=self.body_font(10, "bold"),
)
y = top + 40
for block in self.blocks_for_date(day)[:8]:
block_fill = CATEGORY_COLORS.get(block.get("kind"), self.colors["primary"])
canvas.create_rectangle(x + 8, y, x + day_width - 16, y + 48, fill=block_fill, outline="")
canvas.create_text(
x + 14,
y + 8,
text=f"{block.get('start', '')} {block.get('title', 'Block')}",
anchor="nw",
fill=LOGO_INK,
font=self.body_font(8, "bold"),
width=max(70, int(day_width - 34)),
)
canvas.create_text(
x + 14,
y + 28,
text=f"{block.get('kind', 'Plan')} | {block.get('duration', 0)} min",
anchor="nw",
fill=LOGO_CHARCOAL,
font=self.body_font(8),
width=max(70, int(day_width - 34)),
)
y += 56
if not self.blocks_for_date(day):
canvas.create_text(
x + 10,
y,
text="No blocks",
anchor="w",
fill=self.colors["muted"],
font=self.body_font(9),
)
def draw_month_view(self, canvas: tk.Canvas) -> None:
today = date.today()
first = today.replace(day=1)
weeks = calendar.Calendar(firstweekday=0).monthdatescalendar(first.year, first.month)
width = max(canvas.winfo_width(), 760)
left = 28
top = 54
cell_width = (width - 56) / 7
cell_height = 92
canvas.create_text(
left,
22,
text=first.strftime("%B %Y"),
anchor="w",
fill=self.colors["text"],
font=self.body_font(15, "bold"),
)
for index, day_name in enumerate(("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")):
canvas.create_text(
left + index * cell_width + 8,
top - 18,
text=day_name,
anchor="w",
fill=self.colors["muted"],
font=self.body_font(9, "bold"),
)
for row_index, week in enumerate(weeks):
for col_index, day in enumerate(week):
x = left + col_index * cell_width
y = top + row_index * cell_height
in_month = day.month == first.month
fill = self.colors["soft"] if day == today else self.colors["panel"]
outline = self.colors["accent"] if day == today else self.colors["line"]
canvas.create_rectangle(x, y, x + cell_width - 8, y + cell_height - 8, fill=fill, outline=outline, width=2 if day == today else 1)
canvas.create_text(
x + 10,
y + 10,
text=str(day.day),
anchor="nw",
fill=self.colors["text"] if in_month else self.colors["muted"],
font=self.body_font(10, "bold"),
)
blocks = self.blocks_for_date(day)
if blocks:
total = sum(block.get("duration", 0) for block in blocks)
canvas.create_text(
x + 10,
y + 34,
text=f"{len(blocks)} block{'s' if len(blocks) != 1 else ''}",
anchor="nw",
fill=self.colors["text"],
font=self.body_font(9, "bold"),
)
canvas.create_text(
x + 10,
y + 54,
text=f"{total} min planned",
anchor="nw",
fill=self.colors["muted"],
font=self.body_font(8),
)
def block_press(self, event: tk.Event, block_id: str) -> None:
self.drag_block_id = block_id
self.drag_start_y = event.y
self.drag_total_pixels = 0
block = self._find_block(block_id)
self.drag_original_start = parse_clock(block.get("start", "06:00")) if block else 6 * 60
def block_drag(self, event: tk.Event) -> None:
if not self.drag_block_id:
return
dy = event.y - self.drag_start_y
self.drag_total_pixels += dy
self.planner_canvas.move(f"block_{self.drag_block_id}", 0, dy)
self.drag_start_y = event.y
def block_release(self, _event: tk.Event) -> None:
if not self.drag_block_id:
return
block = self._find_block(self.drag_block_id)
if block:
delta_minutes = round((self.drag_total_pixels / 10) * 15)
new_start = self.drag_original_start + delta_minutes
new_start = max(6 * 60, min(22 * 60, round(new_start / 15) * 15))
block["start"] = format_clock(new_start)
self.save()
self.drag_block_id = None
self.drag_total_pixels = 0
self.show_planner()
def _find_block(self, block_id: str) -> dict[str, Any] | None:
for block in self.data.get("blocks", []):
if block.get("id") == block_id:
return block
return None
def detect_conflicts(self) -> list[str]:
conflicts: list[str] = []
blocks = [block for block in self.data.get("blocks", []) if block.get("date") == today_iso()]
blocks.sort(key=lambda block: parse_clock(block.get("start", "00:00")))
for first, second in zip(blocks, blocks[1:]):
first_end = parse_clock(first["start"]) + first.get("duration", 0)
second_start = parse_clock(second["start"])
if first_end > second_start:
conflicts.append(f"{first['title']} overlaps {second['title']}.")
return conflicts
def render_google_calendar_controls(self, parent: tk.Misc) -> None:
settings = self.data.setdefault("settings", {})
path_var = tk.StringVar(value=settings.get("google_calendar_credentials_path", ""))
status = settings.get("google_calendar_status", "Not connected")
self.label(parent, f"Google Calendar API: {status}", muted=True, pady=(0, 8))
path_row = tk.Frame(parent, bg=self.colors["panel"])
path_row.pack(fill="x")
self.entry(path_row, path_var, 32).pack(side="left", fill="x", expand=True)
self.button(path_row, "Choose OAuth JSON", lambda: self.choose_google_credentials(path_var), side="left", padx=(8, 0))
sync_row = tk.Frame(parent, bg=self.colors["panel"])
sync_row.pack(fill="x", pady=(8, 0))
self.button(sync_row, "Connect Google Calendar", lambda: self.connect_google_calendar(path_var.get()), primary=True, side="left")
self.button(sync_row, "Export today", self.export_today_to_google_calendar, side="left", padx=8)
def choose_google_credentials(self, path_var: tk.StringVar) -> None:
selected = filedialog.askopenfilename(
title="Choose Google OAuth client JSON",
filetypes=[("JSON", "*.json")],
)
if not selected:
return
path_var.set(selected)
self.data.setdefault("settings", {})["google_calendar_credentials_path"] = selected
self.data["settings"]["google_calendar_status"] = "OAuth JSON selected"
self.save()
def connect_google_calendar(self, credentials_path: str | None = None):
if credentials_path:
self.data.setdefault("settings", {})["google_calendar_credentials_path"] = credentials_path
self.save()
service = self.google_calendar_service()
if service is None:
return None
try:
service.calendarList().get(calendarId="primary").execute()
except Exception as exc: # pragma: no cover - depends on Google network/API state
messagebox.showerror("Google Calendar", f"Google Calendar connection failed:\n\n{exc}")
return None
settings = self.data.setdefault("settings", {})
settings["google_calendar_connected"] = True
settings["google_calendar_status"] = "Connected to primary calendar"
self.save()
messagebox.showinfo("Google Calendar", "Connected to your primary Google Calendar.")
self.show_planner()
return service
def google_calendar_service(self):
credentials_path = self.data.get("settings", {}).get("google_calendar_credentials_path", "")
if not credentials_path or not Path(credentials_path).exists():
messagebox.showinfo(
"Google Calendar",
"Choose a Google OAuth client JSON file first. Create it in Google Cloud Console for the Google Calendar API.",
)
return None
try:
from google_auth_oauthlib.flow import InstalledAppFlow
from googleapiclient.discovery import build
except ImportError:
messagebox.showinfo(
"Google Calendar",
"Google Calendar API support is ready, but these packages are not installed yet:\n\n"
"pip install google-api-python-client google-auth-oauthlib google-auth-httplib2",
)
self.data.setdefault("settings", {})["google_calendar_status"] = "Google API packages needed"
self.save()
return None
scopes = ["https://www.googleapis.com/auth/calendar.events"]
try:
flow = InstalledAppFlow.from_client_secrets_file(credentials_path, scopes)
credentials = flow.run_local_server(port=0)
return build("calendar", "v3", credentials=credentials)
except Exception as exc: # pragma: no cover - depends on OAuth/browser/network state
messagebox.showerror("Google Calendar", f"Could not start Google OAuth:\n\n{exc}")
return None
def export_today_to_google_calendar(self) -> None:
service = self.google_calendar_service()
if service is None:
return
blocks = self.blocks_for_date(date.today())
if not blocks:
messagebox.showinfo("Google Calendar", "No blocks are planned for today.")
return
timezone = datetime.now().astimezone().tzinfo
created = 0
for block in blocks:
try:
block_date = parse_date(block.get("date")) or date.today()
start_time = datetime.strptime(block.get("start", "09:00"), "%H:%M").time()
start_dt = datetime.combine(block_date, start_time).replace(tzinfo=timezone)
end_dt = start_dt + timedelta(minutes=int(block.get("duration", 30)))
event = {
"summary": f"{block.get('title', 'TIJDMANAGER block')}",
"description": f"TIJDMANAGER {block.get('kind', 'Plan')} block",
"start": {"dateTime": start_dt.isoformat()},
"end": {"dateTime": end_dt.isoformat()},
}
service.events().insert(calendarId="primary", body=event).execute()
created += 1
except Exception as exc: # pragma: no cover - depends on Google network/API state
messagebox.showerror("Google Calendar", f"Could not export '{block.get('title', 'block')}':\n\n{exc}")
return
settings = self.data.setdefault("settings", {})
settings["google_calendar_connected"] = True
settings["google_calendar_status"] = f"Exported {created} block(s) today"
self.save()
messagebox.showinfo("Google Calendar", f"Exported {created} block(s) to Google Calendar.")
self.show_planner()
def calendar_placeholder(self) -> None:
messagebox.showinfo(
"Calendar sync",
"Use the Google Calendar controls in Planner to choose an OAuth JSON file and connect through the Google Calendar API.",
)
# Tasks and deadlines
def show_tasks(self) -> None:
self.screen("Tasks", self.render_tasks)
def render_tasks(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Task Management",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(frame, "Recurring tasks, subtasks, dependencies, tags, durations, notes, search, and archive.", muted=True, pady=(4, 18))
form = self.panel(frame)
form.pack(fill="x", pady=(0, 14))
form.configure(padx=18, pady=16)
title_var = tk.StringVar()
due_var = tk.StringVar(value=today_iso())
priority_var = tk.StringVar(value="🟡 Important")
tag_var = tk.StringVar(value="School")
estimate_var = tk.StringVar(value="30")
recurring_var = tk.StringVar(value="None")
dependency_var = tk.StringVar()
attachment_var = tk.StringVar()
rows = [
("Task", self.entry(form, title_var, 24)),
("Due", self.entry(form, due_var, 10)),
("Tag", self.entry(form, tag_var, 12)),
("Estimate", self.entry(form, estimate_var, 6)),
]
first_row = tk.Frame(form, bg=self.colors["panel"])
first_row.pack(fill="x", pady=(0, 10))
for label_text, widget in rows:
self.label(first_row, label_text, side="left", padx=(0, 5))
widget.pack(side="left", padx=(0, 12))
second_row = tk.Frame(form, bg=self.colors["panel"])
second_row.pack(fill="x")
for label_text, widget in [
("Priority", tk.OptionMenu(second_row, priority_var, *PRIORITIES)),
("Recurring", tk.OptionMenu(second_row, recurring_var, *RECURRENCE)),
("Depends on", self.entry(second_row, dependency_var, 18)),
]:
self.label(second_row, label_text, side="left", padx=(0, 5))
widget.configure(bg=self.colors["soft"] if isinstance(widget, tk.OptionMenu) else self.colors["panel"], fg=self.colors["text"])
widget.pack(side="left", padx=(0, 12))
self.button(
second_row,
"Attach file",
lambda: self.choose_attachment(attachment_var),
side="left",
)
self.button(
second_row,
"Add task",
lambda: self.add_task(
title_var.get(),
due_var.get(),
priority_var.get(),
tag_var.get(),
estimate_var.get(),
recurring_var.get(),
dependency_var.get(),
attachment_var.get(),
),
primary=True,
side="right",
)
search_row = tk.Frame(frame, bg=self.colors["bg"])
search_row.pack(fill="x", pady=(0, 10))
search_var = tk.StringVar()
self.label(search_row, "Search", side="left", padx=(0, 8))
search_entry = self.entry(search_row, search_var, 28)
search_entry.pack(side="left")
self.button(search_row, "Apply", lambda: self.render_task_list(frame, search_var.get()), side="left", padx=8)
list_holder = tk.Frame(frame, bg=self.colors["bg"])
list_holder.pack(fill="both", expand=True)
self.task_list_holder = list_holder
self.render_task_list(frame, "")
deadline_panel = self.panel(frame)
deadline_panel.pack(fill="x", pady=(18, 0))
deadline_panel.configure(padx=18, pady=16)
self.label(deadline_panel, "Deadline Tracker", size=15, weight="bold", pady=(0, 10))
for deadline in self.data.get("deadlines", []):
self.label(
deadline_panel,
f"{deadline['priority']} {deadline['title']} · {deadline['date']} · {deadline_recommendation(deadline)}",
muted=True,
)
def render_task_list(self, parent: tk.Frame, search: str) -> None:
holder = getattr(self, "task_list_holder", None)
if not holder:
return
for child in holder.winfo_children():
child.destroy()
search_lower = search.lower().strip()
tasks = [
task
for task in self.data.get("tasks", [])
if not task.get("archived")
and (
not search_lower
or search_lower in task.get("title", "").lower()
or search_lower in task.get("tag", "").lower()
)
]
for task in tasks:
row = self.panel(holder)
row.pack(fill="x", pady=6)
row.configure(padx=14, pady=12)
top = tk.Frame(row, bg=self.colors["panel"])
top.pack(fill="x")
var = tk.IntVar(value=1 if task.get("completed") else 0)
tk.Checkbutton(
top,
variable=var,
command=lambda task_id=task["id"], state=var: self.toggle_task(task_id, bool(state.get()), refresh="Tasks"),
bg=self.colors["panel"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
).pack(side="left")
title = f"{task.get('priority', '')} {task.get('title', 'Untitled')}"
tk.Label(
top,
text=title,
bg=self.colors["panel"],
fg=self.colors["text"],
font=self.body_font(12, "bold"),
anchor="w",
).pack(side="left", fill="x", expand=True)
meta = (
f"Due {task.get('due', 'none')} · {task.get('tag', 'General')} · "
f"{task.get('estimated_minutes', 0)}m est / {task.get('actual_minutes', 0)}m actual · "
f"{task.get('progress', 0)}%"
)
self.label(row, meta, muted=True, pady=(4, 0))
if task.get("dependency"):
self.label(row, f"Depends on: {task['dependency']}", muted=True)
if task.get("notes"):
self.label(row, task["notes"], muted=True)
for subtask in task.get("subtasks", []):
marker = "✓" if subtask.get("completed") else "○"
self.label(row, f" {marker} {subtask.get('title', '')}", muted=True)
actions = tk.Frame(row, bg=self.colors["panel"])
actions.pack(fill="x", pady=(8, 0))
self.button(actions, "Break into steps", lambda task_id=task["id"]: self.breakdown_task(task_id), side="left")
self.button(actions, "+25% progress", lambda task_id=task["id"]: self.bump_progress(task_id), side="left", padx=8)
self.button(actions, "+15m actual", lambda task_id=task["id"]: self.add_actual_time(task_id), side="left")
self.button(actions, "Archive", lambda task_id=task["id"]: self.archive_task(task_id), side="right")
def choose_attachment(self, var: tk.StringVar) -> None:
selected = filedialog.askopenfilename(title="Attach file")
if selected:
var.set(selected)
def add_task(
self,
title: str,
due: str,
priority: str,
tag: str,
estimate: str,
recurring: str,
dependency: str,
attachment: str,
) -> None:
if not title.strip():
messagebox.showerror("Task not added", "Add a task title first.")
return
if not parse_date(due):
messagebox.showerror("Task not added", "Use a due date like 2026-07-12.")
return
try:
estimated_minutes = max(5, int(estimate))
except ValueError:
estimated_minutes = estimate_minutes(title)
self.data.setdefault("tasks", []).append(
{
"id": str(uuid.uuid4()),
"title": title.strip(),
"completed": False,
"archived": False,
"priority": priority,
"due": due,
"tag": tag.strip() or "General",
"estimated_minutes": estimated_minutes,
"actual_minutes": 0,
"progress": 0,
"recurring": recurring,
"dependency": dependency.strip(),
"notes": "",
"attachments": [attachment] if attachment else [],
"subtasks": [],
}
)
if due != today_iso():
self.data.setdefault("deadlines", []).append(
{
"id": str(uuid.uuid4()),
"title": title.strip(),
"date": due,
"type": "Task",
"priority": priority,
}
)
self.save()
self.show_tasks()
def breakdown_task(self, task_id: str) -> None:
for task in self.data.get("tasks", []):
if task.get("id") == task_id:
task["subtasks"] = [
{"title": step, "completed": False}
for step in break_task_into_steps(task.get("title", ""))
]
break
self.save()
self.show_tasks()
def bump_progress(self, task_id: str) -> None:
for task in self.data.get("tasks", []):
if task.get("id") == task_id:
task["progress"] = min(100, task.get("progress", 0) + 25)
task["completed"] = task["progress"] == 100
break
self.save()
self.show_tasks()
def add_actual_time(self, task_id: str) -> None:
for task in self.data.get("tasks", []):
if task.get("id") == task_id:
task["actual_minutes"] = task.get("actual_minutes", 0) + 15
break
self.save()
self.show_tasks()
def archive_task(self, task_id: str) -> None:
for task in self.data.get("tasks", []):
if task.get("id") == task_id:
task["archived"] = True
break
self.save()
self.show_tasks()
# Mood and reflection
def show_mood(self) -> None:
self.screen("Mood", self.render_mood)
def render_mood(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Mood & Energy Tracking",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(frame, "A short check-in with optional energy, stress, and reflection prompts.", muted=True, pady=(4, 18))
panel = self.panel(frame)
panel.pack(fill="x")
panel.configure(padx=18, pady=18)
mood_var = tk.StringVar(value="🙂 Good")
mood_row = tk.Frame(panel, bg=self.colors["panel"])
mood_row.pack(fill="x", pady=(0, 12))
for mood in MOODS:
tk.Radiobutton(
mood_row,
text=mood,
variable=mood_var,
value=mood,
indicatoron=False,
bg=self.colors["soft"],
fg=self.colors["text"],
selectcolor=self.colors["accent"],
relief="flat",
padx=12,
pady=8,
font=self.body_font(10, "bold"),
).pack(side="left", padx=(0, 8))
energy_var = tk.IntVar(value=5)
stress_var = tk.IntVar(value=5)
self.label(panel, "Energy level", weight="bold")
tk.Scale(panel, from_=1, to=10, variable=energy_var, orient="horizontal", bg=self.colors["panel"], fg=self.colors["text"], highlightthickness=0).pack(fill="x")
self.label(panel, "Stress level", weight="bold", pady=(10, 0))
tk.Scale(panel, from_=1, to=10, variable=stress_var, orient="horizontal", bg=self.colors["panel"], fg=self.colors["text"], highlightthickness=0).pack(fill="x")
journal = self.panel(frame)
journal.pack(fill="x", pady=(16, 0))
journal.configure(padx=18, pady=18)
self.label(journal, "Reflection Journal", size=15, weight="bold", pady=(0, 8))
went = self._text_box(journal, "What went well?")
difficult = self._text_box(journal, "What was difficult?")
tomorrow = self._text_box(journal, "What will I do tomorrow?")
self.button(
journal,
"Save check-in",
lambda: self.save_mood(mood_var.get(), energy_var.get(), stress_var.get(), went, difficult, tomorrow),
primary=True,
anchor="w",
pady=(12, 0),
)
history = self.panel(frame)
history.pack(fill="x", pady=(16, 0))
history.configure(padx=18, pady=18)
self.label(history, "Recent check-ins", size=15, weight="bold", pady=(0, 8))
for item in sorted(self.data.get("moods", []), key=lambda entry: entry.get("date", ""), reverse=True)[:7]:
self.label(history, f"{item['date']} · {item['mood']} · energy {item['energy']} · stress {item['stress']}", muted=True)
def _text_box(self, parent: tk.Misc, prompt: str) -> tk.Text:
self.label(parent, prompt, weight="bold", pady=(8, 4))
text = tk.Text(
parent,
height=3,
bg=self.colors["panel"],
fg=self.colors["text"],
insertbackground=self.colors["text"],
relief="solid",
bd=1,
highlightthickness=1,
highlightbackground=self.colors["line"],
font=self.body_font(10),
wrap="word",
)
text.pack(fill="x")
return text
def save_mood(
self,
mood: str,
energy: int,
stress: int,
went: tk.Text,
difficult: tk.Text,
tomorrow: tk.Text,
) -> None:
entry = {"date": today_iso(), "mood": mood, "energy": energy, "stress": stress}
moods = [item for item in self.data.get("moods", []) if item.get("date") != today_iso()]
moods.append(entry)
self.data["moods"] = moods
journal_entry = {
"date": today_iso(),
"went_well": went.get("1.0", "end").strip(),
"difficult": difficult.get("1.0", "end").strip(),
"tomorrow": tomorrow.get("1.0", "end").strip(),
}
journals = [item for item in self.data.get("journal_entries", []) if item.get("date") != today_iso()]
journals.append(journal_entry)
self.data["journal_entries"] = journals
self.save()
messagebox.showinfo("Saved", "Your check-in was saved locally.")
self.show_mood()
# Habits
def show_habits(self) -> None:
self.screen("Habits", self.render_habits)
def render_habits(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Habit Tracker",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(frame, "Track streaks, completion rate, and monthly consistency separately from task routines.", muted=True, pady=(4, 18))
add_panel = self.panel(frame)
add_panel.pack(fill="x", pady=(0, 14))
add_panel.configure(padx=18, pady=14)
habit_var = tk.StringVar()
category_var = tk.StringVar(value="Wellbeing")
self.label(add_panel, "Habit", side="left", padx=(0, 6))
self.entry(add_panel, habit_var, 24).pack(side="left", padx=(0, 12))
self.label(add_panel, "Category", side="left", padx=(0, 6))
self.entry(add_panel, category_var, 14).pack(side="left", padx=(0, 12))
self.button(add_panel, "Add habit", lambda: self.add_habit(habit_var.get(), category_var.get()), primary=True, side="left")
for habit in self.data.get("habits", []):
row = self.panel(frame)
row.pack(fill="x", pady=6)
row.configure(padx=16, pady=12)
top = tk.Frame(row, bg=self.colors["panel"])
top.pack(fill="x")
completed = today_iso() in habit.get("completions", [])
var = tk.IntVar(value=1 if completed else 0)
tk.Checkbutton(
top,
variable=var,
command=lambda habit_id=habit["id"], state=var: self.toggle_habit(habit_id, bool(state.get())),
bg=self.colors["panel"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
).pack(side="left")
tk.Label(
top,
text=habit.get("name", "Habit"),
bg=self.colors["panel"],
fg=self.colors["text"],
font=self.body_font(12, "bold"),
).pack(side="left")
streak = self.habit_streak(habit)
rate = self.habit_completion_rate(habit)
self.label(row, f"{habit.get('category', 'General')} · streak {streak} days · monthly consistency {rate:.0%}", muted=True)
def add_habit(self, name: str, category: str) -> None:
if not name.strip():
return
self.data.setdefault("habits", []).append(
{"id": str(uuid.uuid4()), "name": name.strip(), "category": category.strip() or "General", "completions": []}
)
self.save()
self.show_habits()
def toggle_habit(self, habit_id: str, completed: bool) -> None:
for habit in self.data.get("habits", []):
if habit.get("id") == habit_id:
completions = set(habit.get("completions", []))
if completed:
completions.add(today_iso())
else:
completions.discard(today_iso())
habit["completions"] = sorted(completions)
break
self.save()
self.show_habits()
def habit_streak(self, habit: dict[str, Any]) -> int:
completions = set(habit.get("completions", []))
streak = 0
day = date.today()
while day.isoformat() in completions:
streak += 1
day -= timedelta(days=1)
return streak
def habit_completion_rate(self, habit: dict[str, Any]) -> float:
completions = set(habit.get("completions", []))
start = date.today() - timedelta(days=29)
done = sum(1 for offset in range(30) if (start + timedelta(days=offset)).isoformat() in completions)
return done / 30
# Analytics
def show_analytics(self) -> None:
self.screen("Analytics", self.render_analytics)
def render_analytics(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Study Analytics Dashboard",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(frame, "Daily hours, weekly hours, subject breakdown, focus score, completion rate, best times, heatmaps, and trends.", muted=True, pady=(4, 18))
stats = self.panel(frame)
stats.pack(fill="x", pady=(0, 14))
stats.configure(padx=18, pady=16)
sessions = self.data.get("focus_sessions", [])
week_start = date.today() - timedelta(days=6)
weekly = sum(s.get("minutes", 0) for s in sessions if parse_date(s.get("date")) and parse_date(s.get("date")) >= week_start)
daily = sum(s.get("minutes", 0) for s in sessions if s.get("date") == today_iso())
focus_score = min(100, int((weekly / 300) * 70 + completion_rate(self.data.get("tasks", [])) * 30))
best_time = self.best_study_time()
for text in [
f"Today: {daily // 60}h {daily % 60}m",
f"This week: {weekly // 60}h {weekly % 60}m",
f"Focus score: {focus_score}/100",
f"Completion rate: {completion_rate(self.data.get('tasks', [])):.0%}",
f"Best study time: {best_time}",
]:
self.label(stats, text, size=12, weight="bold", side="left", padx=(0, 22))
self.button(stats, "Save report as PDF", self.save_analytics_pdf, side="right")
chart_grid = tk.Frame(frame, bg=self.colors["bg"])
chart_grid.pack(fill="both", expand=True)
chart_grid.columnconfigure(0, weight=1)
chart_grid.columnconfigure(1, weight=1)
line_panel = self.panel(chart_grid, row=0, column=0, sticky="nsew", padx=(0, 8), pady=(0, 12))
pie_panel = self.panel(chart_grid, row=0, column=1, sticky="nsew", padx=(8, 0), pady=(0, 12))
heat_panel = self.panel(chart_grid, row=1, column=0, columnspan=2, sticky="nsew")
for panel, title in [(line_panel, "Daily focus line"), (pie_panel, "Subject breakdown"), (heat_panel, "Streak calendar")]:
panel.configure(padx=16, pady=16)
self.label(panel, title, size=14, weight="bold", pady=(0, 8))
line_canvas = tk.Canvas(line_panel, width=470, height=220, bg=self.colors["panel"], highlightthickness=0)
line_canvas.pack(fill="x")
self.draw_line_chart(line_canvas)
pie_canvas = tk.Canvas(pie_panel, width=420, height=220, bg=self.colors["panel"], highlightthickness=0)
pie_canvas.pack(fill="x")
self.draw_pie_chart(pie_canvas)
heat_canvas = tk.Canvas(heat_panel, width=920, height=170, bg=self.colors["panel"], highlightthickness=0)
heat_canvas.pack(fill="x")
self.draw_heatmap(heat_canvas)
insight_panel = self.panel(frame)
insight_panel.pack(fill="x", pady=(14, 0))
insight_panel.configure(padx=18, pady=16)
self.label(insight_panel, "Wellbeing Insights", size=15, weight="bold", pady=(0, 8))
for insight in wellbeing_insights(self.data):
self.label(insight_panel, insight, muted=True)
def best_study_time(self) -> str:
hours: Counter[int] = Counter()
for session in self.data.get("focus_sessions", []):
try:
hour = int(session.get("started_at", "00:00").split(":")[0])
except ValueError:
continue
hours[hour] += session.get("minutes", 0)
if not hours:
return "Not enough data"
hour = hours.most_common(1)[0][0]
return f"{hour:02d}:00"
def draw_line_chart(self, canvas: tk.Canvas) -> None:
start = date.today() - timedelta(days=6)
values = []
for offset in range(7):
day = start + timedelta(days=offset)
minutes = sum(s.get("minutes", 0) for s in self.data.get("focus_sessions", []) if s.get("date") == day.isoformat())
values.append((day.strftime("%a"), minutes))
max_value = max([value for _label, value in values] + [60])
points = []
for index, (_label, value) in enumerate(values):
x = 40 + index * 62
y = 180 - (value / max_value) * 140
points.append((x, y))
for index, (label, value) in enumerate(values):
x, y = points[index]
canvas.create_text(x, 202, text=label, fill=self.colors["muted"], font=self.body_font(8))
canvas.create_text(x, y - 12, text=str(value), fill=self.colors["muted"], font=self.body_font(8))
canvas.create_oval(x - 4, y - 4, x + 4, y + 4, fill=self.colors["primary"], outline="")
for first, second in zip(points, points[1:]):
canvas.create_line(*first, *second, fill=self.colors["primary"], width=3)
canvas.create_line(34, 182, 432, 182, fill=self.colors["line"])
def draw_pie_chart(self, canvas: tk.Canvas) -> None:
by_subject: Counter[str] = Counter()
for session in self.data.get("focus_sessions", []):
by_subject[session.get("subject", "Other")] += session.get("minutes", 0)
if not by_subject:
canvas.create_text(210, 100, text="No focus sessions yet", fill=self.colors["muted"], font=self.body_font(11))
return
colors = [LOGO_CYAN, LOGO_LIME, LOGO_CHARCOAL, LOGO_CYAN_SOFT, LOGO_LIME_SOFT]
total = sum(by_subject.values())
start_angle = 0
for index, (subject, minutes) in enumerate(by_subject.items()):
extent = (minutes / total) * 360
canvas.create_arc(40, 20, 200, 180, start=start_angle, extent=extent, fill=colors[index % len(colors)], outline=self.colors["panel"])
canvas.create_rectangle(235, 36 + index * 24, 247, 48 + index * 24, fill=colors[index % len(colors)], outline="")
canvas.create_text(254, 42 + index * 24, text=f"{subject}: {minutes}m", anchor="w", fill=self.colors["text"], font=self.body_font(9))
start_angle += extent
def draw_heatmap(self, canvas: tk.Canvas) -> None:
start = date.today() - timedelta(days=27)
minutes_by_day: defaultdict[str, int] = defaultdict(int)
for session in self.data.get("focus_sessions", []):
minutes_by_day[session.get("date", "")] += session.get("minutes", 0)
for offset in range(28):
day = start + timedelta(days=offset)
minutes = minutes_by_day[day.isoformat()]
row = offset // 14
col = offset % 14
x = 28 + col * 58
y = 26 + row * 58
fill = self.colors["soft"]
if minutes >= 25:
fill = LOGO_LIME
if minutes >= 50:
fill = LOGO_CYAN
if minutes >= 90:
fill = LOGO_CHARCOAL
canvas.create_rectangle(x, y, x + 36, y + 36, fill=fill, outline=self.colors["line"])
canvas.create_text(x + 18, y + 48, text=day.strftime("%d"), fill=self.colors["muted"], font=self.body_font(8))
# Coach
def show_coach(self) -> None:
self.screen("Coach", self.render_coach)
def render_coach(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Executive Function Assistant",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(frame, "Local planning help for task breakdown, study coaching, flashcards, quizzes, summaries, and time estimation.", muted=True, pady=(4, 18))
grid = tk.Frame(frame, bg=self.colors["bg"])
grid.pack(fill="both", expand=True)
grid.columnconfigure(0, weight=1)
grid.columnconfigure(1, weight=1)
breakdown = self.panel(grid, row=0, column=0, sticky="nsew", padx=(0, 8), pady=(0, 12))
goals = self.panel(grid, row=0, column=1, sticky="nsew", padx=(8, 0), pady=(0, 12))
for panel in (breakdown, goals):
panel.configure(padx=18, pady=18)
self.label(breakdown, "Break a task into steps", size=14, weight="bold", pady=(0, 8))
task_var = tk.StringVar(value="Write essay")
self.entry(breakdown, task_var, 34).pack(anchor="w")
steps_out = tk.Text(breakdown, height=9, bg=self.colors["panel"], fg=self.colors["text"], relief="solid", bd=1, font=self.body_font(10), wrap="word")
steps_out.pack(fill="x", pady=(10, 0))
self.button(
breakdown,
"Break down",
lambda: self.fill_steps(steps_out, task_var.get()),
primary=True,
anchor="w",
pady=(10, 0),
)
self.label(goals, "Adaptive goals and time estimate", size=14, weight="bold", pady=(0, 8))
estimate_task = tk.StringVar(value="Write report")
guessed = tk.StringVar(value="30")
self.label(goals, "Task", pady=(0, 4))
self.entry(goals, estimate_task, 34).pack(anchor="w")
self.label(goals, "Your guess in minutes", pady=(8, 4))
self.entry(goals, guessed, 10).pack(anchor="w")
estimate_out = tk.Label(goals, text="", bg=self.colors["panel"], fg=self.colors["text"], font=self.body_font(11), justify="left")
estimate_out.pack(anchor="w", pady=(12, 0))
self.button(
goals,
"Estimate",
lambda: self.fill_estimate(estimate_out, estimate_task.get(), guessed.get()),
primary=True,
anchor="w",
pady=(10, 0),
)
chat = self.panel(frame)
chat.pack(fill="both", expand=True)
chat.configure(padx=18, pady=18)
self.label(chat, "AI Study Coach (local)", size=15, weight="bold", pady=(0, 8))
history = ScrollFrame(chat, self.colors["panel"])
history.canvas.configure(height=230, bg=self.colors["panel"])
history.inner.configure(bg=self.colors["panel"])
history.pack(fill="both", expand=True)
self.add_chat_bubble(
history.inner,
"Coach",
"Ask for an explanation, quiz, flashcards, summary, study plan, or mistake review.",
from_user=False,
)
message_var = tk.StringVar()
send_row = tk.Frame(chat, bg=self.colors["panel"])
send_row.pack(fill="x", pady=(10, 0))
message_entry = self.entry(send_row, message_var, 70)
message_entry.pack(side="left", fill="x", expand=True)
message_entry.bind("<Return>", lambda _event: self.send_coach_message(history, message_var) or "break")
self.button(send_row, "Send", lambda: self.send_coach_message(history, message_var), primary=True, side="left", padx=(8, 0))
review = weekly_review(self.data)
review_panel = self.panel(frame)
review_panel.pack(fill="x", pady=(14, 0))
review_panel.configure(padx=18, pady=16)
self.label(review_panel, "AI Weekly Review", size=15, weight="bold", pady=(0, 8))
for key, value in review.items():
self.label(review_panel, f"{key}: {value}", muted=True)
def fill_steps(self, output: tk.Text, task: str) -> None:
output.delete("1.0", "end")
for index, step in enumerate(break_task_into_steps(task), start=1):
output.insert("end", f"{index}. {step}\n")
def fill_estimate(self, label: tk.Label, task: str, guess: str) -> None:
try:
user_guess = int(guess)
except ValueError:
user_guess = None
predicted = estimate_minutes(task, user_guess)
goals = adaptive_goals(predicted)
label.configure(
text=(
f"AI time estimate: {predicted} minutes\n"
f"Minimum: {goals['Minimum Goal']} min\n"
f"Target: {goals['Target Goal']} min\n"
f"Stretch: {goals['Stretch Goal']} min"
)
)
def add_chat_bubble(self, parent: tk.Misc, sender: str, message: str, from_user: bool) -> None:
row = tk.Frame(parent, bg=self.colors["panel"])
row.pack(fill="x", pady=5)
bubble = tk.Frame(
row,
bg=self.colors["accent"] if from_user else self.colors["soft"],
highlightbackground=self.colors["line"],
highlightthickness=1,
padx=12,
pady=9,
)
bubble.pack(side="right" if from_user else "left", padx=(90, 0) if from_user else (0, 90))
tk.Label(
bubble,
text=sender,
bg=bubble.cget("bg"),
fg=self.colors["muted"],
font=self.body_font(8, "bold"),
anchor="w",
).pack(anchor="w")
tk.Label(
bubble,
text=message,
bg=bubble.cget("bg"),
fg=self.colors["text"],
font=self.body_font(10),
justify="left",
wraplength=640,
).pack(anchor="w")
def send_coach_message(self, history: ScrollFrame, var: tk.StringVar) -> None:
message = var.get().strip()
if not message:
return
self.add_chat_bubble(history.inner, "You", message, from_user=True)
self.add_chat_bubble(history.inner, "Coach", coach_reply(message, self.data), from_user=False)
history.update_idletasks()
history.canvas.yview_moveto(1.0)
var.set("")
# Family sharing
def show_family(self) -> None:
self.screen("Account", self.render_family)
def render_family(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Account Sharing",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(
frame,
"Use one shared account space for trusted family members, colleagues, or support people.",
muted=True,
pady=(4, 18),
)
profile = self.data.setdefault("profile", {})
account_panel = self.panel(frame)
account_panel.pack(fill="x", pady=(0, 16))
account_panel.configure(padx=18, pady=16)
self.label(account_panel, "Shared account", size=15, weight="bold", pady=(0, 8))
account_name_var = tk.StringVar(value=profile.get("share_account_name", "TIJDMANAGER shared account"))
account_row = tk.Frame(account_panel, bg=self.colors["panel"])
account_row.pack(fill="x")
self.label(account_row, "Account name", side="left", padx=(0, 8))
self.entry(account_row, account_name_var, 34).pack(side="left", padx=(0, 16))
self.label(
account_row,
f"Account ID: {profile.get('share_account_id', 'TM-LOCAL')}",
muted=True,
side="left",
padx=(0, 16),
)
self.button(
account_row,
"Save account",
lambda: self.save_share_account(account_name_var.get()),
primary=True,
side="left",
)
grid = tk.Frame(frame, bg=self.colors["bg"])
grid.pack(fill="both", expand=True)
grid.columnconfigure(0, weight=1)
grid.columnconfigure(1, weight=1)
add_panel = self.panel(grid, row=0, column=0, sticky="nsew", padx=(0, 10))
add_panel.configure(padx=18, pady=18)
self.label(add_panel, "Invite trusted person", size=15, weight="bold", pady=(0, 10))
name_var = tk.StringVar()
email_var = tk.StringVar()
relation_var = tk.StringVar(value="Family")
self.label(add_panel, "Name", weight="bold", pady=(0, 4))
self.entry(add_panel, name_var, 34).pack(fill="x")
self.label(add_panel, "Email", weight="bold", pady=(10, 4))
self.entry(add_panel, email_var, 34).pack(fill="x")
self.label(add_panel, "Relationship", weight="bold", pady=(10, 4))
relation = tk.OptionMenu(
add_panel,
relation_var,
"Family",
"Parent",
"Sibling",
"Partner",
"Friend",
"Colleague",
"Teacher",
"Support person",
)
relation.configure(bg=self.colors["soft"], fg=self.colors["text"], relief="flat", highlightthickness=0)
relation.pack(fill="x")
self.button(
add_panel,
"Add to account",
lambda: self.add_family_member(name_var.get(), email_var.get(), relation_var.get()),
primary=True,
anchor="w",
pady=(14, 0),
)
consent_panel = self.panel(grid, row=0, column=1, sticky="nsew", padx=(10, 0))
consent_panel.configure(padx=18, pady=18)
self.label(consent_panel, "Sharing consent", size=15, weight="bold", pady=(0, 8))
family_enabled = tk.IntVar(value=1 if profile.get("family_sharing_enabled") else 0)
tk.Checkbutton(
consent_panel,
text="Enable shared account mode",
variable=family_enabled,
bg=self.colors["panel"],
fg=self.colors["text"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
font=self.body_font(10, "bold"),
).pack(anchor="w", pady=(0, 8))
labels = {
"tasks": "Share task titles and completion",
"deadlines": "Share deadline dates",
"focus_totals": "Share focus totals",
"schedule": "Share today's schedule",
"mood_trend": "Share mood trend only",
"journal": "Share journal entries",
}
consent_vars: dict[str, tk.IntVar] = {}
for key, label_text in labels.items():
consent_vars[key] = tk.IntVar(value=1 if profile.get("share_consent", {}).get(key) else 0)
tk.Checkbutton(
consent_panel,
text=label_text,
variable=consent_vars[key],
bg=self.colors["panel"],
fg=self.colors["text"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
font=self.body_font(10),
).pack(anchor="w", pady=2)
action_row = tk.Frame(consent_panel, bg=self.colors["panel"])
action_row.pack(fill="x", pady=(14, 0))
self.button(
action_row,
"Save sharing consent",
lambda: self.save_family_consent(family_enabled.get(), consent_vars),
primary=True,
side="left",
)
self.button(action_row, "Revoke all sharing", self.revoke_sharing, side="left", padx=8)
members_panel = self.panel(frame)
members_panel.pack(fill="x", pady=(16, 0))
members_panel.configure(padx=18, pady=18)
self.label(members_panel, "Trusted people", size=15, weight="bold", pady=(0, 10))
members = self.data.get("family_members", [])
if not members:
self.label(members_panel, "No family or colleague contacts added yet.", muted=True)
for member in members:
row = tk.Frame(members_panel, bg=self.colors["panel"])
row.pack(fill="x", pady=5)
text = f"{member.get('name', 'Trusted person')} | {member.get('relationship', 'Family')} | {member.get('email', '')}"
tk.Label(
row,
text=text,
bg=self.colors["panel"],
fg=self.colors["text"],
font=self.body_font(11, "bold"),
anchor="w",
).pack(side="left", fill="x", expand=True)
self.button(row, "Remove", lambda member_id=member["id"]: self.remove_family_member(member_id), side="right")
preview_panel = self.panel(frame)
preview_panel.pack(fill="x", pady=(16, 0))
preview_panel.configure(padx=18, pady=18)
self.label(preview_panel, "Account report preview", size=15, weight="bold", pady=(0, 8))
preview = tk.Text(
preview_panel,
height=7,
bg=self.colors["panel"],
fg=self.colors["text"],
relief="solid",
bd=1,
font=self.body_font(10),
wrap="word",
)
preview.pack(fill="x")
preview.insert("end", self.family_share_summary())
preview.configure(state="disabled")
preview_actions = tk.Frame(preview_panel, bg=self.colors["panel"])
preview_actions.pack(fill="x", pady=(12, 0))
self.button(preview_actions, "Share schedule with account", self.share_family_summary, primary=True, side="left")
self.button(preview_actions, "Save report as PDF", self.save_family_pdf, side="left", padx=8)
def add_family_member(self, name: str, email: str, relationship: str) -> None:
if not name.strip() and not email.strip():
messagebox.showerror("Account sharing", "Add a name or email first.")
return
self.data.setdefault("family_members", []).append(
{
"id": str(uuid.uuid4()),
"name": name.strip() or "Trusted person",
"email": email.strip(),
"relationship": relationship,
"active": True,
}
)
self.data.setdefault("profile", {})["family_sharing_enabled"] = True
self.save()
self.show_family()
def save_share_account(self, account_name: str) -> None:
profile = self.data.setdefault("profile", {})
profile["share_account_name"] = account_name.strip() or "TIJDMANAGER shared account"
profile.setdefault("share_account_id", f"TM-{uuid.uuid4().hex[:8].upper()}")
self.save()
messagebox.showinfo("Account sharing", "Shared account details were saved locally.")
self.show_family()
def remove_family_member(self, member_id: str) -> None:
self.data["family_members"] = [
member for member in self.data.get("family_members", []) if member.get("id") != member_id
]
self.save()
self.show_family()
def save_family_consent(self, family_enabled: int, consent_vars: dict[str, tk.IntVar]) -> None:
profile = self.data.setdefault("profile", {})
profile["family_sharing_enabled"] = bool(family_enabled)
profile["share_consent"] = {key: bool(var.get()) for key, var in consent_vars.items()}
self.save()
messagebox.showinfo("Account sharing", "Sharing preferences were saved locally.")
self.show_family()
def family_share_summary(self) -> str:
profile = self.data.get("profile", {})
consent = profile.get("share_consent", {})
members = self.data.get("family_members", [])
lines = [
f"TIJDMANAGER account report for {profile.get('name', 'Alex')}",
f"Shared account: {profile.get('share_account_name', 'TIJDMANAGER shared account')}",
f"Account ID: {profile.get('share_account_id', 'TM-LOCAL')}",
f"Trusted people: {len(members)}",
]
if not profile.get("family_sharing_enabled"):
return "Account sharing is off. Nothing will be shared."
if consent.get("schedule"):
todays_blocks = [
block for block in self.data.get("blocks", [])
if block.get("date") == today_iso()
]
todays_blocks.sort(key=lambda block: block.get("start", "99:99"))
if todays_blocks:
lines.append("Today's schedule:")
for block in todays_blocks:
lines.append(
f"- {block.get('start', '--:--')} {block.get('title', 'Block')} "
f"({block.get('kind', 'Plan')}, {block.get('duration', 0)} min)"
)
else:
lines.append("Today's schedule: no blocks planned.")
if consent.get("tasks"):
visible = [task for task in self._today_tasks() if not task.get("archived")]
done = sum(1 for task in visible if task.get("completed"))
lines.append(f"Tasks today: {done}/{len(visible)} completed.")
if consent.get("deadlines"):
upcoming = sorted(self.data.get("deadlines", []), key=lambda item: item.get("date", ""))[:2]
if upcoming:
lines.append("Upcoming deadlines: " + ", ".join(f"{item['title']} ({item['date']})" for item in upcoming))
if consent.get("focus_totals"):
minutes = sum(
session.get("minutes", 0)
for session in self.data.get("focus_sessions", [])
if session.get("date") == today_iso()
)
lines.append(f"Focus today: {minutes} minutes.")
if consent.get("mood_trend"):
mood = next((item for item in reversed(self.data.get("moods", [])) if item.get("date")), None)
if mood:
lines.append(f"Mood trend: latest check-in was {mood.get('mood')}.")
if consent.get("journal"):
lines.append("Journal sharing is enabled. Review this carefully before real online sharing.")
if len(lines) == 4:
lines.append("No data categories are currently allowed.")
return "\n".join(lines)
def share_family_summary(self) -> None:
members = self.data.get("family_members", [])
if not self.data.get("profile", {}).get("family_sharing_enabled") or not members:
messagebox.showinfo("Account sharing", "Add a trusted person and enable sharing first.")
return
messagebox.showinfo(
"Account sharing",
"This local build prepared the account schedule report. Real sending needs Google/OAuth or another sharing service.",
)
def save_family_pdf(self) -> None:
self.save_text_report_pdf(
"Save account report",
"TIJDMANAGER account report",
self.family_share_summary(),
"tijdmanager-account-report.pdf",
)
def analytics_report_text(self) -> str:
sessions = self.data.get("focus_sessions", [])
week_start = date.today() - timedelta(days=6)
weekly = sum(
session.get("minutes", 0)
for session in sessions
if parse_date(session.get("date")) and parse_date(session.get("date")) >= week_start
)
daily = sum(session.get("minutes", 0) for session in sessions if session.get("date") == today_iso())
task_rate = completion_rate(self.data.get("tasks", []))
focus_score = min(100, int((weekly / 300) * 70 + task_rate * 30))
by_subject: Counter[str] = Counter()
for session in sessions:
by_subject[session.get("subject", "Other")] += session.get("minutes", 0)
lines = [
f"TIJDMANAGER study report for {self.data.get('profile', {}).get('name', 'Alex')}",
f"Date: {today_iso()}",
f"Focus today: {daily} minutes",
f"Focus this week: {weekly} minutes",
f"Focus score: {focus_score}/100",
f"Completion rate: {task_rate:.0%}",
f"Best study time: {self.best_study_time()}",
"",
"Subject breakdown:",
]
if by_subject:
lines.extend(f"- {subject}: {minutes} minutes" for subject, minutes in by_subject.items())
else:
lines.append("- No focus sessions yet")
lines.append("")
lines.append("Wellbeing insights:")
lines.extend(f"- {insight}" for insight in wellbeing_insights(self.data))
return "\n".join(lines)
def save_analytics_pdf(self) -> None:
self.save_text_report_pdf(
"Save study report",
"TIJDMANAGER study report",
self.analytics_report_text(),
"tijdmanager-study-report.pdf",
)
def save_text_report_pdf(self, dialog_title: str, report_title: str, body: str, filename: str) -> None:
selected = filedialog.asksaveasfilename(
title=dialog_title,
defaultextension=".pdf",
initialfile=filename,
filetypes=[("PDF", "*.pdf")],
)
if not selected:
return
self.write_text_pdf(Path(selected), report_title, body)
messagebox.showinfo("PDF saved", "The PDF report was saved locally.")
def write_text_pdf(self, path: Path, title: str, body: str) -> None:
lines: list[str] = []
for raw_line in body.splitlines():
if not raw_line:
lines.append("")
continue
lines.extend(textwrap.wrap(raw_line, width=88) or [""])
lines = lines[:46]
safe_title = self.pdf_text(title)
content_lines = [
"BT",
"/F1 18 Tf",
"72 770 Td",
f"({self.pdf_escape(safe_title)}) Tj",
"0 -30 Td",
"/F1 11 Tf",
"15 TL",
]
for line in lines:
content_lines.append(f"({self.pdf_escape(self.pdf_text(line))}) Tj")
content_lines.append("T*")
content_lines.append("ET")
content = "\n".join(content_lines).encode("latin-1", "replace")
objects = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
b"<< /Length " + str(len(content)).encode("ascii") + b" >>\nstream\n" + content + b"\nendstream",
]
pdf = b"%PDF-1.4\n"
offsets = [0]
for index, obj in enumerate(objects, start=1):
offsets.append(len(pdf))
pdf += f"{index} 0 obj\n".encode("ascii") + obj + b"\nendobj\n"
xref_offset = len(pdf)
pdf += f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode("ascii")
for offset in offsets[1:]:
pdf += f"{offset:010d} 00000 n \n".encode("ascii")
pdf += (
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
f"startxref\n{xref_offset}\n%%EOF\n"
).encode("ascii")
path.write_bytes(pdf)
def pdf_text(self, text: str) -> str:
return text.encode("latin-1", "replace").decode("latin-1")
def pdf_escape(self, text: str) -> str:
return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
# Settings, privacy, account controls
def show_settings(self) -> None:
self.screen("Settings", self.render_settings)
def render_settings(self, frame: tk.Frame) -> None:
tk.Label(
frame,
text="Settings & Privacy",
bg=self.colors["bg"],
fg=self.colors["text"],
font=self.title_font(25),
).pack(anchor="w")
self.label(frame, "Sensory-friendly controls, language options, local data control, and account sharing consent.", muted=True, pady=(4, 18))
profile = self.data.setdefault("profile", {})
settings = self.data.setdefault("settings", {})
name_var = tk.StringVar(value=profile.get("name", "Alex"))
language_var = tk.StringVar(value=profile.get("language", LANGUAGES[0]))
toggles = {
"reduce_animations": tk.IntVar(value=1 if settings.get("reduce_animations") else 0),
"dark_mode": tk.IntVar(value=1 if settings.get("dark_mode") else 0),
"low_stimulation": tk.IntVar(value=1 if settings.get("low_stimulation") else 0),
"high_contrast": tk.IntVar(value=1 if settings.get("high_contrast") else 0),
"sound": tk.IntVar(value=1 if settings.get("sound") else 0),
}
sensory = self.panel(frame)
sensory.pack(fill="x")
sensory.configure(padx=18, pady=18)
self.label(sensory, "Sensory-Friendly Settings", size=15, weight="bold", pady=(0, 10))
row = tk.Frame(sensory, bg=self.colors["panel"])
row.pack(fill="x", pady=(0, 10))
self.label(row, "Name", side="left", padx=(0, 8))
self.entry(row, name_var, 18).pack(side="left", padx=(0, 18))
self.label(row, "Language", side="left", padx=(0, 8))
lang_menu = tk.OptionMenu(row, language_var, *LANGUAGES)
lang_menu.configure(bg=self.colors["soft"], fg=self.colors["text"], relief="flat", highlightthickness=0)
lang_menu.pack(side="left")
checks = tk.Frame(sensory, bg=self.colors["panel"])
checks.pack(fill="x")
labels = {
"reduce_animations": "Reduce animations",
"dark_mode": "Dark mode",
"low_stimulation": "Low stimulation mode",
"high_contrast": "High contrast mode",
"sound": "Sound on/off",
}
for key, label_text in labels.items():
tk.Checkbutton(
checks,
text=label_text,
variable=toggles[key],
bg=self.colors["panel"],
fg=self.colors["text"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
font=self.body_font(10),
).pack(side="left", padx=(0, 14))
self.button(
sensory,
"Apply settings",
lambda: self.apply_settings(name_var.get(), language_var.get(), toggles),
primary=True,
anchor="w",
pady=(12, 0),
)
privacy = self.panel(frame)
privacy.pack(fill="x", pady=(16, 0))
privacy.configure(padx=18, pady=18)
self.label(privacy, "Privacy & Data Control", size=15, weight="bold", pady=(0, 8))
policy = (
"Storage is local-only by default. Export and delete are available here. "
"Online sync, Google login, and account sharing stay off until you connect credentials and give granular consent."
)
self.label(privacy, policy, muted=True, pady=(0, 12))
privacy_actions = tk.Frame(privacy, bg=self.colors["panel"])
privacy_actions.pack(fill="x")
self.button(privacy_actions, "Enable privacy lock", self.enable_privacy_lock, side="left")
self.button(privacy_actions, "Disable privacy lock", self.disable_privacy_lock, side="left", padx=8)
self.button(privacy_actions, "Export data", self.export_data, side="left")
self.button(privacy_actions, "Delete all data", self.delete_all_data, side="left", padx=8)
account = self.panel(frame)
account.pack(fill="x", pady=(16, 0))
account.configure(padx=18, pady=18)
self.label(account, "Account & Sharing", size=15, weight="bold", pady=(0, 8))
account_text = (
f"Signed in as {profile.get('name', 'Alex')}"
f" · {profile.get('account_provider', 'Local')}"
)
if profile.get("email"):
account_text += f" · {profile.get('email')}"
self.label(account, account_text, muted=True, pady=(0, 8))
self.button(account, "Connect Google account", self.google_placeholder, anchor="w")
self.button(account, "Sign out", self.sign_out, anchor="w", pady=(8, 0))
family_var = tk.IntVar(value=1 if profile.get("family_sharing_enabled") else 0)
tk.Checkbutton(
account,
text="Enable shared account mode",
variable=family_var,
bg=self.colors["panel"],
fg=self.colors["text"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
font=self.body_font(10, "bold"),
).pack(anchor="w", pady=(12, 6))
consent_vars: dict[str, tk.IntVar] = {}
consent_labels = {
"tasks": "Share task titles and completion",
"deadlines": "Share deadline dates",
"focus_totals": "Share focus totals",
"schedule": "Share today's schedule",
"mood_trend": "Share mood trend only",
"journal": "Share journal entries",
}
for key, label_text in consent_labels.items():
consent_vars[key] = tk.IntVar(value=1 if profile.get("share_consent", {}).get(key) else 0)
tk.Checkbutton(
account,
text=label_text,
variable=consent_vars[key],
bg=self.colors["panel"],
fg=self.colors["text"],
activebackground=self.colors["panel"],
selectcolor=self.colors["soft"],
font=self.body_font(10),
).pack(anchor="w")
account_actions = tk.Frame(account, bg=self.colors["panel"])
account_actions.pack(fill="x", pady=(12, 0))
self.button(
account_actions,
"Save sharing consent",
lambda: self.save_consent(family_var.get(), consent_vars),
primary=True,
side="left",
)
self.button(account_actions, "Revoke all sharing", self.revoke_sharing, side="left", padx=8)
def apply_settings(self, name: str, language: str, toggles: dict[str, tk.IntVar]) -> None:
self.data["profile"]["name"] = name.strip() or "Alex"
self.data["profile"]["language"] = language
for key, var in toggles.items():
self.data["settings"][key] = bool(var.get())
self.save()
self.build_shell()
self.show_settings()
def enable_privacy_lock(self) -> None:
first = simpledialog.askstring("Privacy lock", "Create a local privacy passphrase:", show="*")
if not first:
return
second = simpledialog.askstring("Privacy lock", "Confirm the passphrase:", show="*")
if first != second:
messagebox.showerror("Privacy lock", "Passphrases did not match.")
return
self.store.enable_privacy_lock(first)
messagebox.showinfo("Privacy lock", "Local privacy lock enabled. Keep the passphrase safe.")
self.build_shell()
self.show_settings()
def disable_privacy_lock(self) -> None:
if not messagebox.askyesno("Disable privacy lock", "Save future data without the local privacy lock?"):
return
self.store.disable_privacy_lock()
messagebox.showinfo("Privacy lock", "Privacy lock disabled for future saves.")
self.build_shell()
self.show_settings()
def export_data(self) -> None:
selected = filedialog.asksaveasfilename(
title="Export TIJDMANAGER data",
defaultextension=".json",
filetypes=[("JSON", "*.json")],
)
if selected:
self.store.export_to(Path(selected))
messagebox.showinfo("Export complete", "Your decrypted local data export was created.")
def delete_all_data(self) -> None:
if not messagebox.askyesno("Delete all data", "Delete all local TIJDMANAGER data and reset the app?"):
return
self.store.delete_all()
self.data = self.store.data
self.build_shell()
self.show_today()
def google_placeholder(self) -> None:
messagebox.showinfo(
"Google account",
"Google login needs OAuth client credentials before it can be enabled. No account data is shared in this build.",
)
def sign_out(self) -> None:
if not messagebox.askyesno("Sign out", "Return to the TIJDMANAGER login screen?"):
return
self.data.setdefault("profile", {})["logged_in"] = False
self.save()
self.show_login()
def save_consent(self, family_enabled: int, consent_vars: dict[str, tk.IntVar]) -> None:
profile = self.data.setdefault("profile", {})
profile["family_sharing_enabled"] = bool(family_enabled)
profile["share_consent"] = {key: bool(var.get()) for key, var in consent_vars.items()}
self.save()
messagebox.showinfo("Consent saved", "Sharing preferences were saved locally.")
def revoke_sharing(self) -> None:
profile = self.data.setdefault("profile", {})
profile["family_sharing_enabled"] = False
profile["share_consent"] = {
"tasks": False,
"deadlines": False,
"focus_totals": False,
"schedule": False,
"mood_trend": False,
"journal": False,
}
self.save()
messagebox.showinfo("Sharing revoked", "All sharing consent has been turned off.")
self.show_settings()
def main() -> None:
prompt = tk.Tk()
prompt.withdraw()
passphrase = None
if Store.is_locked():
passphrase = simpledialog.askstring(
"TIJDMANAGER privacy lock",
"Enter your local privacy passphrase:",
show="*",
parent=prompt,
)
if not passphrase:
prompt.destroy()
return
try:
store = Store(passphrase=passphrase)
except PrivacyError as exc:
messagebox.showerror("Could not unlock TIJDMANAGER", str(exc), parent=prompt)
prompt.destroy()
return
prompt.destroy()
app = TijdManagerApp(store)
app.mainloop()
if __name__ == "__main__":
main()