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" })}]`; +}