diff --git a/index.ts b/index.ts index b576cb9..17ef725 100644 --- a/index.ts +++ b/index.ts @@ -50,18 +50,41 @@ async function setupAndCreateWidget() { } async function presentWidget() { - const widget = await setupAndCreateWidget() - switch (PREVIEW_WIDGET_SIZE) { - case 'small': - widget.presentSmall() - break - case 'medium': - widget.presentMedium() - break - case 'large': - widget.presentLarge() - break - } + // Ask the user which widget size to preview + const alert = new Alert(); + alert.title = "Widget Preview Size"; + alert.message = "Select which widget size you want to preview:"; + alert.addAction("πŸ“± Small"); + alert.addAction("πŸ–₯️ Medium"); + alert.addAction("🧩 Large"); + alert.addCancelAction("Cancel"); + + const response = await alert.presentAlert(); + if (response === -1) { + console.log("Preview cancelled β€” no widget shown."); + return; + } + + const sizeMap = ["small", "medium", "large"]; + const chosenSize = sizeMap[response]; + + config.widgetFamily = chosenSize; + console.log(`πŸ“± Previewing as ${chosenSize} widget`); + + const widget = await setupAndCreateWidget(); + + // Present the widget in the chosen size + switch (chosenSize) { + case "small": + await widget.presentSmall(); + break; + case "medium": + await widget.presentMedium(); + break; + case "large": + await widget.presentLarge(); + break; + } } function showDocumentation() { diff --git a/src/api/cacheOrFetch.ts b/src/api/cacheOrFetch.ts index f03dc13..e753d76 100644 --- a/src/api/cacheOrFetch.ts +++ b/src/api/cacheOrFetch.ts @@ -1,7 +1,7 @@ import { readFromCache, writeToCache } from '@/api/cache' -import { fetchAbsencesFor, fetchExamsFor, fetchGradesFor, fetchLessonsFor, fetchSchoolYears } from '@/api/fetch' +import { fetchAbsencesFor, fetchExamsFor, fetchGradesFor, fetchLessonsFor, fetchSchoolYears, fetchHomeworksFor } from '@/api/fetch' import { CURRENT_DATETIME, NOTIFIABLE_TOPICS } from '@/constants' -import { compareCachedAbsences, compareCachedExams, compareCachedGrades, compareCachedLessons } from '@/features/notify' +import { compareCachedAbsences, compareCachedExams, compareCachedGrades, compareCachedLessons, compareCachedHomeworks } from '@/features/notify' import { Settings } from '@/settings/settings' import { sortKeysByDate } from '@/utils/helper' @@ -119,6 +119,15 @@ export async function getAbsencesFor(user: FullUser, from: Date, to: Date, widge ) } +export async function getHomeworksFor(user: FullUser, from: Date, to: Date, widgetConfig: Settings) { + return getCachedOrFetch( + 'homeworks', + widgetConfig.cache.homeworks, + widgetConfig, () => fetchHomeworksFor(user, from, to), + compareCachedHomeworks, + ) +} + export async function getSchoolYears(user: FullUser, widgetConfig: Settings) { return getCachedOrFetch('school_years', widgetConfig.cache.schoolYears, widgetConfig, () => fetchSchoolYears(user)) } diff --git a/src/api/fetch.ts b/src/api/fetch.ts index 62fb3e0..e36f7f3 100644 --- a/src/api/fetch.ts +++ b/src/api/fetch.ts @@ -8,6 +8,7 @@ import { transformExams, transformGrades, transformSchoolYears, + transformHomeworks } from './transform' import { transformLessons } from './transformLessons' import { getModuleFileManager, writeConfig } from '@/utils/scriptable/fileSystem' @@ -222,6 +223,17 @@ export async function fetchAbsencesFor(user: FullUser, from: Date, to: Date) { return fetchArrayLikeData(user, urlAbsences, 'absences', transformAbsences) } +export async function fetchHomeworksFor(user, from, to) { + const urlHomeworks = `https://${user.server}.webuntis.com/WebUntis/api/homeworks/lessons?startDate=${formatDateForUntis(from)}&endDate=${formatDateForUntis(to)}` + const request = prepareRequest(urlHomeworks, user) + const json = await request.loadJSON() + if (!json || !json.data) { + console.warn('⚠️ Could not fetch homeworks!') + return [] + } + return transformHomeworks(json.data) +} + export async function fetchClassRolesFor(user: FullUser, from: Date, to: Date) { const urlClassRoles = `https://${ user.server diff --git a/src/api/fetchManager.ts b/src/api/fetchManager.ts index d2c0732..36d94df 100644 --- a/src/api/fetchManager.ts +++ b/src/api/fetchManager.ts @@ -7,11 +7,12 @@ import { TransformedExam, TransformedGrade, TransformedLesson, + TransformedHomework, } from '@/types/transformed' import { getDateInXSeconds } from '@/utils/helper' import { getRefreshDateForLessons } from '@/utils/refreshDate' import { checkNewRefreshDate, proposeRefreshIn } from '@/widget' -import { getAbsencesFor, getExamsFor, getGradesFor, getSchoolYears, getTimetable } from './cacheOrFetch' +import { getAbsencesFor, getExamsFor, getGradesFor, getSchoolYears, getTimetable, getHomeworksFor } from './cacheOrFetch' import { fetchClassRolesFor } from './fetch' export interface FetchedData { @@ -21,6 +22,7 @@ export interface FetchedData { exams?: TransformedExam[] grades?: TransformedGrade[] absences?: TransformedAbsence[] + homeworks?: TransformedHomework[] classRoles?: TransformedClassRole[] refreshDate?: Date } @@ -31,6 +33,7 @@ enum FetchableItems { GRADES, ABSENCES, ROLES, + HOMEWORKS, } const VIEW_FETCH_MAP = new Map([ @@ -39,6 +42,7 @@ const VIEW_FETCH_MAP = new Map([ [View.EXAMS, FetchableItems.EXAMS], [View.GRADES, FetchableItems.GRADES], [View.ABSENCES, FetchableItems.ABSENCES], + [View.HOMEWORKS, FetchableItems.HOMEWORKS] ]) /** @@ -101,6 +105,15 @@ export async function fetchDataForViews(viewNames: View[], user: FullUser, widge fetchPromises.push(promise) } + if (itemsToFetch.has(FetchableItems.HOMEWORKS)) { + const homeworksTo = getDateInXSeconds(widgetConfig.views.homeworks.scope); + const promise = getHomeworksFor(user, CURRENT_DATETIME, homeworksTo, widgetConfig).then((homeworks) => { + fetchedData.homeworks = homeworks; + }); + proposeRefreshIn(widgetConfig.cache.homeworks, fetchedData); + fetchPromises.push(promise); + } + if (itemsToFetch.has(FetchableItems.ROLES)) { const promise = fetchClassRolesFor(user, CURRENT_DATETIME, CURRENT_DATETIME).then((roles) => { fetchedData.classRoles = roles diff --git a/src/api/transform.ts b/src/api/transform.ts index 81d760b..1cc67a1 100644 --- a/src/api/transform.ts +++ b/src/api/transform.ts @@ -1,10 +1,11 @@ -import { Absence, ClassRole, Exam, Grade, SchoolYear } from '@/types/api' +import { Absence, ClassRole, Exam, Grade, SchoolYear , HomeworkApiData} from '@/types/api' import { TransformedAbsence, TransformedClassRole, TransformedExam, TransformedGrade, TransformedSchoolYear, + TransformedHomework, } from '@/types/transformed' function parseDateNumber(date: number) { @@ -94,15 +95,80 @@ export function transformAbsences(absences: Absence[]) { from: combineDateAndTime(absence.startDate, absence.startTime), to: combineDateAndTime(absence.endDate, absence.endTime), createdBy: absence.createdUser, + reason: absence.reason ?? "Unknown", + text: absence.text ?? "", reasonId: absence.reasonId, isExcused: absence.isExcused, - excusedBy: absence.excuse.username, + excuseStatus: absence.excuseStatus ?? absence.excuse?.excuseStatus ?? "open", + excusedBy: absence.excuse?.username ?? "", } transformedAbsences.push(transformedAbsence) } return transformedAbsences } +export function transformHomeworks(apiData: HomeworkApiData): TransformedHomework[] { + const transformedHomeworks: TransformedHomework[] = [] + + const records = apiData.records ?? [] + const homeworks = apiData.homeworks ?? [] + const teachers = apiData.teachers ?? [] + const lessons = apiData.lessons ?? [] + + const hwMap = new Map(homeworks.map(hw => [hw.id, hw])) + + // 1️⃣ VerknΓΌpfte Homeworks (mit Record) + for (const record of records) { + const hw = hwMap.get(record.homeworkId) + if (!hw) continue + + const teacher = teachers.find(t => t.id === record.teacherId) + const lesson = lessons.find(l => l.id === hw.lessonId) + + transformedHomeworks.push({ + id: hw.id, + lessonId: hw.lessonId, + subject: lesson?.subject ?? "", + teacher: teacher?.name ?? "", + text: hw.text ?? "", + remark: hw.remark ?? "", + completed: !!hw.completed, + date: hw.date ? parseDateNumber(hw.date) : undefined, + dueDate: hw.dueDate ? parseDateNumber(hw.dueDate) : undefined, + attachments: hw.attachments ?? [], + }) + } + + // 2️⃣ Homeworks ohne Record + for (const hw of homeworks) { + if (!transformedHomeworks.find(t => t.id === hw.id)) { + const lesson = lessons.find(l => l.id === hw.lessonId) + + transformedHomeworks.push({ + id: hw.id, + lessonId: hw.lessonId, + subject: lesson?.subject ?? "", + teacher: "", + text: hw.text ?? "", + remark: hw.remark ?? "", + completed: !!hw.completed, + date: hw.date ? parseDateNumber(hw.date) : undefined, + dueDate: hw.dueDate ? parseDateNumber(hw.dueDate) : undefined, + attachments: hw.attachments ?? [], + }) + } + } + + // 3️⃣ Sortieren + transformedHomeworks.sort((a, b) => { + const aTime = a.dueDate ? a.dueDate.getTime() : (a.date ? a.date.getTime() : 0) + const bTime = b.dueDate ? b.dueDate.getTime() : (b.date ? b.date.getTime() : 0) + return aTime - bTime + }) + + return transformedHomeworks +} + export function transformClassRoles(classRoles: ClassRole[]) { const transformedClassRoles: TransformedClassRole[] = [] for (const classRole of classRoles) { diff --git a/src/constants.ts b/src/constants.ts index 8d04bde..95f490b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -25,4 +25,4 @@ export const CONFIG_FILE_NAME = 'untis-config.json' export const CUSTOM_CONFIG_KEYS = ['subjects'] export const GITHUB_USER = 'kaiser-jan' export const GITHUB_REPO = 'scriptable-untis' -export const GITHUB_SCRIPT_NAME = 'UntisWidget.js' +export const GITHUB_SCRIPT_NAME = 'UntisWidget.js' \ No newline at end of file diff --git a/src/features/notify.ts b/src/features/notify.ts index 10ef544..188be85 100644 --- a/src/features/notify.ts +++ b/src/features/notify.ts @@ -2,9 +2,10 @@ import { LOCALE, NO_VALUE_PLACEHOLDERS } from '@/constants' import { applySubjectConfig, getSubjectConfigFor } from '@/settings/subjectConfig' import { Settings } from '@/settings/settings' import { LessonState } from '@/types/api' -import { TransformedAbsence, TransformedExam, TransformedGrade, TransformedLessonWeek } from '@/types/transformed' +import { TransformedAbsence, TransformedExam, TransformedGrade, TransformedLessonWeek, TransformedHomework } from '@/types/transformed' import { asNumericTime, scheduleNotification } from '@/utils/helper' import { getSubjectTitle } from '@/utils/lessonHelper' +import { cleanupExpiredHomeworks } from '@/utils/scriptable/componentHelper' /** * Compares the fetched lessons with the cached lessons and sends notifications for most changes. @@ -223,3 +224,52 @@ export function compareCachedAbsences( } } } + +export function compareCachedHomeworks( + homeworks: TransformedHomework[], + cachedHomeworks: TransformedHomework[], + widgetConfig: Settings +) { + const offset = widgetConfig?.views?.homeworks?.dueNotificationOffset ?? 86400 // default 1 day + const now = new Date() + // Cleanup expired items + const activeHomeworks = cleanupExpiredHomeworks(homeworks) + // Detect removed homeworks + const removed = cachedHomeworks.filter( + old => !activeHomeworks.some(hw => hw.id === old.id) + ) + for (const r of removed) { + scheduleNotification( + `πŸ—‘οΈ Homework removed`, + `${r.subject || 'Unknown'} β€” ${r.text || 'deleted by teacher'}`, + 'event' + ) + console.log(`Removed homework: ${r.text || r.subject}`) + } + // Detect new or updated homeworks + for (const hw of activeHomeworks) { + const cached = cachedHomeworks.find(c => c.id === hw.id) + if (!cached) { + // New homework + scheduleNotification( + `πŸ“ New homework: ${hw.subject || 'Unknown'}`, + hw.text ?? 'Check the widget for details.' + ); + continue + } + // Due date changed β†’ re-schedule notification + if (hw.dueDate && (!cached.dueDate || hw.dueDate.getTime() !== new Date(cached.dueDate).getTime())) { + const notifyDate = new Date(hw.dueDate.getTime() - offset * 1000) + if (notifyDate > now) { + scheduleNotification( + `⏰ Upcoming: ${hw.subject}`, + hw.text ?? '', + 'event', + notifyDate + ) + } + } + } + // Return cleaned list to be cached again + return activeHomeworks +} \ No newline at end of file diff --git a/src/layout.ts b/src/layout.ts index b23be8c..b8daa3e 100644 --- a/src/layout.ts +++ b/src/layout.ts @@ -7,6 +7,7 @@ export enum View { EXAMS = 'exams', GRADES = 'grades', ABSENCES = 'absences', + HOMEWORKS = 'homeworks', } function parseLayoutString(layoutString: string) { @@ -43,10 +44,37 @@ function adaptLayoutForSize(layout: View[][]) { } } +// keep the last raw layout string (so createWidget can show better error messages) +let LAST_LAYOUT_STRING = "" export function getLayout() { - const layoutString = args.widgetParameter ?? defaultLayout - console.log(`Parsing layout string "${layoutString}..."`) - const layout = adaptLayoutForSize(parseLayoutString(layoutString)) - console.log(`Got parsed layout: ${layout}`) - return layout + let layoutStringRaw = args.widgetParameter?.trim() ?? "" + LAST_LAYOUT_STRING = layoutStringRaw // store raw param for createWidget + let layoutString = layoutStringRaw + console.log(`Parsing layout string "${layoutString}"...`) + + // Handle "all" or empty parameter + if (layoutString === "" || layoutString.toLowerCase() === "all") { + layoutString = defaultLayout + // If it was truly empty, clear LAST_LAYOUT_STRING so we don't treat it as "unknown" + if (layoutStringRaw === "") LAST_LAYOUT_STRING = "" + } + + // Allow comma-separated values (e.g. "homeworks,lessons") + layoutString = layoutString + .split(",") + .map(v => v.trim().toLowerCase()) + .filter(v => v.length > 0) + .join(",") + + const layout = adaptLayoutForSize(parseLayoutString(layoutString)) + console.log(`Got parsed layout: ${layout}`) + return layout } + +export function getLastLayoutString(): string { + return LAST_LAYOUT_STRING +} + +export function setLastLayoutString(value: string) { + LAST_LAYOUT_STRING = value +} \ No newline at end of file diff --git a/src/settings/editor/settingsBlueprint.ts b/src/settings/editor/settingsBlueprint.ts index 40d2d57..91367fb 100644 --- a/src/settings/editor/settingsBlueprint.ts +++ b/src/settings/editor/settingsBlueprint.ts @@ -4,6 +4,7 @@ import { showInfoPopup } from '@/utils/scriptable/input' import { checkForUpdates } from '@/utils/updater' import { defaultSettings } from '../settings' import { autoSetElementType, fillLoginDataInKeychain } from '@/setup' +import { Duration, DurationUnit } from '@/utils/duration' const subjectBlueprint = { color: { @@ -208,6 +209,11 @@ export const settingsBlueprint: SettingsCategory = { description: 'How long absences should be cached.', type: SettingsValueType.DURATION, }, + homeworks: { + title: 'πŸ“ Homeworks', + description: 'How long homeworks should be cached.', + type: SettingsValueType.DURATION, + }, schoolYears: { title: 'πŸ“… School Years', description: 'How long school years should be cached. This can be quite long.', @@ -331,6 +337,52 @@ export const settingsBlueprint: SettingsCategory = { }, }, }, + homeworks: { + title: 'πŸ“ Homeworks', + description: 'Edit which homeworks to display.', + items: { + maxCount: { + title: 'πŸ”’ Maximum Count', + description: 'Up to how many homeworks should be shown.', + type: SettingsValueType.COUNT, + }, + scope: { + title: 'πŸ“… Scope', + description: 'How long in advance the homeworks should be shown.', + type: SettingsValueType.DURATION, + }, + dueWarningDays: { + title: '⚠️ Warning Days', + description: 'Number of days before due date to show as warning.', + type: SettingsValueType.DURATION, + }, + dueOverdueDays: { + title: '❌ Overdue Days', + description: 'Number of days past due date to show as overdue.', + type: SettingsValueType.DURATION, + }, + enableCompactMode: { + title: '🧩 Enable Compact Mode', + description: 'Use a more compact layout.', + type: SettingsValueType.ON_OFF, + }, + dueWarningColor: { + title: '🟑 Warning Color', + description: 'Color used for soon-due items (yellow by default).', + type: SettingsValueType.COLOR + }, + dueOverdueColor: { + title: 'πŸ”΄ Overdue Color', + description: 'Color used for overdue items (red by default).', + type: SettingsValueType.COLOR, + }, + dueNotificationOffset: { + title: 'πŸ• Notification Offset', + description: 'The time a notification is sent before the due date.', + type: SettingsValueType.DURATION, + }, + }, + }, }, }, notifications: { @@ -358,6 +410,11 @@ export const settingsBlueprint: SettingsCategory = { description: 'added absences', type: SettingsValueType.ON_OFF, }, + homeworks: { + title: 'πŸ“ Homework Notifications', + description: 'Notify for new homeworks and near due dates.', + type: SettingsValueType.ON_OFF, + }, }, }, appearance: { @@ -394,6 +451,11 @@ export const settingsBlueprint: SettingsCategory = { description: 'The background color of the widget.', type: SettingsValueType.COLOR, }, + liquidGlass: { + title: 'πŸͺŸ Liquid Glass Mode', + description: 'Use slightly transparent backgrounds for better readability on iOS "Clear" homescreens (iOS 26).', + type: SettingsValueType.ON_OFF, + }, }, }, debugSettings: { diff --git a/src/settings/settings.ts b/src/settings/settings.ts index a38326c..b1e3d01 100644 --- a/src/settings/settings.ts +++ b/src/settings/settings.ts @@ -48,6 +48,7 @@ export const defaultSettings = { grades: Duration.asSeconds(8, DurationUnit.HOUR), absences: Duration.asSeconds(12, DurationUnit.HOUR), schoolYears: Duration.asSeconds(1, DurationUnit.DAY), + homeworks: Duration.asSeconds(12, DurationUnit.HOUR), }, refresh: { normalScope: Duration.asSeconds(12, DurationUnit.HOUR), @@ -74,6 +75,16 @@ export const defaultSettings = { absences: { maxCount: 3, }, + homeworks: { + maxCount: 6, + scope: Duration.asSeconds(7, DurationUnit.DAY), + dueWarningDays: Duration.asSeconds(2, DurationUnit.DAY), + dueOverdueDays: 1, + enableCompactMode: false, + dueWarningColor: "#ffd60a", + dueOverdueColor: "#ff453a", + dueNotificationOffset: Duration.asSeconds(1, DurationUnit.DAY), + }, }, notifications: { @@ -81,6 +92,7 @@ export const defaultSettings = { exams: true, grades: true, absences: true, + homeworks: true, }, appearance: { @@ -90,6 +102,7 @@ export const defaultSettings = { fontSize: 14, footer: true, backgroundColor: unparsedColors.background.tertiary, + liquidGlass: false, }, debugSettings: { diff --git a/src/types/api.ts b/src/types/api.ts index f6aeca4..3501b69 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -216,6 +216,36 @@ export interface Absence { } } +export interface HomeworkApiData { + records: { + homeworkId: number + teacherId: number + elementIds: number[] + }[] + + homeworks: { + id: number + lessonId: number + date?: number + dueDate?: number + text?: string + remark?: string + completed: boolean + attachments?: any[] + }[] + + teachers: { + id: number + name: string + }[] + + lessons: { + id: number + subject: string + lessonType: string + }[] +} + export interface ClassRole { id: number personId: number diff --git a/src/types/settings.ts b/src/types/settings.ts index 71054ea..32b11ae 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -1,5 +1,16 @@ import { Settings } from '@/settings/settings' +/** + * Configuration for the "homeworks" view in the widget. + */ +export interface HomeworkViewConfig { + enableCompactMode?: boolean; + dueWarningDays?: number; + dueOverdueDays?: number; + dueWarningColor?: string; + dueOverdueColor?: string; +} + /** * A configuration for a single subject. * @property color The color of the subject as one of the Colors or a hex value. diff --git a/src/types/transformed.ts b/src/types/transformed.ts index f360316..cb77526 100644 --- a/src/types/transformed.ts +++ b/src/types/transformed.ts @@ -24,7 +24,9 @@ export type StatelessElement = Teacher | Group | Subject | Room export type Stateful = T & { state: ElementState original?: T -} +} & ( + T extends Teacher ? { longName: string } : {} +) export type StatefulElement = Stateful export interface TransformedLesson { @@ -110,11 +112,27 @@ export interface TransformedAbsence { from: Date to: Date createdBy: string + reason: string + text: string reasonId: number isExcused: boolean + excuseStatus: string excusedBy?: string } +export interface TransformedHomework { + id: number + lessonId: number + subject: string + teacher: string + text: string + remark: string + completed: boolean + date?: Date + dueDate?: Date + attachments: any[] +} + export interface TransformedClassRole { fromDate: Date toDate: Date diff --git a/src/utils/helper.ts b/src/utils/helper.ts index 015bf1c..7130e3a 100644 --- a/src/utils/helper.ts +++ b/src/utils/helper.ts @@ -37,16 +37,18 @@ export function getCharWidth(size: number) { } export function getTextWidth(text: string, fontSize: number) { + // ensure text is a string + text = (text ?? "").toString() if (!text) return 0 const charWidth = getCharWidth(fontSize) // count the number of really narrow characters - let reallyNarrowCharCount = text.match(/[\|Ili\.,:;\ ]/g)?.length ?? 0 + const reallyNarrowCharCount = (text.match(/[\|Ili\.,:;\ ]/g) || []).length // count the number of narrow characters - let narrowCharCount = text.match(/[1ljtr]/g)?.length ?? 0 + const narrowCharCount = (text.match(/[1ljtr]/g) || []).length // count the number of wide characters - let wideCharCount = text.match(/[wmWM]/g)?.length ?? 0 + const wideCharCount = (text.match(/[wmWM]/g) || []).length - let normalCharCount = text.length - reallyNarrowCharCount - narrowCharCount - wideCharCount + const normalCharCount = text.length - reallyNarrowCharCount - narrowCharCount - wideCharCount // approximate the width of the text return charWidth * (normalCharCount + reallyNarrowCharCount * 0.4 + narrowCharCount * 0.75 + wideCharCount * 1.25) diff --git a/src/utils/scriptable/componentHelper.ts b/src/utils/scriptable/componentHelper.ts index 5a527fe..798df77 100644 --- a/src/utils/scriptable/componentHelper.ts +++ b/src/utils/scriptable/componentHelper.ts @@ -34,15 +34,20 @@ export function addBreak( showToTime: boolean, widgetConfig: Settings ) { + // Liquid Glass + const __liquidItemColors = getItemColors(colors.background.primary, widgetConfig, false) const breakContainer = makeTimelineEntry(to, breakFrom, widgetConfig, { - backgroundColor: colors.background.primary, + backgroundColor: __liquidItemColors.backgroundColor, showTime: true, showToTime: showToTime, toTime: breakTo, }) + + breakContainer.cornerRadius = widgetConfig.appearance.cornerRadius + const breakTitle = breakContainer.addText('Break') breakTitle.font = Font.mediumSystemFont(widgetConfig.appearance.fontSize) - breakTitle.textColor = colors.text.secondary + breakTitle.textColor = __liquidItemColors.secondaryTextColor breakContainer.addSpacer() } @@ -114,7 +119,7 @@ function makeTimelineEntry( return lessonContainer } -function getLessonColors(lesson: TransformedLesson) { +function getLessonColors(lesson: TransformedLesson, widgetConfig: Settings) { /** Whether the lesson was rescheduled away from here. (isSource) * The lesson state seems to be CANCELED? */ const isRescheduledAway = lesson.rescheduleInfo?.isSource @@ -136,9 +141,81 @@ function getLessonColors(lesson: TransformedLesson) { secondaryTextColor = colors.text.red } + // Liquid Glass override + try { + const liquid = !!(widgetConfig && widgetConfig.appearance && widgetConfig.appearance.liquidGlass) + if (liquid) { + // For normal lessons use a slight translucent overlay that adapts to appearance: + // light appearance white 30% overlay, dark appearance black 30% overlay + if (lesson.state !== LessonState.CANCELED && lesson.state !== LessonState.FREE && !isRescheduledAway) { + backgroundColor = Color.dynamic(new Color('#ffffff', 0.30), new Color('#000000', 0.30)) + // Ensure high contrast text colors + textColor = Color.dynamic(new Color('#000000'), new Color('#ffffff')) + secondaryTextColor = Color.dynamic(new Color('#666666'), new Color('#999999')) + } else { + // For cancelled/free/rescheduled keep disabled look but ensure contrast + textColor = colors.text.disabled + secondaryTextColor = colors.text.disabled + backgroundColor = colors.background.primary + } + } + } catch (e) { + // if something fails, fall back to default colors + console.warn('Liquid Glass override failed: ' + e) + } + return { backgroundColor, textColor, secondaryTextColor } } +/** + * Generic color helper that applies the Liquid Glass override when enabled. + * baseBackgroundColor: the original background color that would have been used (may be null) + * widgetConfig: the widget configuration object + * disabled: whether the item should be shown as disabled (uses disabled text/background) + */ +export function getItemColors(baseBackgroundColor: Color, widgetConfig: Settings, disabled = false) { + let backgroundColor = baseBackgroundColor ?? colors.background.primary + let textColor = colors.text.primary + let secondaryTextColor = colors.text.secondary + if (disabled) { + backgroundColor = colors.background.primary + textColor = colors.text.disabled + secondaryTextColor = colors.text.disabled + } + // Liquid Glass override (same approach as lessons) + try { + const liquid = !!(widgetConfig && widgetConfig.appearance && widgetConfig.appearance.liquidGlass) + if (liquid && !disabled) { + // translucent overlay that adapts to appearance + backgroundColor = Color.dynamic(new Color('#ffffff', 0.30), new Color('#000000', 0.30)) + // high contrast text colors + textColor = Color.dynamic(new Color('#000000'), new Color('#ffffff')); + secondaryTextColor = Color.dynamic(new Color('#666666'), new Color('#999999')) + } + } catch (e) { + console.warn('getItemColors Liquid Glass override failed: ' + e) + } + return { backgroundColor, textColor, secondaryTextColor } +} + +function isColorLight(hex: any) { + try { + const c = hex.replace("#", ""); + const r = parseInt(c.substring(0, 2), 16); + const g = parseInt(c.substring(2, 4), 16); + const b = parseInt(c.substring(4, 6), 16); + const brightness = (r * 299 + g * 587 + b * 114) / 1000; + return brightness > 155; + } catch { + return false; + } +} + +export function getReadableTextColor(bgColor: Color) { + const hex = (bgColor && typeof bgColor.hex === "string") ? bgColor.hex : (typeof bgColor === "string" ? bgColor : "#000000"); + return isColorLight(hex) ? Color.black() : Color.white(); +} + /** * Adds a lesson to the widget. This includes its subject, additional info (as an icon) and the time. * The state is also shown as through colors. (canceled, event) @@ -161,89 +238,78 @@ export function addWidgetLesson( useSubjectLongName: false, } ) { - // get the colors for the lesson based on its state (red, disabled, normal) - const { backgroundColor, textColor, secondaryTextColor } = getLessonColors(lesson) + const widgetFamily = config.widgetFamily || "medium"; + const isSmall = widgetFamily === "small"; + + const { backgroundColor, textColor, secondaryTextColor } = getLessonColors(lesson, widgetConfig); + const readableTextColor = getReadableTextColor(backgroundColor); + const readableSecondary = isColorLight(backgroundColor) ? Color.dynamic(Color.gray(), Color.lightGray()) : colors.text.secondary; - // consider breaks during the combined lesson - let toTime = lesson.to - if (widgetConfig.views.lessons.skipShortBreaks && lesson.break) { - toTime = new Date(lesson.to.getTime() - lesson.break) - } - - // add the entry with the time - const lessonContainer = makeTimelineEntry(to, lesson.from, widgetConfig, { - showTime: options.showTime, - showToTime: options.showToTime, - toTime: toTime, - backgroundColor: backgroundColor, - }) - lessonContainer.spacing = widgetConfig.appearance.spacing + const lessonContainer = makeTimelineEntry(to, lesson.from, widgetConfig, { + showTime: options.showTime, + showToTime: options.showToTime, + toTime: lesson.to, + backgroundColor: backgroundColor, + }); + lessonContainer.spacing = widgetConfig.appearance.spacing; - // add the name of the subject - const lessonText = lessonContainer.addText(getSubjectTitle(lesson, options.useSubjectLongName)) - lessonText.font = Font.semiboldSystemFont(widgetConfig.appearance.fontSize) - lessonText.textColor = textColor - lessonText.leftAlignText() + // dynamic font Size + const fontSize = isSmall ? widgetConfig.appearance.fontSize * 0.85 : widgetConfig.appearance.fontSize; - // add a x2 for double lessons etc. - if (lesson.duration > 1 && widgetConfig.views.lessons.showMultiplier) { - const durationText = lessonContainer.addText(`x${lesson.duration}`) - durationText.font = Font.mediumSystemFont(widgetConfig.appearance.fontSize) - durationText.textColor = secondaryTextColor - } + // subject name + const subjectTitle = getSubjectTitle(lesson, options.useSubjectLongName); + const lessonText = lessonContainer.addText(subjectTitle); + lessonText.font = Font.semiboldSystemFont(fontSize); + lessonText.textColor = readableTextColor; + lessonText.lineLimit = isSmall ? 1 : 2; + lessonText.minimumScaleFactor = 0.5; + lessonText.leftAlignText(); - let iconName: string | undefined = undefined - - // TODO: consider adding another icon for rescheduled source lessons, as they have state canceled - const STATE_ICON_MAP: Record = { - [LessonState.NORMAL]: undefined, - [LessonState.CANCELED]: 'xmark.circle', - [LessonState.FREE]: 'xmark.circle', - [LessonState.ADDITIONAL]: 'plus.circle', - [LessonState.RESCHEDULED]: 'calendar.circle', - [LessonState.EXAM]: 'book.circle', - [LessonState.SUBSTITUTED]: 'person.circle', - [LessonState.TEACHER_SUBSTITUTED]: 'person.circle', - [LessonState.ROOM_SUBSTITUTED]: 'location.circle', - } + // show teacher + room only in medium or big widget + if (!isSmall && lesson.rooms?.length) { + const roomNames = lesson.rooms.map(r => r.name).join(", "); + const roomText = lessonContainer.addText(" β€’ " + roomNames); + roomText.font = Font.mediumSystemFont(fontSize * 0.9); + roomText.textColor = readableSecondary; + roomText.lineLimit = 1; + roomText.minimumScaleFactor = 0.7; + } - // add icons for the lesson state - if (lesson.isEvent) { - iconName = 'calendar.circle' - } else if (lesson.rescheduleInfo?.isSource) { - // do not add an icon, as the lesson already has the reschedule info - } else if (STATE_ICON_MAP[lesson.state]) { - iconName = STATE_ICON_MAP[lesson.state] - } else if (lesson.text || lesson.info || lesson.note) { - iconName = 'info.circle' - } + if (!isSmall && lesson.teachers?.length) { + const teacherNames = lesson.teachers.map(t => t.longName || t.name).join(", "); + const teacherText = lessonContainer.addText(" β€’ " + teacherNames + " β€’ "); + teacherText.font = Font.regularSystemFont(fontSize * 0.9); + teacherText.textColor = readableSecondary; + teacherText.leftAlignText(); + } - if (!iconName) { - lessonContainer.addSpacer() - } + if (lesson.duration > 1 && widgetConfig.views.lessons.showMultiplier) { + const durationText = lessonContainer.addText(`x${lesson.duration}`); + durationText.font = Font.mediumSystemFont(fontSize); + durationText.textColor = readableSecondary; + } - // add a shift info if the lesson was rescheduled - if (lesson.isRescheduled && lesson.rescheduleInfo?.isSource) { - const iconShift = addSymbol('arrow.right', lessonContainer, { - color: colors.text.disabled, - size: widgetConfig.appearance.fontSize * 0.8, - }) - // manually correct the arrow box - iconShift.imageSize = new Size( - getCharWidth(widgetConfig.appearance.fontSize * 0.8), - getCharHeight(widgetConfig.appearance.fontSize) - ) + let iconName; + const STATE_ICON_MAP = { + STANDARD: undefined, + CANCEL: 'xmark.circle', + FREE: 'xmark.circle', + ADDITIONAL: 'plus.circle', + SHIFT: 'calendar.circle', + EXAM: 'book.circle', + SUBSTITUTION: 'person.circle', + TEACHERSUBSTITUTION: 'person.circle', + ROOMSUBSTITUTION: 'location.circle', + }; - // display the time it was rescheduled to - const rescheduledTime = lessonContainer.addText(asNumericTime(lesson.rescheduleInfo?.otherFrom)) - rescheduledTime.font = Font.mediumSystemFont(widgetConfig.appearance.fontSize) - rescheduledTime.textColor = colors.text.disabled - } + if (lesson.isEvent) iconName = 'calendar.circle'; + else if (lesson.rescheduleInfo?.isSource); + else iconName = STATE_ICON_MAP[lesson.state]; - if (iconName) { - lessonContainer.addSpacer() - addSymbol(iconName, lessonContainer, { color: secondaryTextColor, size: widgetConfig.appearance.fontSize }) - } + if (iconName) { + lessonContainer.addSpacer(); + addSymbol(iconName, lessonContainer, { color: secondaryTextColor, size: fontSize }); + } } /** @@ -253,7 +319,7 @@ export function addWidgetLesson( * @param widgetConfig */ export function fillContainerWithSubject(lesson: TransformedLesson, container: WidgetStack, widgetConfig: Settings) { - const { backgroundColor, textColor, secondaryTextColor } = getLessonColors(lesson) + const { backgroundColor, textColor, secondaryTextColor } = getLessonColors(lesson, widgetConfig) container.backgroundColor = backgroundColor container.layoutHorizontally() @@ -276,3 +342,32 @@ export function fillContainerWithSubject(lesson: TransformedLesson, container: W durationText.textColor = colors.text.secondary } } + + +/** + * Remove homework that is past its due date (next midnight after due date) + * @param {Array} homeworks - Current homework list from Untis + * @returns {Array} - Filtered list (expired ones removed) + */ +export function cleanupExpiredHomeworks(homeworks) { + const now = new Date(); + const cleaned = []; + for (const hw of homeworks) { + const due = hw.dueDate ?? hw.date; + if (!due) { + cleaned.push(hw); + continue; + } + // Compute expiration = start of next day after due date + const expiration = new Date(due); + expiration.setDate(expiration.getDate() + 1); + expiration.setHours(0, 0, 0, 0); + // Keep if not expired + if (now < expiration) { + cleaned.push(hw); + } else { + console.log(`πŸ—‘οΈ Removing expired homework: ${hw.text || hw.subject || hw.id}`); + } + } + return cleaned; +} \ No newline at end of file diff --git a/src/utils/scriptable/fileSystem.ts b/src/utils/scriptable/fileSystem.ts index 54850fd..a2f6ed9 100644 --- a/src/utils/scriptable/fileSystem.ts +++ b/src/utils/scriptable/fileSystem.ts @@ -111,3 +111,15 @@ export async function readFolder(options: { return currentPath } + +// Path to store homework state +const HOMEWORK_STATE_FILE = 'homeworkStates.json'; +const fm = FileManager.local(); +const pathHWState = fm.joinPath(fm.documentsDirectory(), HOMEWORK_STATE_FILE); +export function loadHomeworkStates() { + if (!fm.fileExists(pathHWState)) return {}; + try { return JSON.parse(fm.readString(pathHWState)); } catch { return {}; } +} +export function saveHomeworkStates(states) { + fm.writeString(pathHWState, JSON.stringify(states)); +} \ No newline at end of file diff --git a/src/views/absences.ts b/src/views/absences.ts index e819ddd..1dcae6c 100644 --- a/src/views/absences.ts +++ b/src/views/absences.ts @@ -5,137 +5,113 @@ import { Duration } from '@/utils/duration' import { getCharHeight } from '@/utils/helper' import { StaticLayoutRow } from '@/utils/scriptable/layout/staticLayoutRow' import { ViewBuildData } from '@/widget' +import { getItemColors } from '@/utils/scriptable/componentHelper' export function addViewAbsences( absences: TransformedAbsence[], maxCount: number, { container, width, height, widgetConfig }: ViewBuildData ) { - let remainingHeight = height - const charHeight = getCharHeight(widgetConfig.appearance.fontSize) - const padding = 4 - const containerHeight = charHeight + 2 * padding - - if (height < containerHeight) return 0 - - // sort the absences by date, starting with the most recent - const sortedAbsences = absences.sort((a, b) => b.from.getTime() - a.from.getTime()) - - let absenceCount = 0 - - // add the remaining lessons until the max item count is reached - for (let i = 0; i < sortedAbsences.length; i++) { - const absence = sortedAbsences[i] - - if (absence.isExcused) continue - - // subtract the spacing between the items - if (i > 0) remainingHeight -= widgetConfig.appearance.spacing - - const absenceContainer = container.addStack() - absenceContainer.size = new Size(width, containerHeight) - absenceContainer.layoutHorizontally() - absenceContainer.setPadding(padding, padding, padding, padding) - absenceContainer.spacing = widgetConfig.appearance.spacing - absenceContainer.backgroundColor = colors.background.primary - absenceContainer.cornerRadius = widgetConfig.appearance.cornerRadius - - const shortFromDate = absence.from.toLocaleDateString(LOCALE, { day: '2-digit', month: 'short' }) - const longFromDate = absence.from.toLocaleDateString(LOCALE, { - weekday: 'short', - day: '2-digit', - month: 'short', - }) - - const durationMilliseconds = absence.to.getTime() - absence.from.getTime() - const duration = Duration.fromSeconds(durationMilliseconds / 1000) - const formattedDurationSimple = duration.toString() - const formattedDurationMixed = duration.toMixedUnitString() - - // build the layout row - const staticLayoutRow = new StaticLayoutRow( - width - 2 * padding, - widgetConfig.appearance.spacing, - Font.mediumSystemFont(widgetConfig.appearance.fontSize), - widgetConfig.appearance.fontSize, - colors.text.primary - ) - - /** - * Priorities: - * 1. icon - * 2. date - * 3. duration - * 4. long duration (mixed units) - * 5. long date - * 6. creator - * TODO(transform): reason (parse reasons when transforming) - */ - - // add the absence icon - staticLayoutRow.addItem({ - type: 'icon', - icon: 'pills.circle', - size: widgetConfig.appearance.fontSize, - color: colors.text.primary, - priority: 1, - }) - - // add the absence date - staticLayoutRow.addItem({ - type: 'text', - color: colors.text.secondary, - variants: [ - { - text: shortFromDate, - priority: 2, - }, - { - text: longFromDate, - priority: 5, - }, - ], - }) - - // add the absence duration - staticLayoutRow.addItem({ - type: 'text', - variants: [ - { - text: formattedDurationSimple, - priority: 3, - }, - { - text: formattedDurationMixed, - priority: 4, - }, - ], - }) - - // add the creator - staticLayoutRow.addItem({ - type: 'text', - color: colors.text.secondary, - variants: [ - { - // toLoweCase to make it less prominent - text: absence.createdBy.toLowerCase(), - priority: 6, - }, - ], - }) - - staticLayoutRow.build(absenceContainer) - - remainingHeight -= containerHeight - absenceCount++ - - // exit if the max item count is reached - if (absenceCount >= maxCount) break - - // exit if it would get too big, use the maximum height - if (containerHeight + widgetConfig.appearance.spacing > remainingHeight) break - } - - return height - remainingHeight -} + let remainingHeight = height + const baseFontSize = widgetConfig.appearance.fontSize + const charHeight = getCharHeight(baseFontSize) + const padding = 4 + const singleLineHeight = charHeight + 2 * padding + const detailFontSize = Math.max(9, baseFontSize - 1) + const detailCharHeight = getCharHeight(detailFontSize) + if (height < singleLineHeight) return 0 + + // Sort: unexcused first, newest first + const sorted = absences.sort((a, b) => { + if (a.isExcused !== b.isExcused) return a.isExcused ? 1 : -1 + return b.from.getTime() - a.from.getTime() + }) + + let shown = 0 + for (const absence of sorted) { + const hasDetail = !!(absence.reason || absence.text) + const itemHeight = singleLineHeight + (hasDetail ? detailCharHeight + 2 : 0) + if (itemHeight > remainingHeight) break + + const stack = container.addStack() + stack.layoutVertically() + stack.setPadding(padding, padding, padding, padding) + stack.spacing = 1 + stack.cornerRadius = widgetConfig.appearance.cornerRadius + + const isExcused = absence.isExcused + const colorset = getItemColors(colors.background.primary, widgetConfig, false) + stack.backgroundColor = isExcused + ? Color.dynamic(new Color("#003300", 0.25), new Color("#003300", 0.35)) + : Color.dynamic(new Color("#330000", 0.25), new Color("#330000", 0.35)) + stack.opacity = isExcused ? 0.6 : 1.0 + + // Row 1: Icon + date/time/duration + teacher + const row1 = stack.addStack() + row1.layoutHorizontally() + row1.centerAlignContent() + row1.spacing = widgetConfig.appearance.spacing + + const staticLayout = new StaticLayoutRow( + width - 2 * padding, + widgetConfig.appearance.spacing, + Font.mediumSystemFont(baseFontSize), + baseFontSize, + colorset.textColor + ) + + const day = absence.from.toLocaleDateString(LOCALE, { weekday: "short" }) + const dateStr = absence.from.toLocaleDateString(LOCALE, { day: "2-digit", month: "short" }) + const fromStr = absence.from.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) + const toStr = absence.to.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" }) + const durationMs = absence.to.getTime() - absence.from.getTime() + const durationStr = Duration.fromSeconds(durationMs / 1000).toString() + + const icon = isExcused ? "checkmark.circle.fill" : "exclamationmark.circle.fill" + const iconColor = isExcused ? new Color("#00cc66") : new Color("#ff3b30") + staticLayout.addItem({ type: "icon", icon, size: baseFontSize, color: iconColor, priority: 1 }) + + staticLayout.addItem({ + type: "text", + color: colorset.textColor, + variants: [ + { text: `${day}, ${dateStr} ${fromStr}-${toStr} (${durationStr})`, priority: 2 }, + ], + }) + + if (absence.createdBy) { + staticLayout.addItem({ + type: "text", + color: colorset.secondaryTextColor, + variants: [{ text: absence.createdBy, priority: 3 }], + }) + } + + staticLayout.build(row1) + + // --- Row 2: reason + text --- + if (hasDetail) { + const row2 = stack.addStack() + row2.layoutHorizontally() + row2.centerAlignContent() + row2.spacing = 3 + + const reasonText = absence.reason?.trim() ?? "" + const detailText = absence.text?.trim() ?? "" + const combined = reasonText && detailText ? `${reasonText} β€’ ${detailText}` : reasonText || detailText + + const txt = row2.addText(combined) + txt.font = Font.systemFont(detailFontSize) + txt.textColor = colorset.secondaryTextColor + txt.lineLimit = 2 + txt.minimumScaleFactor = 0.8 + } + + remainingHeight -= itemHeight + shown++ + if (shown >= maxCount) break + if (singleLineHeight + widgetConfig.appearance.spacing > remainingHeight) break + } + + return height - remainingHeight +} \ No newline at end of file diff --git a/src/views/exams.ts b/src/views/exams.ts index 102990c..f4ed669 100644 --- a/src/views/exams.ts +++ b/src/views/exams.ts @@ -6,6 +6,7 @@ import { getCharHeight, getCharWidth, getTextWidth } from '@/utils/helper' import { FlowLayoutRow } from '@/utils/scriptable/layout/flowLayoutRow' import { StaticLayoutRow } from '@/utils/scriptable/layout/staticLayoutRow' import { ViewBuildData } from '@/widget' +import { getItemColors } from '@/utils/scriptable/componentHelper' export function addViewExams( exams: TransformedExam[], @@ -75,7 +76,9 @@ export function addViewExams( examContainer.layoutHorizontally() examContainer.setPadding(padding, padding, padding, padding) examContainer.spacing = widgetConfig.appearance.spacing - examContainer.backgroundColor = backgroundColor + const __liquidItemColors = getItemColors(backgroundColor, widgetConfig, false) + examContainer.backgroundColor = __liquidItemColors.backgroundColor + const textColor = __liquidItemColors.textColor examContainer.cornerRadius = widgetConfig.appearance.cornerRadius // build the layout row @@ -84,7 +87,7 @@ export function addViewExams( widgetConfig.appearance.spacing, Font.mediumSystemFont(widgetConfig.appearance.fontSize), widgetConfig.appearance.fontSize, - colors.text.primary + textColor ) /** @@ -102,7 +105,7 @@ export function addViewExams( type: 'icon', icon: 'book.circle', size: widgetConfig.appearance.fontSize, - color: colors.text.primary, + color: textColor, priority: 1, }) @@ -120,14 +123,14 @@ export function addViewExams( }, ], fontSize: widgetConfig.appearance.fontSize, - color: colors.text.primary, + color: textColor, }) // add the exam type staticLayoutRow.addItem({ type: 'text', variants: [{ text: exam.type, priority: 4 }], - color: colors.text.secondary, + color: __liquidItemColors.secondaryTextColor, }) // add the date @@ -143,7 +146,7 @@ export function addViewExams( priority: 6, }, ], - color: colors.text.primary, + color: textColor, }) staticLayoutRow.build(examContainer) diff --git a/src/views/grades.ts b/src/views/grades.ts index ca10c4d..7d0c165 100644 --- a/src/views/grades.ts +++ b/src/views/grades.ts @@ -5,6 +5,7 @@ import { TransformedGrade } from '@/types/transformed' import { getCharHeight } from '@/utils/helper' import { StaticLayoutRow } from '@/utils/scriptable/layout/staticLayoutRow' import { ViewBuildData } from '@/widget' +import { getItemColors } from '@/utils/scriptable/componentHelper' // TODO: the date of grades matches the date of the exam, not when it was added (breaks scope) export function addViewGrades( @@ -73,7 +74,9 @@ export function addViewGrades( gradeContainer.layoutHorizontally() gradeContainer.setPadding(padding, padding, padding, padding) gradeContainer.spacing = widgetConfig.appearance.spacing - gradeContainer.backgroundColor = backgroundColor + const __liquidItemColors = getItemColors(backgroundColor, widgetConfig, false) + gradeContainer.backgroundColor = __liquidItemColors.backgroundColor + const textColor = __liquidItemColors.textColor gradeContainer.cornerRadius = widgetConfig.appearance.cornerRadius // build the layout row @@ -82,7 +85,7 @@ export function addViewGrades( widgetConfig.appearance.spacing, Font.mediumSystemFont(widgetConfig.appearance.fontSize), widgetConfig.appearance.fontSize, - colors.text.primary + textColor ) /** @@ -100,7 +103,7 @@ export function addViewGrades( type: 'icon', icon: iconName ?? defaultIconName, size: widgetConfig.appearance.fontSize, - color: colors.text.primary, + color: textColor, priority: iconName ? 1 : 3, }) @@ -115,7 +118,7 @@ export function addViewGrades( priority: 1, }, ], - color: colors.text.primary, + color: textColor, }) } @@ -128,7 +131,7 @@ export function addViewGrades( priority: 2, }, ], - color: colors.text.primary, + color: textColor, }) // add the exam type @@ -140,13 +143,13 @@ export function addViewGrades( priority: 5, }, ], - color: colors.text.secondary, + color: __liquidItemColors.secondaryTextColor, }) // add the date staticLayoutRow.addItem({ type: 'text', - color: colors.text.secondary, + color: __liquidItemColors.secondaryTextColor, variants: [ { text: shortDate, diff --git a/src/views/homeworks.ts b/src/views/homeworks.ts new file mode 100644 index 0000000..8a7ecc1 --- /dev/null +++ b/src/views/homeworks.ts @@ -0,0 +1,256 @@ +import { loadHomeworkStates, saveHomeworkStates } from '@/utils/scriptable/fileSystem' + +// Early exit for toggleHomework +function handleToggle() { + if (args.queryParameters.toggleHomework) { + const id = args.queryParameters.toggleHomework + const states = loadHomeworkStates() + states[id] = !states[id] + saveHomeworkStates(states) + try { App.close() } catch(e) {} + Script.complete() + return + } +} +handleToggle() + +import { LOCALE } from '@/constants' +import { colors } from '@/settings/colors' +import { TransformedHomework } from '@/types/transformed' +import { Duration, DurationUnit } from '@/utils/duration' +import { getCharHeight, getTextWidth } from '@/utils/helper' +import { getReadableTextColor } from '@/utils/scriptable/componentHelper' +import { ViewBuildData } from '@/widget' +import { HomeworkViewConfig } from '@/types/settings' + +export function addViewHomeworks( + homeworks: TransformedHomework[], + maxCount: number, + { container, width, height, widgetConfig }: ViewBuildData +) { + if (!Array.isArray(homeworks) || homeworks.length === 0) return 0; + + const states = loadHomeworkStates(); + const { appearance } = widgetConfig; + const hwCfg: HomeworkViewConfig = widgetConfig.views?.homeworks ?? {} + const compact = hwCfg.enableCompactMode ?? false; + const warningDaysSeconds = hwCfg.dueWarningDays ?? Duration.asSeconds(2, DurationUnit.DAY); + const overdueDaysSeconds = hwCfg.dueOverdueDays ?? Duration.asSeconds(0, DurationUnit.DAY); + const dueWarningColor = hwCfg.dueWarningColor ?? "#ffd60a"; + const dueOverdueColor = hwCfg.dueOverdueColor ?? "#ff453a"; + const liquidGlass = appearance?.liquidGlass ?? false; + + const now = new Date(); + const sorted = [...homeworks].sort((a, b) => { + const at = a.dueDate ? a.dueDate.getTime() : (a.date ? a.date.getTime() : 0); + const bt = b.dueDate ? b.dueDate.getTime() : (b.date ? b.date.getTime() : 0); + return at - bt; + }); + + const neutralBg = liquidGlass + ? Color.dynamic(new Color("#ffffff", 0.18), new Color("#000000", 0.18)) + : colors.background.primary; + + const baseSizes = { + paddingV: 8, + paddingH: 10, + iconSize: 18, + circleSize: 22, + maxTitleFont: 12, + minTitleFont: 7, + subFont: Math.max(12, appearance.fontSize), + titleFont: Math.max(appearance.fontSize, appearance.fontSize + 1) + }; + const compactWidth = 0.48; + const S = { ...baseSizes, widthFactor: compact ? compactWidth : 1.0 }; + + const charHeight = getCharHeight(appearance.fontSize); + const itemHeight = Math.ceil(charHeight * 1.8) + S.paddingV * 2; + const itemSpacing = widgetConfig.appearance.spacing ?? 6; + + let count = 0; + let lastRowStack = null; + + for (const hw of sorted) { + if (maxCount && count >= maxCount) break; + + let rowStack; + if (compact) { + if (count % 2 === 0) { + if (count > 0) container.addSpacer(itemSpacing); + rowStack = container.addStack(); + rowStack.layoutHorizontally(); + rowStack.spacing = 8; + lastRowStack = rowStack; + } else { + rowStack = lastRowStack || container; + } + } else { + if (count > 0) container.addSpacer(itemSpacing); + rowStack = container.addStack(); + rowStack.layoutHorizontally(); + rowStack.centerAlignContent(); + } + + const done = (states[hw.id] ?? hw.completed) ?? false; + const due = hw.dueDate ?? hw.date; + const diffSec = due ? (due.getTime() - now.getTime()) / 1000 : 999999; + let dueColor; + if (done) dueColor = "#888888"; + else if (diffSec < overdueDaysSeconds) dueColor = dueOverdueColor; + else if (diffSec <= warningDaysSeconds) dueColor = dueWarningColor; + else dueColor = "#30d158"; + + const hwContainer = rowStack.addStack(); + hwContainer.layoutHorizontally(); + hwContainer.centerAlignContent(); + hwContainer.setPadding(S.paddingV, S.paddingH, S.paddingV, S.paddingH); + hwContainer.cornerRadius = appearance.cornerRadius; + hwContainer.backgroundColor = neutralBg; + hwContainer.url = `scriptable:///run?scriptName=UntisWidget&toggleHomework=${hw.id}`; + + if (compact) { + const availableW = (width - 3 * itemSpacing) / 2; + hwContainer.size = new Size(availableW, 0); + } + + const left = hwContainer.addStack(); + left.size = new Size(S.circleSize, S.circleSize); + left.centerAlignContent(); + left.cornerRadius = S.circleSize / 2; + + if (done) { + if (!liquidGlass) left.backgroundColor = new Color("#30d158"); + const chk = SFSymbol.named("checkmark"); + chk.applyFont(Font.systemFont(Math.floor(S.iconSize * 0.65))); + const chkImg = left.addImage(chk.image); + chkImg.tintColor = liquidGlass ? new Color("#30d158") : Color.white(); + chkImg.imageSize = new Size(Math.floor(S.iconSize * 0.55), Math.floor(S.iconSize * 0.55)); + } else { + const circ = SFSymbol.named("circle"); + circ.applyFont(Font.systemFont(Math.floor(S.iconSize * 0.9))); + const circImg = left.addImage(circ.image); + circImg.tintColor = new Color("#888888"); + circImg.imageSize = new Size(Math.floor(S.iconSize * 0.9), Math.floor(S.iconSize * 0.9)); + } + + hwContainer.addSpacer(6); + + const textStack = hwContainer.addStack(); + textStack.layoutVertically(); + textStack.centerAlignContent(); + + const subjCfg = widgetConfig.subjects?.[hw.subject]; + const shortName = subjCfg?.nameOverride ?? hw.subject ?? "?"; + + if (compact) { + const subjText = textStack.addText(shortName); + subjText.font = Font.mediumSystemFont(S.subFont - 2); + subjText.textColor = done + ? new Color("#888888") + : new Color(widgetConfig.subjects?.[hw.subject]?.color ?? "#aaaaaa"); + subjText.lineLimit = 1; + } + + const titleText = hw.text?.trim() || "Task"; + let fontSize = S.maxTitleFont; + const reservedRightWidth = S.circleSize + 8; + const availableWidth = Math.max(60, Math.floor((width * S.widthFactor) - S.paddingH * 2 - reservedRightWidth - S.circleSize - 8)); + const avgCharWidth = fontSize * 0.55; + const estWidth = titleText.length * avgCharWidth; + if (estWidth > availableWidth) { + const scale = Math.max(0.7, availableWidth / estWidth); + fontSize = Math.max(S.minTitleFont, Math.floor(fontSize * scale)); + } + + const title = textStack.addText(titleText); + title.font = Font.semiboldSystemFont(compact ? fontSize : S.titleFont); + title.textColor = done ? new Color("#888888") : getReadableTextColor(neutralBg); + title.lineLimit = compact ? 1 : 2; + title.minimumScaleFactor = 0.6; + + if (!compact) { + const subjLineStack = textStack.addStack(); + subjLineStack.layoutHorizontally(); + subjLineStack.centerAlignContent(); + subjLineStack.spacing = 6; + + if (hw.subject) { + const shortName = subjCfg?.nameOverride ?? hw.subject; + const longName = subjCfg?.longNameOverride ?? shortName; + const maxWidth = width - 80; + const longWidth = getTextWidth(longName, S.subFont); + const subjTitle = longWidth < maxWidth ? longName : shortName; + + let subjHex = subjCfg?.color ?? subjCfg ?? "#aaaaaa"; + let subjTextColor = subjCfg?.textColor ?? null; + try { + if (typeof subjHex === "object") subjHex = subjHex.color ?? "#aaaaaa"; + const subjColorObj = new Color(subjHex); + const subjColorToUse = done ? new Color("#888888") : subjColorObj; + const subj = subjLineStack.addText(subjTitle); + subj.font = Font.systemFont(S.subFont); + subj.textColor = subjTextColor ? new Color(subjTextColor) : subjColorToUse; + } catch (e) { + const subj = subjLineStack.addText(subjTitle); + subj.font = Font.systemFont(S.subFont); + subj.textColor = new Color(done ? "#888888" : "#aaaaaa"); + } + } + + if (hw.teacher) { + const sep1 = subjLineStack.addText("β€’"); + sep1.font = Font.systemFont(S.subFont); + sep1.textColor = new Color("#888888"); + const teacher = subjLineStack.addText(hw.teacher); + teacher.font = Font.systemFont(S.subFont); + teacher.textColor = new Color("#888888"); + } + + if (due) { + const sep2 = subjLineStack.addText("β€’"); + sep2.font = Font.systemFont(S.subFont); + sep2.textColor = new Color("#888888"); + const dueText = subjLineStack.addText(due.toLocaleDateString(LOCALE, { day: "2-digit", month: "short" })); + dueText.font = Font.systemFont(S.subFont); + dueText.textColor = done ? new Color("#888888") : new Color(dueColor); + } + } + + hwContainer.addSpacer(); + + if (!done) { + if (compact && dueColor !== "#30d158") { + const alert = hwContainer.addStack(); + alert.size = new Size(S.circleSize * 0.8, S.circleSize * 0.8); + alert.cornerRadius = S.circleSize / 2; + alert.centerAlignContent(); + if (!liquidGlass) alert.backgroundColor = new Color(dueColor); + const exTxt = alert.addText("!"); + exTxt.font = Font.boldSystemFont(Math.max(8, Math.floor(S.iconSize * 0.8))); + exTxt.textColor = liquidGlass ? new Color(dueColor) : Color.white(); + } else if (!compact && dueColor !== "#30d158") { + const alert = hwContainer.addStack(); + alert.size = new Size(S.circleSize, S.circleSize); + alert.cornerRadius = S.circleSize / 2; + alert.centerAlignContent(); + if (!liquidGlass) alert.backgroundColor = new Color(dueColor); + const exTxt = alert.addText("!"); + exTxt.font = Font.boldSystemFont(Math.max(10, Math.floor(S.iconSize * 0.9))); + exTxt.textColor = liquidGlass ? new Color(dueColor) : Color.white(); + } + } + + count++; + } + + let usedHeight; + if (compact) { + const rows = Math.ceil(count / 2); + usedHeight = rows * itemHeight + Math.max(0, rows - 1) * itemSpacing; + } else { + usedHeight = count * itemHeight + Math.max(0, count - 1) * itemSpacing; + } + usedHeight = Math.min(usedHeight, height); + return Math.max(0, Math.floor(usedHeight)); +} \ No newline at end of file diff --git a/src/widget.ts b/src/widget.ts index b2763f9..9e267ce 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -1,15 +1,17 @@ import { FetchedData, fetchDataForViews } from './api/fetchManager' -import { View } from './layout' -import { getColor } from './settings/colors' +import { View, getLastLayoutString, setLastLayoutString } from './layout' +import { getColor, colors } from './settings/colors' import { Settings } from './settings/settings' import { getCharHeight, getDateInXSeconds } from './utils/helper' import { getWidgetSize, getWidgetSizes } from './utils/scriptable/widgetSize' import { addViewAbsences } from './views/absences' +import { addViewHomeworks } from './views/homeworks' import { addViewExams } from './views/exams' import { addFooter, getFooterHeight } from './views/footer' import { addViewGrades } from './views/grades' import { addViewLessons } from './views/lessons' import { addViewPreview } from './views/preview' +import { defaultLayout } from './constants' export interface ViewBuildData { container: WidgetStack @@ -30,58 +32,217 @@ export function proposeRefreshIn(seconds: number, fetchedData: FetchedData) { checkNewRefreshDate(newDate, fetchedData) } +// Random motivational / funny quotes for empty data views +const EMPTY_VIEW_QUOTES = { + homeworks: [ + "πŸ“š No homeworks left β€” time to chill 😎", + "πŸŽ‰ Homework-free zone!", + "πŸš€ All done β€” you're a legend!", + "πŸ’€ Nothing to do. Take a nap.", + "🧠 Brain: finally, some rest!" + ], + absences: [ + "🎯 Perfect attendance!", + "βœ… No absences β€” keep it up!", + "🏫 You’ve been 100% present!", + "πŸ˜‡ Attendance angel detected!" + ], + exams: [ + "πŸ“† No exams β€” enjoy the calm!", + "πŸŽ“ Exam-free week β€” celebrate!", + "β˜• Relax, no tests incoming." + ], + grades: [ + "πŸ“ˆ No new grades β€” yet!", + "πŸ’€ Still waiting for results...", + "πŸ• Patience pays β€” grades coming soon." + ], + lessons: [ + "πŸ“– No lessons right now!", + "🌴 Class dismissed!", + "πŸ€ Free time β€” use it wisely." + ] +} + /** * Creates the widget by adding as many views to it as fit. * Also adds the footer. */ export async function createWidget(user: FullUser, layout: View[][], widgetConfig: Settings) { - const widget = new ListWidget() + console.log("πŸ”Ή createWidget() called") + console.log("Layout:", JSON.stringify(layout, null, 2)) + console.log("WidgetConfig:", JSON.stringify(widgetConfig, null, 2)) - const widgetSizes = getWidgetSizes() + const widget = new ListWidget() + const widgetSizes = getWidgetSizes() - const paddingHorizontal = Math.max(widgetConfig.appearance.padding, 4) - const paddingVertical = Math.max(widgetConfig.appearance.padding, 6) + const paddingHorizontal = Math.max(widgetConfig.appearance.padding, 8) + const paddingVertical = Math.max(widgetConfig.appearance.padding, 8) - const widgetSize = getWidgetSize(widgetSizes, config.widgetFamily) - const contentSize = new Size(widgetSize.width - paddingHorizontal * 2, widgetSize.height - paddingVertical * 2) + const widgetSize = getWidgetSize(widgetSizes, config.widgetFamily) + const contentWidth = widgetSize.width - paddingHorizontal * 2 + const contentHeight = widgetSize.height - paddingVertical * 2 - widget.setPadding(paddingHorizontal, paddingVertical, paddingHorizontal, paddingVertical) - widget.backgroundColor = getColor(widgetConfig.appearance.backgroundColor) ?? Color.black() + widget.setPadding(paddingVertical, paddingHorizontal, paddingVertical, paddingHorizontal) + widget.backgroundColor = getColor(widgetConfig.appearance.backgroundColor) ?? Color.black() - const widgetContent = widget.addStack() - widgetContent.layoutHorizontally() - widgetContent.topAlignContent() - widgetContent.spacing = widgetConfig.appearance.spacing + // --- Which views will be shown --- + const shownViews = new Set() + for (const column of layout) { + for (const view of column) shownViews.add(view) + } - // make a list of the shown views (without duplicates) - const shownViews = new Set() - for (const column of layout) { - for (const view of column) { - shownViews.add(view) - } - } + console.log("ShownViews:", Array.from(shownViews)) - // fetch the data for the shown views - const fetchedData = await fetchDataForViews(Array.from(shownViews), user, widgetConfig) + // --- Fetch data for shown views --- + const fetchedData = await fetchDataForViews(Array.from(shownViews), user, widgetConfig) + console.log("FetchedData keys:", Object.keys(fetchedData)) + for (const key of Object.keys(fetchedData)) { + const val = fetchedData[key] + if (Array.isArray(val)) { + console.log(` β†ͺ ${key}: Array(${val.length})`) + } else { + console.log(` β†ͺ ${key}:`, val) + } + } - if (fetchedData.refreshDate) { - console.log(`Refresh date: ${fetchedData.refreshDate}`) - widget.refreshAfterDate = fetchedData.refreshDate - } + if (fetchedData.refreshDate) { + console.log(`Refresh date: ${fetchedData.refreshDate}`) + widget.refreshAfterDate = fetchedData.refreshDate + } - // TODO: flexible layout when only one column - const columnWidth = contentSize.width / layout.length + // --- Heights --- + let headerFontSize = 14 + let headerHeight = 0 + const showHeader = config.widgetFamily !== "small" + if (showHeader) headerHeight = headerFontSize + 8 - // add all the columns with the views - for (const column of layout) { - addColumn(fetchedData, widgetContent, column, contentSize.height, columnWidth, shownViews, widgetConfig) - } + const footerHeight = widgetConfig.appearance.footer ? getFooterHeight(widgetConfig) : 0 + let availableContentHeight = + contentHeight - headerHeight - footerHeight - widgetConfig.appearance.spacing * 2 + if (availableContentHeight < 0) availableContentHeight = 0 - if (widgetConfig.appearance.footer) { - addFooter(widget, contentSize.width, widgetConfig) - } + const allViews = defaultLayout.split(",").map((v) => v.trim().toLowerCase()) + const shown = Array.from(shownViews) + const isAll = shown.length === allViews.length + + if (showHeader) { + const headerStack = widget.addStack() + headerStack.layoutHorizontally() + headerStack.centerAlignContent() + headerStack.size = new Size(contentWidth, headerHeight) + const headerTitle = isAll + ? "All" + : shown.map((v) => v.charAt(0).toUpperCase() + v.slice(1)).join(" & ") + const header = headerStack.addText(headerTitle) + header.font = Font.boldSystemFont(headerFontSize) + header.textColor = colors.text.secondary + header.centerAlignText() + widget.addSpacer(4) + } + + const contentContainer = widget.addStack() + contentContainer.layoutVertically() + contentContainer.centerAlignContent() + contentContainer.size = new Size(contentWidth, availableContentHeight) + + // --- Check if any of the *shown* views actually has data --- + let hasAnyData = false + for (const v of shownViews) { + let data = fetchedData[v] + // special handling for lessons + if (v === "lessons") { + const today = fetchedData.lessonsTodayRemaining ?? [] + const next = fetchedData.lessonsNextDay ?? [] + if ((Array.isArray(today) && today.length > 0) || (Array.isArray(next) && next.length > 0)) { + data = [...today, ...next] + } + } + + if (Array.isArray(data) && data.length > 0) { + hasAnyData = true + break + } else if (data && !Array.isArray(data)) { + hasAnyData = true + break + } + } + console.log("➑️ hasAnyData (filtered to shown views):", hasAnyData) + + const columnWidth = contentWidth / Math.max(layout.length, 1) + const LAST_LAYOUT_STRING = getLastLayoutString() + + if (hasAnyData) { + console.log("πŸ“¦ Rendering widget columns...") + const contentRow = contentContainer.addStack() + contentRow.layoutHorizontally() + contentRow.topAlignContent() + contentRow.spacing = widgetConfig.appearance.spacing + contentRow.size = new Size(contentWidth, availableContentHeight) + + for (const column of layout) { + console.log(" β†’ Column:", column) + addColumn(fetchedData, contentRow, column, availableContentHeight, columnWidth, shownViews, widgetConfig) + } + } else { + console.log("⚠️ No data detected – preparing empty view message.") + + const allColumnsEmpty = + layout.length > 0 && layout.every((col) => Array.isArray(col) && col.length === 0) + + if (allColumnsEmpty && LAST_LAYOUT_STRING && LAST_LAYOUT_STRING.length > 0) { + // --- Unknown parameter fallback --- + const available = Object.values(View).join(", ") + const title = `Unknown parameter: ${LAST_LAYOUT_STRING}` + console.warn(`Unknown widget parameter "${LAST_LAYOUT_STRING}"`) + const titleText = contentContainer.addText(title) + titleText.font = Font.semiboldSystemFont(14) + titleText.textColor = colors.text.primary + titleText.centerAlignText() + + contentContainer.addSpacer(4) + const infoText = contentContainer.addText(`Available views: ${available}`) + infoText.font = Font.regularSystemFont(12) + infoText.textColor = colors.text.secondary + infoText.centerAlignText() + + setLastLayoutString("") + } else { + // --- Valid views but no data --- + let candidateQuotes = [] + + if (shown.length === 1) { + const viewKey = shown[0] + candidateQuotes = EMPTY_VIEW_QUOTES[viewKey] ?? ["No data available."] + } else if (shown.length > 1) { + // Add some global quotes for fully empty multi-view widgets + candidateQuotes.push( + "πŸ§ƒ Everything’s calm β€” no news today!", + "πŸ“¦ Nothing to show right now.", + "🌀️ Quiet day ahead!", + "πŸ’‘ Looks like everything’s sorted β€” enjoy your free time!" + ) + + if (candidateQuotes.length === 0) candidateQuotes = ["No data available."] + } else { + candidateQuotes = ["No data available."] + } + + const randomQuote = candidateQuotes[Math.floor(Math.random() * candidateQuotes.length)] + const noDataText = contentContainer.addText(randomQuote) + noDataText.font = Font.mediumSystemFont(13) + noDataText.textColor = colors.text.disabled + noDataText.centerAlignText() + } + } + + if (widgetConfig.appearance.footer) { + widget.addSpacer() + addFooter(widget, contentWidth, widgetConfig) + } - return widget + console.log("βœ… createWidget() done\n") + return widget } function addColumn( @@ -170,7 +331,7 @@ function addView(fetchedData: FetchedData, view: View, viewData: ViewBuildData, case View.PREVIEW: if (!fetchedData.lessonsNextDay || fetchedData.lessonsNextDay.length === 0 || !fetchedData.nextDayKey) { console.warn(`Tried to add preview view, but no lessons data was fetched`) - return + return 0 } // HACK: only show the day preview, if it is not already shown if (shownViews.has(View.LESSONS) && fetchedData.lessonsTodayRemaining?.length === 0) break @@ -179,20 +340,26 @@ function addView(fetchedData: FetchedData, view: View, viewData: ViewBuildData, case View.EXAMS: if (!fetchedData.exams || fetchedData.exams.length === 0) { console.warn(`Tried to add exams view, but no exams data was fetched`) - return + return 0 } return addViewExams(fetchedData.exams, widgetConfig.views.exams.maxCount, viewData) case View.GRADES: if (!fetchedData.grades || fetchedData.grades.length === 0) { console.warn(`Tried to add grades view, but no grades data was fetched`) - return + return 0 } return addViewGrades(fetchedData.grades, widgetConfig.views.grades.maxCount, viewData) case View.ABSENCES: if (!fetchedData.absences || fetchedData.absences.length === 0) { console.warn(`Tried to add absences view, but no absences data was fetched`) - return + return 0 } return addViewAbsences(fetchedData.absences, widgetConfig.views.absences.maxCount, viewData) + case View.HOMEWORKS: + if (!fetchedData.homeworks || fetchedData.homeworks.length === 0) { + console.warn(`Tried to add homeworks view, but no homeworks data was fetched`) + return 0 + } + return addViewHomeworks(fetchedData.homeworks, widgetConfig.views.homeworks.maxCount, viewData) } }