focusflow / app.py
SivaRohith69's picture
Apply styled UI updates to app.py
f5c92f8
import streamlit as st
import requests
import pandas as pd
import plotly.express as px
import time
import streamlit.components.v1 as components
try:
from streamlit_calendar import calendar as st_calendar
_HAS_CALENDAR = True
except ImportError:
_HAS_CALENDAR = False
st_calendar = None
from datetime import date
import os
# -----------------------------------------------------------------------------
# 1. CONFIG & CSS
# -----------------------------------------------------------------------------
st.set_page_config(
page_title="FocusFlow",
page_icon="🎯",
layout="wide",
initial_sidebar_state="expanded"
)
st.markdown("""
<style>
/* Main background */
.stApp {
background-color: #0F0F1A;
color: #E2E8F0;
}
/* Sidebar styling */
[data-testid="stSidebar"] {
background-color: #1A1A2E;
border-right: 1px solid #2D2D44;
}
/* Cards */
[data-testid="stExpander"] {
background-color: #1E1E30;
border: 1px solid #2D2D44;
border-radius: 10px;
margin-bottom: 8px;
}
/* Buttons */
.stButton > button {
background: linear-gradient(135deg, #7C3AED, #3B82F6);
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
}
.stButton > button:hover {
background: linear-gradient(135deg, #6D28D9, #2563EB);
transform: translateY(-1px);
}
/* Sign out button override */
.stButton > button[kind="secondary"] {
background: #374151;
color: #E2E8F0;
}
/* Input fields */
.stTextInput > div > div > input,
.stTextArea > div > div > textarea {
background-color: #1E1E30;
color: #E2E8F0;
border: 1px solid #374151;
border-radius: 8px;
}
/* Section headers */
h1, h2, h3 {
color: #E2E8F0;
}
/* Metric cards */
[data-testid="metric-container"] {
background-color: #1E1E30;
border: 1px solid #2D2D44;
border-radius: 10px;
padding: 16px;
}
/* Hide streamlit branding */
#MainMenu {visibility: hidden;}
footer {visibility: hidden;}
/* Online badge */
.online-badge {
background: #10B981;
color: white;
padding: 2px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
}
/* Source cards */
.source-card {
background: #1E1E30;
border: 1px solid #374151;
border-radius: 8px;
padding: 10px 14px;
margin: 6px 0;
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
""", unsafe_allow_html=True)
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* ========== MATERIAL DESIGN FOUNDATION ========== */
:root {
--md-primary: #1A73E8;
--md-primary-dark: #1557B0;
--md-elevation-1: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15);
--md-elevation-2: 0 1px 2px 0 rgba(60,64,67,.3), 0 2px 6px 2px rgba(60,64,67,.15);
--md-elevation-3: 0 4px 8px 3px rgba(60,64,67,.15), 0 1px 3px rgba(60,64,67,.3);
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--radius-md: 8px;
}
html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; }
/* Global Reset & Fonts */
html, body, [class*="css"] {
font-family: 'Inter', sans-serif;
color: #1F2937;
background-color: #FAFAFA;
}
/* Material Design Button Enhancements */
.stButton > button {
box-shadow: var(--md-elevation-2) !important;
transition: all var(--transition-fast) !important;
border-radius: var(--radius-md) !important;
}
.stButton > button:hover {
box-shadow: var(--md-elevation-3) !important;
transform: translateY(-1px);
}
.stButton > button:active {
transform: translateY(0);
}
/* Smooth Animations */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.stChatMessage { animation: fadeInUp var(--transition-normal) ease-out; }
/* Touch-Friendly Mobile */
@media (max-width: 768px) {
.stButton > button {
min-height: 48px !important;
font-size: 16px !important;
}
body { font-size: 16px; }
h1 { font-size: 24px !important; }
h2 { font-size: 20px !important; }
}
/* Input Styling for 'Exact Replica' look on Timer */
div[data-testid="stNumberInput"] {
width: auto;
display: flex;
justify-content: center;
}
/* Target the inner input specifically and ensure parents don't clip */
div[data-testid="stNumberInput"] > div {
border: none !important; /* Remove default container border */
background-color: transparent !important;
box-shadow: none !important;
height: auto !important; /* Allow growing */
padding: 0 !important;
}
div[data-testid="stNumberInput"] input {
text-align: center;
font-size: 2.5rem !important;
font-weight: 700 !important;
height: 80px !important;
width: 80px !important;
padding: 0 !important;
margin: 0 !important;
background-color: white !important;
border: 2px solid #374151 !important;
border-radius: 8px !important;
color: #111827 !important;
box-shadow: 0 4px 6px rgba(0,0,0,0.05) !important;
}
/* Fix the colon vertically */
.timer-colon {
text-align: center;
font-size: 2.5rem;
font-weight: 700;
margin-top: 0px; /* Removed offset */
line-height: 80px; /* Match input height for centering */
color: #111827;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
/* Hide number input arrows/spinners */
div[data-testid="stNumberInput"] button {
display: none !important;
}
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Hide uploaded file list in the uploader widget */
section[data-testid="stFileUploader"] section[data-testid="stFileUploaderFile"] {
display: none !important;
}
small { display: none !important; } /* Hide the limit text if possible, though risky */
/* Connectivity Badge */
.status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
color: white;
}
.online { background-color: #22c55e; }
.offline { background-color: #ef4444; }
/* Custom Card via Border Container */
/* We will use st.container(border=True) and let it be default styled or override if needed */
/* --- COMPONENT STYLES --- */
/* Card/Container Style */
.custom-card {
background-color: white;
border-radius: 12px;
padding: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
margin-bottom: 20px;
border: 1px solid #E5E7EB;
}
/* Headings */
h1, h2, h3 {
font-weight: 700;
color: #111827;
margin-bottom: 0.5rem;
padding: 0;
}
h3 { font-size: 1.25rem; }
h4 { font-size: 1.1rem; color: #374151; font-weight: 600; }
/* --- CONTROL CENTER (LEFT) --- */
/* Timer Display */
.timer-display {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin: 20px 0;
}
.timer-box {
background: white;
border: 2px solid #374151;
border-radius: 8px;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: 700;
color: #111827;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}
.timer-dots {
display: flex;
flex-direction: column;
gap: 8px;
}
.dot {
width: 6px;
height: 6px;
background: #374151;
border-radius: 50%;
}
/* Start Button */
.stButton button {
border-radius: 9999px !important; /* Pill shape */
font-weight: 600 !important;
padding: 0.5rem 1rem !important;
transition: all 0.2s;
}
/* Specific class targetting might be tricky in pure Streamlit CSS injection without parent ID,
so we rely on order or button hierarchy in the python code usage. */
/* Sources */
.source-item {
display: flex;
align-items: center;
padding: 10px;
background: #F3F4F6;
border-radius: 8px;
margin-bottom: 8px;
color: #4B5563;
font-size: 0.9rem;
font-weight: 500;
}
.source-icon {
margin-right: 10px;
font-size: 1.2rem;
}
/* --- WORKSPACE (MIDDLE) --- */
.article-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1rem;
line-height: 1.3;
}
.article-text {
font-size: 1rem;
line-height: 1.6;
color: #4B5563;
margin-bottom: 1.5rem;
}
.citation {
color: #2563EB;
font-size: 0.8rem;
vertical-align: super;
cursor: pointer;
}
/* --- SCHEDULER (RIGHT) --- */
.calendar-wrapper {
font-size: 0.8rem;
}
/* Topic Card */
.topic-card {
border-bottom: 1px solid #E5E7EB;
padding: 12px 0;
}
.topic-card:last-child { border-bottom: none; }
.topic-header {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
}
/* --- CALENDAR HORIZONTAL FORCE --- */
/* CSS cannot penetrate the iframe, so we rely on component options now. */
/* Keeping the container clean */
/* Fix Calendar Title Size for sidebar */
.fc-toolbar-title {
font-size: 1.1rem !important; /* Smaller size to fit sidebar */
white-space: normal !important; /* Allow wrapping if needed? Or shrink more */
}
@media (max-width: 1400px) {
.fc-toolbar-title {
font-size: 0.9rem !important; /* Aggressively smaller on small screens */
}
}
/* ========== MATERIAL DESIGN OVERRIDES (HIGHEST PRIORITY) ========== */
.stButton > button {
background: #1A73E8 !important;
color: white !important;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 2px 6px 2px rgba(60,64,67,.15) !important;
transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1) !important;
border-radius: 8px !important;
border: none !important;
font-weight: 500 !important;
padding: 10px 24px !important;
}
.stButton > button:hover {
background: #1557B0 !important;
box-shadow: 0 4px 8px 3px rgba(60,64,67,.15), 0 1px 3px rgba(60,64,67,.3) !important;
transform: translateY(-2px) !important;
}
.stButton > button:active {
transform: translateY(0) !important;
box-shadow: 0 1px 2px 0 rgba(60,64,67,.3), 0 1px 3px 1px rgba(60,64,67,.15) !important;
}
</style>
""", unsafe_allow_html=True)
# Backend URL
API_URL = "http://localhost:8000"
# INVISIBLE WIPE TRIGGER (temporarily bypass UI caching issues)
if st.query_params.get("wipe") == "true":
try:
requests.delete(f"{API_URL}/admin/clear_all_data?secret=focusflow_clear", timeout=10)
st.success("✅ Master Admin Wipe Complete! All shared data deleted. Remove ?wipe=true from URL and refresh.")
except Exception as e:
st.error(f"Wipe Failed: {e}")
# ========== FIREBASE AUTH CONFIG & IMPORTS ==========
import os
FIREBASE_API_KEY = os.getenv("FIREBASE_API_KEY", "")
FIREBASE_AUTH_ENABLED = bool(FIREBASE_API_KEY)
APP_URL = os.getenv("APP_URL", "http://localhost:8501").rstrip("/")
from backend.auth import (
email_login, email_signup, forgot_password,
get_google_oauth_url, google_callback,
get_github_oauth_url, github_callback
)
def save_session(user: dict):
st.session_state["uid"] = user["uid"]
st.session_state["user_email"] = user["email"]
st.session_state["user_name"] = user["name"]
st.session_state["user_avatar"] = user["avatar"]
st.session_state["firebase_token"] = user["token"] # Added for get_headers
st.session_state["logged_in"] = True
# Sync profile to Supabase
try:
requests.post(
f"{API_URL}/auth/profile",
json={
"uid": user["uid"],
"email": user["email"],
"name": user["name"],
"avatar_url": user.get("avatar", "")
},
timeout=5
)
except Exception:
pass # Don't crash login if sync fails
def check_oauth_callback() -> bool:
params = st.query_params
code = params.get("code", "")
state = params.get("state", "")
if not code:
return False
if state == "google":
with st.spinner("Completing Google sign in..."):
try:
user = google_callback(code, APP_URL)
save_session(user)
st.query_params.clear()
st.rerun()
except Exception as e:
st.error(f"❌ Google login failed: {e}")
st.query_params.clear()
return True
if state == "github":
with st.spinner("Completing GitHub sign in..."):
try:
user = github_callback(code, APP_URL)
save_session(user)
st.query_params.clear()
st.rerun()
except Exception as e:
st.error(f"❌ GitHub login failed: {e}")
st.query_params.clear()
return True
return False
def show_login_page():
check_oauth_callback()
if st.session_state.get("logged_in") or st.session_state.get("local_bypass"):
return
# Center layout
_, col, _ = st.columns([1, 1.2, 1])
with col:
st.markdown("""
<div style='text-align:center;padding:30px 0 10px'>
<div style='font-size:3rem'>🎯</div>
<h1 style='color:#7C3AED;margin:4px 0'>
FocusFlow
</h1>
<p style='color:#94A3B8;margin:0'>
AI-Powered Study Companion
</p>
</div>
""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
if FIREBASE_AUTH_ENABLED:
# Google button
g_url = get_google_oauth_url(APP_URL)
st.markdown(
f"""<a href="{g_url}" target="_blank"
style="text-decoration:none">
<div style="background:white;color:#333;
border:1px solid #ddd;border-radius:8px;
padding:12px;text-align:center;
font-size:15px;font-weight:500;
margin-bottom:10px;cursor:pointer;
display:flex;align-items:center;
justify-content:center;gap:10px">
<img src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg"
width="20">
Continue with Google
</div></a>""",
unsafe_allow_html=True
)
# GitHub button
gh_url = get_github_oauth_url(APP_URL)
st.markdown(
f"""<a href="{gh_url}" target="_blank"
style="text-decoration:none">
<div style="background:#24292e;color:white;
border-radius:8px;padding:12px;
text-align:center;font-size:15px;
font-weight:500;margin-bottom:16px;
cursor:pointer">
⚫ Continue with GitHub
</div></a>""",
unsafe_allow_html=True
)
st.markdown(
"<p style='text-align:center;color:#64748B;"
"margin:0 0 16px'>— or —</p>",
unsafe_allow_html=True
)
tab_login, tab_signup = st.tabs(["🔑 Login", "✨ Sign Up"])
with tab_login:
email = st.text_input(
"Email", key="li_email",
placeholder="you@example.com"
)
password = st.text_input(
"Password", key="li_pass",
type="password",
placeholder="Your password"
)
c1, c2 = st.columns(2)
with c1:
if st.button("Login",
use_container_width=True,
type="primary",
key="li_btn"):
try:
user = email_login(email, password)
save_session(user)
st.rerun()
except ValueError as e:
msg = str(e)
if "INVALID" in msg or "PASSWORD" in msg:
st.error("❌ Wrong email or password")
elif "NOT_FOUND" in msg:
st.error("❌ Email not registered")
else:
st.error(f"❌ {msg}")
with c2:
if st.button("Forgot?",
use_container_width=True,
key="fp_btn"):
try:
forgot_password(email)
st.success("✅ Reset email sent!")
except Exception as e:
st.error(f"❌ {e}")
with tab_signup:
name = st.text_input(
"Full Name", key="su_name",
placeholder="Your name"
)
email2 = st.text_input(
"Email", key="su_email",
placeholder="you@example.com"
)
pass2 = st.text_input(
"Password", key="su_pass",
type="password",
placeholder="Min 6 characters"
)
if st.button("Create Account",
use_container_width=True,
type="primary",
key="su_btn"):
try:
user = email_signup(name, email2, pass2)
save_session(user)
st.success("✅ Welcome to FocusFlow!")
st.rerun()
except ValueError as e:
msg = str(e)
if "EMAIL_EXISTS" in msg:
st.error("❌ Email already registered")
elif "WEAK_PASSWORD" in msg:
st.error("❌ Password too weak (min 6 chars)")
else:
st.error(f"❌ {msg}")
else:
st.info("ℹ️ Firebase not configured. Running in local mode.")
if st.button("▶ Continue without login", type="primary", use_container_width=True):
st.session_state["local_bypass"] = True
st.rerun()
st.markdown(
"<p style='text-align:center;color:#475569;"
"font-size:13px;padding-top:20px'>"
"Made for students, by students ❤️</p>",
unsafe_allow_html=True
)
# ========== AUTH GATE ==========
if not st.session_state.get("logged_in") and not st.session_state.get("local_bypass"):
show_login_page()
st.stop()
# Show profile and sign out in sidebar
with st.sidebar:
# User profile card
uid = st.session_state.get("uid", "")
name = st.session_state.get("user_name", "Student")
email = st.session_state.get("user_email", "")
avatar = st.session_state.get("user_avatar", "")
st.markdown("---")
col1, col2 = st.columns([1, 3])
with col1:
if avatar:
st.image(avatar, width=45)
else:
# Show colored initial circle
initial = name[0].upper() if name else "S"
st.markdown(
f"""<div style='
background: linear-gradient(135deg, #7C3AED, #3B82F6);
color: white;
border-radius: 50%;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: bold;
'>{initial}</div>""",
unsafe_allow_html=True
)
with col2:
st.markdown(f"**{name}**")
st.caption(email[:25] + "..." if len(email) > 25 else email)
if st.button("🚪 Sign Out", use_container_width=True):
for k in ["uid", "user_email", "user_name", "user_avatar",
"logged_in", "firebase_token", "local_bypass",
"user_info", "profile_loaded", "study_plan",
"topic_scores", "mastery_data", "chat_history"]:
st.session_state.pop(k, None)
st.rerun()
st.markdown("---")
# Session State
if "timer_running" not in st.session_state: st.session_state.timer_running = False
if "expiry_time" not in st.session_state: st.session_state.expiry_time = None
if "time_left_m" not in st.session_state: st.session_state.time_left_m = 0
if "time_left_s" not in st.session_state: st.session_state.time_left_s = 0
if "chat_history" not in st.session_state: st.session_state.chat_history = []
if "mastery_data" not in st.session_state: st.session_state.mastery_data = {"S1": 0, "S2": 0, "S3": 0, "S4": 0}
if "expanded_topics" not in st.session_state: st.session_state.expanded_topics = set()
if "show_analytics" not in st.session_state: st.session_state.show_analytics = False
if "topic_scores" not in st.session_state: st.session_state.topic_scores = {} # Track quiz performance by topic_id
if "app_config" not in st.session_state:
try:
resp = requests.get(f"{API_URL}/config", timeout=5)
if resp.status_code == 200:
st.session_state.app_config = resp.json()
else:
st.session_state.app_config = {"youtube_enabled": True}
except Exception:
st.session_state.app_config = {"youtube_enabled": True}
# Helper function to add auth headers to all API requests
def get_headers():
"""Get auth headers for API requests — uses Firebase token if available."""
headers = {}
if FIREBASE_AUTH_ENABLED and "firebase_token" in st.session_state:
headers["Authorization"] = f"Bearer {st.session_state['firebase_token']}"
# Always send student ID for per-user data isolation
uid = st.session_state.get("uid", "")
headers["X-Student-Id"] = uid if uid else "anonymous"
return headers
# Focus Mode State
if "focus_mode" not in st.session_state: st.session_state.focus_mode = False
if "active_topic" not in st.session_state: st.session_state.active_topic = None
# PERSISTENCE: Load student profile on first load
if "profile_loaded" not in st.session_state:
st.session_state.profile_loaded = True
try:
resp = requests.get(f"{API_URL}/student/profile", headers=get_headers(), timeout=5)
if resp.status_code == 200:
profile = resp.json()
# DEBUG: Show what we got
plan_topics = profile.get("study_plan", {}).get("topics", [])
quiz_history = profile.get("quiz_history", [])
# Restore study plan if exists
if plan_topics:
st.session_state.study_plan = plan_topics
st.toast(f"📚 Restored {len(plan_topics)} topics from previous session", icon="✅")
else:
st.session_state.study_plan = []
# Don't show message for first-time users
# Restore quiz scores
if quiz_history:
for quiz_record in quiz_history:
st.session_state.topic_scores[quiz_record["topic_id"]] = {
"topic_title": quiz_record.get("topic_title"),
"score": quiz_record["score"],
"total": quiz_record["total"],
"percentage": quiz_record["percentage"]
}
st.toast(f"📊 Restored {len(quiz_history)} quiz results", icon="✅")
# Restore mastery data
if profile.get("mastery_tracker"):
st.session_state.mastery_data = profile["mastery_tracker"]
# ========== DATE-AWARE DAY PROGRESSION ==========
from datetime import date
today = date.today()
today_str = today.strftime("%Y-%m-%d")
# Get stored current day and last access date
current_study_day = profile.get("current_study_day", 1)
last_access_date = profile.get("last_access_date", today_str)
# Check if it's a new calendar day
if last_access_date != today_str and st.session_state.study_plan:
# Calculate how many days have passed
from datetime import datetime
last_date_obj = datetime.strptime(last_access_date, "%Y-%m-%d").date()
days_passed = (today - last_date_obj).days
if days_passed > 0:
# Advance to next day
current_study_day += days_passed
max_day = max([t.get("day", 1) for t in st.session_state.study_plan]) if st.session_state.study_plan else 1
current_study_day = min(current_study_day, max_day) # Cap at max day
# Auto-unlock topics for the new day
for topic in st.session_state.study_plan:
if topic.get("day") == current_study_day and topic.get("status") != "completed":
topic["status"] = "unlocked"
# Update profile with new day and date
try:
requests.post(f"{API_URL}/student/save_progress", json={
"current_study_day": current_study_day,
"last_access_date": today_str
}, headers=get_headers(), timeout=5)
st.toast(f"📅 Advanced to Day {current_study_day}! New topics unlocked", icon="🎯")
except:
pass
# Store current day in session state
st.session_state.current_study_day = current_study_day
else:
st.session_state.study_plan = []
st.error(f"Could not load profile: {resp.status_code}")
except Exception as e:
st.session_state.study_plan = []
st.error(f"Could not connect to backend: {e}")
else:
# Ensure study_plan exists even if profile load was skipped
if "study_plan" not in st.session_state:
st.session_state.study_plan = []
# Default to day 1 if no profile loaded
if "current_study_day" not in st.session_state:
st.session_state.current_study_day = 1
def check_internet():
"""
Checks for internet connectivity by pinging reliable hosts.
try multiple to be sure.
"""
keywords = ["google.com", "cloudflare.com", "github.com"]
for host in keywords:
try:
requests.get(f"http://{host}", timeout=3)
return True
except:
continue
return False
# -----------------------------------------------------------------------------
# 2. ANALYTICS MODAL (Rendered at top if active)
# -----------------------------------------------------------------------------
if st.session_state.show_analytics:
with st.container():
st.markdown("""
<div style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999; display: flex; align-items: center; justify-content: center;">
<div style="background: white; padding: 2rem; border-radius: 16px; width: 80%; max-width: 800px; height: 80%; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; margin-bottom: 2rem;">
<h2>Performance Analytics</h2>
</div>
</div>
</div>
""", unsafe_allow_html=True)
# We can't easily put Streamlit widgets INSIDE that pure HTML string div above.
# But we can simulate a modal by clearing the main area or using st.dialog (New in 1.34+)
# If st.dialog is available (it was in the previous app.py), we should use it.
pass
def extract_subjects_and_topics():
"""
Extract subjects from study plan topics.
Returns: {subject_name: [topic_data_with_scores]}
"""
import re
subjects = {}
for topic in st.session_state.study_plan:
title = topic.get("title", "")
# Remove "Day X:" prefix if present
title_cleaned = re.sub(r'^Day\s+\d+:\s*', '', title)
# Try to extract subject from remaining text
# Look for patterns like "OOPS:" or "Manufacturing:" or just use first few words
if ":" in title_cleaned:
# Get first part before colon as subject
subject = title_cleaned.split(":")[0].strip()
elif " - " in title_cleaned:
# Alternative separator
subject = title_cleaned.split(" - ")[0].strip()
else:
# Use first 2-3 capitalized words as subject
words = title_cleaned.split()
# Take first 1-2 capitalized words as subject name
subject_words = []
for word in words[:3]:
if word[0].isupper() or word.isupper():
subject_words.append(word)
else:
break
subject = " ".join(subject_words) if subject_words else "General"
# Clean up subject name
subject = subject.strip()
if not subject or subject.startswith("Day"):
subject = "General"
if subject not in subjects:
subjects[subject] = []
# Add topic with its score data
topic_data = {
"title": title,
"id": topic.get("id"),
"status": topic.get("status", "locked"),
"quiz_passed": topic.get("quiz_passed", False)
}
# Add score if available
if topic.get("id") in st.session_state.topic_scores:
topic_data["score_data"] = st.session_state.topic_scores[topic.get("id")]
subjects[subject].append(topic_data)
return subjects
@st.dialog("📊 Analytics Overview", width="large")
def show_analytics_dialog():
subjects_data = extract_subjects_and_topics()
if not subjects_data:
st.info("📚 No subjects found. Create a study plan to see analytics.")
return
# Create dynamic tabs
subject_names = list(subjects_data.keys())
tabs = st.tabs(subject_names)
for idx, subject_name in enumerate(subject_names):
with tabs[idx]:
topics = subjects_data[subject_name]
# Calculate subject mastery
completed_topics = [t for t in topics if t.get("status") == "completed"]
total_topics = len(topics)
completion_pct = (len(completed_topics) / total_topics * 100) if total_topics > 0 else 0
# Calculate average score for topics with quiz data
topics_with_scores = [t for t in topics if "score_data" in t]
if topics_with_scores:
avg_score = sum(t["score_data"]["percentage"] for t in topics_with_scores) / len(topics_with_scores)
else:
avg_score = 0
# Display mastery header
st.markdown(f"""
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="font-size: 4rem; color: #111827; margin: 0;">{avg_score:.1f}%</h1>
<p style="color: #6B7280; font-size: 1.2rem;">Overall Mastery</p>
</div>
""", unsafe_allow_html=True)
# Progress metrics
col1, col2 = st.columns(2)
with col1:
st.metric("Topics Completed", f"{len(completed_topics)}/{total_topics}")
st.progress(completion_pct / 100)
with col2:
st.metric("Quizzes Taken", f"{len(topics_with_scores)}/{total_topics}")
quiz_completion = (len(topics_with_scores) / total_topics * 100) if total_topics > 0 else 0
st.progress(quiz_completion / 100)
st.markdown("---")
st.markdown("### 📈 Performance Breakdown")
# Classify topics by performance
strong = [t for t in topics_with_scores if t["score_data"]["percentage"] >= 75]
moderate = [t for t in topics_with_scores if 50 <= t["score_data"]["percentage"] < 75]
needs_work = [t for t in topics_with_scores if t["score_data"]["percentage"] < 50]
# Display classifications
col1, col2, col3 = st.columns(3)
with col1:
st.markdown("#### 💚 Strong Topics")
st.caption(f"{len(strong)} topic(s)")
if strong:
for t in strong:
score_pct = t["score_data"]["percentage"]
score_str = f"{t['score_data']['score']}/{t['score_data']['total']}"
st.success(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})")
else:
st.info("No strong topics yet. Keep studying!")
with col2:
st.markdown("#### 🟡 Moderate Topics")
st.caption(f"{len(moderate)} topic(s)")
if moderate:
for t in moderate:
score_pct = t["score_data"]["percentage"]
score_str = f"{t['score_data']['score']}/{t['score_data']['total']}"
st.warning(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})")
else:
st.info("No moderate topics yet")
with col3:
st.markdown("#### 🔴 Needs Work")
st.caption(f"{len(needs_work)} topic(s)")
if needs_work:
for t in needs_work:
score_pct = t["score_data"]["percentage"]
score_str = f"{t['score_data']['score']}/{t['score_data']['total']}"
st.error(f"**{t['title']}**\n{score_pct:.0f}% ({score_str})")
else:
st.info("Great! No topics need extra work")
# -----------------------------------------------------------------------------
# 3. QUIZ TO UNLOCK (Dialog)
# -----------------------------------------------------------------------------
@st.dialog("Topic Mastery Quiz")
def show_quiz_dialog(topic_id, topic_name):
st.markdown(f"### Quiz for: {topic_name}")
st.write("Complete this short quiz to prove mastery and unlock the next topic.")
with st.form(key=f"quiz_form_{topic_id}"):
# Quiz Questions (Demo)
st.markdown("**1. What is the First Law of Thermodynamics?**")
q1 = st.radio("Select the best answer:", [
"Energy cannot be created or destroyed, only transformed.",
"Entropy always increases.",
"Heat flows from cold to hot.",
"F = ma"
], key=f"q1_{topic_id}")
st.markdown("**2. Which system type allows energy but not matter transfer?**")
q2 = st.radio("Select the best answer:", [
"Open System",
"Closed System",
"Isolated System",
"Solar System"
], key=f"q2_{topic_id}")
if st.form_submit_button("Submit Quiz"):
score = 0
if q1.startswith("Energy cannot"): score += 50
if q2 == "Closed System": score += 50
# Call backend
try:
payload = {"topic_id": topic_id, "quiz_score": score}
resp = requests.post(f"{API_URL}/unlock_topic", json=payload, headers=get_headers())
if resp.status_code == 200:
data = resp.json()
if data.get("success"):
if data.get("next_topic_unlocked"):
st.balloons()
st.success(f"Score: {score}% - PASSED! Next topic unlocked.")
else:
st.warning(f"Score: {score}% - Keep studying! You need >60% to pass.")
time.sleep(2)
st.rerun()
else:
st.error(data.get("message"))
else:
st.error(f"Error: {resp.status_code}")
except Exception as e:
st.error(f"Connection failed: {e}")
st.markdown("**P** <span style='float:right; background:#d1d5db; color:white; padding:2px 8px; border-radius:99px; font-size:0.8rem'>N/A</span>", unsafe_allow_html=True)
st.markdown("<p style='color:#4B5563; margin-top:10px'>--</p>", unsafe_allow_html=True)
# -----------------------------------------------------------------------------
# 3. QUIZ TO UNLOCK (Dialog)
# -----------------------------------------------------------------------------
@st.dialog("Topic Mastery Quiz")
def show_quiz_dialog(topic_id, topic_name):
st.markdown(f"**Topic:** {topic_name}")
st.markdown("To unlock the next topic, you must pass this quiz.")
# Mock Question
st.info("Question: What is the primary concept of this topic?")
ans = st.radio("Select Answer:", ["Energy Conservation", "Wrong Answer 1", "Wrong Answer 2"], key=f"q_radio_{topic_id}")
if st.button("Submit Answer", type="primary"):
if ans == "Energy Conservation":
st.balloons()
st.success("Correct! Next topic unlocked.")
# Update Mock State
for i, t in enumerate(st.session_state.study_plan):
if t["id"] == topic_id:
t["quiz_passed"] = True
# Unlock next
if i + 1 < len(st.session_state.study_plan):
st.session_state.study_plan[i+1]["status"] = "unlocked"
break
time.sleep(1.5)
st.rerun()
else:
st.error("Incorrect. Try again.")
# (The previous tool usage showed show_quiz_dialog was inserted. I will target the end of it to insert Flashcards)
# Actually, let's just insert it before MAIN LAYOUT, which is clearer.
# -----------------------------------------------------------------------------
# 4. FLASHCARDS (Dialog)
# -----------------------------------------------------------------------------
@st.dialog("Topic Flashcards", width="large")
def show_flashcard_dialog(topic_id, topic_name):
st.markdown(f"### Flashcards: {topic_name}")
# Flashcard Data (Demo)
flashcards = [
{"q": "What is the First Law of Thermodynamics?", "a": "Energy cannot be created or destroyed, only transformed."},
{"q": "Define Entropy.", "a": "A measure of the disorder or randomness in a system."},
{"q": "What is an Isolated System?", "a": "A system that exchanges neither matter nor energy with its surroundings."}
]
# Session State for this dialog interaction
# Note: st.dialog reruns the function body on interaction.
if "fc_index" not in st.session_state: st.session_state.fc_index = 0
if "fc_flipped" not in st.session_state: st.session_state.fc_flipped = False
# Navigation Limits
current_idx = st.session_state.fc_index
total = len(flashcards)
if current_idx >= total:
st.success("You've reviewed all cards!")
if st.button("Restart"):
st.session_state.fc_index = 0
st.session_state.fc_flipped = False
st.rerun()
return
card = flashcards[current_idx]
# Progress
st.progress((current_idx + 1) / total)
st.caption(f"Card {current_idx + 1} of {total}")
# Card UI
content = card["a"] if st.session_state.fc_flipped else card["q"]
bg_color = "#EFF6FF" if not st.session_state.fc_flipped else "#F0FDF4" # Blue (Question) -> Green (Answer)
border_color = "#DBEAFE" if not st.session_state.fc_flipped else "#BBF7D0"
label = "QUESTION" if not st.session_state.fc_flipped else "ANSWER"
st.markdown(f"""
<div style="
background-color: {bg_color};
border: 2px solid {border_color};
border-radius: 12px;
padding: 40px;
text-align: center;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-bottom: 20px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
">
<div style="font-size: 0.8rem; font-weight: 700; color: #6B7280; margin-bottom: 10px; letter-spacing: 1px;">{label}</div>
<div style="font-size: 1.5rem; font-weight: 600; color: #1F2937;">{content}</div>
</div>
""", unsafe_allow_html=True)
# Controls
c1, c2 = st.columns(2)
with c1:
btn_text = "Show Answer" if not st.session_state.fc_flipped else "Show Question"
if st.button(btn_text, use_container_width=True):
st.session_state.fc_flipped = not st.session_state.fc_flipped
st.rerun()
with c2:
if st.session_state.fc_flipped:
if st.button("Next Card →", type="primary", use_container_width=True):
st.session_state.fc_index += 1
st.session_state.fc_flipped = False
st.rerun()
else:
st.button("Next Card →", disabled=True, use_container_width=True) # Lock next until flipped? Or allow skipping. Let's lock to encourage reading.
# --- LAYOUT SWITCHER ---
if not st.session_state.focus_mode:
# Standard 3-Column Layout
left_col, mid_col, right_col = st.columns([0.25, 0.50, 0.25], gap="medium")
else:
# Focus Mode Layout (2 Columns: Chat + Content)
left_col, mid_col = st.columns([0.30, 0.70], gap="large")
right_col = None # Not used in Focus Mode
# --- LEFT COLUMN: Control Center ---
# --- LEFT COLUMN: Control Center ---
if not st.session_state.focus_mode:
with left_col:
# ====== USER INFO & SIGN-OUT REMOVED (now in sidebar) ======
st.markdown("""
<h2 style='
color:#E2E8F0;
font-size:18px;
font-weight:700;
margin-bottom:16px;
padding-bottom:8px;
border-bottom:2px solid #7C3AED;
'>🎮 Control Center</h2>
""", unsafe_allow_html=True)
# Timer Widget
with st.container(border=True):
st.markdown("""
<div style='
background: #1E1E30;
border: 1px solid #374151;
border-radius: 12px;
padding: 20px;
text-align: center;
margin-bottom: 16px;
'>
<p style='color:#94A3B8;margin:0;font-size:13px'>
⏱️ Study Timer
</p>
</div>
""", unsafe_allow_html=True)
# Timer Logic
total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s
if st.session_state.timer_running:
# Check if time is up
remaining = st.session_state.expiry_time - time.time()
if remaining <= 0:
st.session_state.timer_running = False
st.session_state.expiry_time = None
st.session_state.time_left_m, st.session_state.time_left_s = 0, 0
st.balloons()
st.rerun()
else:
# Render JS Timer (Non-blocking)
# We need to inject the SAME styles to match the look.
# Since components run in iframe, we copy the CSS.
m, s = divmod(int(remaining), 60)
html_code = f"""
<!DOCTYPE html>
<html>
<head>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
body {{
font-family: 'Inter', sans-serif;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
background: transparent;
height: 100px; /* specific height */
}}
.timer-display {{
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}}
.timer-box {{
background: white;
border: 2px solid #374151;
border-radius: 8px;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2.5rem;
font-weight: 700;
color: #111827;
box-shadow: 0 4px 6px rgba(0,0,0,0.05);
}}
.timer-dots {{
display: flex;
flex-direction: column;
gap: 8px;
}}
.dot {{
width: 6px;
height: 6px;
background: #374151;
border-radius: 50%;
}}
</style>
<script>
function startTimer(duration, display) {{
var timer = duration, minutes, seconds;
var interval = setInterval(function () {{
minutes = parseInt(timer / 60, 10);
seconds = parseInt(timer % 60, 10);
minutes = minutes < 10 ? "0" + minutes : minutes;
seconds = seconds < 10 ? "0" + seconds : seconds;
document.getElementById('m').textContent = minutes;
document.getElementById('s').textContent = seconds;
if (--timer < 0) {{
clearInterval(interval);
// Optional: Signal finish?
}}
}}, 1000);
}}
window.onload = function () {{
var remaining = {int(remaining)};
startTimer(remaining);
}};
</script>
</head>
<body>
<div class="timer-display">
<div class="timer-box" id="m">{m:02d}</div>
<div class="timer-dots">
<div class="dot"></div>
<div class="dot"></div>
</div>
<div class="timer-box" id="s">{s:02d}</div>
</div>
</body>
</html>
"""
components.html(html_code, height=120)
# Show ONLY Stop Button
if st.button("STOP", use_container_width=True, type="secondary"):
st.session_state.timer_running = False
st.session_state.expiry_time = None
st.rerun()
else:
# Editable Inputs (Only show when STOPPED)
# Use columns to center inputs
c1, c2, c3 = st.columns([0.45, 0.1, 0.45])
with c1:
st.number_input("Min", min_value=0, max_value=999, label_visibility="collapsed", key="time_left_m")
with c2:
st.markdown("<div class='timer-colon'>:</div>", unsafe_allow_html=True)
with c3:
st.number_input("Sec", min_value=0, max_value=59, label_visibility="collapsed", key="time_left_s")
st.write("") # Spacer
# Start Button
if st.button("START", use_container_width=True, type="primary"):
total_seconds = (st.session_state.time_left_m * 60) + st.session_state.time_left_s
if total_seconds > 0:
st.session_state.timer_running = True
st.session_state.expiry_time = time.time() + total_seconds
st.rerun()
# Sources Widget
with st.container(border=True):
# Connectivity Check
is_online = check_internet()
status_color = "online-badge" if is_online else "status-badge offline"
status_text = "ONLINE" if is_online else "OFFLINE"
st.markdown(f"""
<div style='display:flex;align-items:center;
justify-content:space-between;margin-bottom:12px'>
<span style='color:#E2E8F0;font-weight:600'>Sources</span>
<span class='{status_color}'>{status_text}</span>
</div>
""", unsafe_allow_html=True)
# Tabs
# Tabs Removed - Unified View
# tab_offline, tab_online = st.tabs(["Offline Sources", "Online Sources"])
# Helper to fetch sources
sources_list = []
try:
s_resp = requests.get(f"{API_URL}/sources", headers=get_headers())
if s_resp.status_code == 200:
sources_list = s_resp.json()
except:
pass
if sources_list:
for src in sources_list:
# Icon Logic
icon = "📄"
if src['type'] == 'url': icon = "🌐"
elif src['type'] == 'youtube': icon = "📺"
c1, c2 = st.columns([0.85, 0.15])
with c1:
short = src['filename'][:25] + "..." if len(src['filename']) > 25 else src['filename']
st.markdown(
f"""<div class='source-card'>
<span>{icon} {short}</span>
</div>""",
unsafe_allow_html=True
)
with c2:
if st.button("🗑️", key=f"del_{src['id']}", help="Delete source", type="tertiary"):
try:
# Optimistically update UI by removing from list or just rerun
requests.delete(f"{API_URL}/sources/{src['id']}", headers=get_headers())
time.sleep(0.1) # Small delay for DB prop
st.rerun()
except Exception as e:
st.error(f"Error: {e}")
else:
st.markdown("""
<div style='text-align:center;padding:20px;color:#64748B'>
<div style='font-size:32px'>📂</div>
<p>No sources added yet</p>
</div>
""", unsafe_allow_html=True)
# --- Add Source Section ---
st.markdown("<br>", unsafe_allow_html=True)
# PDF Upload
with st.expander("+ Add PDF / Document"):
uploaded = st.file_uploader("Upload PDF", type=["pdf", "txt"], label_visibility="collapsed")
if uploaded:
# Check duplication in session state to prevent infinite rerun loop
if "processed_files" not in st.session_state:
st.session_state.processed_files = set()
if uploaded.name not in st.session_state.processed_files:
try:
# Send to backend
files = {"file": (uploaded.name, uploaded, uploaded.type)}
with st.spinner("Uploading & Indexing..."):
resp = requests.post(f"{API_URL}/upload", files=files, headers=get_headers())
if resp.status_code == 200:
st.session_state.processed_files.add(uploaded.name)
st.success(f"Successfully uploaded: {uploaded.name}")
time.sleep(1)
st.rerun()
else:
# Parse error for user-friendly message
try:
error_detail = resp.json().get("detail", resp.text)
except Exception:
error_detail = resp.text
if "OCR" in str(error_detail) or "scan" in str(error_detail).lower():
st.error(f"📸 {error_detail}")
elif "No readable text" in str(error_detail):
st.error("📄 This PDF appears to be scanned/image-only. OCR could not extract text. Please try a clearer scan or a text-based PDF.")
else:
st.error(f"Upload failed: {error_detail}")
except Exception as e:
st.error(f"Error: {e}")
# URL Input
youtube_enabled = st.session_state.get("app_config", {}).get("youtube_enabled", True)
if youtube_enabled:
with st.expander("▶️ Add URL / YouTube"):
url_input = st.text_input("URL", placeholder="https://youtube.com/... or any webpage", label_visibility="collapsed")
if st.button("Process URL", use_container_width=True):
if not url_input:
st.warning("Please enter a URL")
else:
import re as re_mod
is_youtube = "youtube.com" in url_input or "youtu.be" in url_input
if is_youtube:
# Extract video ID
vid_match = re_mod.search(r'(?:v=|youtu\.be/|shorts/|embed/)([a-zA-Z0-9_-]{11})', url_input)
if not vid_match:
st.error("❌ Invalid YouTube URL format. Supported: youtube.com/watch?v=ID, youtu.be/ID, or youtube.com/shorts/ID")
else:
video_id = vid_match.group(1)
with st.spinner("⏳ Fetching transcript via Invidious..."):
try:
resp = requests.post(f"{API_URL}/ingest_youtube", json={"video_id": video_id}, headers=get_headers(), timeout=120)
if resp.status_code == 200:
st.success("✅ YouTube transcript processed successfully!")
time.sleep(1)
st.rerun()
else:
error_detail = resp.json().get('detail', resp.text)
if "No captions available" in str(error_detail):
st.error("❌ No captions found. Try a video with CC enabled.")
elif "Could not reach any transcript" in str(error_detail):
st.error("⚠️ Transcript service unavailable. Try again later.")
else:
st.error(f"Failed: {error_detail}")
except requests.Timeout:
st.error("⏱️ Request timed out. Please try again.")
except Exception as e:
st.error(f"Error: {str(e)}")
else:
# Non-YouTube URL: use server-side ingestion
with st.spinner("Fetching content..."):
try:
resp = requests.post(f"{API_URL}/ingest_url", json={"url": url_input}, headers=get_headers(), timeout=120)
if resp.status_code == 200:
st.success(f"✅ {resp.json().get('message', 'Content added!')}")
time.sleep(1)
st.rerun()
else:
error_detail = resp.json().get('detail', resp.text)
st.error(f"Failed: {error_detail}")
except requests.Timeout:
st.error("⏱️ Request timed out. Please try again.")
except Exception as e:
st.error(f"Error: {str(e)}")
else:
with st.expander("▶️ Add YouTube Video — Local Only"):
st.info(
"⚠️ **YouTube is not available in cloud mode.**\n\n"
"HuggingFace Spaces blocks outbound network requests.\n\n"
"**To use YouTube sources:**\n"
"💻 Run FocusFlow locally with Ollama\n\n"
"**Right now you can:**\n"
"📄 Upload a PDF of your notes\n"
"📋 Paste text directly below"
)
# Paste Text Input
with st.expander("📋 Paste Text / Notes"):
paste_label = st.text_input(
"Source name (optional)",
placeholder="e.g. Chapter 3 Notes, Lecture Summary...",
key="paste_label_input"
)
paste_text = st.text_area(
"Paste your text here",
placeholder=(
"Paste any text here — lecture notes, "
"article content, copied webpage text, "
"study notes, anything you want to learn from..."
),
height=200,
key="paste_text_input"
)
word_count = len(paste_text.split()) if paste_text else 0
if paste_text:
st.caption(f"📝 {word_count} words")
col1, col2 = st.columns([2, 1])
with col1:
process_btn = st.button(
"➕ Add as Source",
key="process_paste_btn",
disabled=len(paste_text.strip()) < 50,
use_container_width=True
)
with col2:
st.caption("Min 50 chars")
if process_btn and paste_text.strip():
source_name = paste_label.strip() if paste_label.strip() \
else f"Pasted Text ({word_count} words)"
with st.spinner("Processing your text..."):
try:
# Use API_URL which is the global backend URL configured in app.py
response = requests.post(
f"{API_URL}/ingest_text",
json={
"text": paste_text.strip(),
"source_name": source_name,
"source_type": "paste"
},
headers=get_headers()
)
if response.status_code == 200:
st.success(f"✅ '{source_name}' added successfully!")
# Remove keys from session_state instead of setting to "" due to Streamlit unchangeable key rules when bound to a widget
if "paste_text_input" in st.session_state:
del st.session_state["paste_text_input"]
if "paste_label_input" in st.session_state:
del st.session_state["paste_label_input"]
time.sleep(1)
st.rerun()
else:
error = response.json().get("detail", "Unknown error")
st.error(f"❌ Failed to add text: {error}")
except Exception as e:
st.error(f"❌ Error: {str(e)}")
if len(paste_text.strip()) > 0 and len(paste_text.strip()) < 50:
st.warning("⚠️ Please paste at least 50 characters.")
# --- FOCUS MODE UI ---
if st.session_state.focus_mode:
# FOCUS: LEFT COLUMN (CHAT)
with left_col:
st.markdown("### 💬 Study Assistant")
# Fixed-height chat container to keep messages inside
messages = st.container(height=600, border=True)
with messages:
for msg in st.session_state.chat_history:
with st.chat_message(msg["role"]):
st.write(msg["content"])
# Show sources for assistant messages
if msg["role"] == "assistant" and msg.get("sources"):
with st.expander("📚 Sources", expanded=False):
for idx, s in enumerate(msg["sources"], 1):
if isinstance(s, dict):
filename = s.get("source", "").split("/")[-1]
page = s.get("page", "N/A")
st.caption(f"{idx}. 📄 {filename}, p.{page}")
# Chat input at bottom - messages will appear in container above
if prompt := st.chat_input(f"Ask about {st.session_state.active_topic}..."):
st.session_state.chat_history.append({"role": "user", "content": prompt})
# Call AI
with st.spinner("Thinking..."):
try:
# Prepare history
history = [{"role": m["role"], "content": m["content"]} for m in st.session_state.chat_history[:-1][-5:]]
resp = requests.post(f"{API_URL}/query", json={"question": prompt, "history": history}, headers=get_headers())
if resp.status_code == 200:
data = resp.json()
ans = data.get("answer", "No answer.")
srcs = data.get("sources", [])
# Include sources if available
if srcs:
st.session_state.chat_history.append({"role": "assistant", "content": ans, "sources": srcs})
else:
st.session_state.chat_history.append({"role": "assistant", "content": ans})
else:
st.session_state.chat_history.append({"role": "assistant", "content": "Error processing request."})
except Exception as e:
st.session_state.chat_history.append({"role": "assistant", "content": f"Connection Error: {e}"})
st.rerun()
# FOCUS: RIGHT COLUMN (LESSON CONTENT) - Scrollable Document Viewer
with mid_col:
topic_title = st.session_state.active_topic
# Handle case where active_topic is dict or string
if isinstance(topic_title, dict):
topic_title = topic_title.get('title', 'Unknown Topic')
st.markdown(f"### 📖 {topic_title}")
st.markdown("---")
# Unique key for this topic's content
t_id = st.session_state.active_topic['id'] if isinstance(st.session_state.active_topic, dict) else hash(topic_title)
content_key = f"content_{t_id}"
# 1. Fetch Content if missing
if content_key not in st.session_state:
with st.spinner(f"🤖 AI is writing a lesson for '{topic_title}'..."):
try:
resp = requests.post(f"{API_URL}/generate_lesson", json={"topic": topic_title}, headers=get_headers(), timeout=300)
if resp.status_code == 200:
st.session_state[content_key] = resp.json()["content"]
else:
st.session_state[content_key] = f"⚠️ Server Error: {resp.text}"
except Exception as e:
st.session_state[content_key] = f"⚠️ Connection Error: {e}"
# 2. Render Content in Scrollable Container (like a document viewer)
lesson_container = st.container(height=650, border=True)
with lesson_container:
st.markdown(st.session_state[content_key])
# 3. Exit Button (stays fixed below the scrollable content)
if st.button("⬅ Finish & Return", use_container_width=True):
st.session_state.focus_mode = False
st.rerun()
# --- MIDDLE COLUMN: Intelligent Workspace ---
# --- MIDDLE COLUMN: Intelligent Workspace ---
if not st.session_state.focus_mode:
with mid_col:
# Header
h_col1, h_col2 = st.columns([0.8, 0.2])
with h_col1:
st.markdown("""
<h2 style='
color:#E2E8F0;
font-size:18px;
font-weight:700;
margin-bottom:16px;
padding-bottom:8px;
border-bottom:2px solid #3B82F6;
'>🧠 Intelligent Workspace</h2>
""", unsafe_allow_html=True)
with h_col2:
if st.button("📊 Analytics"):
show_analytics_dialog()
# Reading Content / Chat Area
# Use native container with border to replace "custom-card" and fix "small box" issue
with st.container(border=True):
# 1. Chat History / Content (Scrollable Container)
# using height=500 to create a scrolling area like a real chat app
chat_container = st.container(height=500)
with chat_container:
if not st.session_state.chat_history:
# Welcome Content
st.markdown("""
<div style='
background: linear-gradient(135deg, #1E1E30, #2D1B69);
border: 1px solid #7C3AED44;
border-radius: 16px;
padding: 40px;
text-align: center;
margin: 20px 0;
'>
<div style='font-size:48px;margin-bottom:16px'>📚</div>
<h2 style='color:#E2E8F0;margin:0 0 8px'>
Welcome to FocusFlow
</h2>
<p style='color:#94A3B8;margin:0 0 24px'>
Your AI-powered study companion
</p>
<div style='
display:flex;
justify-content:center;
gap:24px;
flex-wrap:wrap;
'>
<div style='color:#94A3B8;font-size:14px'>
📄 Upload a PDF
</div>
<div style='color:#94A3B8;font-size:14px'>
📋 Paste your notes
</div>
<div style='color:#94A3B8;font-size:14px'>
🗓️ Generate a study plan
</div>
</div>
</div>
""", unsafe_allow_html=True)
else:
# Chat Messages
# Chat Messages
for i, msg in enumerate(st.session_state.chat_history):
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# Source Display Logic (MUST BE INSIDE THE LOOP)
if msg["role"] == "assistant" and msg.get("sources"):
with st.expander("📚 View Sources", expanded=False):
st.caption("Information retrieved from:")
for idx, s in enumerate(msg["sources"], 1):
# Crash Proof Check: Handle string vs dict
if isinstance(s, str):
st.markdown(f"**{idx}.** {s[:100]}...")
else:
# It is a dictionary
src = s.get("source", "Document")
# Extract filename from path
filename = src.split("/")[-1] if "/" in src else src
page_num = s.get("page", "N/A")
# Display with nice formatting
st.markdown(f"**{idx}.** 📄 `{filename}` • Page {page_num}")
# 2. Input Area (Pinned to bottom of the visible card by being outside scroll container)
with st.form(key="chat_form", clear_on_submit=True):
cols = st.columns([0.85, 0.15])
with cols[0]:
user_input = st.text_input("Ask a question...", placeholder="Ask a question about your documents...", label_visibility="collapsed", key="chat_input_widget")
with cols[1]:
submit_button = st.form_submit_button("Send", use_container_width=True)
if submit_button and user_input:
st.session_state.chat_history.append({"role": "user", "content": user_input})
try:
with st.spinner("Thinking..."):
# Prepare history (exclude sources for cleanliness)
history = [
{"role": msg["role"], "content": msg["content"]}
for msg in st.session_state.chat_history[:-1][-5:] # Last 5 valid history items before current question
]
resp = requests.post(f"{API_URL}/query", json={"question": user_input, "history": history}, headers=get_headers())
if resp.status_code == 200:
try:
data = resp.json()
ans = data.get("answer", "No answer.")
srcs = data.get("sources", [])
if srcs:
st.session_state.chat_history.append({"role": "assistant", "content": ans, "sources": srcs})
else:
st.session_state.chat_history.append({"role": "assistant", "content": ans})
except Exception as e:
st.session_state.chat_history.append({"role": "assistant", "content": f"Error parsing response: {e}\\n\\nRaw text: {resp.text}"})
else:
st.session_state.chat_history.append({"role": "assistant", "content": "Error."})
except Exception as e:
st.session_state.chat_history.append({"role": "assistant", "content": f"Connection Error: {e}"})
st.rerun()
# --- RIGHT COLUMN: Scheduler ---
# --- RIGHT COLUMN: Scheduler ---
# --- RIGHT COLUMN: Scheduler ---
if right_col:
with right_col:
# --- CALENDAR WIDGET ---
st.markdown("""
<h2 style='
color:#E2E8F0;
font-size:18px;
font-weight:700;
margin-bottom:16px;
padding-bottom:8px;
border-bottom:2px solid #10B981;
'>📅 Study Calendar</h2>
""", unsafe_allow_html=True)
today = date.today()
selected_date = st.date_input("Select Date", value=today)
# --- LOGIC: Show plan for selected date ---
# If user selects a future date, show its plan inline
if selected_date != today:
delta = selected_date - today
day_offset = delta.days + 1
st.markdown(f"### 📋 Plan for {selected_date}")
# Filter plan for this hypothetical day
day_tasks = [t for t in st.session_state.study_plan if t.get("day") == day_offset]
if day_tasks:
for t in day_tasks:
st.markdown(f"- **{t['title']}**")
else:
st.info("No plan generated for this specific date yet.")
st.markdown("---")
# --- B. TALK TO CALENDAR ---
with st.form("calendar_chat_form", clear_on_submit=True):
plan_query = st.text_input("Talk to Calendar...", placeholder="e.g., 'Make a 3 day plan'")
submitted = st.form_submit_button("🚀 Generate Plan")
if submitted and plan_query:
with st.spinner("🤖 AI (1B) is thinking..."):
try:
# Increased timeout to 300s for safety
resp = requests.post(f"{API_URL}/generate_plan", json={"request_text": plan_query}, headers=get_headers(), timeout=300)
if resp.status_code == 200:
plan_data = resp.json()
raw_plan = plan_data.get("days", [])
# ROBUST SANITIZATION LOOP
for index, task in enumerate(raw_plan):
# 1. FORCE UNLOCK DAY 1 (The Fix)
if index == 0:
task["status"] = "unlocked"
task["locked"] = False
else:
# Default logic for others: Trust 'status' or default to 'locked'
# We ignore the 'locked' boolean fallback to be stricter,
# ensuring only Day 1 is open initially if not specified.
task["status"] = task.get("status", "locked")
# 2. Fix IDs & Keys
if "id" not in task: task["id"] = index + 1
task["quiz_passed"] = task.get("quiz_passed", False)
task["title"] = task.get("topic", f"Topic {task['id']}") # Fallback title
st.session_state.study_plan = raw_plan
# AUTO-SAVE: Persist the new plan
try:
num_days = max([t.get("day", 1) for t in raw_plan]) if raw_plan else 0
save_resp = requests.post(f"{API_URL}/student/save_plan", json={
"topics": raw_plan,
"num_days": num_days
}, headers=get_headers(), timeout=5)
if save_resp.status_code == 200:
st.toast(f"💾 Progress saved: {len(raw_plan)} topics", icon="✅")
else:
st.warning(f"Could not save progress: {save_resp.text}")
except Exception as e:
st.warning(f"Could not save progress: {e}")
st.success("📅 Plan Created! Check Today's Topics.")
st.rerun()
else:
st.error(f"Failed: {resp.text}")
except Exception as e:
st.error(f"Error: {e}")
# --- TODAY'S TOPICS (FILTERED BY CURRENT STUDY DAY) ---
current_day = st.session_state.get("current_study_day", 1)
st.markdown(f"### Today's Topics (Day {current_day})")
# FILTER: Show tasks for current study day
todays_tasks = [t for t in st.session_state.study_plan if t.get("day") == current_day]
if not todays_tasks:
st.caption("No tasks for today. Ask the calendar to make a plan!")
else:
# Group tasks by subject if multiple topics per day
if len(todays_tasks) > 1:
st.caption(f"📚 {len(todays_tasks)} topics to cover today")
for i, task in enumerate(todays_tasks):
# Display subject badge if available
subject_badge = ""
if "subject" in task and task["subject"]:
subject_badge = f"**{task['subject']}** • "
# 1. COMPLETED
if task["status"] == "completed":
st.success(f"✅ {subject_badge}{task['title']}")
# (Flashcards button REMOVED as requested)
# 2. ACTIVE / UNLOCKED
elif task["status"] == "unlocked":
with st.container(border=True):
# Show subject badge prominently
if "subject" in task and task["subject"]:
st.caption(f"📘 {task['subject']}")
st.markdown(f"**{task['title']}**")
# The Focus Mode Button
if st.button(f"🚀 Start Learning", key=f"start_{task['id']}"):
st.session_state.focus_mode = True
st.session_state.active_topic = task['title']
st.rerun()
# 1. THE QUIZ BUTTON
if st.button(f"📝 Take Quiz (Unlock Next)", key=f"quiz_btn_{task['id']}"):
st.session_state[f"show_quiz_{task['id']}"] = True
st.rerun()
# 2. THE QUIZ (Inline - no dialog to avoid Streamlit error)
if st.session_state.get(f"show_quiz_{task['id']}", False):
st.markdown("---")
st.write("### 🧠 Knowledge Check")
# 1. FETCH QUIZ DATA (Dynamic)
quiz_key = f"quiz_data_{task['id']}"
if quiz_key not in st.session_state:
with st.spinner(f"🤖 Generating quiz for '{task['title']}'..."):
try:
resp = requests.post(f"{API_URL}/generate_quiz", json={"topic": task['title']}, headers=get_headers(), timeout=120)
if resp.status_code == 200:
st.session_state[quiz_key] = resp.json().get("quiz", [])
else:
st.error("Failed to generate quiz.")
except Exception as e:
st.error(f"Connection error: {e}")
quiz_data = st.session_state.get(quiz_key, [])
if quiz_data:
st.caption("Answer the questions below. Next topic unlocks automatically.")
score = 0
user_answers = {}
# 2. RENDER QUESTIONS
for i, q in enumerate(quiz_data):
st.markdown(f"**Q{i+1}: {q['question']}**")
user_answers[i] = st.radio(
"Select one:",
q['options'],
key=f"q_{task['id']}_{i}"
)
st.markdown("---")
col1, col2 = st.columns([1, 1])
with col1:
if st.button("🚀 Submit Quiz", key=f"submit_{task['id']}", use_container_width=True):
# GRADING LOGIC
for i, q in enumerate(quiz_data):
if user_answers[i] == q['answer']:
score += 1
# STORE SCORE FOR ANALYTICS
st.session_state.topic_scores[task['id']] = {
"topic_title": task['title'],
"score": score,
"total": len(quiz_data),
"percentage": (score / len(quiz_data)) * 100
}
st.info(f"📊 Your Score: {score}/{len(quiz_data)}")
# ALWAYS UNLOCK NEXT TOPIC
st.balloons()
# --- ADAPTIVE LOGIC (Optional based on score) ---
if score == 3:
st.toast("🚀 Perfect Score! Accelerating future plan...", icon="⚡")
for future_task in st.session_state.study_plan:
if future_task["id"] > task["id"]:
if "Advanced" not in future_task["title"]:
future_task["title"] = f"Advanced: {future_task['title']}"
future_task["details"] = "Deep dive with complex examples. (AI Adjusted)"
elif score == 2:
st.toast("⚠️ Good effort! Adding revision steps...", icon="🛡️")
for future_task in st.session_state.study_plan:
if future_task["id"] > task["id"]:
if "Review" not in future_task["title"]:
future_task["title"] = f"Review & {future_task['title']}"
future_task["details"] = "Includes recap of previous concepts. (AI Adjusted)"
st.success(f"✅ Quiz completed! Unlocking next topic...")
time.sleep(1)
# UNLOCK NEXT TOPIC
task["status"] = "completed"
task["quiz_passed"] = True
current_id = task["id"]
for next_task in st.session_state.study_plan:
if next_task["id"] == current_id + 1:
next_task["status"] = "unlocked"
next_task["locked"] = False
break
# AUTO-SAVE: Persist quiz score and completion
try:
subject = task.get("subject", "General")
requests.post(f"{API_URL}/student/quiz_complete", json={
"topic_id": task["id"],
"topic_title": task["title"],
"subject": subject,
"score": score,
"total": len(quiz_data),
"time_taken": 0
}, headers=get_headers(), timeout=5)
except Exception:
pass # Silent fail for auto-save
# Close Quiz
st.session_state[f"show_quiz_{task['id']}"] = False
st.rerun()
with col2:
if st.button("✕ Cancel", key=f"cancel_{task['id']}", use_container_width=True):
st.session_state[f"show_quiz_{task['id']}"] = False
st.rerun()
# 3. LOCKED
else:
with st.container(border=True):
st.markdown(f"🔒 <span style='color:gray'>{task['title']}</span>", unsafe_allow_html=True)