307 lines
10 KiB
Svelte
307 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import {
|
|
timer,
|
|
currentView,
|
|
formatTime,
|
|
formatDurationAgo,
|
|
type TimerSnapshot,
|
|
} from "../stores/timer";
|
|
import { config } from "../stores/config";
|
|
import TimerRing from "./TimerRing.svelte";
|
|
import { scaleIn, fadeIn, pressable, glowHover } from "../utils/animate";
|
|
|
|
async function toggleTimer() {
|
|
const snap = await invoke<TimerSnapshot>("toggle_timer");
|
|
timer.set(snap);
|
|
}
|
|
|
|
async function startBreakNow() {
|
|
const snap = await invoke<TimerSnapshot>("start_break_now");
|
|
timer.set(snap);
|
|
currentView.set(snap.currentView);
|
|
}
|
|
|
|
function openSettings() {
|
|
invoke("set_view", { view: "settings" });
|
|
currentView.set("settings");
|
|
}
|
|
|
|
const statusText = $derived(
|
|
$timer.idlePaused
|
|
? "IDLE"
|
|
: $timer.prebreakWarning
|
|
? "BREAK SOON"
|
|
: $timer.state === "running"
|
|
? "FOCUS"
|
|
: "PAUSED",
|
|
);
|
|
|
|
// Track status changes for aria-live region (announce only on change, not every tick)
|
|
let lastAnnouncedStatus = $state("");
|
|
let statusAnnouncement = $state("");
|
|
$effect(() => {
|
|
if (statusText !== lastAnnouncedStatus) {
|
|
lastAnnouncedStatus = statusText;
|
|
statusAnnouncement = `Timer status: ${statusText}. ${formatTime($timer.timeRemaining)} remaining.`;
|
|
}
|
|
});
|
|
|
|
const toggleBtnText = $derived(
|
|
$timer.state === "running" ? "PAUSE" : "START",
|
|
);
|
|
|
|
// Responsive ring scaling computed from window dimensions
|
|
let windowW = $state(window.innerWidth);
|
|
let windowH = $state(window.innerHeight);
|
|
|
|
// Ring scales 1.0 → 0.6 based on both dimensions
|
|
const ringScale = $derived(
|
|
Math.min(1, Math.max(0.6, Math.min(
|
|
(windowH - 300) / 400,
|
|
(windowW - 200) / 300,
|
|
))),
|
|
);
|
|
|
|
// Text scales less aggressively: 1.0 → 0.7 (counter-scale inside ring)
|
|
// counterScale = textScale / ringScale, where textScale = lerp(0.7, 1.0, (ringScale-0.6)/0.4)
|
|
const textCounterScale = $derived(
|
|
ringScale < 1
|
|
? Math.min(1.2, (0.7 + (ringScale - 0.6) * 0.75) / ringScale)
|
|
: 1,
|
|
);
|
|
|
|
// Gap between ring and button, compensating for CSS transform phantom space.
|
|
// transform: scale() doesn't affect layout, so the 280px box stays full-size
|
|
// even when visually shrunk - creating phantom space below the visual ring.
|
|
const ringSize = 280;
|
|
const phantomBelow = $derived((ringSize * (1 - ringScale)) / 2);
|
|
const targetGap = $derived(Math.max(16, Math.round((windowH - 400) * 0.05 + 16)));
|
|
const ringMargin = $derived(targetGap - phantomBelow);
|
|
|
|
// Natural break notification
|
|
let showNaturalBreakToast = $state(false);
|
|
let naturalBreakToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
// Watch for natural break detection
|
|
$effect(() => {
|
|
if ($timer.naturalBreakOccurred) {
|
|
showNaturalBreakToast = true;
|
|
if (naturalBreakToastTimeout) clearTimeout(naturalBreakToastTimeout);
|
|
naturalBreakToastTimeout = setTimeout(() => {
|
|
showNaturalBreakToast = false;
|
|
}, 5000);
|
|
}
|
|
});
|
|
</script>
|
|
|
|
<svelte:window bind:innerWidth={windowW} bind:innerHeight={windowH} />
|
|
|
|
<h1 class="sr-only" tabindex="-1">Dashboard</h1>
|
|
<div aria-live="polite" class="sr-only">{statusAnnouncement}</div>
|
|
|
|
<div class="relative flex h-full flex-col items-center justify-center">
|
|
<!-- Outer: responsive scaling (JS-driven), Inner: mount animation -->
|
|
<div style="transform: scale({ringScale}); transform-origin: center center; margin-bottom: {ringMargin}px;">
|
|
<div use:scaleIn={{ duration: 0.7, delay: 0.1 }}>
|
|
<TimerRing
|
|
progress={$timer.progress}
|
|
size={280}
|
|
strokeWidth={8}
|
|
accentColor={$config.accent_color}
|
|
label="Focus timer"
|
|
valueText="{formatTime($timer.timeRemaining)} remaining"
|
|
>
|
|
<!-- Counter-scale wrapper: text shrinks less than ring -->
|
|
<div style="transform: scale({textCounterScale}); transform-origin: center center;">
|
|
<!-- Eye icon -->
|
|
<svg
|
|
aria-hidden="true"
|
|
class="mx-auto mb-3 eye-blink"
|
|
width="26"
|
|
height="26"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="#888"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"
|
|
/>
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
|
|
<!-- Time display -->
|
|
<span
|
|
class="block text-center text-[54px] font-semibold leading-none tracking-tight tabular-nums text-white"
|
|
style={$config.countdown_font ? `font-family: '${$config.countdown_font}', monospace;` : ""}
|
|
>
|
|
{formatTime($timer.timeRemaining)}
|
|
</span>
|
|
|
|
<div class="h-3"></div>
|
|
|
|
<!-- Status label -->
|
|
<span
|
|
class="block text-center text-[11px] font-medium tracking-[0.25em]"
|
|
class:text-[#8a8a8a]={!$timer.prebreakWarning}
|
|
class:text-warning={$timer.prebreakWarning}
|
|
>
|
|
{statusText}
|
|
</span>
|
|
</div>
|
|
</TimerRing>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Last break info -->
|
|
<div use:fadeIn={{ delay: 0.4, y: 10 }}>
|
|
{#if $timer.hasHadBreak}
|
|
<p style="margin-bottom: {ringMargin}px;" class="text-[12px] text-[#8a8a8a]">
|
|
Last break {formatDurationAgo($timer.secondsSinceLastBreak)}
|
|
</p>
|
|
{:else}
|
|
<div style="margin-bottom: {ringMargin}px; height: 18px;"></div>
|
|
{/if}
|
|
</div>
|
|
|
|
<!-- Natural break notification toast -->
|
|
{#if showNaturalBreakToast}
|
|
<div
|
|
role="alert"
|
|
class="absolute top-6 left-1/2 -translate-x-1/2 rounded-2xl border px-5 py-3 text-center backdrop-blur-xl transition-all duration-500"
|
|
style="border-color: rgba(63, 185, 80, 0.3); background: rgba(63, 185, 80, 0.1);"
|
|
use:scaleIn={{ duration: 0.3, delay: 0 }}
|
|
>
|
|
<div class="flex items-center gap-2">
|
|
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2">
|
|
<path d="M20 6L9 17l-5-5"/>
|
|
</svg>
|
|
<span class="text-[13px] text-[#3fb950]">Natural break detected - timer reset</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!-- Pause / Start button -->
|
|
<button
|
|
use:fadeIn={{ delay: 0.3, y: 12 }}
|
|
use:pressable
|
|
use:glowHover={{ color: $config.accent_color }}
|
|
class="w-[200px] rounded-full py-3.5 text-[13px] font-medium
|
|
tracking-[0.2em] text-white uppercase backdrop-blur-xl
|
|
transition-colors duration-200"
|
|
style="background: rgba(20,20,20,0.7);"
|
|
onclick={toggleTimer}
|
|
>
|
|
{toggleBtnText}
|
|
</button>
|
|
|
|
<!-- Bottom left: start break now -->
|
|
<div class="absolute bottom-5 left-5" use:fadeIn={{ delay: 0.5, y: 8 }}>
|
|
<button
|
|
aria-label="Start break now"
|
|
use:pressable
|
|
class="flex h-11 w-11 items-center justify-center rounded-full
|
|
border border-[#222] text-[#8a8a8a]
|
|
transition-colors duration-200
|
|
hover:border-[#333] hover:text-[#aaa]"
|
|
onclick={startBreakNow}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Bottom center: stats -->
|
|
<div class="absolute bottom-5 left-1/2 -translate-x-1/2" use:fadeIn={{ delay: 0.52, y: 8 }}>
|
|
<button
|
|
aria-label="Statistics"
|
|
use:pressable
|
|
class="flex h-11 w-11 items-center justify-center rounded-full
|
|
border border-[#222] text-[#8a8a8a]
|
|
transition-colors duration-200
|
|
hover:border-[#333] hover:text-[#aaa]"
|
|
onclick={() => {
|
|
invoke("set_view", { view: "stats" });
|
|
currentView.set("stats");
|
|
}}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
width="17"
|
|
height="17"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<rect x="3" y="12" width="4" height="9" rx="1" />
|
|
<rect x="10" y="7" width="4" height="14" rx="1" />
|
|
<rect x="17" y="3" width="4" height="18" rx="1" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Bottom right: settings -->
|
|
<div class="absolute bottom-5 right-5" use:fadeIn={{ delay: 0.55, y: 8 }}>
|
|
<button
|
|
aria-label="Settings"
|
|
use:pressable
|
|
class="flex h-11 w-11 items-center justify-center rounded-full
|
|
border border-[#222] text-[#8a8a8a]
|
|
transition-colors duration-200
|
|
hover:border-[#333] hover:text-[#aaa]"
|
|
onclick={openSettings}
|
|
>
|
|
<svg
|
|
aria-hidden="true"
|
|
width="17"
|
|
height="17"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path
|
|
d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"
|
|
/>
|
|
<circle cx="12" cy="12" r="3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Eye blink animation - natural human blink (~4s interval, 0.5s duration) */
|
|
.eye-blink {
|
|
transform-origin: center center;
|
|
animation: blink 4s infinite ease-in-out;
|
|
}
|
|
|
|
@keyframes blink {
|
|
0%, 45%, 55%, 100% {
|
|
transform: scaleY(1);
|
|
}
|
|
50% {
|
|
transform: scaleY(0.1);
|
|
}
|
|
}
|
|
</style>
|