diff --git a/.gitignore b/.gitignore
index ca2c8d13d..b63b5b822 100644
--- a/.gitignore
+++ b/.gitignore
@@ -58,4 +58,14 @@ android/*.json
android/fastlane/play-store-credentials.json
android/fastlane/report.xml
-*.md
\ No newline at end of file
+*.md
+
+# Claude Code
+.claude/
+
+# Eclipse/Buildship IDE files (Android Studio)
+android/.project
+android/.settings/
+android/app/.classpath
+android/app/.project
+android/app/.settings/
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 000000000..7253a5cee
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+min-release-age=7
diff --git a/src/api/dailyAudio.js b/src/api/dailyAudio.js
index 6688ed7be..2f4498b2a 100644
--- a/src/api/dailyAudio.js
+++ b/src/api/dailyAudio.js
@@ -43,14 +43,17 @@ Zeeguu_API.prototype.getTodaysLesson = function (callback, onError) {
});
};
-Zeeguu_API.prototype.generateDailyLesson = function (callback, onError, topicSuggestion) {
+Zeeguu_API.prototype.generateDailyLesson = function (callback, onError, suggestion, suggestionType) {
this.apiLog("POST generate_daily_lesson");
const formData = new FormData();
const timezoneOffset = getTimezoneOffsetMinutes();
formData.append("timezone_offset", timezoneOffset);
- if (topicSuggestion) {
- formData.append("topic_suggestion", topicSuggestion);
+ if (suggestion) {
+ formData.append("suggestion", suggestion);
+ if (suggestionType) {
+ formData.append("suggestion_type", suggestionType);
+ }
}
fetch(this._appendSessionToUrl("generate_daily_lesson"), {
diff --git a/src/components/ClearableInput.js b/src/components/ClearableInput.js
new file mode 100644
index 000000000..93495ff8f
--- /dev/null
+++ b/src/components/ClearableInput.js
@@ -0,0 +1,64 @@
+import React, { useRef } from "react";
+import styled from "styled-components";
+import CancelIcon from "@mui/icons-material/Cancel";
+
+const Wrapper = styled.div`
+ position: relative;
+ width: 100%;
+ display: flex;
+ align-items: center;
+`;
+
+const StyledInput = styled.textarea`
+ width: 100%;
+ padding: 12px 32px 12px 12px;
+ border: 1px solid var(--border-light);
+ border-radius: 4px;
+ font-size: 16px;
+ font-family: inherit;
+ color: var(--text-primary);
+ background-color: var(--bg-secondary);
+ text-align: center;
+ resize: none;
+ field-sizing: content;
+`;
+
+const ClearBtn = styled.span`
+ position: absolute;
+ right: 8px;
+ top: 50%;
+ transform: translateY(-50%);
+ cursor: pointer;
+ color: var(--text-secondary);
+ font-size: 18px;
+ display: flex;
+`;
+
+export default function ClearableInput({ value, onChange, onClear, placeholder, maxLength, tabIndex, rows = 1, ...props }) {
+ const inputRef = useRef(null);
+
+ return (
+
+
+ {value && (
+ {
+ onClear?.();
+ inputRef.current?.focus();
+ }}
+ >
+
+
+ )}
+
+ );
+}
diff --git a/src/components/colors.js b/src/components/colors.js
index 2f669e1ea..1de72e9da 100644
--- a/src/components/colors.js
+++ b/src/components/colors.js
@@ -64,6 +64,7 @@ let zeeguuViolet = "#4a0d67";
let darkGreen = "#006400";
let alertGreen = "#4caf50"; //careful when changing this color. It is defined to match the color in the success-alert to undo feedback submits.
let matchGreen = "#B3F78F";
+let successGreen = "#28a745";
let translationHover = "#2f76ac";
let lightOrange = "#ffe5b9";
let brown = "#A46A00";
@@ -156,6 +157,7 @@ export {
darkGreen,
alertGreen,
matchGreen,
+ successGreen,
lightOrange,
translationHover,
brown,
diff --git a/src/dailyAudio/GenerateButton.sc.js b/src/dailyAudio/GenerateButton.sc.js
new file mode 100644
index 000000000..71ad10ff7
--- /dev/null
+++ b/src/dailyAudio/GenerateButton.sc.js
@@ -0,0 +1,41 @@
+import styled from "styled-components";
+import { orange500, orange600, orange800 } from "../components/colors";
+
+export const VerticalCentering = styled.div`
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: calc(100vh - 260px);
+`;
+
+export const GenerateButton = styled.button`
+ width: 150px;
+ height: 150px;
+ border-radius: 50%;
+ background-color: ${orange500};
+ color: white;
+ border: none;
+ font-size: 16px;
+ font-weight: 600;
+ cursor: pointer;
+ box-shadow: 0px 0.3rem ${orange800};
+ transition: all 0.3s ease-in-out;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ line-height: 1.2;
+ margin-bottom: 30px;
+
+ &:hover {
+ background-color: ${orange600};
+ }
+
+ &:active {
+ box-shadow: none;
+ transform: translateY(0.2em);
+ transition: all 0.08s ease-in;
+ }
+`;
diff --git a/src/dailyAudio/LessonPlaybackView.js b/src/dailyAudio/LessonPlaybackView.js
new file mode 100644
index 000000000..a55067256
--- /dev/null
+++ b/src/dailyAudio/LessonPlaybackView.js
@@ -0,0 +1,152 @@
+import React, { useState } from "react";
+import CustomAudioPlayer from "../components/CustomAudioPlayer";
+import FeedbackModal from "../components/FeedbackModal";
+import { FEEDBACK_OPTIONS, FEEDBACK_CODES_NAME } from "../components/FeedbackConstants";
+import Word from "../words/Word";
+import { successGreen } from "../components/colors";
+import { AUDIO_STATUS } from "./AudioLessonConstants";
+import { LessonWrapper, LessonTitle, SuggestionSubtitle, CompletionCheck } from "./LessonView.sc";
+import { wordsAsTile } from "./audioUtils";
+
+export default function LessonPlaybackView({
+ lessonData,
+ setLessonData,
+ words,
+ error,
+ api,
+ userDetails,
+ setUserDetails,
+ listeningSession,
+ currentPlaybackTime,
+ setCurrentPlaybackTime,
+}) {
+ const [openFeedback, setOpenFeedback] = useState(false);
+
+ return (
+
+
+ {lessonData.is_completed && ✓}
+ {wordsAsTile(words)}
+
+ {lessonData.suggestion && (
+
+ {lessonData.suggestion_type === "situation" ? "Situation" : "Topic"}: {lessonData.suggestion}
+
+ )}
+
+ {error && {error}
}
+
+
+
+
{
+ if (lessonData.lesson_id) {
+ api.updateLessonState(lessonData.lesson_id, "resume");
+ listeningSession.start();
+ setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.IN_PROGRESS }));
+ }
+ }}
+ onPause={() => {
+ listeningSession.pause();
+ }}
+ onProgressUpdate={(progressSeconds) => {
+ setCurrentPlaybackTime(progressSeconds);
+ if (lessonData.lesson_id) {
+ api.updateLessonState(lessonData.lesson_id, "pause", progressSeconds);
+ }
+ }}
+ onEnded={() => {
+ listeningSession.end();
+ if (lessonData.lesson_id) {
+ api.updateLessonState(lessonData.lesson_id, "complete", null, () => {
+ setLessonData((prev) => ({
+ ...prev,
+ is_completed: true,
+ completed_at: new Date().toISOString(),
+ }));
+ setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.COMPLETED }));
+ });
+ }
+ }}
+ onError={() => {}}
+ style={{
+ width: "100%",
+ marginBottom: "20px",
+ maxWidth: "600px",
+ margin: "0 auto 20px auto",
+ }}
+ />
+
+ {lessonData.is_completed && (
+
+
+ ✓ Lesson completed! Great job on finishing today's lesson.
+
+
+ )}
+
+ {words && words.length > 0 && (
+
+
+ Words in this lesson
+
+ {words.map((word, index) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/dailyAudio/LessonView.sc.js b/src/dailyAudio/LessonView.sc.js
new file mode 100644
index 000000000..cc290d237
--- /dev/null
+++ b/src/dailyAudio/LessonView.sc.js
@@ -0,0 +1,25 @@
+import styled from "styled-components";
+import { zeeguuOrange, successGreen } from "../components/colors";
+
+export const LessonWrapper = styled.div`
+ padding: 20px;
+`;
+
+export const LessonTitle = styled.h2`
+ color: ${zeeguuOrange};
+ margin-bottom: ${({ $compact }) => ($compact ? "4px" : "10px")};
+ display: flex;
+ align-items: center;
+ gap: 8px;
+`;
+
+export const SuggestionSubtitle = styled.p`
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: 10px;
+`;
+
+export const CompletionCheck = styled.span`
+ color: ${successGreen};
+ font-size: 20px;
+`;
diff --git a/src/dailyAudio/PastLessons.js b/src/dailyAudio/PastLessons.js
index c7c48ec36..698637f73 100644
--- a/src/dailyAudio/PastLessons.js
+++ b/src/dailyAudio/PastLessons.js
@@ -4,7 +4,7 @@ import { APIContext } from "../contexts/APIContext";
import { UserContext } from "../contexts/UserContext";
import LoadingAnimation from "../components/LoadingAnimation";
import CustomAudioPlayer from "../components/CustomAudioPlayer";
-import { wordsAsTile } from "./TodayAudio";
+import { wordsAsTile } from "./audioUtils";
export default function PastLessons() {
const api = useContext(APIContext);
diff --git a/src/dailyAudio/SuggestionSelector.js b/src/dailyAudio/SuggestionSelector.js
new file mode 100644
index 000000000..841f351f3
--- /dev/null
+++ b/src/dailyAudio/SuggestionSelector.js
@@ -0,0 +1,95 @@
+import React from "react";
+import ClearableInput from "../components/ClearableInput";
+import {
+ SuggestionWrapper,
+ PillRow,
+ SelectablePill,
+ DescriptionText,
+ InputArea,
+} from "./SuggestionSelector.sc";
+
+const MAX_SUGGESTION_LENGTH = 80;
+
+const SUGGESTION_TYPES = {
+ auto: {
+ label: "Automatic",
+ description: "A listening lesson with three of your words, each in a short dialogue.",
+ placeholder: null,
+ },
+ topic: {
+ label: "Topic",
+ description: "A listening lesson with three of your words, each practiced in a short dialogue on a given topic.",
+ placeholder: "e.g. cooking, sports",
+ },
+ situation: {
+ label: "Situation",
+ description: "A listening lesson with three of your words, each practiced in a dialogue simulating a real-world scenario.",
+ placeholder: "e.g. at a restaurant, job interview",
+ },
+};
+
+const SELECTED_SUGGESTION_TYPE = "audio_lesson_suggestion_type_";
+const suggestionKey = (type, lang) => `audio_lesson_suggestion_${type}_${lang}`;
+
+export function getSavedSuggestionType(lang) {
+ return localStorage.getItem(SELECTED_SUGGESTION_TYPE + lang) || "auto";
+}
+
+export function getSavedSuggestion(lang) {
+ return localStorage.getItem(suggestionKey(getSavedSuggestionType(lang), lang)) || "";
+}
+
+export default function SuggestionSelector({ suggestionType, setSuggestionType, suggestion, setSuggestion, lang }) {
+ return (
+
+
+
+ {Object.entries(SUGGESTION_TYPES).map(([key, { label }]) => (
+ {
+ if (suggestionType === key) return;
+ setSuggestionType(key);
+ localStorage.setItem(SELECTED_SUGGESTION_TYPE + lang, key);
+ setSuggestion(key === "auto" ? "" : localStorage.getItem(suggestionKey(key, lang)) || "");
+ }}
+ >
+ {label}
+
+ ))}
+
+
+
+ {SUGGESTION_TYPES[suggestionType].description}
+
+
+
+ {
+ const val = e.target.value.replace(/\n/g, " ");
+ setSuggestion(val);
+ const key = suggestionKey(suggestionType, lang);
+ if (val.trim()) {
+ localStorage.setItem(key, val);
+ } else {
+ localStorage.removeItem(key);
+ }
+ }}
+ onClear={() => {
+ setSuggestion("");
+ localStorage.removeItem(suggestionKey(suggestionType, lang));
+ }}
+ />
+
+
+
+ );
+}
diff --git a/src/dailyAudio/SuggestionSelector.sc.js b/src/dailyAudio/SuggestionSelector.sc.js
new file mode 100644
index 000000000..740432565
--- /dev/null
+++ b/src/dailyAudio/SuggestionSelector.sc.js
@@ -0,0 +1,58 @@
+import styled from "styled-components";
+import { blue100, blue700, blue900, lightGrey } from "../components/colors";
+
+export const SuggestionWrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.8rem;
+ width: 100%;
+ max-width: 360px;
+`;
+
+export const PillRow = styled.div`
+ display: flex;
+ gap: 0.4rem;
+`;
+
+export const SelectablePill = styled.button`
+ padding: 0.4rem 1rem;
+ border-radius: 2rem;
+ border: 1.5px solid ${({ $selected }) => ($selected ? blue700 : lightGrey)};
+ background: ${({ $selected }) => ($selected ? blue100 : "var(--bg-secondary)")};
+ color: ${({ $selected }) => ($selected ? blue900 : "var(--text-primary)")};
+ font-family: inherit;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: border-color 150ms, background-color 150ms;
+
+ &:active {
+ transform: scale(0.96);
+ transition: transform 80ms;
+ }
+`;
+
+export const DescriptionText = styled.p`
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ margin: 0;
+ margin-top: 0.3em;
+ text-align: center;
+ max-width: 500px;
+ height: 4.5em;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+export const InputArea = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0.5rem;
+ width: 100%;
+ visibility: ${({ $hidden }) => ($hidden ? "hidden" : "visible")};
+`;
+
+
diff --git a/src/dailyAudio/TodayAudio.js b/src/dailyAudio/TodayAudio.js
index 99c073170..2872a4d4b 100644
--- a/src/dailyAudio/TodayAudio.js
+++ b/src/dailyAudio/TodayAudio.js
@@ -1,28 +1,16 @@
import React, { useContext, useEffect, useState } from "react";
-import { orange500, orange600, orange800, zeeguuOrange } from "../components/colors";
+import { orange500, zeeguuOrange } from "../components/colors";
import { APIContext } from "../contexts/APIContext";
import { UserContext } from "../contexts/UserContext";
import LoadingAnimation from "../components/LoadingAnimation";
import EmptyState from "../components/EmptyState";
import FullWidthErrorMsg from "../components/FullWidthErrorMsg.sc";
-import CustomAudioPlayer from "../components/CustomAudioPlayer";
-import FeedbackModal from "../components/FeedbackModal";
-import { FEEDBACK_OPTIONS, FEEDBACK_CODES_NAME } from "../components/FeedbackConstants";
-import Word from "../words/Word";
import useListeningSession from "../hooks/useListeningSession";
import { AUDIO_STATUS, GENERATION_PROGRESS } from "./AudioLessonConstants";
-
-
-export function wordsAsTile(words) {
- if (!words || !words.length) return "";
-
- const comma_separated_words = words.map((word) => word.origin || word).join(", ");
- const capitalized_comma_separated_words =
- comma_separated_words.charAt(0).toUpperCase() + comma_separated_words.slice(1);
- return capitalized_comma_separated_words;
-}
-
-const TOPIC_STORAGE_KEY_PREFIX = "zeeguu_lesson_topic_";
+import { VerticalCentering, GenerateButton } from "./GenerateButton.sc";
+import SuggestionSelector, { getSavedSuggestion, getSavedSuggestionType } from "./SuggestionSelector";
+import LessonPlaybackView from "./LessonPlaybackView";
+import { wordsAsTile, shortDate } from "./audioUtils";
export default function TodayAudio({ setShowTabs }) {
const api = useContext(APIContext);
@@ -31,9 +19,12 @@ export default function TodayAudio({ setShowTabs }) {
const [isLoading, setIsLoading] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState(null);
- const [topicSuggestion, setTopicSuggestion] = useState(
- () => localStorage.getItem(TOPIC_STORAGE_KEY_PREFIX + lang) || "",
+ const [suggestionType, setSuggestionType] = useState(
+ () => getSavedSuggestionType(lang),
);
+ const [suggestion, setSuggestion] = useState(() => {
+ return getSavedSuggestion(lang);
+ });
// Poll for progress when generating
useEffect(() => {
@@ -163,11 +154,7 @@ export default function TodayAudio({ setShowTabs }) {
// Update page title and playback time when lessonData changes
useEffect(() => {
if (lessonData && lessonData.words) {
- const topicPrefix = lessonData.topic_suggestion ? `${lessonData.topic_suggestion}: ` : "";
- document.title = `[${new Date().toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- })}] Daily Audio: ${topicPrefix}${wordsAsTile(words)}`;
+ document.title = shortDate() + " Daily Audio: " + wordsAsTile(words);
// Initialize playback time from lesson data
const initialTime = lessonData.pause_position_seconds || lessonData.position_seconds || lessonData.progress_seconds || 0;
@@ -280,7 +267,8 @@ export default function TodayAudio({ setShowTabs }) {
// Set localStorage flag to track generation across page reloads
localStorage.setItem(generatingKey, "true");
- const trimmedTopic = topicSuggestion.trim() || null;
+ const trimmedSuggestion = suggestion.trim() || null;
+ const suggestionTypeToSend = trimmedSuggestion && suggestionType !== "auto" ? suggestionType : null;
api.generateDailyLesson(
(data) => {
if (data.status === AUDIO_STATUS.GENERATING) {
@@ -323,7 +311,8 @@ export default function TodayAudio({ setShowTabs }) {
}
setError(errorMsg);
},
- trimmedTopic,
+ trimmedSuggestion,
+ suggestionTypeToSend,
);
};
@@ -412,94 +401,25 @@ export default function TodayAudio({ setShowTabs }) {
// Can generate lesson - show the generate button
if (canGenerateLesson === true) {
return (
-
+
{error && (
{error}
)}
-
-
- Generate a personalized audio lesson based on the words you're learning.
-
- {
- const val = e.target.value;
- setTopicSuggestion(val);
- if (val.trim()) {
- localStorage.setItem(TOPIC_STORAGE_KEY_PREFIX + lang, val);
- } else {
- localStorage.removeItem(TOPIC_STORAGE_KEY_PREFIX + lang);
- }
- }}
- style={{
- width: "100%",
- maxWidth: "300px",
- padding: "8px 12px",
- border: "1px solid var(--border-light)",
- borderRadius: "4px",
- fontSize: "14px",
- color: "var(--text-primary)",
- backgroundColor: "var(--bg-secondary)",
- textAlign: "center",
- }}
+
+
-
+
);
}
@@ -515,138 +435,17 @@ export default function TodayAudio({ setShowTabs }) {
return (
-
-
- {lessonData.is_completed && ✓}
- {lessonData.topic_suggestion
- ? `${lessonData.topic_suggestion}: ${wordsAsTile(words)}`
- : wordsAsTile(words)}
-
-
- {error &&
{error}
}
-
-
- {!lessonData.is_completed && (
-
Here's your daily lesson! Listen to improve your comprehension skills.
- )}
-
-
{
- if (lessonData.lesson_id) {
- api.updateLessonState(lessonData.lesson_id, "resume");
- // Start or resume listening session
- listeningSession.start();
- // Update context so navigation dot disappears (in_progress)
- setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.IN_PROGRESS }));
- }
- }}
- onPause={() => {
- // Pause listening session (accumulates time, doesn't end)
- listeningSession.pause();
- }}
- onProgressUpdate={(progressSeconds) => {
- setCurrentPlaybackTime(progressSeconds);
- if (lessonData.lesson_id) {
- // Use pause action to save progress position
- api.updateLessonState(lessonData.lesson_id, "pause", progressSeconds);
- }
- }}
- onEnded={() => {
- // End listening session when audio ends
- listeningSession.end();
- if (lessonData.lesson_id) {
- api.updateLessonState(lessonData.lesson_id, "complete", null, () => {
- // Update local state to show completion immediately
- setLessonData((prev) => ({
- ...prev,
- is_completed: true,
- completed_at: new Date().toISOString(),
- }));
- // Update context so navigation dot disappears
- setUserDetails((prev) => ({ ...prev, daily_audio_status: AUDIO_STATUS.COMPLETED }));
- });
- }
- }}
- onError={() => {}}
- style={{
- width: "100%",
- marginBottom: "20px",
- maxWidth: "600px",
- margin: "0 auto 20px auto",
- }}
- />
-
- {lessonData.is_completed && (
-
-
- ✓ Lesson completed! Great job on finishing today's lesson.
-
-
- )}
-
- {/* Display word details with type badges */}
- {words && words.length > 0 && (
-
-
- Words in this lesson
-
- {words.map((word, index) => (
-
- ))}
-
- )}
-
-
-
-
-
-
-
-
+
);
}
diff --git a/src/dailyAudio/audioUtils.js b/src/dailyAudio/audioUtils.js
new file mode 100644
index 000000000..b5aaccbf8
--- /dev/null
+++ b/src/dailyAudio/audioUtils.js
@@ -0,0 +1,12 @@
+export function wordsAsTile(words) {
+ if (!words || !words.length) return "";
+
+ const comma_separated_words = words.map((word) => word.origin || word).join(", ");
+ const capitalized_comma_separated_words =
+ comma_separated_words.charAt(0).toUpperCase() + comma_separated_words.slice(1);
+ return capitalized_comma_separated_words;
+}
+
+export function shortDate() {
+ return `[${new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" })}]`;
+}