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("""
""", unsafe_allow_html=True)
st.markdown("""
""", 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("""
đ¯
FocusFlow
AI-Powered Study Companion
""", unsafe_allow_html=True)
st.markdown("
", unsafe_allow_html=True)
if FIREBASE_AUTH_ENABLED:
# Google button
g_url = get_google_oauth_url(APP_URL)
st.markdown(
f"""

Continue with Google
""",
unsafe_allow_html=True
)
# GitHub button
gh_url = get_github_oauth_url(APP_URL)
st.markdown(
f"""
âĢ Continue with GitHub
""",
unsafe_allow_html=True
)
st.markdown(
"â or â
",
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(
""
"Made for students, by students â¤ī¸
",
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"""{initial}
""",
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("""
""", 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"""
{avg_score:.1f}%
Overall Mastery
""", 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** N/A", unsafe_allow_html=True)
st.markdown("--
", 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"""
""", 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("""
đŽ Control Center
""", unsafe_allow_html=True)
# Timer Widget
with st.container(border=True):
st.markdown("""
""", 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"""
"""
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(":
", 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"""
Sources
{status_text}
""", 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"""
{icon} {short}
""",
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("""
đ
No sources added yet
""", unsafe_allow_html=True)
# --- Add Source Section ---
st.markdown("
", 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("""
đ§ Intelligent Workspace
""", 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("""
đ
Welcome to FocusFlow
Your AI-powered study companion
đ Upload a PDF
đ Paste your notes
đī¸ Generate a study plan
""", 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("""
đ
Study Calendar
""", 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"đ {task['title']}", unsafe_allow_html=True)