-
Notifications
You must be signed in to change notification settings - Fork 0
feat(ui): screen refresh redesign — home, edit, settings, and window management #190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
567e1bd
0b221a2
d5faa62
a0319c7
4f2c6bc
d4a72a3
a458cd6
804ae50
2f8c8c1
ad994ca
26c217f
6ef4716
a8b7b6e
07d1980
69e8bc9
7d026f4
2dfa31a
f13d298
00458e9
5c66dc3
8d557a6
24348bf
e6c8b52
8c876f5
7ae0ebd
9da4040
2b65053
e04bdc1
8b01fad
bc5f6d7
c2c8a64
e8a5a45
06f0418
98c9fbb
1badfad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import React from 'react'; | ||
| import { Box, Typography } from '@mui/material'; | ||
| import { alpha, useTheme } from '@mui/material/styles'; | ||
| import { AccessTime as AccessTimeIcon } from '@mui/icons-material'; | ||
| import { AlarmRecord } from '../types/alarm'; | ||
| import { TimeFormatHelper } from '../utils/TimeFormatHelper'; | ||
| import { UI } from '../theme/uiTokens'; | ||
|
|
||
| interface NextAlarmBannerProps { | ||
| alarms: AlarmRecord[]; | ||
| is24h: boolean; | ||
| } | ||
|
|
||
| export const NextAlarmBanner: React.FC<NextAlarmBannerProps> = ({ alarms, is24h }) => { | ||
| const theme = useTheme(); | ||
|
|
||
| const now = Date.now(); | ||
| const nextAlarm = alarms | ||
|
Comment on lines
+17
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The countdown is derived from Useful? React with 👍 / 👎. |
||
| .filter((a) => a.enabled && a.nextTrigger && a.nextTrigger > now) | ||
| .sort((a, b) => a.nextTrigger! - b.nextTrigger!) | ||
| [0]; | ||
|
|
||
| if (!nextAlarm) return null; | ||
|
|
||
| const triggerDate = new Date(nextAlarm.nextTrigger!); | ||
| const diffMs = nextAlarm.nextTrigger! - now; | ||
| const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); | ||
| const diffMinutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); | ||
|
|
||
| const countdownParts: string[] = []; | ||
| if (diffHours > 0) countdownParts.push(`${diffHours}h`); | ||
| countdownParts.push(`${diffMinutes}m`); | ||
| const countdown = countdownParts.join(' '); | ||
|
|
||
| const formattedTime = TimeFormatHelper.format(triggerDate, is24h); | ||
|
|
||
| const ariaLabel = `Next alarm in ${countdown}, at ${formattedTime}`; | ||
|
|
||
| return ( | ||
| <Box | ||
| role="status" | ||
| aria-label={ariaLabel} | ||
| sx={{ | ||
| background: `linear-gradient(to right, ${alpha(theme.palette.primary.main, 0.16)}, ${alpha(theme.palette.primary.main, 0.03)})`, | ||
| borderRadius: UI.banner.borderRadius, | ||
| px: 2, | ||
| py: 1.5, | ||
| mb: 2, | ||
| display: 'flex', | ||
| alignItems: 'center', | ||
| gap: 1.5, | ||
| }} | ||
| > | ||
| <AccessTimeIcon sx={{ color: 'primary.main', fontSize: 20 }} /> | ||
| <Typography variant="body2" sx={{ fontWeight: 600, color: 'primary.main' }}> | ||
| Next alarm in {countdown} · Scheduled: {formattedTime} | ||
| </Typography> | ||
| </Box> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import React, { useRef, useState, useCallback } from 'react'; | ||
| import { Box, CircularProgress } from '@mui/material'; | ||
| import { motion, useMotionValue, useTransform, animate } from 'motion/react'; | ||
|
|
||
| const PULL_THRESHOLD = 72; | ||
|
|
||
| interface PullToRefreshProps { | ||
| onRefresh: () => void; | ||
| children: React.ReactNode; | ||
| } | ||
|
|
||
| export const PullToRefresh: React.FC<PullToRefreshProps> = ({ onRefresh, children }) => { | ||
| const containerRef = useRef<HTMLDivElement>(null); | ||
| const [isRefreshing, setIsRefreshing] = useState(false); | ||
| const [isDragging, setIsDragging] = useState(false); | ||
| const pullY = useMotionValue(0); | ||
| const spinnerOpacity = useTransform(pullY, [0, PULL_THRESHOLD], [0, 1]); | ||
| const spinnerScale = useTransform(pullY, [0, PULL_THRESHOLD], [0.5, 1]); | ||
|
|
||
| const prefersReducedMotion = | ||
| typeof window !== 'undefined' && | ||
| window.matchMedia('(prefers-reduced-motion: reduce)').matches; | ||
|
|
||
| const startYRef = useRef<number | null>(null); | ||
|
|
||
| const handlePointerDown = useCallback((e: React.PointerEvent) => { | ||
| const el = containerRef.current; | ||
| if (!el || el.scrollTop > 0 || isRefreshing) return; | ||
| startYRef.current = e.clientY; | ||
| setIsDragging(true); | ||
| }, [isRefreshing]); | ||
|
|
||
| const handlePointerMove = useCallback((e: React.PointerEvent) => { | ||
| if (!isDragging || startYRef.current === null) return; | ||
| const el = containerRef.current; | ||
| if (!el || el.scrollTop > 0) { | ||
| setIsDragging(false); | ||
| pullY.set(0); | ||
| startYRef.current = null; | ||
| return; | ||
| } | ||
| const delta = Math.max(0, e.clientY - startYRef.current); | ||
| // Dampen the pull for a natural feel | ||
| const dampened = Math.min(delta * 0.5, PULL_THRESHOLD * 1.5); | ||
| pullY.set(dampened); | ||
| }, [isDragging, pullY]); | ||
|
|
||
| const handlePointerUp = useCallback(() => { | ||
| if (!isDragging) return; | ||
| setIsDragging(false); | ||
| startYRef.current = null; | ||
|
|
||
| const currentPull = pullY.get(); | ||
| if (currentPull >= PULL_THRESHOLD) { | ||
| setIsRefreshing(true); | ||
| onRefresh(); | ||
| // Reset after a short delay to show the spinner | ||
| setTimeout(() => { | ||
| setIsRefreshing(false); | ||
| if (prefersReducedMotion) { | ||
| pullY.set(0); | ||
| } else { | ||
| animate(pullY, 0, { type: 'spring', stiffness: 300, damping: 30 }); | ||
| } | ||
| }, 600); | ||
| } else { | ||
| if (prefersReducedMotion) { | ||
| pullY.set(0); | ||
| } else { | ||
| animate(pullY, 0, { type: 'spring', stiffness: 300, damping: 30 }); | ||
| } | ||
| } | ||
| }, [isDragging, pullY, onRefresh, prefersReducedMotion]); | ||
|
|
||
| return ( | ||
| <Box | ||
| ref={containerRef} | ||
| onPointerDown={handlePointerDown} | ||
| onPointerMove={handlePointerMove} | ||
| onPointerUp={handlePointerUp} | ||
| onPointerCancel={handlePointerUp} | ||
| sx={{ | ||
| position: 'relative', | ||
| touchAction: 'pan-x', | ||
| overflowY: 'auto', | ||
|
Comment on lines
+84
to
+85
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On mobile Home, this wrapper is the scroll container ( Useful? React with 👍 / 👎. |
||
| flexGrow: 1, | ||
| }} | ||
| > | ||
| {/* Pull indicator */} | ||
| <motion.div | ||
| style={{ | ||
| display: 'flex', | ||
| justifyContent: 'center', | ||
| alignItems: 'center', | ||
| overflow: 'hidden', | ||
| height: pullY, | ||
| opacity: spinnerOpacity, | ||
| scale: spinnerScale, | ||
| }} | ||
| > | ||
| <CircularProgress | ||
| size={28} | ||
| color="primary" | ||
| variant={isRefreshing ? 'indeterminate' : 'determinate'} | ||
| value={isRefreshing ? undefined : 100} | ||
| /> | ||
| </motion.div> | ||
| {children} | ||
| </Box> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The repo guideline in
/workspace/threshold/AGENTS.md("Licence and Copyright") requires each source file to begin with the summary/copyright/SPDX header, but this new file starts directly with imports. That introduces a compliance regression in this commit (and the same pattern appears in other newly added TS files), so the required header block should be added at the top.Useful? React with 👍 / 👎.