feat(setup): bootstrap Android plugin and web UI
This initial commit adds a multi-part scaffold for Wechselbild, including a web UI and an Android plugin. It introduces frontend assets and a package.json-based dev setup, plus plugin scaffolding for Android with Kotlin sources and Cargo.toml for the Tauri plugin. - Adds Android wallpaper plugin with Kotlin sources and manifest - Includes web UI scaffold with index.html and package.json - Adds Tauri plugin scaffold with Cargo.toml and build config
This commit is contained in:
+70
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, ChevronRight, Home, Images, LockKeyhole, Settings, Shuffle, Smartphone, Sparkles } from "lucide-react";
|
||||
import { getState, nextWallpaper, selectImages, setSetting, type WallpaperState } from "./native";
|
||||
|
||||
const initial: WallpaperState = { imageCount: 0, enabled: false, shuffle: true, lockScreenOnly: true, currentIndex: 0, imageUrls: [] };
|
||||
|
||||
function Switch({ checked, onChange, label }: { checked: boolean; onChange: (value: boolean) => void; label: string }) {
|
||||
return <button className={`switch ${checked ? "on" : ""}`} role="switch" aria-checked={checked} aria-label={label} onClick={() => onChange(!checked)}><span /></button>;
|
||||
}
|
||||
|
||||
function SettingRow({ icon, label, value, onChange }: { icon: React.ReactNode; label: string; value: boolean; onChange: (value: boolean) => void }) {
|
||||
return <div className="setting-row"><div className="setting-icon">{icon}</div><span>{label}</span><Switch checked={value} onChange={onChange} label={label} /></div>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [state, setState] = useState(initial);
|
||||
const [tab, setTab] = useState<"home" | "settings">("home");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
|
||||
useEffect(() => { getState().then(setState).catch(() => setState(initial)); }, []);
|
||||
const current = state.imageUrls[state.currentIndex] ?? "/wallpapers/alpine.png";
|
||||
const photos = useMemo(() => state.imageUrls.length ? state.imageUrls : ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"], [state.imageUrls]);
|
||||
|
||||
async function update(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
|
||||
setState(prev => ({ ...prev, [name]: value }));
|
||||
try { setState(await setSetting(name, value)); setNotice(name === "enabled" ? (value ? "Automatischer Wechsel ist aktiv" : "Automatischer Wechsel pausiert") : "Einstellung gespeichert"); } catch { setNotice("Diese Funktion ist auf Android verfügbar"); }
|
||||
}
|
||||
|
||||
async function choose() {
|
||||
setBusy(true);
|
||||
try { setState(await selectImages()); setNotice("Bilder wurden zur Sammlung hinzugefügt"); } catch (error) { setNotice(String(error).includes("cancel") ? "Auswahl abgebrochen" : "Bildauswahl ist auf Android verfügbar"); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function next() {
|
||||
setBusy(true);
|
||||
try { setState(await nextWallpaper()); setNotice("Sperrbildschirm wurde aktualisiert"); } catch { setState(prev => ({ ...prev, currentIndex: (prev.currentIndex + 1) % Math.max(1, photos.length) })); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <main className="app-shell">
|
||||
<header><div><h1>Wechselbild</h1><p>{state.enabled ? "Deine Motive wechseln automatisch" : "Automatischer Wechsel ist pausiert"}</p></div><button className="icon-button" aria-label="Einstellungen" onClick={() => setTab("settings")}><Settings /></button></header>
|
||||
|
||||
<div className="content">
|
||||
{tab === "home" ? <>
|
||||
<section className="hero" aria-label="Aktuelles Hintergrundbild">
|
||||
<div className="photo-stack" />
|
||||
<img src={current} alt="Aktuelles Motiv" />
|
||||
<div className="hero-shade" />
|
||||
<div className="hero-meta"><span><Sparkles size={16} /> Aktuelles Motiv</span><button onClick={next} disabled={busy}>Nächstes Motiv <ChevronRight size={18} /></button></div>
|
||||
</section>
|
||||
|
||||
<SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} />
|
||||
|
||||
<button className="primary" onClick={choose} disabled={busy}><Images />{busy ? "Bitte warten …" : "Bilder auswählen"}</button>
|
||||
|
||||
<section className="collection"><div className="section-heading"><h2>Meine Sammlung</h2><button>{state.imageCount || photos.length} Bilder <ChevronRight /></button></div>
|
||||
<div className="photo-rail">{photos.map((photo, index) => <button key={`${photo}-${index}`} className={index === state.currentIndex ? "selected" : ""} onClick={() => setState(prev => ({ ...prev, currentIndex: index }))}><img src={photo} alt={`Motiv ${index + 1}`} />{index === state.currentIndex && <span><Check /></span>}</button>)}</div>
|
||||
<p className="hint">Tippe auf ein Bild, um es als Vorschau zu sehen.</p>
|
||||
</section>
|
||||
|
||||
<section className="settings-list"><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></section>
|
||||
</> : <section className="settings-page"><h2>Einstellungen</h2><p>Lege fest, wie Wechselbild im Hintergrund arbeitet.</p><div className="settings-list"><SettingRow icon={<Smartphone />} label="Bei jedem Aktivieren wechseln" value={state.enabled} onChange={v => update("enabled", v)} /><SettingRow icon={<Shuffle />} label="Zufällige Reihenfolge" value={state.shuffle} onChange={v => update("shuffle", v)} /><SettingRow icon={<LockKeyhole />} label="Nur Sperrbildschirm" value={state.lockScreenOnly} onChange={v => update("lockScreenOnly", v)} /></div><div className="info"><h3>Ohne Bilderlimit</h3><p>Deine Auswahl wird privat auf dem Gerät gespeichert. Die einzige Grenze ist der freie Speicherplatz.</p></div></section>}
|
||||
</div>
|
||||
|
||||
{notice && <button className="snackbar" onClick={() => setNotice("")}>{notice}</button>}
|
||||
<nav><button className={tab === "home" ? "active" : ""} onClick={() => setTab("home")}><Home /><span>Start</span></button><button className={tab === "settings" ? "active" : ""} onClick={() => setTab("settings")}><Settings /><span>Einstellungen</span></button></nav>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode><App /></React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export type WallpaperState = {
|
||||
imageCount: number;
|
||||
enabled: boolean;
|
||||
shuffle: boolean;
|
||||
lockScreenOnly: boolean;
|
||||
currentIndex: number;
|
||||
imageUrls: string[];
|
||||
};
|
||||
|
||||
const demoState: WallpaperState = {
|
||||
imageCount: 3,
|
||||
enabled: true,
|
||||
shuffle: true,
|
||||
lockScreenOnly: true,
|
||||
currentIndex: 0,
|
||||
imageUrls: ["/wallpapers/alpine.png", "/wallpapers/waterfall.png", "/wallpapers/coast.png"],
|
||||
};
|
||||
|
||||
const inTauri = () => "__TAURI_INTERNALS__" in window;
|
||||
|
||||
export async function getState(): Promise<WallpaperState> {
|
||||
return inTauri() ? invoke<WallpaperState>("plugin:wallpaper|get_state") : demoState;
|
||||
}
|
||||
|
||||
export async function selectImages(): Promise<WallpaperState> {
|
||||
if (!inTauri()) return demoState;
|
||||
return invoke<WallpaperState>("plugin:wallpaper|select_images");
|
||||
}
|
||||
|
||||
export async function setSetting(name: "enabled" | "shuffle" | "lockScreenOnly", value: boolean) {
|
||||
if (!inTauri()) return { ...demoState, [name]: value };
|
||||
return invoke<WallpaperState>("plugin:wallpaper|set_setting", { name, value });
|
||||
}
|
||||
|
||||
export async function nextWallpaper(): Promise<WallpaperState> {
|
||||
if (!inTauri()) {
|
||||
demoState.currentIndex = (demoState.currentIndex + 1) % demoState.imageUrls.length;
|
||||
return { ...demoState };
|
||||
}
|
||||
return invoke<WallpaperState>("plugin:wallpaper|next_wallpaper");
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
:root { font-family: Inter, Roboto, "Noto Sans", system-ui, sans-serif; color: #18201b; background: #edf1ed; font-synthesis: none; --green: #145d32; --sage: #e6eee4; --line: #dce3dc; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; }
|
||||
button { font: inherit; }
|
||||
.app-shell { width: min(100%, 480px); min-height: 100dvh; margin: 0 auto; background: #f8faf7; position: relative; padding-bottom: 94px; box-shadow: 0 0 50px rgba(32, 50, 37, .12); }
|
||||
header { padding: max(24px, env(safe-area-inset-top)) 24px 18px; display: flex; align-items: center; justify-content: space-between; }
|
||||
h1 { font-size: 34px; letter-spacing: -1.7px; line-height: 1.05; margin: 0 0 6px; font-weight: 800; }
|
||||
header p { margin: 0; font-size: 13px; color: #657068; font-weight: 500; }
|
||||
.icon-button { width: 44px; height: 44px; border: 0; border-radius: 50%; background: #edf2eb; color: #31523b; display: grid; place-items: center; }
|
||||
.icon-button svg { width: 21px; }
|
||||
.content { padding: 0 20px; }
|
||||
.hero { position: relative; height: 294px; margin: 0 0 15px; border-radius: 26px; isolation: isolate; }
|
||||
.hero img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; z-index: 1; }
|
||||
.photo-stack { position: absolute; inset: -7px 10px 8px; background: #dce5d9; transform: rotate(1.2deg); border-radius: 26px; }
|
||||
.hero-shade { position: absolute; inset: 45% 0 0; z-index: 2; border-radius: 0 0 26px 26px; background: linear-gradient(transparent, rgba(3, 17, 9, .64)); }
|
||||
.hero-meta { position: absolute; z-index: 3; left: 18px; right: 16px; bottom: 17px; display: flex; align-items: center; justify-content: space-between; color: white; }
|
||||
.hero-meta > span { display: flex; gap: 7px; align-items: center; font-size: 12px; font-weight: 700; }
|
||||
.hero-meta button { border: 0; background: white; color: var(--green); border-radius: 999px; padding: 11px 14px 11px 16px; font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 4px; }
|
||||
.setting-row { min-height: 66px; display: flex; align-items: center; gap: 13px; font-size: 15px; font-weight: 650; border-bottom: 1px solid var(--line); }
|
||||
.setting-icon { width: 39px; height: 39px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 50%; background: var(--sage); color: var(--green); }
|
||||
.setting-icon svg { width: 19px; }
|
||||
.setting-row > span { flex: 1; line-height: 1.3; }
|
||||
.switch { width: 48px; height: 28px; padding: 3px; border-radius: 30px; border: 0; background: #c7cec8; transition: .2s ease; }
|
||||
.switch span { display: block; width: 22px; height: 22px; background: white; border-radius: 50%; box-shadow: 0 1px 3px rgba(0,0,0,.22); transition: transform .2s ease; }
|
||||
.switch.on { background: var(--green); }
|
||||
.switch.on span { transform: translateX(20px); }
|
||||
.primary { border: 0; width: 100%; height: 56px; margin: 18px 0 24px; border-radius: 17px; background: var(--green); color: white; font-size: 16px; font-weight: 750; display: flex; align-items: center; justify-content: center; gap: 10px; box-shadow: 0 9px 24px rgba(20, 93, 50, .18); }
|
||||
.primary svg { width: 21px; }
|
||||
.primary:disabled, .hero-meta button:disabled { opacity: .65; }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
h2 { margin: 0; font-size: 21px; letter-spacing: -.6px; }
|
||||
.section-heading button { border: 0; padding: 4px 0; color: var(--green); background: transparent; font-size: 13px; font-weight: 750; display: flex; align-items: center; }
|
||||
.section-heading svg { width: 17px; }
|
||||
.photo-rail { display: flex; gap: 10px; overflow-x: auto; padding: 2px 2px 4px; scrollbar-width: none; }
|
||||
.photo-rail::-webkit-scrollbar { display: none; }
|
||||
.photo-rail button { position: relative; width: 80px; height: 102px; padding: 0; border-radius: 13px; border: 2px solid transparent; background: #ddd; flex: 0 0 auto; overflow: hidden; }
|
||||
.photo-rail button.selected { border-color: #73a17c; box-shadow: 0 0 0 2px #f8faf7 inset; }
|
||||
.photo-rail img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.photo-rail span { position: absolute; left: 7px; top: 7px; width: 23px; height: 23px; background: var(--green); color: white; border-radius: 50%; display: grid; place-items: center; }
|
||||
.photo-rail span svg { width: 14px; }
|
||||
.hint { margin: 8px 0 12px; text-align: center; font-size: 11px; color: #7a837d; }
|
||||
.settings-list { border-top: 1px solid var(--line); }
|
||||
.settings-page > p { color: #667069; margin: 8px 0 24px; font-size: 14px; }
|
||||
.settings-page .settings-list { margin-top: 8px; }
|
||||
.info { margin-top: 28px; padding: 20px; background: #eaf2e8; border-radius: 20px; }
|
||||
.info h3 { color: var(--green); margin: 0 0 6px; font-size: 16px; }
|
||||
.info p { margin: 0; font-size: 13px; line-height: 1.55; color: #526158; }
|
||||
nav { position: fixed; z-index: 10; bottom: 0; left: 50%; transform: translateX(-50%); width: min(100%, 480px); height: calc(76px + env(safe-area-inset-bottom)); padding: 8px 34px env(safe-area-inset-bottom); background: rgba(248,250,247,.96); backdrop-filter: blur(16px); border-top: 1px solid var(--line); display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
nav button { border: 0; color: #657068; background: transparent; border-radius: 18px; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 3px; font-size: 11px; font-weight: 650; }
|
||||
nav button.active { color: var(--green); background: var(--sage); }
|
||||
nav svg { width: 21px; }
|
||||
.snackbar { position: fixed; z-index: 20; left: 50%; transform: translateX(-50%); bottom: 92px; max-width: calc(100% - 40px); background: #26312a; color: white; border: 0; border-radius: 12px; padding: 13px 18px; font-size: 12px; box-shadow: 0 8px 26px rgba(0,0,0,.22); }
|
||||
@media (min-width: 700px) { .app-shell { margin-top: 20px; min-height: calc(100vh - 40px); border-radius: 32px; overflow: hidden; } nav { bottom: 20px; border-radius: 0 0 32px 32px; } }
|
||||
@media (max-height: 740px) { .hero { height: 235px; } }
|
||||
@media (prefers-reduced-motion: reduce) { * { scroll-behavior: auto !important; transition: none !important; } }
|
||||
Reference in New Issue
Block a user