diff --git a/src/context/free-widget/use-widget-operations.ts b/src/context/free-widget/use-widget-operations.ts index ae39c4e0a..7ff676bc1 100644 --- a/src/context/free-widget/use-widget-operations.ts +++ b/src/context/free-widget/use-widget-operations.ts @@ -208,7 +208,7 @@ export function useWidgetOperations({ w.instanceId === instanceId ? { ...w, meta } : w ) - showToast('مدل ویجت با موفقیت تغییر کرد', 'success') + playNativeToastSound('success') return commitMutation('resize', layoutWithMeta, instanceId) }, [ diff --git a/src/context/wallpaper.context.tsx b/src/context/wallpaper.context.tsx index 5d11f0f83..27ce47ac5 100644 --- a/src/context/wallpaper.context.tsx +++ b/src/context/wallpaper.context.tsx @@ -9,7 +9,6 @@ import { translateError } from '@/common/utils/translate-error' import Analytics from '@/analytics' import { showToast } from '@/common/toast' import { useQueryClient } from '@tanstack/react-query' -import { playAlarm } from '@/common/play-alarm' import { useAuth } from '@/context/auth.context' import { getRandomWallpaper } from '@/services/hooks/wallpapers/get-wallpaper-categories.hook' @@ -184,7 +183,6 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { if (wallpaper.coin && !wallpaper.isOwned) { showToast('هووورا! تصویر زمینه فعال شد 🎉', 'success') queryClient.invalidateQueries({ queryKey: ['userProfile'] }) - playAlarm('market') } if (!isSet) setSelectedBackground(responseWallpaper) diff --git a/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx b/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx index f8be8715c..f93d8683a 100644 --- a/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx +++ b/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx @@ -93,7 +93,7 @@ function WallpaperItemFu({ )} {error && ( -
+

خطا در بارگذاری

diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx index 27d1ff1e8..ba57b92e8 100644 --- a/src/layouts/widgets-manager/add-widget-modal/index.tsx +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -329,7 +329,10 @@ export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalPr {isHelpOpen && ( - setIsHelpOpen(false)} /> + setIsHelpOpen(false)} + /> )} ) diff --git a/src/layouts/widgets/calendar/variants/calendar-1x1.tsx b/src/layouts/widgets/calendar/variants/calendar-1x1.tsx index a072fa3ef..4f3e84a1a 100644 --- a/src/layouts/widgets/calendar/variants/calendar-1x1.tsx +++ b/src/layouts/widgets/calendar/variants/calendar-1x1.tsx @@ -3,51 +3,32 @@ import { cn } from '@/common/utils/cn' export function Calendar1x1() { const { today, todayIsHoliday } = useDate() - const weekDayName = today.format('dddd') const dayNumber = today.jDate() const monthName = today.format('jMMMM') + const dayDigits = String(dayNumber).split('') return ( -
+
+ + {monthName} + +
-
- - {monthName} - -
+ {dayDigits.map((digit, index) => ( + {digit} + ))}
-
- -
- - {dayNumber} - - -
- - {weekDayName} - -
-
+ + {weekDayName} +
) } diff --git a/src/layouts/widgets/layout-engine/types.ts b/src/layouts/widgets/layout-engine/types.ts index c57bd321d..8fefeb63d 100644 --- a/src/layouts/widgets/layout-engine/types.ts +++ b/src/layouts/widgets/layout-engine/types.ts @@ -13,7 +13,6 @@ export enum WidgetKeys { HabitTracker = 'HabitTracker', search = 'search', bookmarks = 'bookmarks', - widgetify = 'widgetify', // @deprecated, will be removed in future versions pet = 'pet', transparentClock = 'transparentClock', moodTracker = 'moodTracker', diff --git a/src/layouts/widgets/migration.ts b/src/layouts/widgets/migration.ts index 9207aa79c..90508f4da 100644 --- a/src/layouts/widgets/migration.ts +++ b/src/layouts/widgets/migration.ts @@ -52,7 +52,7 @@ export function replaceWidgetifyWithPetAndPhoto(widgets: StoredWidget[]): { const result: StoredWidget[] = [] for (const widget of widgets) { - if (widget.id === WidgetKeys.widgetify) { + if (widget.id === ('widgetify' as any)) { hasChanged = true const col = widget.position?.col ?? 6 const baseRow = widget.position?.row ?? 0 diff --git a/src/layouts/widgets/network/network.widget.tsx b/src/layouts/widgets/network/network.widget.tsx index 7c7deb2d9..4c49e856a 100644 --- a/src/layouts/widgets/network/network.widget.tsx +++ b/src/layouts/widgets/network/network.widget.tsx @@ -182,6 +182,7 @@ export function NetworkLayout({ ping={networkInfo.ping} isLoading={isLoading} blurMode={blurMode} + onRefresh={handleRefresh} /> ) @@ -205,7 +206,7 @@ export function NetworkLayout({
-
- - {ip || '---'} +
+ + {isOnline ? 'متصل' : 'اتصال ندارد'} - - {city ? `${city} · ` : ''} - {isp || 'ارائه‌دهنده نامشخص'} + + • + + + {city ? `${city}، ` : ''} + {displayIsp}
-
- {isOnline ? ( -
+
+ {countryIcon ? ( + پرچم کشور + ) : ( +
+ 🌐 +
+ )} + + +
+ + {onRefresh && ( +
- ) : ( -
- - قطع -
+ + )} - - {isOnline ? 'متصل' : 'اتصال ندارد'} -
) diff --git a/src/layouts/widgets/photo/components/photo-empty-state.tsx b/src/layouts/widgets/photo/components/photo-empty-state.tsx index 85a79ac41..5a89be67e 100644 --- a/src/layouts/widgets/photo/components/photo-empty-state.tsx +++ b/src/layouts/widgets/photo/components/photo-empty-state.tsx @@ -8,6 +8,7 @@ interface PhotoEmptyStateProps { export function PhotoEmptyState({ size }: PhotoEmptyStateProps) { const is1x1 = size?.w === 1 && size?.h === 1 const is2x2 = size?.w === 2 && size?.h === 2 + const is2x4 = size?.w === 2 && size?.h === 4 if (is1x1) { return ( @@ -21,7 +22,7 @@ export function PhotoEmptyState({ size }: PhotoEmptyStateProps) { ) } - if (is2x2) { + if (is2x2 || is2x4) { return (
diff --git a/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts b/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts index f4dc01cbd..cc6cc7232 100644 --- a/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts +++ b/src/layouts/widgets/transparent-clock/hooks/use-wallpaper-clock-theme.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { getFromStorage } from '@/common/storage' import { listenEvent } from '@/common/utils/call-event' import type { StoredWallpaper } from '@/common/wallpaper.interface' @@ -23,6 +23,11 @@ const DEFAULT_THEME: WallpaperClockTheme = { isDark: true, } +const MIN_CONTRAST_RATIO = 4.5 + +const SECONDARY_HUE_WEIGHT_RATIO = 0.45 +const SECONDARY_HUE_MIN_DISTANCE = 40 + function rgbToHsl(r: number, g: number, b: number): [number, number, number] { r /= 255 g /= 255 @@ -53,6 +58,16 @@ function rgbToHsl(r: number, g: number, b: number): [number, number, number] { return [Math.round(h * 360), Math.round(s * 100), Math.round(l * 100)] } +function hslToRgb(h: number, s: number, l: number): [number, number, number] { + const sNorm = s / 100 + const lNorm = l / 100 + const k = (n: number) => (n + h / 30) % 12 + const a = sNorm * Math.min(lNorm, 1 - lNorm) + const f = (n: number) => + lNorm - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))) + return [Math.round(f(0) * 255), Math.round(f(8) * 255), Math.round(f(4) * 255)] +} + function hexToRgb(hex: string): [number, number, number] { const cleaned = hex.replace('#', '').trim() const full = @@ -67,47 +82,249 @@ function hexToRgb(hex: string): [number, number, number] { return [(num >> 16) & 255, (num >> 8) & 255, num & 255] } -function computeThemeFromHsl( - avgL: number, - dominantHue: number, - dominantS: number -): WallpaperClockTheme { - const isDark = avgL < 55 - - if (dominantS < 12) { - if (isDark) { - return { - primaryColor: '#f1f5f9', - secondaryColor: 'rgba(241, 245, 249, 0.85)', - accentGlow: CLOCK_SHADOW_SETTINGS.darkBgShadow, - isDark: true, - } - } +function relativeLuminance(r: number, g: number, b: number): number { + const toLinear = (c: number) => { + const cs = c / 255 + return cs <= 0.03928 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4) + } + const rl = toLinear(r) + const gl = toLinear(g) + const bl = toLinear(b) + return 0.2126 * rl + 0.7152 * gl + 0.0722 * bl +} + +function contrastRatio(lumA: number, lumB: number): number { + const brightest = Math.max(lumA, lumB) + 0.05 + const darkest = Math.min(lumA, lumB) + 0.05 + return brightest / darkest +} + +function ensureContrast( + hue: number, + sat: number, + initialL: number, + bgLuminance: number, + isDark: boolean +): number { + let l = initialL + const step = isDark ? 2 : -2 + const limit = isDark ? 97 : 4 + + for (let i = 0; i < 40; i++) { + const [r, g, b] = hslToRgb(hue, sat, l) + const ratio = contrastRatio(relativeLuminance(r, g, b), bgLuminance) + if (ratio >= MIN_CONTRAST_RATIO) break + if ((isDark && l >= limit) || (!isDark && l <= limit)) break + l += step + } + + return Math.max(0, Math.min(100, l)) +} + +interface DominantColor { + hue: number + saturation: number + weight: number +} + +interface ImageColorStats { + avgLuminance: number + primary: DominantColor + secondary: DominantColor | null +} + +function analyzePixels(data: Uint8ClampedArray): ImageColorStats { + const BIN_COUNT = 12 + const BIN_SIZE = 360 / BIN_COUNT + + const hueWeights = new Array(BIN_COUNT).fill(0) + const hueSats = new Array(BIN_COUNT).fill(0) + const hueCounts = new Array(BIN_COUNT).fill(0) + + let totalLuminance = 0 + let opaquePixelCount = 0 + + const huesForRefine: { h: number; s: number; weight: number }[] = [] + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3] + if (alpha < 20) continue + + const r = data[i] + const g = data[i + 1] + const b = data[i + 2] + const [h, s, l] = rgbToHsl(r, g, b) + + totalLuminance += relativeLuminance(r, g, b) + opaquePixelCount++ + + const bin = Math.min(BIN_COUNT - 1, Math.floor(h / BIN_SIZE)) + const vibrancy = (s / 100) * (1 - Math.abs(l - 50) / 50) + hueWeights[bin] += vibrancy + hueSats[bin] += s + hueCounts[bin] += 1 + huesForRefine.push({ h, s, weight: vibrancy }) + } + + if (opaquePixelCount === 0) { return { - primaryColor: '#1e293b', - secondaryColor: 'rgba(30, 41, 59, 0.85)', - accentGlow: CLOCK_SHADOW_SETTINGS.lightBgShadow, - isDark: false, + avgLuminance: 0.5, + primary: { hue: 0, saturation: 0, weight: 0 }, + secondary: null, } } - if (isDark) { - const targetS = Math.min(85, Math.max(50, dominantS + 10)) - return { - primaryColor: `hsl(${dominantHue}, ${targetS}%, 90%)`, - secondaryColor: `hsla(${dominantHue}, ${Math.max(40, targetS - 15)}%, 82%, 0.88)`, - accentGlow: CLOCK_SHADOW_SETTINGS.darkBgShadow, - isDark: true, + const avgLuminance = totalLuminance / opaquePixelCount + + const ranked = hueWeights + .map((weight, bin) => ({ bin, weight })) + .sort((a, b) => b.weight - a.weight) + + const bestBin = ranked[0].bin + const bestBinCenter = bestBin * BIN_SIZE + BIN_SIZE / 2 + + let sumSin = 0 + let sumCos = 0 + let sumSat = 0 + let refineWeight = 0 + const window = BIN_SIZE + 10 + + for (const { h, s, weight } of huesForRefine) { + let diff = Math.abs(h - bestBinCenter) + if (diff > 180) diff = 360 - diff + if (diff > window) continue + const rad = (h * Math.PI) / 180 + sumSin += Math.sin(rad) * weight + sumCos += Math.cos(rad) * weight + sumSat += s * weight + refineWeight += weight + } + + let refinedHue = bestBinCenter + let refinedSat = hueCounts[bestBin] > 0 ? hueSats[bestBin] / hueCounts[bestBin] : 0 + + if (refineWeight > 0) { + const angle = Math.atan2(sumSin, sumCos) + refinedHue = ((angle * 180) / Math.PI + 360) % 360 + refinedSat = sumSat / refineWeight + } + + const primary: DominantColor = { + hue: Math.round(refinedHue), + saturation: Math.round(refinedSat), + weight: ranked[0].weight, + } + + let secondary: DominantColor | null = null + for (const candidate of ranked.slice(1)) { + const candidateCenter = candidate.bin * BIN_SIZE + BIN_SIZE / 2 + let hueDistance = Math.abs(candidateCenter - primary.hue) + if (hueDistance > 180) hueDistance = 360 - hueDistance + + if ( + candidate.weight >= primary.weight * SECONDARY_HUE_WEIGHT_RATIO && + hueDistance >= SECONDARY_HUE_MIN_DISTANCE + ) { + secondary = { + hue: Math.round(candidateCenter), + saturation: + hueCounts[candidate.bin] > 0 + ? Math.round(hueSats[candidate.bin] / hueCounts[candidate.bin]) + : 0, + weight: candidate.weight, + } + break } } - const targetS = Math.min(90, Math.max(60, dominantS + 20)) - return { - primaryColor: `hsl(${dominantHue}, ${targetS}%, 18%)`, - secondaryColor: `hsla(${dominantHue}, ${Math.max(50, targetS - 10)}%, 26%, 0.88)`, - accentGlow: CLOCK_SHADOW_SETTINGS.lightBgShadow, - isDark: false, + return { avgLuminance, primary, secondary } +} + +function buildTheme(stats: ImageColorStats): WallpaperClockTheme { + const { avgLuminance, primary, secondary } = stats + const isDark = avgLuminance < 0.4 + + const shadow = isDark + ? CLOCK_SHADOW_SETTINGS.darkBgShadow + : CLOCK_SHADOW_SETTINGS.lightBgShadow + + if (primary.saturation < 12) { + return isDark + ? { + primaryColor: '#f1f5f9', + secondaryColor: 'rgba(241, 245, 249, 0.85)', + accentGlow: shadow, + isDark: true, + } + : { + primaryColor: '#1e293b', + secondaryColor: 'rgba(30, 41, 59, 0.85)', + accentGlow: shadow, + isDark: false, + } + } + + const targetS = isDark + ? Math.min(85, Math.max(50, primary.saturation + 10)) + : Math.min(90, Math.max(60, primary.saturation + 20)) + + const initialL = isDark ? 90 : 18 + const finalL = ensureContrast(primary.hue, targetS, initialL, avgLuminance, isDark) + + const primaryColor = `hsl(${primary.hue}, ${targetS}%, ${finalL}%)` + + let secondaryColor: string + if (secondary) { + const secTargetS = isDark + ? Math.max(40, Math.min(80, secondary.saturation)) + : Math.max(50, Math.min(85, secondary.saturation + 10)) + const secInitialL = isDark ? 82 : 26 + const secFinalL = ensureContrast( + secondary.hue, + secTargetS, + secInitialL, + avgLuminance, + isDark + ) + secondaryColor = `hsla(${secondary.hue}, ${secTargetS}%, ${secFinalL}%, 0.88)` + } else { + const secS = Math.max(isDark ? 40 : 50, targetS - (isDark ? 15 : 10)) + const secL = isDark ? Math.max(70, finalL - 8) : Math.min(35, finalL + 8) + secondaryColor = `hsla(${primary.hue}, ${secS}%, ${secL}%, 0.88)` } + + return { primaryColor, secondaryColor, accentGlow: shadow, isDark } +} + +function extractThemeFromGradient(gradient: { + from: string + to: string +}): WallpaperClockTheme { + const rgb1 = hexToRgb(gradient.from) + const rgb2 = hexToRgb(gradient.to) + const [h1, s1] = rgbToHsl(...rgb1) + const [h2, s2] = rgbToHsl(...rgb2) + + const avgLuminance = (relativeLuminance(...rgb1) + relativeLuminance(...rgb2)) / 2 + const dominantHue = s1 >= s2 ? h1 : h2 + const dominantS = Math.max(s1, s2) + + let hueDistance = Math.abs(h1 - h2) + if (hueDistance > 180) hueDistance = 360 - hueDistance + const secondary: DominantColor | null = + hueDistance >= SECONDARY_HUE_MIN_DISTANCE + ? { + hue: dominantHue === h1 ? h2 : h1, + saturation: dominantHue === h1 ? s2 : s1, + weight: Math.min(s1, s2), + } + : null + + return buildTheme({ + avgLuminance, + primary: { hue: dominantHue, saturation: dominantS, weight: Math.max(s1, s2) }, + secondary, + }) } async function extractThemeFromImage(src: string): Promise { @@ -118,6 +335,11 @@ async function extractThemeFromImage(src: string): Promise img.onload = () => { try { + if (!img.naturalWidth || !img.naturalHeight) { + resolve(DEFAULT_THEME) + return + } + const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') if (!ctx) { @@ -125,53 +347,15 @@ async function extractThemeFromImage(src: string): Promise return } - const sampleW = 48 - const sampleH = 48 + const sampleW = 64 + const sampleH = 64 canvas.width = sampleW canvas.height = sampleH ctx.drawImage(img, 0, 0, sampleW, sampleH) - const data = ctx.getImageData(0, 0, sampleW, sampleH).data - let totalL = 0 - const totalPixels = data.length / 4 - - const hueWeights = new Array(12).fill(0) - const hueSats = new Array(12).fill(0) - const hueCounts = new Array(12).fill(0) - - for (let i = 0; i < data.length; i += 4) { - const r = data[i] - const g = data[i + 1] - const b = data[i + 2] - const [h, s, l] = rgbToHsl(r, g, b) - - totalL += l - - const bin = Math.min(11, Math.floor(h / 30)) - const vibrancy = (s / 100) * (1 - Math.abs(l - 50) / 50) - hueWeights[bin] += vibrancy - hueSats[bin] += s - hueCounts[bin] += 1 - } - - const avgL = totalL / totalPixels - - let bestBin = 0 - let maxWeight = -1 - for (let b = 0; b < 12; b++) { - if (hueWeights[b] > maxWeight) { - maxWeight = hueWeights[b] - bestBin = b - } - } - - const dominantHue = bestBin * 30 + 15 - const dominantS = - hueCounts[bestBin] > 0 - ? Math.round(hueSats[bestBin] / hueCounts[bestBin]) - : 0 - - resolve(computeThemeFromHsl(avgL, dominantHue, dominantS)) + const { data } = ctx.getImageData(0, 0, sampleW, sampleH) + const stats = analyzePixels(data) + resolve(buildTheme(stats)) } catch { resolve(DEFAULT_THEME) } @@ -183,24 +367,9 @@ async function extractThemeFromImage(src: string): Promise }) } -function extractThemeFromGradient(gradient: { - from: string - to: string -}): WallpaperClockTheme { - const rgb1 = hexToRgb(gradient.from) - const rgb2 = hexToRgb(gradient.to) - const [h1, s1, l1] = rgbToHsl(...rgb1) - const [h2, s2, l2] = rgbToHsl(...rgb2) - - const avgL = (l1 + l2) / 2 - const dominantHue = s1 >= s2 ? h1 : h2 - const dominantS = Math.max(s1, s2) - - return computeThemeFromHsl(avgL, dominantHue, dominantS) -} - export function useWallpaperClockTheme(): WallpaperClockTheme { const [theme, setTheme] = useState(DEFAULT_THEME) + const requestIdRef = useRef(0) useEffect(() => { let isMounted = true @@ -208,14 +377,17 @@ export function useWallpaperClockTheme(): WallpaperClockTheme { async function updateFromWallpaper(wallpaper: StoredWallpaper | null) { if (!wallpaper) return + const requestId = ++requestIdRef.current + if (wallpaper.type === 'IMAGE' && wallpaper.src) { const computed = await extractThemeFromImage(wallpaper.src) - if (isMounted) setTheme(computed) + if (isMounted && requestId === requestIdRef.current) setTheme(computed) } else if (wallpaper.type === 'GRADIENT' && wallpaper.gradient) { const computed = extractThemeFromGradient(wallpaper.gradient) - if (isMounted) setTheme(computed) + if (isMounted && requestId === requestIdRef.current) setTheme(computed) } else if (wallpaper.type === 'VIDEO') { - if (isMounted) setTheme(DEFAULT_THEME) + if (isMounted && requestId === requestIdRef.current) + setTheme(DEFAULT_THEME) } } diff --git a/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx b/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx index a8594759a..c0e1eac93 100644 --- a/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx +++ b/src/layouts/widgets/transparent-clock/variants/transparent-clock-english.tsx @@ -17,7 +17,6 @@ export function TransparentClockEnglish({ const gregorianDate = time.toLocaleDateString('en-US', { month: 'long', day: 'numeric', - year: 'numeric', }) return ( @@ -43,7 +42,6 @@ export function TransparentClockEnglish({ {renderDigitSlots(minutes)}
-
diff --git a/src/layouts/widgets/widget-registry.tsx b/src/layouts/widgets/widget-registry.tsx index 57aaa0587..e0732b03f 100644 --- a/src/layouts/widgets/widget-registry.tsx +++ b/src/layouts/widgets/widget-registry.tsx @@ -60,17 +60,6 @@ export const WIDGET_DEFINITIONS: Record = { ), }, - [WidgetKeys.widgetify]: { - id: WidgetKeys.widgetify, - label: 'ویجتیفای', - emoji: '✨', - category: 'lifestyle', - allowedSizes: [{ w: 2, h: 3 }], - defaultSize: { w: 2, h: 3 }, - settingsTab: WidgetTabKeys.Pet, - canDuplicate: false, - node: (_instanceId) => , - }, [WidgetKeys.pet]: { id: WidgetKeys.pet, label: 'پت (حیوان خانگی)', @@ -489,6 +478,7 @@ export const WIDGET_DEFINITIONS: Record = { { w: 1, h: 1, isVipOnly: true }, { w: 2, h: 1 }, { w: 2, h: 2 }, + { w: 2, h: 4 }, ], defaultSize: { w: 2, h: 2 }, canDuplicate: true, diff --git a/src/main.tsx b/src/main.tsx index 77216b9f9..9e9cc9f22 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,7 +7,7 @@ import App from './app' if (import.meta.env.DEV) { scan({ - enabled: true, + enabled: false, log: true, }) } diff --git a/wxt.config.ts b/wxt.config.ts index 7b2d02bf8..e337a9cbb 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -47,7 +47,7 @@ export default defineConfig({ '@wxt-dev/module-react', ], manifest: { - version: '1.1.8', + version: '1.1.18', name: 'Widgetify beta', description: 'Transform your new tab into a smart dashboard with Widgetify! Get currency rates, crypto prices, weather & more.',