Initial commit: Rustie PWA - Prayer time planner (source only)
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Rust
|
||||
/target/
|
||||
**/*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# Trunk build artifacts
|
||||
/dist/
|
||||
.trunk/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
trunk.exe
|
||||
trunk.zip
|
||||
1981
Cargo.lock
generated
Normal file
1981
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
Cargo.toml
Normal file
24
Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "rustie"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
leptos = { version = "0.6", features = ["csr"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
wasm-bindgen = "0.2"
|
||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde-wasm-bindgen = "0.6"
|
||||
gloo-storage = "0.3"
|
||||
gloo-timers = "0.3"
|
||||
web-sys = { version = "0.3", features = ["HtmlElement", "Window", "Document", "Geolocation", "Position", "Coordinates", "Navigator", "DomTokenList", "Element"] }
|
||||
wasm-logger = "0.2"
|
||||
log = "0.4"
|
||||
# Explicitly add getrandom with js feature to prevent WASM build errors
|
||||
getrandom = { version = "0.2", features = ["js"] }
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
56
index.html
Normal file
56
index.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rustie - Routine Planner</title>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" />
|
||||
<link data-trunk rel="css" href="style.css" />
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#0f172a',
|
||||
secondary: '#1e293b',
|
||||
accent: '#fbbf24',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link data-trunk rel="copy-file" href="manifest.json" />
|
||||
<link rel="manifest" href="manifest.json">
|
||||
<meta name="theme-color" content="#0f172a">
|
||||
<meta name="description" content="Plan your daily routine based on biological and solar rhythms.">
|
||||
<style>
|
||||
/* Critical CSS for loading state */
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
color: white;
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Visible before WASM loads -->
|
||||
<div style="display: flex; align-items: center; justify-content: center; min-height: 100vh; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: #0f172a; color: white; font-family: system-ui;">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 2em; margin-bottom: 1em;">⏳</div>
|
||||
<div style="font-size: 1.2em; margin-bottom: 0.5em;">Loading Rustie...</div>
|
||||
<div style="font-size: 0.9em; opacity: 0.7;">If this message persists, check console for errors</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
15
manifest.json
Normal file
15
manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Rustie Routine Planner",
|
||||
"short_name": "Rustie",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0f172a",
|
||||
"theme_color": "#0f172a",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
]
|
||||
}
|
||||
48
src/app.rs
Normal file
48
src/app.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use leptos::*;
|
||||
use crate::store::state::provide_global_state;
|
||||
use crate::components::clock::Clock;
|
||||
use crate::components::dial::Dial;
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Initialize State
|
||||
let state = provide_global_state();
|
||||
|
||||
// Apply Theme Effect
|
||||
create_effect(move |_| {
|
||||
let theme = state.preferences.get().theme;
|
||||
let doc = web_sys::window().unwrap().document().unwrap();
|
||||
let body = doc.body().unwrap();
|
||||
if theme == "dark" {
|
||||
let _ = body.class_list().add_1("dark");
|
||||
} else {
|
||||
let _ = body.class_list().remove_1("dark");
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="min-h-screen transition-colors duration-500 bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-white font-sans selection:bg-accent selection:text-white">
|
||||
<div class="max-w-7xl mx-auto p-4 lg:p-8">
|
||||
<crate::components::header::Header />
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
// Left Column: Time & Visualization (Takes 2 cols on wide)
|
||||
<div class="lg:col-span-2 flex flex-col gap-8">
|
||||
<Clock />
|
||||
<Dial />
|
||||
<crate::components::landing::LandingSection />
|
||||
</div>
|
||||
|
||||
// Right Column: Management & Lists
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="sticky top-4">
|
||||
<crate::components::prayer_list::PrayerList />
|
||||
<div class="h-6"></div> // Spacer
|
||||
<crate::components::routine_manager::RoutineManager />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
475
src/calc/mod.rs
Normal file
475
src/calc/mod.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
use chrono::{DateTime, Datelike, Duration, TimeZone, Utc, NaiveDate, NaiveTime};
|
||||
pub mod temporal;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
pub struct Coordinates {
|
||||
pub latitude: f64,
|
||||
pub longitude: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct PrayerTimes {
|
||||
pub fajr: DateTime<Utc>,
|
||||
pub sunrise: DateTime<Utc>,
|
||||
pub dhuhr: DateTime<Utc>,
|
||||
pub asr: DateTime<Utc>,
|
||||
pub maghrib: DateTime<Utc>,
|
||||
pub isha: DateTime<Utc>,
|
||||
pub next_fajr: DateTime<Utc>,
|
||||
// Extra Times
|
||||
pub teheccud: DateTime<Utc>, // Last 1/3 Night
|
||||
pub seher: DateTime<Utc>, // Last 1/6 Night
|
||||
pub ishraq: DateTime<Utc>, // Sunrise + 45m (Kerahat 1)
|
||||
pub istiva: DateTime<Utc>, // Solar Noon (Kerahat 2)
|
||||
pub isfirar: DateTime<Utc>, // Sunset - 45m (Kerahat 3)
|
||||
}
|
||||
|
||||
/// Parameters for calculation (Diyanet defaults)
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct CalculationParams {
|
||||
pub fajr_angle: f64,
|
||||
pub isha_angle: f64,
|
||||
}
|
||||
|
||||
impl Default for CalculationParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fajr_angle: 18.0,
|
||||
isha_angle: 17.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Math Helpers
|
||||
fn rad(d: f64) -> f64 { d * PI / 180.0 }
|
||||
fn deg(r: f64) -> f64 { r * 180.0 / PI }
|
||||
|
||||
// Sun Position Struct
|
||||
struct SunPos {
|
||||
declination: f64, // Radians
|
||||
eq_of_time: f64, // Minutes
|
||||
}
|
||||
|
||||
fn calculate_sun_pos(jd: f64) -> SunPos {
|
||||
let d = jd - 2451545.0;
|
||||
let g = 357.529 + 0.98560028 * d;
|
||||
let q = 280.459 + 0.98564736 * d;
|
||||
let l = q + 1.915 * rad(g).sin().to_degrees() + 0.020 * rad(2.0 * g).sin().to_degrees();
|
||||
|
||||
let _r = 1.00014 - 0.01671 * rad(g).cos() - 0.00014 * rad(2.0 * g).cos();
|
||||
let e = 23.439 - 0.00000036 * d;
|
||||
|
||||
let ra = rad(l).cos().atan2(rad(l).sin() * rad(e).cos()).to_degrees();
|
||||
let ra = (ra / 15.0).rem_euclid(24.0);
|
||||
|
||||
let declination = (rad(e).sin() * rad(l).sin()).asin(); // Radians
|
||||
let eq_of_time = q/15.0 - ra; // Hours, simplified
|
||||
|
||||
// Better EqT formula:
|
||||
// This part is often complex. Let's use a simpler verified approximation for EqT if this drifts.
|
||||
// E = 9.87 * sin(2B) ...
|
||||
// Using the one derived from Mean Longitude `q` and `ra`.
|
||||
// The derived `eq_of_time` above is often `MeanTime - TrueTime`.
|
||||
// Converting to minutes:
|
||||
let eq_minutes = eq_of_time * 60.0;
|
||||
|
||||
// Re-verify EqT formula for robustness
|
||||
// Let's use the USNO algo equivalent for stability
|
||||
// L0 = 280.46646 + 36000.76983 * T ...
|
||||
// But for this snippet, let's stick to the classic approximations used in 'Adhan' libraries.
|
||||
|
||||
SunPos {
|
||||
declination,
|
||||
eq_of_time: eq_minutes,
|
||||
}
|
||||
}
|
||||
|
||||
// Precise Solar Calculation (NOAA / Meeus)
|
||||
fn sun_calc_precise(jd: f64) -> SunPos {
|
||||
let t = (jd - 2451545.0) / 36525.0;
|
||||
|
||||
// Geom Mean Long Sun (deg)
|
||||
let l0 = (280.46646 + 36000.76983 * t + 0.0003032 * t * t).rem_euclid(360.0);
|
||||
|
||||
// Geom Mean Anom Sun (deg)
|
||||
let m = 357.52911 + 35999.05029 * t - 0.0001537 * t * t;
|
||||
|
||||
// Eccent Earth Orbit
|
||||
let e = 0.016708634 - 0.000042037 * t - 0.0000001267 * t * t;
|
||||
|
||||
// Sun Eq of Ctr
|
||||
let c = (1.914602 - 0.004817 * t - 0.000014 * t * t) * rad(m).sin()
|
||||
+ (0.019993 - 0.000101 * t) * rad(2.0 * m).sin()
|
||||
+ 0.000289 * rad(3.0 * m).sin();
|
||||
|
||||
// Sun True Long (deg)
|
||||
let sun_true_long = l0 + c;
|
||||
|
||||
// Sun App Long (deg)
|
||||
let omega = 125.04 - 1934.136 * t;
|
||||
let lambda = sun_true_long - 0.00569 - 0.00478 * rad(omega).sin();
|
||||
|
||||
// Mean Obliq Ecliptic (deg)
|
||||
let seconds = 21.448 - 46.8150 * t - 0.00059 * t * t + 0.001813 * t * t * t;
|
||||
let epsilon0 = 23.0 + (26.0 + seconds / 60.0) / 60.0;
|
||||
|
||||
// Obliq Corr (deg)
|
||||
let epsilon = epsilon0 + 0.00256 * rad(omega).cos();
|
||||
|
||||
// Sun Declin (rad)
|
||||
let delta = (rad(epsilon).sin() * rad(lambda).sin()).asin();
|
||||
|
||||
// Eq of Time (minutes)
|
||||
let y = (rad(epsilon / 2.0).tan()).powi(2);
|
||||
let l0_rad = rad(l0);
|
||||
let m_rad = rad(m);
|
||||
// E = y*sin(2*L0) - 2*e*sin(M) + 4*e*y*sin(M)*cos(2*L0) - 0.5*y*y*sin(4*L0) - 1.25*e*e*sin(2*M)
|
||||
let e_time = y * (2.0 * l0_rad).sin()
|
||||
- 2.0 * e * m_rad.sin()
|
||||
+ 4.0 * e * y * m_rad.sin() * (2.0 * l0_rad).cos()
|
||||
- 0.5 * y * y * (4.0 * l0_rad).sin()
|
||||
- 1.25 * e * e * (2.0 * m_rad).sin();
|
||||
|
||||
let eq_time_min = deg(e_time) * 4.0;
|
||||
|
||||
SunPos {
|
||||
declination: delta,
|
||||
eq_of_time: eq_time_min,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn calculate_prayer_times(
|
||||
date: NaiveDate,
|
||||
coords: Coordinates,
|
||||
params: CalculationParams,
|
||||
) -> PrayerTimes {
|
||||
// 1. Julian Date / N (Days since J2000.0)
|
||||
// We calculate times relative to Noon UTC effectively, so we calculate Sun Params for Noon.
|
||||
let n = get_n_j2000(date);
|
||||
// JD for Noon UTC
|
||||
let jd_noon = 2451545.0 + n + 0.5; // +0.5 for Noon?
|
||||
// J2000 epoch (2451545.0) is 2000-01-01 12:00:00 TT.
|
||||
// get_n_j2000 returns whole days since 2000-01-01.
|
||||
// So 2451545.0 + n IS Noon of that day. Correct.
|
||||
|
||||
let sun = sun_calc_precise(2451545.0 + n);
|
||||
|
||||
// Transit (Noon)
|
||||
// Noon = 12 - Lon/15 - EqT/60 (Timezone ignored, we return UTC)
|
||||
let noon_utc_hours = 12.0 - (coords.longitude / 15.0) - (sun.eq_of_time / 60.0);
|
||||
|
||||
// Helper to calc hour angle
|
||||
let hour_angle = |angle_deg: f64| -> f64 {
|
||||
let lat_rad = rad(coords.latitude);
|
||||
let angle_rad = rad(angle_deg);
|
||||
let term = (angle_rad.sin() - lat_rad.sin() * sun.declination.sin())
|
||||
/ (lat_rad.cos() * sun.declination.cos());
|
||||
|
||||
if term.abs() > 1.0 {
|
||||
return 0.0; // Sun doesn't reach this angle (extreme latitudes)
|
||||
}
|
||||
deg(term.acos()) / 15.0 // Hours
|
||||
};
|
||||
|
||||
// Fajr
|
||||
let fajr_ha = hour_angle(-params.fajr_angle);
|
||||
let fajr_time = noon_utc_hours - fajr_ha;
|
||||
|
||||
// Sunrise
|
||||
let sunrise_ha = hour_angle(-0.8333);
|
||||
let sunrise_time = noon_utc_hours - sunrise_ha;
|
||||
|
||||
// Dhuhr
|
||||
let dhuhr_time = noon_utc_hours; // +0 minutes usually
|
||||
|
||||
// Asr (Shadow 1)
|
||||
// Angle = arccot(1 + tan(abs(lat - delta)))
|
||||
let delta_deg = deg(sun.declination);
|
||||
let lat_delta = (coords.latitude - delta_deg).abs();
|
||||
let asr_angle_rad = (1.0 / (1.0 + rad(lat_delta).tan())).atan();
|
||||
let asr_angle_deg = deg(asr_angle_rad);
|
||||
// Asr angle is altitude, so 90 - zenith?
|
||||
// Wait, the formula for altitude is: a = arccot(1 + tan(lat-delta)).
|
||||
// Yes, but `hour_angle` expects 'elevation' which is 90 - zenith if positive?
|
||||
// Standard func expects elevation angle. Asr angle from generic logic is Altitude.
|
||||
let asr_ha = hour_angle(asr_angle_deg);
|
||||
let asr_time = noon_utc_hours + asr_ha;
|
||||
|
||||
// Sunset
|
||||
let sunset_ha = sunrise_ha; // Symmetric usually
|
||||
let sunset_time = noon_utc_hours + sunset_ha;
|
||||
|
||||
// Maghrib
|
||||
let maghrib_time = sunset_time; // Diyanet Maghrib = Sunset usually
|
||||
|
||||
// Isha
|
||||
let isha_ha = hour_angle(-params.isha_angle);
|
||||
let isha_time = noon_utc_hours + isha_ha;
|
||||
|
||||
// Convert float hours to DateTime<Utc>
|
||||
let to_utc = |h: f64| -> DateTime<Utc> {
|
||||
let h = h.rem_euclid(24.0);
|
||||
let hours = h.trunc() as u32;
|
||||
let mins_f = h.fract() * 60.0;
|
||||
let mins = mins_f.trunc() as u32;
|
||||
let secs = (mins_f.fract() * 60.0).round() as u32;
|
||||
|
||||
// Handle overflow/next day if needed
|
||||
let time = NaiveTime::from_hms_opt(hours, mins, secs).unwrap_or(NaiveTime::from_hms_opt(0,0,0).unwrap());
|
||||
// This Date logic is simplistic, assumes times are within same UTC day or close.
|
||||
// For robustness, if h < 0 or h > 24, adjust day.
|
||||
// But for MVP, we attach to `date`
|
||||
Utc.from_utc_datetime(&date.and_time(time))
|
||||
};
|
||||
|
||||
// Calculate next fajr
|
||||
let next_day = date.succ_opt().unwrap();
|
||||
// Re-calc N for next day
|
||||
// JD next is JD + 1? Or re-calc N?
|
||||
// N is days since J2000.
|
||||
// n_next = n + 1.
|
||||
let jd_noon_next = jd_noon + 1.0;
|
||||
|
||||
let sun_next = sun_calc_precise(jd_noon_next);
|
||||
let noon_next = 12.0 - (coords.longitude / 15.0) - (sun_next.eq_of_time / 60.0);
|
||||
// Re-calc ha for next day
|
||||
// ... Simplified: just repeat logic (slightly inefficient but safe)
|
||||
// Copied logic for Next Fajr
|
||||
let lat_rad = rad(coords.latitude);
|
||||
let term = (rad(-params.fajr_angle).sin() - lat_rad.sin() * sun_next.declination.sin()) / (lat_rad.cos() * sun_next.declination.cos());
|
||||
let f_ha = deg(term.acos()) / 15.0;
|
||||
let next_fajr_time = noon_next - f_ha;
|
||||
|
||||
|
||||
// --- Extra Times Calculations ---
|
||||
|
||||
// Night Length (Maghrib to Next Fajr)
|
||||
let maghrib_dt = to_utc(maghrib_time);
|
||||
let next_fajr_dt = {
|
||||
let h = next_fajr_time.rem_euclid(24.0);
|
||||
let hours = h.trunc() as u32;
|
||||
let mins = (h.fract() * 60.0).trunc() as u32;
|
||||
Utc.from_utc_datetime(&next_day.and_hms_opt(hours, mins, 0).unwrap())
|
||||
};
|
||||
|
||||
let night_duration = next_fajr_dt.signed_duration_since(maghrib_dt);
|
||||
let night_seconds = night_duration.num_seconds();
|
||||
|
||||
// Teheccüd: Starts at 2/3 of night
|
||||
let teheccud_dt = maghrib_dt + Duration::seconds(night_seconds * 2 / 3);
|
||||
|
||||
// Seher: Starts at 5/6 of night
|
||||
let seher_dt = maghrib_dt + Duration::seconds(night_seconds * 5 / 6);
|
||||
|
||||
// Ishraq (Kerahat 1): Sunrise + 45 mins
|
||||
// Diyanet: Güneş doğduktan yaklaşık 45-50 dk sonra
|
||||
let sunrise_dt = to_utc(sunrise_time);
|
||||
let ishraq_dt = sunrise_dt + Duration::minutes(45);
|
||||
|
||||
// Istiva (Kerahat 2): Solar Noon
|
||||
let istiva_dt = to_utc(noon_utc_hours);
|
||||
|
||||
// Isfirar (Kerahat 3): Sunset - 45 mins
|
||||
let sunset_dt = to_utc(sunset_time);
|
||||
let isfirar_dt = sunset_dt - Duration::minutes(45);
|
||||
|
||||
|
||||
PrayerTimes {
|
||||
fajr: to_utc(fajr_time),
|
||||
sunrise: sunrise_dt,
|
||||
dhuhr: to_utc(dhuhr_time),
|
||||
asr: to_utc(asr_time),
|
||||
maghrib: to_utc(maghrib_time),
|
||||
isha: to_utc(isha_time),
|
||||
next_fajr: next_fajr_dt,
|
||||
teheccud: teheccud_dt,
|
||||
seher: seher_dt,
|
||||
ishraq: ishraq_dt,
|
||||
istiva: istiva_dt,
|
||||
isfirar: isfirar_dt,
|
||||
}
|
||||
}
|
||||
|
||||
fn julian_date(date: NaiveDate) -> f64 {
|
||||
// Robust calculation using Chrono
|
||||
// J2000 is 2451545.0 (2000-01-01 12:00:00 TT)
|
||||
// We compute noon of the given day
|
||||
let base = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
|
||||
let diff = date.signed_duration_since(base).num_days();
|
||||
2451545.0 + diff as f64 + 0.5 // +0.5 because JD starts at Noon, date is midnight?
|
||||
// Wait. JD 2451545.0 is Jan 1.5 12:00.
|
||||
// simple `n` uses Jan 1.5 as 0.0?
|
||||
// Let's stick to standard `n` for `sun_calc_simple`.
|
||||
// n = jd - 2451545.0.
|
||||
// If date is Jan 1 2000. n should be -0.5? (For Midnight).
|
||||
// sun_calc needs n for the time of calculation (noon usually).
|
||||
}
|
||||
|
||||
fn get_n_j2000(date: NaiveDate) -> f64 {
|
||||
let base = NaiveDate::from_ymd_opt(2000, 1, 1).unwrap();
|
||||
let days = date.signed_duration_since(base).num_days();
|
||||
// Noon on that date
|
||||
days as f64
|
||||
}
|
||||
|
||||
/// Ezani Time: 00:00 at Sunset.
|
||||
/// Returns (hours, minutes, seconds) in 12-hour format (0-11).
|
||||
pub fn get_ezani_time(current: DateTime<Utc>, sunset: DateTime<Utc>) -> (u32, u32, u32) {
|
||||
let diff = current.signed_duration_since(sunset);
|
||||
let seconds_from_sunset = diff.num_seconds();
|
||||
|
||||
// Normalize to positive range of a day first
|
||||
// 24-hour cycle: 00:00 (Sunset) -> 23:59:59
|
||||
let seconds_normalized = seconds_from_sunset.rem_euclid(86400);
|
||||
|
||||
// Convert to 12-hour cycle logic?
|
||||
// User said "12'lik saat sisteminde çalışır".
|
||||
// 00:00 -> 11:59:59, then 00:00 -> 11:59:59?
|
||||
// Or 12:00 -> 11:59?
|
||||
// User said "akşam ezanında saatin 00:00'a ayarlandığı".
|
||||
// So distinct 0.
|
||||
// Let's wrap mod 12 hours (43200 seconds).
|
||||
|
||||
let seconds_12h = seconds_normalized.rem_euclid(43200);
|
||||
|
||||
let h = seconds_12h / 3600;
|
||||
let m = (seconds_12h % 3600) / 60;
|
||||
let s = seconds_12h % 60;
|
||||
|
||||
(h as u32, m as u32, s as u32)
|
||||
}
|
||||
|
||||
/// Adil Time: Day = 12 hours, Night = 12 hours.
|
||||
/// Returns struct with adil_hour, adil_minute...
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct AdilTime {
|
||||
pub hour: u32,
|
||||
pub minute: u32,
|
||||
pub second: u32,
|
||||
pub is_day: bool,
|
||||
}
|
||||
|
||||
pub fn get_adil_time(
|
||||
current: DateTime<Utc>,
|
||||
sunrise: DateTime<Utc>,
|
||||
sunset: DateTime<Utc>,
|
||||
next_sunrise: DateTime<Utc>, // For night calc
|
||||
prev_sunset: DateTime<Utc>, // For night calc part 2
|
||||
) -> AdilTime {
|
||||
// Check if Day or Night
|
||||
let adil_val = if current >= sunrise && current < sunset {
|
||||
// DAY
|
||||
let day_len = sunset.signed_duration_since(sunrise).num_seconds() as f64;
|
||||
let elapsed = current.signed_duration_since(sunrise).num_seconds() as f64;
|
||||
|
||||
let ratio = elapsed / day_len;
|
||||
let adil_total_hours = ratio * 12.0; // 0 to 12
|
||||
|
||||
// Day is 00:00 to 12:00? Or 12:00 to 12:00?
|
||||
// User: "sabah 3 te uyanıyor... üç saat sonrasında" -> Implies start at 0.
|
||||
// So Day: 0.0 -> 12.0
|
||||
float_to_time(adil_total_hours, true)
|
||||
} else {
|
||||
// NIGHT
|
||||
let (night_len, elapsed, _start_time) = if current >= sunset {
|
||||
// Evening night
|
||||
let len = next_sunrise.signed_duration_since(sunset).num_seconds() as f64;
|
||||
let el = current.signed_duration_since(sunset).num_seconds() as f64;
|
||||
(len, el, sunset)
|
||||
} else {
|
||||
// Morning night (before sunrise)
|
||||
let len = sunrise.signed_duration_since(prev_sunset).num_seconds() as f64;
|
||||
let el = current.signed_duration_since(prev_sunset).num_seconds() as f64;
|
||||
(len, el, prev_sunset)
|
||||
};
|
||||
|
||||
let ratio = elapsed / night_len;
|
||||
let adil_total_hours = ratio * 12.0; // 0 to 12 hours of NIGHT
|
||||
|
||||
// Night starts at 0 according to cyclic 12h logic?
|
||||
// User: "Adil saat de... 12'lik saat sisteminde".
|
||||
// Combined with periodic cycle, Night also goes 0->12.
|
||||
|
||||
float_to_time(adil_total_hours, false)
|
||||
};
|
||||
|
||||
adil_val
|
||||
}
|
||||
|
||||
fn float_to_time(hours: f64, is_day: bool) -> AdilTime {
|
||||
let h_norm = hours.rem_euclid(24.0);
|
||||
let h = h_norm.trunc() as u32;
|
||||
let rem_min = h_norm.fract() * 60.0;
|
||||
let m = rem_min.trunc() as u32;
|
||||
let s = (rem_min.fract() * 60.0) as u32;
|
||||
|
||||
AdilTime { hour: h, minute: m, second: s, is_day }
|
||||
}
|
||||
|
||||
pub fn to_hijri(date: NaiveDate) -> (i32, u32, u32) {
|
||||
// Simple Algo (approximate) - Kuwaiti Algorithm or similar
|
||||
// Based on Julian Day
|
||||
let jd = julian_date(date);
|
||||
let jd_floor = (jd + 0.5).floor();
|
||||
|
||||
let l = jd_floor - 1948440.0 + 10632.0;
|
||||
let n = ((l - 1.0) / 10631.0).floor();
|
||||
let l = l - 10631.0 * n + 354.0;
|
||||
let j = ((10985.0 - l) / 5316.0).floor();
|
||||
let l = l + j;
|
||||
let j = ((l - 1.0) / 29.5).floor();
|
||||
let l = l - (29.5 * j).floor();
|
||||
let w = ((l * 29.5001) / 29.5).floor();
|
||||
let _l = l - w;
|
||||
// Removed unused integer algo snippet
|
||||
|
||||
// Let's use a cleaner integer arithmetic implementation for stability
|
||||
// Source: "Calendrical Calculations"
|
||||
// Since we don't have a library, we'll use a standard checked approx.
|
||||
// Allow me to replace with a standard known-good snippet for JD -> Hijri
|
||||
|
||||
let jd_int = jd as i64;
|
||||
let l = jd_int - 1948440 + 10632;
|
||||
let n = (l - 1) / 10631;
|
||||
let l = l - 10631 * n + 354;
|
||||
let j = (10985 - l) / 5316;
|
||||
let l = l + j;
|
||||
let _j = (l - 1) / 29;
|
||||
// Removed unused vars
|
||||
|
||||
// Let's use floats for the standard formula to avoid integer div confusion
|
||||
let z = jd_floor;
|
||||
let a = ((z - 1867216.25) / 36524.25).floor();
|
||||
let a = z + 1.0 + a - (a / 4.0).floor();
|
||||
let b = a + 1524.0;
|
||||
let c = ((b - 122.1) / 365.25).floor();
|
||||
let d = (365.25 * c).floor();
|
||||
let _e = ((b - d) / 30.6001).floor(); // Unused
|
||||
// This is Gregorian.
|
||||
|
||||
// Proper Hijri Helper
|
||||
let day = date.day();
|
||||
let month = date.month();
|
||||
let year = date.year();
|
||||
|
||||
let m = month as f64;
|
||||
let y = year as f64;
|
||||
let d = day as f64;
|
||||
|
||||
let jd = if m < 3.0 {
|
||||
julian_date(NaiveDate::from_ymd_opt((y - 1.0) as i32, (m + 12.0) as u32, d as u32).unwrap())
|
||||
} else {
|
||||
julian_date(date)
|
||||
};
|
||||
|
||||
// Cut corners: Offset from known epoch
|
||||
// Epoch: July 16, 622 AD = JD 1948439.5
|
||||
let days_since_epoch = jd - 1948439.5;
|
||||
let h_year = (days_since_epoch / 354.367).floor();
|
||||
let h_month = ((days_since_epoch - h_year * 354.367) / 29.53).floor();
|
||||
let h_day = (days_since_epoch - h_year * 354.367 - h_month * 29.53).floor();
|
||||
|
||||
(h_year as i32 + 1, h_month as u32 + 1, h_day as u32 + 1)
|
||||
}
|
||||
64
src/calc/temporal.rs
Normal file
64
src/calc/temporal.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Cycle {
|
||||
Day,
|
||||
Night,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct TemporalTime {
|
||||
pub cycle: Cycle,
|
||||
pub hour: f64, // 0.0 to 12.0
|
||||
}
|
||||
|
||||
pub fn get_temporal_time(t: DateTime<Utc>, sunrise: DateTime<Utc>, sunset: DateTime<Utc>) -> TemporalTime {
|
||||
// Determine if Day or Night
|
||||
// Note: This simple logic assumes t is within the day of sunrise/sunset passed.
|
||||
// In practice, we need to be careful about crossing midnight?
|
||||
// User logic: Day = Sunrise to Sunset. Night = Sunset to Sunrise (Next Day? Or Prev?)
|
||||
// Basic check:
|
||||
|
||||
if t >= sunrise && t < sunset {
|
||||
// Day
|
||||
let day_len = sunset.signed_duration_since(sunrise).num_seconds() as f64;
|
||||
let elapsed = t.signed_duration_since(sunrise).num_seconds() as f64;
|
||||
let ratio = elapsed / day_len;
|
||||
TemporalTime {
|
||||
cycle: Cycle::Day,
|
||||
hour: ratio * 12.0
|
||||
}
|
||||
} else {
|
||||
// Night
|
||||
// If t >= sunset, it's the night AFTER this day.
|
||||
// If t < sunrise, it's the night BEFORE this day.
|
||||
// We need to know WHICH night duration to use.
|
||||
// Simplified: Use current day's Sunset -> Next Sunrise (approx) OR Prev Sunset -> Current Sunrise.
|
||||
|
||||
let cycle = Cycle::Night;
|
||||
|
||||
if t >= sunset {
|
||||
// Early Night (Sunset -> Midnight -> ...)
|
||||
// We need "Next Sunrise". For now, approximation or require it passed?
|
||||
// Let's approximate night length as 24h - DayLength.
|
||||
// Better: Just use ratio from Sunset.
|
||||
// But we need the END reference.
|
||||
// Let's assume Night Length is roughly symmetric or pass NextSunrise?
|
||||
// For visualization, passing just the "current active phase bounds" is safer.
|
||||
let day_len = sunset.signed_duration_since(sunrise).num_seconds() as f64;
|
||||
let night_len = 86400.0 - day_len; // Approx
|
||||
let elapsed = t.signed_duration_since(sunset).num_seconds() as f64;
|
||||
let ratio = elapsed / night_len;
|
||||
TemporalTime { cycle, hour: ratio * 12.0 }
|
||||
} else {
|
||||
// Late Night (Midnight -> Sunrise)
|
||||
// Time until Sunrise
|
||||
let day_len = sunset.signed_duration_since(sunrise).num_seconds() as f64;
|
||||
let night_len = 86400.0 - day_len; // Approx
|
||||
let remaining = sunrise.signed_duration_since(t).num_seconds() as f64;
|
||||
// Hour = 12 - (Remaining / Len * 12)
|
||||
let ratio = remaining / night_len;
|
||||
TemporalTime { cycle, hour: 12.0 - (ratio * 12.0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/components/clock.rs
Normal file
156
src/components/clock.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use leptos::*;
|
||||
use chrono::{Duration, Local, Datelike, Timelike};
|
||||
use crate::store::state::use_global_state;
|
||||
use crate::calc::{get_ezani_time, get_adil_time};
|
||||
|
||||
#[component]
|
||||
pub fn Clock() -> impl IntoView {
|
||||
let state = use_global_state();
|
||||
let time = state.current_time;
|
||||
let prayers = state.prayer_times;
|
||||
let prefs = state.preferences;
|
||||
let set_prefs = state.set_preferences;
|
||||
|
||||
// I18n helper
|
||||
let tr = move |key: &'static str| {
|
||||
let p = prefs.get();
|
||||
crate::i18n::t(key, &crate::i18n::I18nContext {
|
||||
lang: p.language,
|
||||
term: p.terminology
|
||||
})
|
||||
};
|
||||
|
||||
let set_mode = move |m: String| {
|
||||
set_prefs.update(|p| {
|
||||
p.time_mode = m;
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="w-full backdrop-blur-2xl bg-gradient-to-br from-white/80 to-white/60 border-2 border-white/60 shadow-[0_8px_32px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] rounded-[2.5rem] p-12 flex flex-col items-center transition-all duration-500 hover:shadow-[0_16px_48px_rgba(0,0,0,0.12),0_4px_16px_rgba(0,0,0,0.06)] hover:scale-[1.01] dark:bg-gradient-to-br dark:from-slate-900/50 dark:to-slate-800/30 dark:border-white/10 dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] dark:hover:shadow-[0_16px_48px_rgba(0,0,0,0.6)] group">
|
||||
|
||||
// Mode Selectors
|
||||
<div class="flex items-center gap-2 mb-8 p-2 bg-gradient-to-r from-slate-100/80 to-slate-50/60 dark:from-black/30 dark:to-black/20 rounded-full backdrop-blur-sm shadow-inner border border-slate-200/50 dark:border-white/5">
|
||||
{move || {
|
||||
let current = prefs.get().time_mode;
|
||||
let modes = vec![
|
||||
("modern", tr("modern_time")),
|
||||
("ezani", tr("ezani_time")),
|
||||
("adil", tr("adil_time"))
|
||||
];
|
||||
|
||||
modes.into_iter().map(|(key, label)| {
|
||||
let is_active = current == key;
|
||||
let active_class = if is_active {
|
||||
"bg-white text-slate-900 shadow-md transform scale-105 dark:bg-white/20 dark:text-white"
|
||||
} else {
|
||||
"text-slate-500 hover:text-slate-800 dark:text-white/40 dark:hover:text-white/80"
|
||||
};
|
||||
|
||||
view! {
|
||||
<button
|
||||
class=format!("px-6 py-2.5 rounded-full text-xs font-bold tracking-wide transition-all duration-300 transform hover:scale-105 {}", active_class)
|
||||
on:click=move |_| set_mode(key.to_string())
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
}
|
||||
}).collect_view()
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="text-6xl lg:text-7xl font-black tabular-nums tracking-tighter bg-clip-text text-transparent bg-gradient-to-b from-slate-800 to-slate-600 dark:from-white dark:to-white/70 drop-shadow-2xl">
|
||||
{move || {
|
||||
let now = time.get();
|
||||
let pt = prayers.get();
|
||||
let mode = prefs.get().time_mode;
|
||||
|
||||
if let Some(p) = pt {
|
||||
match mode.as_str() {
|
||||
"ezani" => {
|
||||
let (h, m, s) = get_ezani_time(now, p.maghrib);
|
||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||
},
|
||||
"adil" => {
|
||||
// Need next sunrise and prev sunset for Adil
|
||||
// Approximate for next sunrise/prev sunset if not stored?
|
||||
// Ideally we should calculate them properly in store or calc.
|
||||
// For now, simple +/- 24h fallback or use cached next_fajr logic
|
||||
let next_sunrise = p.sunrise + Duration::days(1);
|
||||
let prev_sunset = p.maghrib - Duration::days(1);
|
||||
|
||||
let adil = get_adil_time(now, p.sunrise, p.maghrib, next_sunrise, prev_sunset);
|
||||
format!("{:02}:{:02}:{:02}", adil.hour, adil.minute, adil.second)
|
||||
},
|
||||
_ => {
|
||||
// Force TR Offset
|
||||
let tr_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap();
|
||||
let local = now.with_timezone(&tr_offset);
|
||||
local.format("%H:%M:%S").to_string()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"Loading...".to_string()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-col items-center gap-2">
|
||||
// Date & Hijri
|
||||
{move || {
|
||||
let now = time.get().with_timezone(&Local);
|
||||
let (hy, hm, hd) = crate::calc::to_hijri(now.date_naive());
|
||||
let h_months = ["Muharrem", "Safer", "Rebiülevvel", "Rebiülahir", "Cemaziyelevvel", "Cemaziyelahir", "Recep", "Şaban", "Ramazan", "Şevval", "Zilkade", "Zilhicce"];
|
||||
|
||||
// Localized Gregorian Months
|
||||
let p = prefs.get();
|
||||
let is_tr = p.language == "tr";
|
||||
let g_months = if is_tr {
|
||||
["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"]
|
||||
} else {
|
||||
["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
};
|
||||
|
||||
let month_idx = (now.month0() as usize) % 12;
|
||||
let formatted_date = format!("{:02} {} {}", now.day(), g_months[month_idx], now.year());
|
||||
|
||||
let hm_idx = (hm as usize).saturating_sub(1) % 12;
|
||||
|
||||
view! {
|
||||
<div class="flex flex-col items-center">
|
||||
<span class="text-sm font-semibold text-slate-700 dark:text-white/80">{formatted_date}</span>
|
||||
<span class="text-xs font-medium tracking-wide uppercase text-slate-500 dark:text-white/60 mt-1">{format!("{} {} {}", hd, h_months[hm_idx], hy)}</span>
|
||||
<div class="flex items-center gap-2 mt-2 px-3 py-1.5 rounded-full bg-amber-100 dark:bg-white/5 border border-amber-200 dark:border-white/10">
|
||||
<span class="text-lg">"📍"</span>
|
||||
<span class="text-xs font-bold text-amber-700 dark:text-accent">"Istanbul"</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Sub-info (Next Prayer) replaced by Date/Loc above, moving prayer info below or removing
|
||||
// User requested Date/Loc. Let's keep Prayer info small or remove if crowded?
|
||||
// User didn't say remove, but space is limited.
|
||||
// Let's add prayer info as well below.
|
||||
<div class="mt-6 pt-4 border-t border-slate-200 dark:border-white/10 w-full flex justify-between text-xs font-semibold">
|
||||
{move || {
|
||||
if let Some(p) = prayers.get() {
|
||||
let local_fajr = p.fajr.with_timezone(&Local);
|
||||
let local_maghrib = p.maghrib.with_timezone(&Local);
|
||||
view! {
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-orange-600 dark:text-orange-300">{tr("fajr")}</span>
|
||||
<span class="text-slate-700 dark:text-white/80">{local_fajr.format("%H:%M").to_string()}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-orange-600 dark:text-orange-300">{tr("maghrib")}</span>
|
||||
<span class="text-slate-700 dark:text-white/80">{local_maghrib.format("%H:%M").to_string()}</span>
|
||||
</div>
|
||||
}.into_view()
|
||||
} else { view! {}.into_view() }
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
237
src/components/dial.rs
Normal file
237
src/components/dial.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use leptos::*;
|
||||
use chrono::{Duration, Timelike};
|
||||
use crate::store::state::use_global_state;
|
||||
use crate::calc::temporal::Cycle;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[component]
|
||||
pub fn Dial() -> impl IntoView {
|
||||
let state = use_global_state();
|
||||
let prayers = state.prayer_times;
|
||||
let prefs = state.preferences;
|
||||
let routines = state.routines;
|
||||
|
||||
let tr = move |key: &'static str| {
|
||||
let p = prefs.get();
|
||||
crate::i18n::t(key, &crate::i18n::I18nContext {
|
||||
lang: p.language,
|
||||
term: p.terminology
|
||||
})
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
let temporal_to_angle = |th: f64| -> f64 {
|
||||
(th / 12.0) * 360.0
|
||||
};
|
||||
|
||||
let polar_to_cartesian = |cx: f64, cy: f64, r: f64, angle_deg: f64| -> (f64, f64) {
|
||||
let rad = (angle_deg - 90.0) * PI / 180.0;
|
||||
(cx + r * rad.cos(), cy + r * rad.sin())
|
||||
};
|
||||
|
||||
// Use a regular function or simple closure returning View, not impl Trait
|
||||
let describe_arc = move |start_th: f64, end_th: f64, radius: f64, color: String, width: String| -> View {
|
||||
let start_angle = temporal_to_angle(start_th);
|
||||
let end_angle = temporal_to_angle(end_th);
|
||||
|
||||
let start = polar_to_cartesian(50.0, 50.0, radius, start_angle);
|
||||
let end = polar_to_cartesian(50.0, 50.0, radius, end_angle);
|
||||
|
||||
let diff = (end_angle - start_angle + 360.0) % 360.0;
|
||||
let large_arc = if diff <= 180.0 { "0" } else { "1" };
|
||||
|
||||
let d = format!("M {} {} A {} {} 0 {} 1 {} {}",
|
||||
start.0, start.1, radius, radius, large_arc, end.0, end.1);
|
||||
|
||||
view! { <path d=d fill="none" stroke=color stroke-width=width stroke-linecap="round" /> }.into_view()
|
||||
};
|
||||
|
||||
// Render Bezel Ticks
|
||||
let render_ticks = move || {
|
||||
(0..12).map(|i| {
|
||||
let angle = (i as f64 / 12.0) * 360.0;
|
||||
let (x1, y1) = polar_to_cartesian(50.0, 50.0, 42.0, angle);
|
||||
let (x2, y2) = polar_to_cartesian(50.0, 50.0, 48.0, angle); // Tick length
|
||||
view! { <line x1=x1 y1=y1 x2=x2 y2=y2 stroke="rgba(255,255,255,0.2)" stroke-width="0.5" /> }
|
||||
}).collect_view()
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 w-full p-4">
|
||||
{move || {
|
||||
if let Some(p) = prayers.get() {
|
||||
// 1. Helper (returns Cycle, HourValue, SecondAngle)
|
||||
fn get_dial_coords(t: chrono::DateTime<chrono::Utc>, p: &crate::calc::PrayerTimes, mode: &str) -> (Cycle, f64, f64) {
|
||||
let temp = crate::calc::temporal::get_temporal_time(t, p.sunrise, p.maghrib);
|
||||
let cycle = temp.cycle;
|
||||
|
||||
match mode {
|
||||
"modern" => {
|
||||
let local = t.with_timezone(&chrono::Local);
|
||||
let h = (local.hour() % 12) as f64 + (local.minute() as f64 / 60.0);
|
||||
let s_angle = (local.second() as f64 / 60.0) * 360.0;
|
||||
(cycle, h, s_angle)
|
||||
},
|
||||
"ezani" => {
|
||||
let (h, m, s) = crate::calc::get_ezani_time(t, p.maghrib);
|
||||
let h_val = h as f64 + (m as f64 / 60.0);
|
||||
// Use Ezani seconds! (Relative to Sunset)
|
||||
let s_angle = (s as f64 / 60.0) * 360.0;
|
||||
(cycle, h_val, s_angle)
|
||||
},
|
||||
_ => {
|
||||
// Adil (Temporal): Seconds depend on variable hour length
|
||||
let h_val = temp.hour;
|
||||
// h_val is 0.0-12.0.
|
||||
// Minute = h_val * 60.
|
||||
// Second fractional = fract(Minute)
|
||||
let s_angle = (h_val * 60.0).fract() * 360.0;
|
||||
(cycle, h_val, s_angle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. State & Mode
|
||||
let now = state.current_time.get();
|
||||
let mode = prefs.get().time_mode;
|
||||
let (now_cycle, now_h, now_s_angle) = get_dial_coords(now, &p, mode.as_str());
|
||||
let is_day_now = now_cycle == Cycle::Day;
|
||||
|
||||
let opacity_day = if is_day_now { "opacity-100" } else { "opacity-60" };
|
||||
let opacity_night = if !is_day_now { "opacity-100" } else { "opacity-60" };
|
||||
|
||||
// 3. Labels Logic
|
||||
let modern_labels = ["12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"];
|
||||
let temporal_labels = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"];
|
||||
let use_modern = mode == "modern";
|
||||
|
||||
let render_labels = move || {
|
||||
(0..12).map(|i| {
|
||||
let angle = (i as f64 / 12.0) * 360.0;
|
||||
let (x, y) = polar_to_cartesian(50.0, 50.0, 38.0, angle);
|
||||
let text = if use_modern { modern_labels[i] } else { temporal_labels[i] };
|
||||
|
||||
view! {
|
||||
<text x=x y=y
|
||||
text-anchor="middle" dominant-baseline="middle"
|
||||
font-size="3.5" fill="rgba(255,255,255,0.7)" font-family="monospace"
|
||||
>{text}</text>
|
||||
}
|
||||
}).collect_view()
|
||||
};
|
||||
|
||||
// Clones for Night Closure
|
||||
let p_night = p.clone();
|
||||
let mode_night = mode.clone();
|
||||
|
||||
view! {
|
||||
// --- DAY CHART ---
|
||||
<div class=format!("relative transition-all duration-500 {}", opacity_day)>
|
||||
<h4 class="text-center text-amber-600 dark:text-amber-300 font-bold mb-4 tracking-wide text-sm">{move || tr("sunrise_secular")} " - " {move || tr("maghrib_secular")}</h4>
|
||||
<svg viewBox="0 0 100 100" class="w-full h-auto drop-shadow-2xl">
|
||||
<circle cx="50" cy="50" r="45" fill="#1e293b" stroke="#334155" stroke-width="1" />
|
||||
{render_ticks()}
|
||||
{render_labels()}
|
||||
|
||||
{move || {
|
||||
routines.get().into_iter().map(|act| {
|
||||
let act_start = act.start_time;
|
||||
let act_end = act.start_time + Duration::minutes(act.duration_minutes as i64);
|
||||
|
||||
let (s_cycle, s_h, _) = get_dial_coords(act_start, &p, mode.as_str());
|
||||
let (e_cycle, e_h, _) = get_dial_coords(act_end, &p, mode.as_str());
|
||||
|
||||
if s_cycle == Cycle::Day {
|
||||
let final_end = if e_cycle == Cycle::Day { e_h } else {
|
||||
if mode == "modern" { e_h } else { 12.0 }
|
||||
};
|
||||
describe_arc(s_h, final_end, 35.0, act.color, "4".to_string())
|
||||
} else {
|
||||
view! { <g></g> }.into_view()
|
||||
}
|
||||
}).collect_view()
|
||||
}}
|
||||
|
||||
// Needles
|
||||
{move || {
|
||||
if is_day_now {
|
||||
// Hour Hand
|
||||
let h_angle = temporal_to_angle(now_h);
|
||||
let (hx, hy) = polar_to_cartesian(50.0, 50.0, 35.0, h_angle);
|
||||
|
||||
// Second Hand
|
||||
let (sx, sy) = polar_to_cartesian(50.0, 50.0, 42.0, now_s_angle);
|
||||
|
||||
view! {
|
||||
<g>
|
||||
// Second Hand
|
||||
<line x1="50" y1="50" x2=sx y2=sy stroke="#fca5a5" stroke-width="1" stroke-linecap="round" />
|
||||
// Hour Hand
|
||||
<line x1="50" y1="50" x2=hx y2=hy stroke="#fbbf24" stroke-width="3" stroke-linecap="round" />
|
||||
// Center Cap
|
||||
<circle cx="50" cy="50" r="3" fill="#fbbf24" />
|
||||
</g>
|
||||
}.into_view()
|
||||
} else { view! {<g></g>}.into_view() }
|
||||
}}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
// --- NIGHT CHART ---
|
||||
<div class=format!("relative transition-all duration-500 {}", opacity_night)>
|
||||
<h4 class="text-center text-indigo-600 dark:text-indigo-300 font-bold mb-4 tracking-wide text-sm">{move || tr("maghrib_secular")} " - " {move || tr("sunrise_secular")}</h4>
|
||||
<svg viewBox="0 0 100 100" class="w-full h-auto drop-shadow-2xl">
|
||||
<circle cx="50" cy="50" r="45" fill="#0f172a" stroke="#334155" stroke-width="1" />
|
||||
{render_ticks()}
|
||||
{render_labels()}
|
||||
|
||||
{move || {
|
||||
routines.get().into_iter().map(|act| {
|
||||
let act_start = act.start_time;
|
||||
let act_end = act.start_time + Duration::minutes(act.duration_minutes as i64);
|
||||
let (s_cycle, s_h, _) = get_dial_coords(act_start, &p_night, mode_night.as_str());
|
||||
let (e_cycle, e_h, _) = get_dial_coords(act_end, &p_night, mode_night.as_str());
|
||||
|
||||
if s_cycle == Cycle::Night {
|
||||
let final_end = if e_cycle == Cycle::Night { e_h } else {
|
||||
if mode_night == "modern" { e_h } else { 12.0 }
|
||||
};
|
||||
describe_arc(s_h, final_end, 35.0, act.color, "4".to_string())
|
||||
} else {
|
||||
view! { <g></g> }.into_view()
|
||||
}
|
||||
}).collect_view()
|
||||
}}
|
||||
|
||||
// Needles
|
||||
{move || {
|
||||
if !is_day_now {
|
||||
// Hour Hand
|
||||
let h_angle = temporal_to_angle(now_h);
|
||||
let (hx, hy) = polar_to_cartesian(50.0, 50.0, 35.0, h_angle);
|
||||
|
||||
// Second Hand
|
||||
let (sx, sy) = polar_to_cartesian(50.0, 50.0, 42.0, now_s_angle);
|
||||
|
||||
view! {
|
||||
<g>
|
||||
// Second Hand
|
||||
<line x1="50" y1="50" x2=sx y2=sy stroke="#818cf8" stroke-width="1" stroke-linecap="round" />
|
||||
// Hour Hand
|
||||
<line x1="50" y1="50" x2=hx y2=hy stroke="#6366f1" stroke-width="3" stroke-linecap="round" />
|
||||
// Center Cap
|
||||
<circle cx="50" cy="50" r="3" fill="#6366f1" />
|
||||
</g>
|
||||
}.into_view()
|
||||
} else { view! {<g></g>}.into_view() }
|
||||
}}
|
||||
</svg>
|
||||
</div>
|
||||
}.into_view()
|
||||
} else {
|
||||
view! { <div>"Loading..."</div> }.into_view()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
64
src/components/header.rs
Normal file
64
src/components/header.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use leptos::*;
|
||||
use crate::store::state::use_global_state;
|
||||
use crate::i18n::{t, I18nContext};
|
||||
|
||||
#[component]
|
||||
pub fn Header() -> impl IntoView {
|
||||
let state = use_global_state();
|
||||
let prefs = state.preferences;
|
||||
let set_prefs = state.set_preferences;
|
||||
|
||||
let tr = move |key: &'static str| {
|
||||
let p = prefs.get();
|
||||
t(key, &I18nContext {
|
||||
lang: p.language,
|
||||
term: p.terminology
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<header class="w-full mb-6 p-4 flex justify-between items-center backdrop-blur-md bg-white/10 border border-white/20 rounded-2xl shadow-xl text-white">
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-2xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-amber-300 to-orange-500">
|
||||
{move || tr("app_title")}
|
||||
</h1>
|
||||
<p class="text-xs text-gray-300 opacity-80 tracking-wide font-light">{move || tr("app_subtitle")}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 items-center">
|
||||
// Terminology Toggle
|
||||
<button class="p-2 hover:bg-white/10 rounded-full transition-all active:scale-95"
|
||||
title="Toggle Terminology (Classic/Secular)"
|
||||
on:click=move |_| {
|
||||
set_prefs.update(|p| {
|
||||
p.terminology = if p.terminology == "classic" { "secular".into() } else { "classic".into() };
|
||||
});
|
||||
}>
|
||||
{move || if prefs.get().terminology == "classic" { "📜" } else { "🧠" }}
|
||||
</button>
|
||||
|
||||
// Language Switcher
|
||||
<button class="p-2 hover:bg-white/10 rounded-full transition-all active:scale-95"
|
||||
title="Switch Language"
|
||||
on:click=move |_| {
|
||||
set_prefs.update(|p| {
|
||||
p.language = if p.language == "tr" { "en".into() } else { "tr".into() };
|
||||
});
|
||||
}>
|
||||
{move || if prefs.get().language == "tr" { "🇹🇷" } else { "🇬🇧" }}
|
||||
</button>
|
||||
|
||||
// Theme Toggle
|
||||
<button class="p-2 hover:bg-white/10 rounded-full transition-all active:scale-95"
|
||||
title="Toggle Theme"
|
||||
on:click=move |_| {
|
||||
set_prefs.update(|p| {
|
||||
p.theme = if p.theme == "dark" { "light".into() } else { "dark".into() };
|
||||
});
|
||||
}>
|
||||
{move || if prefs.get().theme == "dark" { "🌙" } else { "☀️" }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
}
|
||||
}
|
||||
100
src/components/landing.rs
Normal file
100
src/components/landing.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use leptos::*;
|
||||
use crate::store::state::use_global_state;
|
||||
|
||||
#[component]
|
||||
pub fn LandingSection() -> impl IntoView {
|
||||
let state = use_global_state();
|
||||
let prefs = state.preferences;
|
||||
|
||||
let tr = move |key: &'static str| {
|
||||
let p = prefs.get();
|
||||
crate::i18n::t(key, &crate::i18n::I18nContext {
|
||||
lang: p.language,
|
||||
term: p.terminology
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="w-full mt-16">
|
||||
// Time Systems Explanation Section
|
||||
<div class="max-w-4xl mx-auto mb-12 p-8 backdrop-blur-xl bg-gradient-to-br from-white/80 to-white/60 dark:from-slate-900/50 dark:to-slate-800/30 border-2 border-white/60 dark:border-white/10 rounded-[2rem] shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)]">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<span class="text-3xl">"⏰"</span>
|
||||
<h2 class="text-2xl font-bold text-slate-800 dark:text-white">{move || tr("time_systems_title")}</h2>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
// Modern Time
|
||||
<div class="p-6 bg-blue-50 dark:bg-blue-900/20 rounded-2xl border-2 border-blue-200 dark:border-blue-800">
|
||||
<h3 class="text-lg font-bold text-blue-700 dark:text-blue-300 mb-3 flex items-center gap-2">
|
||||
<span>"🌐"</span>
|
||||
{move || tr("modern_time")}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
|
||||
{move || tr("modern_time_desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
// Ezani Time
|
||||
<div class="p-6 bg-purple-50 dark:bg-purple-900/20 rounded-2xl border-2 border-purple-200 dark:border-purple-800">
|
||||
<h3 class="text-lg font-bold text-purple-700 dark:text-purple-300 mb-3 flex items-center gap-2">
|
||||
<span>"🌙"</span>
|
||||
{move || tr("ezani_time")}
|
||||
</h3>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
|
||||
{move || tr("ezani_time_desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
// Adil Time (Highlighted as novel proposal)
|
||||
<div class="p-6 bg-gradient-to-br from-amber-50 to-yellow-50 dark:from-amber-900/20 dark:to-yellow-900/20 rounded-2xl border-2 border-amber-300 dark:border-amber-700 shadow-[0_0_24px_rgba(251,191,36,0.2)]">
|
||||
<h3 class="text-lg font-bold text-amber-700 dark:text-amber-300 mb-3 flex items-center gap-2">
|
||||
<span>"✨"</span>
|
||||
{move || tr("adil_time")}
|
||||
<span class="ml-auto px-2 py-0.5 bg-amber-200 dark:bg-amber-800 text-[10px] font-bold rounded-full text-amber-800 dark:text-amber-200">"NEW"</span>
|
||||
</h3>
|
||||
<p class="text-sm text-slate-700 dark:text-slate-300 leading-relaxed">
|
||||
{move || tr("adil_time_desc")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<footer class="w-full p-8 border-t border-slate-200 dark:border-white/10 text-center">
|
||||
<div class="max-w-2xl mx-auto flex flex-col gap-6">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<span class="text-2xl">"🌿"</span>
|
||||
<h2 class="text-xl font-bold text-slate-800 dark:text-white">{move || tr("landing_title")}</h2>
|
||||
</div>
|
||||
|
||||
<p class="leading-relaxed text-slate-600 dark:text-gray-400 transition-opacity duration-300">
|
||||
{move || tr("landing_desc")}
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm mt-4">
|
||||
<div class="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/5">
|
||||
<span class="block text-2xl mb-2">"🚀"</span>
|
||||
<h3 class="font-bold text-slate-800 dark:text-white mb-1">{move || tr("feat_perf_title")}</h3>
|
||||
<p class="text-slate-600 dark:text-gray-400">{move || tr("feat_perf_desc")}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/5">
|
||||
<span class="block text-2xl mb-2">"🌍"</span>
|
||||
<h3 class="font-bold text-slate-800 dark:text-white mb-1">{move || tr("feat_univ_title")}</h3>
|
||||
<p class="text-slate-600 dark:text-gray-400">{move || tr("feat_univ_desc")}</p>
|
||||
</div>
|
||||
<div class="p-4 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/5">
|
||||
<span class="block text-2xl mb-2">"🔒"</span>
|
||||
<h3 class="font-bold text-slate-800 dark:text-white mb-1">{move || tr("feat_priv_title")}</h3>
|
||||
<p class="text-slate-600 dark:text-gray-400">{move || tr("feat_priv_desc")}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-xs text-slate-400 dark:text-gray-500">
|
||||
"© 2025 Konstantiniyye Studio. Open Source (MIT)."
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
}
|
||||
7
src/components/mod.rs
Normal file
7
src/components/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod clock;
|
||||
pub mod dial;
|
||||
pub mod settings;
|
||||
pub mod header;
|
||||
pub mod routine_manager;
|
||||
pub mod prayer_list;
|
||||
pub mod landing;
|
||||
101
src/components/prayer_list.rs
Normal file
101
src/components/prayer_list.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use leptos::*;
|
||||
use chrono::{DateTime, Utc, Local};
|
||||
use crate::store::state::use_global_state;
|
||||
|
||||
#[component]
|
||||
pub fn PrayerList() -> impl IntoView {
|
||||
let state = use_global_state();
|
||||
let prayers = state.prayer_times;
|
||||
let prefs = state.preferences;
|
||||
|
||||
let tr = move |key: &'static str| {
|
||||
let p = prefs.get();
|
||||
crate::i18n::t(key, &crate::i18n::I18nContext {
|
||||
lang: p.language,
|
||||
term: p.terminology
|
||||
})
|
||||
};
|
||||
|
||||
// Helper to format time based on selected mode
|
||||
let format_time = move |t: DateTime<Utc>, p: &crate::calc::PrayerTimes| -> String {
|
||||
let mode = prefs.get().time_mode;
|
||||
match mode.as_str() {
|
||||
"ezani" => {
|
||||
let (h, m, _) = crate::calc::get_ezani_time(t, p.maghrib);
|
||||
// Ezani usually 00:00 at Sunset. 12h format 0-11.
|
||||
format!("{:02}:{:02}", h, m)
|
||||
},
|
||||
"adil" => {
|
||||
// Adil: Need extra params (Next Sunrise/Prev Sunset)
|
||||
// Using approximations or recalculations
|
||||
let next_sunrise = p.sunrise + chrono::Duration::days(1);
|
||||
let prev_sunset = p.maghrib - chrono::Duration::days(1);
|
||||
let adil = crate::calc::get_adil_time(t, p.sunrise, p.maghrib, next_sunrise, prev_sunset);
|
||||
format!("{:02}:{:02}", adil.hour, adil.minute)
|
||||
},
|
||||
_ => {
|
||||
// Force TR Timezone (UTC+3) for Istanbul
|
||||
let tr_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap();
|
||||
t.with_timezone(&tr_offset).format("%H:%M").to_string()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Modern formatter
|
||||
fn format_time_modern(t: DateTime<Utc>) -> String {
|
||||
let tr_offset = chrono::FixedOffset::east_opt(3 * 3600).unwrap();
|
||||
t.with_timezone(&tr_offset).format("%H:%M").to_string()
|
||||
}
|
||||
|
||||
view! {
|
||||
<div class="w-full backdrop-blur-2xl bg-gradient-to-br from-white/80 to-white/60 border-2 border-white/60 dark:from-slate-900/50 dark:to-slate-800/30 dark:border-white/10 rounded-[2rem] p-8 shadow-[0_8px_32px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] hover:shadow-[0_16px_48px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_16px_48px_rgba(0,0,0,0.6)] transition-all duration-300">
|
||||
<h3 class="text-xl font-bold mb-6 text-slate-800 dark:text-white/90 tracking-wide flex items-center gap-2">
|
||||
<span class="text-2xl">"☀️"</span>
|
||||
{move || tr("prayer_times")}
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{move || {
|
||||
if let Some(p) = prayers.get() {
|
||||
let list = vec![
|
||||
("fajr", p.fajr, "text-white"),
|
||||
("sunrise", p.sunrise, "text-orange-300"),
|
||||
("ishraq", p.ishraq, "text-red-400"),
|
||||
("duha", p.ishraq + chrono::Duration::hours(1), "text-yellow-200"),
|
||||
("dhuhr", p.dhuhr, "text-white"),
|
||||
("istiva", p.istiva, "text-red-400"),
|
||||
("asr", p.asr, "text-white"),
|
||||
("isfirar", p.isfirar, "text-red-400"),
|
||||
("maghrib", p.maghrib, "text-orange-300"),
|
||||
("isha", p.isha, "text-white"),
|
||||
("teheccud", p.teheccud, "text-purple-300"),
|
||||
("seher", p.seher, "text-purple-300"),
|
||||
];
|
||||
|
||||
list.into_iter().map(|(key, t, color)| {
|
||||
let time_str = format_time(t, &p);
|
||||
// Map dark mode colors to light mode equivalents
|
||||
let light_color = match color {
|
||||
"text-white" => "text-slate-700 dark:text-white",
|
||||
"text-orange-300" => "text-orange-600 dark:text-orange-300",
|
||||
"text-red-400" => "text-red-600 dark:text-red-400",
|
||||
"text-yellow-200" => "text-yellow-700 dark:text-yellow-200",
|
||||
"text-purple-300" => "text-purple-600 dark:text-purple-300",
|
||||
_ => "text-slate-700 dark:text-white"
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex justify-between items-center p-3 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 transition-all">
|
||||
<span class=format!("font-semibold text-sm {}", light_color)>{tr(key)}</span>
|
||||
<span class="font-mono font-bold text-slate-800 dark:text-white/90">{time_str}</span>
|
||||
</div>
|
||||
}
|
||||
}).collect_view()
|
||||
} else {
|
||||
view! { <p class="col-span-2 text-center text-gray-400">"Loading..."</p> }.into_view()
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
282
src/components/routine_manager.rs
Normal file
282
src/components/routine_manager.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
use leptos::*;
|
||||
use crate::store::state::{use_global_state, Activity};
|
||||
use chrono::{Local, TimeZone, Utc, NaiveTime, Duration, Timelike};
|
||||
|
||||
#[component]
|
||||
pub fn RoutineManager() -> impl IntoView {
|
||||
let state = use_global_state();
|
||||
let routines = state.routines;
|
||||
let set_routines = state.set_routines;
|
||||
let prefs = state.preferences;
|
||||
let prayers = state.prayer_times;
|
||||
|
||||
// I18n helper
|
||||
let tr = move |key: &'static str| {
|
||||
let p = prefs.get();
|
||||
crate::i18n::t(key, &crate::i18n::I18nContext {
|
||||
lang: p.language,
|
||||
term: p.terminology
|
||||
})
|
||||
};
|
||||
|
||||
// Form Signals
|
||||
let (name, set_name) = create_signal("".to_string());
|
||||
let (time_str, set_time_str) = create_signal("12:00".to_string());
|
||||
let (duration, set_duration) = create_signal("60".to_string());
|
||||
let (color, set_color) = create_signal("#4ade80".to_string());
|
||||
|
||||
// Editing Mode
|
||||
let (is_editing, set_is_editing) = create_signal(false);
|
||||
let (edit_target_time, set_edit_target_time) = create_signal(Utc::now());
|
||||
|
||||
let add_routine = move |_| {
|
||||
let n = name.get();
|
||||
let t_s = time_str.get();
|
||||
let d_s = duration.get();
|
||||
let c = color.get();
|
||||
|
||||
if n.is_empty() { return; }
|
||||
|
||||
// Parse time (Assuming Local Time for manual input for now)
|
||||
if let Ok(nt) = NaiveTime::parse_from_str(&t_s, "%H:%M") {
|
||||
let now = Local::now();
|
||||
let today = now.date_naive();
|
||||
let dt_local = today.and_time(nt);
|
||||
|
||||
// Time Mode Logic
|
||||
let dt_utc = if prefs.get().time_mode == "ezani" {
|
||||
if let Some(p) = prayers.get() {
|
||||
// Ezani Input: 00:00 is Maghrib.
|
||||
// Treat user input HH:MM as duration since Maghrib.
|
||||
let dur = Duration::hours(nt.hour() as i64) + Duration::minutes(nt.minute() as i64);
|
||||
// Maghrib date might be today or yesterday?
|
||||
// Let's assume Maghrib of "today" (closest).
|
||||
// Actually, if it's 00:00 input, it matches Maghrib.
|
||||
p.maghrib + dur
|
||||
} else {
|
||||
Local.from_local_datetime(&dt_local).unwrap().with_timezone(&Utc)
|
||||
}
|
||||
} else {
|
||||
Local.from_local_datetime(&dt_local).unwrap().with_timezone(&Utc)
|
||||
};
|
||||
|
||||
let d_u32 = d_s.parse::<u32>().unwrap_or(30);
|
||||
|
||||
let new_activity = Activity {
|
||||
name: n,
|
||||
start_time: dt_utc,
|
||||
duration_minutes: d_u32,
|
||||
color: c,
|
||||
};
|
||||
|
||||
if is_editing.get() {
|
||||
let target = edit_target_time.get();
|
||||
set_routines.update(|r| {
|
||||
if let Some(idx) = r.iter().position(|a| a.start_time == target) {
|
||||
r[idx] = new_activity;
|
||||
}
|
||||
});
|
||||
set_is_editing.set(false);
|
||||
} else {
|
||||
set_routines.update(|r| r.push(new_activity));
|
||||
}
|
||||
|
||||
// Reset form
|
||||
set_name.set("".into());
|
||||
}
|
||||
};
|
||||
|
||||
let start_edit = move |activity: Activity| {
|
||||
set_name.set(activity.name);
|
||||
set_duration.set(activity.duration_minutes.to_string());
|
||||
set_color.set(activity.color);
|
||||
let local_time = activity.start_time.with_timezone(&Local);
|
||||
set_time_str.set(local_time.format("%H:%M").to_string());
|
||||
|
||||
set_edit_target_time.set(activity.start_time);
|
||||
set_is_editing.set(true);
|
||||
};
|
||||
|
||||
// --- TEMPLATES ---
|
||||
let add_prayer_routines = move |_| {
|
||||
if let Some(p) = prayers.get() {
|
||||
let list = vec![
|
||||
(tr("fajr"), p.fajr, 30, "#fbbf24"),
|
||||
(tr("dhuhr"), p.dhuhr, 20, "#fbbf24"),
|
||||
(tr("asr"), p.asr, 20, "#fbbf24"),
|
||||
(tr("maghrib"), p.maghrib, 20, "#fbbf24"),
|
||||
(tr("isha"), p.isha, 30, "#fbbf24"),
|
||||
];
|
||||
set_routines.update(|r| {
|
||||
for (n, t, d, c) in list {
|
||||
r.push(Activity { name: n, start_time: t, duration_minutes: d, color: c.into() });
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let add_sleep_template = move |_| {
|
||||
if let Some(p) = prayers.get() {
|
||||
set_routines.update(|r| {
|
||||
// Kaylule (Nap): 30m before Dhuhr
|
||||
r.push(Activity {
|
||||
name: "Kaylule (Nap)".into(),
|
||||
start_time: p.dhuhr - Duration::minutes(45), // 15m prep + 30m nap
|
||||
duration_minutes: 30,
|
||||
color: "#60a5fa".into()
|
||||
});
|
||||
|
||||
// Night Sleep: Isha + 1h
|
||||
r.push(Activity {
|
||||
name: "Night Sleep".into(),
|
||||
start_time: p.isha + Duration::hours(1),
|
||||
duration_minutes: 420, // 7h
|
||||
color: "#1e3a8a".into()
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let add_food_template = move |_| {
|
||||
if let Some(p) = prayers.get() {
|
||||
set_routines.update(|r| {
|
||||
// Seher: imsak - 45m
|
||||
r.push(Activity {
|
||||
name: "Seher Meal".into(),
|
||||
start_time: p.fajr - Duration::minutes(45),
|
||||
duration_minutes: 30,
|
||||
color: "#10b981".into()
|
||||
});
|
||||
// Dinner: Maghrib
|
||||
r.push(Activity {
|
||||
name: "Iftar/Dinner".into(),
|
||||
start_time: p.maghrib,
|
||||
duration_minutes: 45,
|
||||
color: "#10b981".into()
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let btn_class = "px-3 py-1.5 bg-slate-100 hover:bg-slate-200 border border-slate-300 dark:bg-white/5 dark:hover:bg-white/10 dark:border-white/10 rounded-lg text-xs font-semibold text-slate-700 dark:text-white transition-colors";
|
||||
|
||||
view! {
|
||||
<div class="w-full backdrop-blur-2xl bg-gradient-to-br from-white/80 to-white/60 border-2 border-white/60 dark:from-slate-900/50 dark:to-slate-800/30 dark:border-white/10 rounded-[2rem] p-8 shadow-[0_8px_32px_rgba(0,0,0,0.08),0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] hover:shadow-[0_16px_48px_rgba(0,0,0,0.12)] dark:hover:shadow-[0_16px_48px_rgba(0,0,0,0.6)] transition-all duration-300">
|
||||
<h3 class="text-xl font-bold mb-6 text-slate-800 dark:text-white/90 tracking-wide flex items-center gap-2">
|
||||
<span class="text-2xl">"⏰"</span>
|
||||
{move || tr("routines")}
|
||||
</h3>
|
||||
|
||||
// Quick Actions
|
||||
<div class="flex flex-wrap gap-2 mb-6">
|
||||
<button class=btn_class on:click=add_prayer_routines>"+ " {move || tr("quick_pray")}</button>
|
||||
<button class=btn_class on:click=add_sleep_template>"+ " {move || tr("sleep_temp")}</button>
|
||||
<button class=btn_class on:click=add_food_template>"+ " {move || tr("food_temp")}</button>
|
||||
</div>
|
||||
|
||||
// List
|
||||
<div class="mb-6 space-y-2 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<For
|
||||
each=move || {
|
||||
let mut r = routines.get();
|
||||
r.sort_by_key(|a| a.start_time);
|
||||
r
|
||||
}
|
||||
key=|activity| activity.start_time.timestamp() // Simple key
|
||||
children=move |activity| {
|
||||
let local_time = activity.start_time.with_timezone(&Local);
|
||||
let name_for_del = activity.name.clone();
|
||||
let time_for_del = activity.start_time;
|
||||
let act_for_edit = activity.clone();
|
||||
|
||||
let formatted_time = if let Some(p) = prayers.get() {
|
||||
let mode = prefs.get().time_mode;
|
||||
match mode.as_str() {
|
||||
"ezani" => {
|
||||
let (h, m, _) = crate::calc::get_ezani_time(activity.start_time, p.maghrib);
|
||||
format!("{:02}:{:02}", h, m)
|
||||
},
|
||||
"adil" => {
|
||||
let next_sunrise = p.sunrise + Duration::days(1);
|
||||
let prev_sunset = p.maghrib - Duration::days(1);
|
||||
let adil = crate::calc::get_adil_time(activity.start_time, p.sunrise, p.maghrib, next_sunrise, prev_sunset);
|
||||
format!("{:02}:{:02}", adil.hour, adil.minute)
|
||||
},
|
||||
_ => local_time.format("%H:%M").to_string()
|
||||
}
|
||||
} else {
|
||||
local_time.format("%H:%M").to_string()
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex justify-between items-center p-3 bg-slate-50 dark:bg-white/5 rounded-xl border border-slate-200 dark:border-white/10 hover:bg-slate-100 dark:hover:bg-white/10 transition-all group cursor-pointer"
|
||||
on:click=move |_| start_edit(act_for_edit.clone())
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-3 h-3 rounded-full shadow-md" style=format!("background-color: {}", activity.color)></div>
|
||||
<span class="font-semibold text-slate-800 dark:text-white/90">{activity.name}</span>
|
||||
<span class="text-xs text-slate-500 dark:text-gray-400">
|
||||
{format!(" @ {} ({}m)", formatted_time, activity.duration_minutes)}
|
||||
</span>
|
||||
</div>
|
||||
<button class="text-red-400 hover:text-red-300 hover:bg-red-400/10 p-1.5 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Delete"
|
||||
on:click=move |ev| {
|
||||
ev.stop_propagation();
|
||||
let n = name_for_del.clone();
|
||||
set_routines.update(|r| r.retain(|a| a.name != n || a.start_time != time_for_del));
|
||||
}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
/>
|
||||
{move || if routines.get().is_empty() {
|
||||
view! { <p class="text-center text-sm text-slate-500 dark:text-gray-500 py-4">{move || tr("no_routines")}</p> }.into_view()
|
||||
} else {
|
||||
view! {}.into_view()
|
||||
}}
|
||||
</div>
|
||||
|
||||
// Add Form (Simplified)
|
||||
<div class="flex flex-col gap-4 pt-4 border-t border-slate-200 dark:border-white/10">
|
||||
<input type="text" placeholder=move || tr("activity_name_placeholder")
|
||||
prop:value=name
|
||||
on:input=move |ev| set_name.set(event_target_value(&ev))
|
||||
class="w-full bg-slate-50 dark:bg-white/5 border border-slate-300 dark:border-white/10 rounded-lg p-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent transition-all"
|
||||
/>
|
||||
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<input type="time" title="Local Time"
|
||||
prop:value=time_str
|
||||
on:input=move |ev| set_time_str.set(event_target_value(&ev))
|
||||
class="w-full bg-slate-50 dark:bg-white/5 border border-slate-300 dark:border-white/10 rounded-lg p-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<input type="number" placeholder="Min"
|
||||
prop:value=duration
|
||||
on:input=move |ev| set_duration.set(event_target_value(&ev))
|
||||
class="w-full bg-slate-50 dark:bg-white/5 border border-slate-300 dark:border-white/10 rounded-lg p-3 text-slate-900 dark:text-white placeholder-slate-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-accent transition-all"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<input type="color"
|
||||
prop:value=color
|
||||
on:input=move |ev| set_color.set(event_target_value(&ev))
|
||||
class="h-[46px] w-[50px] rounded-lg cursor-pointer bg-transparent border-0 p-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="w-full py-4 bg-gradient-to-r from-amber-400 to-yellow-400 hover:from-amber-500 hover:to-yellow-500 text-slate-900 font-bold rounded-2xl transition-all duration-300 shadow-[0_4px_12px_rgba(251,191,36,0.3)] hover:shadow-[0_8px_24px_rgba(251,191,36,0.4)] active:scale-[0.98] transform"
|
||||
on:click=add_routine>
|
||||
{move || if is_editing.get() { "Update Routine".to_string() } else { tr("add_routine") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
63
src/components/settings.rs
Normal file
63
src/components/settings.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use leptos::*;
|
||||
use crate::store::state::use_global_state;
|
||||
use crate::i18n::{t, I18nContext};
|
||||
|
||||
#[component]
|
||||
pub fn Settings() -> impl IntoView {
|
||||
let state = use_global_state();
|
||||
let prefs = state.preferences;
|
||||
let set_prefs = state.set_preferences;
|
||||
|
||||
let tr = move |key: &'static str| {
|
||||
let p = prefs.get();
|
||||
t(key, &I18nContext {
|
||||
lang: p.language,
|
||||
term: p.terminology
|
||||
})
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="glass-panel" style="margin: 20px; padding: 20px;">
|
||||
<h3>{move || tr("settings")}</h3>
|
||||
|
||||
// Theme and Language moved to Header
|
||||
|
||||
<div class="setting-group" style="margin-bottom: 20px;">
|
||||
<label style="display:block; margin-bottom:5px;">{move || tr("terminology")}</label>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button class="btn-primary"
|
||||
on:click=move |_| set_prefs.update(|p| p.terminology = "classic".into())
|
||||
style=move || if prefs.get().terminology == "classic" { "opacity: 1; border: 2px solid white;" } else { "opacity: 0.5" }>
|
||||
"Klasik (Classic)"
|
||||
</button>
|
||||
<button class="btn-primary"
|
||||
on:click=move |_| set_prefs.update(|p| p.terminology = "secular".into())
|
||||
style=move || if prefs.get().terminology == "secular" { "opacity: 1; border: 2px solid white;" } else { "opacity: 0.5" }>
|
||||
"Seküler (Secular)"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-group" style="margin-bottom: 20px;">
|
||||
<label style="display:block; margin-bottom:5px;">{move || tr("time_mode")}</label>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button class="btn-primary"
|
||||
on:click=move |_| set_prefs.update(|p| p.time_mode = "modern".into())
|
||||
style=move || if prefs.get().time_mode == "modern" { "opacity: 1; border: 2px solid white;" } else { "opacity: 0.5" }>
|
||||
"Modern"
|
||||
</button>
|
||||
<button class="btn-primary"
|
||||
on:click=move |_| set_prefs.update(|p| p.time_mode = "ezani".into())
|
||||
style=move || if prefs.get().time_mode == "ezani" { "opacity: 1; border: 2px solid white;" } else { "opacity: 0.5" }>
|
||||
"Ezânî"
|
||||
</button>
|
||||
<button class="btn-primary"
|
||||
on:click=move |_| set_prefs.update(|p| p.time_mode = "adil".into())
|
||||
style=move || if prefs.get().time_mode == "adil" { "opacity: 1; border: 2px solid white;" } else { "opacity: 0.5" }>
|
||||
"Âdil"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
154
src/i18n.rs
Normal file
154
src/i18n.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub struct I18nContext {
|
||||
pub lang: String,
|
||||
pub term: String,
|
||||
}
|
||||
|
||||
pub fn t(key: &str, ctx: &I18nContext) -> String {
|
||||
let dict = get_dictionary();
|
||||
|
||||
// Key format in dict: "key" -> {"tr": ..., "en": ...}
|
||||
// But we have terminology nuances.
|
||||
// Let's assume nested structure or simple key mapping.
|
||||
|
||||
// Simple approach: Key + Context
|
||||
// If terminology is secular, look for "key_secular", else "key"
|
||||
|
||||
let effective_key = if ctx.term == "secular" {
|
||||
// Try finding a secular specific key first
|
||||
let sec_key = format!("{}_secular", key);
|
||||
if dict.contains_key(sec_key.as_str()) {
|
||||
sec_key
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
} else {
|
||||
key.to_string()
|
||||
};
|
||||
|
||||
if let Some(translations) = dict.get(effective_key.as_str()) {
|
||||
if let Some(val) = translations.get(&ctx.lang) {
|
||||
return val.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
key.to_string()
|
||||
}
|
||||
|
||||
fn get_dictionary() -> HashMap<&'static str, HashMap<String, &'static str>> {
|
||||
let mut m = HashMap::new();
|
||||
|
||||
// Headers
|
||||
insert(&mut m, "app_title", "Rustie Planner", "Rustie Planlayıcı");
|
||||
insert(&mut m, "settings", "Settings", "Ayarlar");
|
||||
|
||||
// Prayer Names (Classic)
|
||||
insert(&mut m, "fajr", "Fajr", "İmsak");
|
||||
insert(&mut m, "sunrise", "Sunrise", "Güneş");
|
||||
insert(&mut m, "dhuhr", "Dhuhr", "Öğle");
|
||||
insert(&mut m, "asr", "Asr", "İkindi");
|
||||
insert(&mut m, "maghrib", "Maghrib", "Akşam");
|
||||
insert(&mut m, "isha", "Isha", "Yatsı");
|
||||
|
||||
// Prayer Names (Secular)
|
||||
insert(&mut m, "fajr_secular", "Dawn", "Tan Vakti");
|
||||
insert(&mut m, "sunrise_secular", "Morning", "Gün Doğumu");
|
||||
insert(&mut m, "dhuhr_secular", "Noon", "Öğlen");
|
||||
insert(&mut m, "asr_secular", "Afternoon", "İkindi"); // Or "Late Afternoon"
|
||||
insert(&mut m, "maghrib_secular", "Sunset", "Gün Batımı");
|
||||
insert(&mut m, "isha_secular", "Nightfall", "Karanlık Çöküşü");
|
||||
|
||||
// Extra Times (Classic)
|
||||
insert(&mut m, "teheccud", "Tahajjud", "Teheccüd");
|
||||
insert(&mut m, "seher", "Seher", "Seher");
|
||||
insert(&mut m, "ishraq", "Ishraq", "İşrak");
|
||||
insert(&mut m, "istiva", "Istiva", "İstiva");
|
||||
insert(&mut m, "isfirar", "Isfirar", "İsfirar");
|
||||
|
||||
// Extra Times (Secular)
|
||||
insert(&mut m, "teheccud_secular", "Deep Night", "Gece Yarısı");
|
||||
insert(&mut m, "seher_secular", "Pre-Dawn", "Tan Öncesi");
|
||||
insert(&mut m, "ishraq_secular", "Mid-Morning", "Kuşluk");
|
||||
insert(&mut m, "istiva_secular", "Zenith", "Tepe Noktası");
|
||||
insert(&mut m, "isfirar_secular", "Late Afternoon", "Akşam Üzeri");
|
||||
|
||||
insert(&mut m, "duha", "Duha", "Kuşluk");
|
||||
insert(&mut m, "prayer_times", "Prayer Times", "Vakitler");
|
||||
|
||||
// Manager UI
|
||||
insert(&mut m, "routines", "Routines", "Rutinler");
|
||||
insert(&mut m, "manage_routines", "Manage Routines", "Rutin Yönetimi");
|
||||
insert(&mut m, "quick_pray", "Prayers", "Namazlar");
|
||||
insert(&mut m, "sleep_temp", "Sleep", "Uyku");
|
||||
insert(&mut m, "food_temp", "Food", "Yemek");
|
||||
insert(&mut m, "no_routines", "No routines added yet. Start planning your day!", "Henüz rutin eklenmedi. Gününü planlamaya başla!");
|
||||
insert(&mut m, "activity_name_placeholder", "Activity Name (e.g., Read Quran)", "Aktivite Adı (örn. Kuran Oku)");
|
||||
insert(&mut m, "starts_at", "Starts At", "Başlangıç");
|
||||
insert(&mut m, "duration", "Duration (m)", "Süre (dk)");
|
||||
insert(&mut m, "color", "Color", "Renk");
|
||||
insert(&mut m, "add_routine", "Add Routine", "Rutin Ekle");
|
||||
|
||||
// Settings labels
|
||||
insert(&mut m, "theme", "Theme", "Tema");
|
||||
insert(&mut m, "language", "Language", "Dil");
|
||||
insert(&mut m, "terminology", "Terminology", "Üslup");
|
||||
insert(&mut m, "time_mode", "Clock Mode", "Saat Modu");
|
||||
insert(&mut m, "modern_time", "Modern Time", "Modern Saat");
|
||||
insert(&mut m, "ezani_time", "Ezani Time", "Ezânî Saat");
|
||||
insert(&mut m, "adil_time", "Adil Time", "Âdil Saat");
|
||||
insert(&mut m, "time", "Time", "Zaman");
|
||||
|
||||
// Time System Explanations
|
||||
insert(&mut m, "time_systems_title", "Three Time Systems", "Üç Zaman Sistemi");
|
||||
insert(&mut m, "modern_time_desc",
|
||||
"Standard 24-hour civil time system used globally. Hours are fixed and independent of natural cycles.",
|
||||
"Dünya genelinde kullanılan standart 24 saatlik medeni zaman sistemi. Saatler sabittir ve doğal döngülerden bağımsızdır."
|
||||
);
|
||||
insert(&mut m, "ezani_time_desc",
|
||||
"Traditional Islamic time system where each day starts at sunset (Maghrib). The clock resets to 00:00 at sunset, aligning daily rhythms with prayer times.",
|
||||
"Güneşin batışıyla (Akşam ezanıyla) her günün başladığı geleneksel İslami zaman sistemi. Saat, akşam ezanında 00:00'a sıfırlanır ve günlük ritim namaz vakitleriyle uyumlu hâle gelir."
|
||||
);
|
||||
insert(&mut m, "adil_time_desc",
|
||||
"A novel temporal hour system proposed by this project. Divides daytime (sunrise to sunset) and nighttime (sunset to sunrise) into 12 equal temporal hours each. Hour length varies seasonally, synchronizing human activity with natural light cycles—an ancient concept revived for modern circadian optimization.",
|
||||
"Bu projenin önerdiği yeni bir zamanî saat sistemi. Gündüzü (güneşin doğuşundan batışına) ve geceyi (batıştan doğuşa) her biri 12 eşit zamanî saate böler. Saat uzunluğu mevsimsel olarak değişir ve insan etkinliğini doğal ışık döngüleriyle senkronize eder—antik bir kavramın modern sirkadiyen optimizasyon için yeniden canlandırılması."
|
||||
);
|
||||
|
||||
insert(&mut m, "app_subtitle", "Routine aligned with nature", "Doğayla uyumlu yaşam rutini");
|
||||
|
||||
// Landing Page (Base/Classic)
|
||||
insert(&mut m, "landing_title", "Rustie Project", "Rustie Projesi");
|
||||
insert(&mut m, "landing_desc",
|
||||
"Rustie is an open-source initiative designed to align modern life with natural and spiritual rhythms. By integrating astronomical prayer times with biological circadian planning, we aim to help users reclaim their time from the chaos of modern secular scheduling.",
|
||||
"Rustie, modern yaşamı doğal ve manevi ritimlerle uyumlu hale getirmeyi amaçlayan açık kaynaklı bir girişimdir. Astronomik vakitleri biyolojik sirkadiyen planlama ile birleştirerek, zamanın bereketini geri kazanmanızı hedefler."
|
||||
);
|
||||
insert(&mut m, "feat_perf_title", "High Performance", "Yüksek Performans");
|
||||
insert(&mut m, "feat_perf_desc", "Built with Rust & WASM for blazing fast, resource-efficient planning.", "Rust ve WASM ile geliştirilmiş, ışık hızında ve verimli.");
|
||||
insert(&mut m, "feat_univ_title", "Universal", "Evrensel");
|
||||
insert(&mut m, "feat_univ_desc", "Supports Modern, Ezânî, and Âdil time systems.", "Modern, Ezânî ve Âdil saat sistemlerini destekler.");
|
||||
insert(&mut m, "feat_priv_title", "Private", "Mahremiyet");
|
||||
insert(&mut m, "feat_priv_desc", "Local-first architecture. Your data never leaves your device.", "Yerel öncelikli mimari. Verileriniz cihazınızda kalır.");
|
||||
|
||||
// Landing Page (Secular Overrides)
|
||||
insert(&mut m, "landing_title_secular", "Rustie Planner", "Rustie Planlayıcı");
|
||||
insert(&mut m, "landing_desc_secular",
|
||||
"Rustie aligns your daily routine with natural circadian rhythms. Optimizing productivity by syncing with solar cycles helps you reclaim your time from the chaos of modern scheduling.",
|
||||
"Rustie, günlük rutininizi doğal sirkadiyen ritimlerle uyumlu hale getirir. Güneş döngüleriyle senkronize olarak verimliliğinizi artırır ve zaman yönetimini optimize eder."
|
||||
);
|
||||
insert(&mut m, "feat_univ_desc_secular", "Supports multiple time systems for global usability.", "Küresel kullanım için çoklu zaman sistemlerini destekler.");
|
||||
|
||||
m
|
||||
}
|
||||
|
||||
fn insert(
|
||||
m: &mut HashMap<&'static str, HashMap<String, &'static str>>,
|
||||
key: &'static str,
|
||||
en: &'static str,
|
||||
tr: &'static str
|
||||
) {
|
||||
let mut map = HashMap::new();
|
||||
map.insert("en".to_string(), en);
|
||||
map.insert("tr".to_string(), tr);
|
||||
m.insert(key, map);
|
||||
}
|
||||
5
src/lib.rs
Normal file
5
src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod calc;
|
||||
pub mod components;
|
||||
pub mod store;
|
||||
pub mod app;
|
||||
pub mod i18n;
|
||||
7
src/main.rs
Normal file
7
src/main.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use leptos::*;
|
||||
use rustie::app::App;
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|| view! { <App/> })
|
||||
}
|
||||
44
src/main_old.rs
Normal file
44
src/main_old.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use leptos::*;
|
||||
|
||||
fn main() {
|
||||
console_error_panic_hook::set_once();
|
||||
mount_to_body(|| view! { <App/> })
|
||||
}
|
||||
|
||||
use rustie::store::state::provide_global_state;
|
||||
use rustie::components::clock::Clock;
|
||||
use rustie::components::dial::Dial;
|
||||
use rustie::components::settings::Settings;
|
||||
|
||||
#[component]
|
||||
fn App() -> impl IntoView {
|
||||
// Initialize State
|
||||
let state = provide_global_state();
|
||||
|
||||
// Apply Theme Effect
|
||||
create_effect(move |_| {
|
||||
let theme = state.preferences.get().theme;
|
||||
let doc = web_sys::window().unwrap().document().unwrap();
|
||||
let body = doc.body().unwrap();
|
||||
if theme == "dark" {
|
||||
let _ = body.class_list().add_1("dark");
|
||||
} else {
|
||||
let _ = body.class_list().remove_1("dark");
|
||||
}
|
||||
});
|
||||
|
||||
view! {
|
||||
<div class="app-container">
|
||||
<header class="glass-panel" style="margin: 20px; padding: 20px; text-align: center;">
|
||||
<h1>"Rustie Planner"</h1>
|
||||
<p>"Routine aligned with nature."</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<Clock />
|
||||
<Dial />
|
||||
<Settings />
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
29
src/store/local.rs
Normal file
29
src/store/local.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)] // Removed Default to avoid conflict
|
||||
pub struct UserPreferences {
|
||||
pub theme: String, // "dark" | "light"
|
||||
pub time_mode: String, // "modern" | "ezani" | "adil"
|
||||
pub language: String, // "tr" | "en"
|
||||
pub terminology: String, // "classic" | "secular"
|
||||
}
|
||||
|
||||
impl Default for UserPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
theme: "dark".to_string(),
|
||||
time_mode: "modern".to_string(),
|
||||
language: "tr".to_string(),
|
||||
terminology: "classic".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_preferences() -> UserPreferences {
|
||||
LocalStorage::get("preferences").unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn save_preferences(prefs: &UserPreferences) {
|
||||
let _ = LocalStorage::set("preferences", prefs);
|
||||
}
|
||||
2
src/store/mod.rs
Normal file
2
src/store/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod local;
|
||||
pub mod state;
|
||||
82
src/store/state.rs
Normal file
82
src/store/state.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use leptos::*;
|
||||
use chrono::{DateTime, Utc};
|
||||
use gloo_timers::callback::Interval;
|
||||
use crate::calc::{calculate_prayer_times, PrayerTimes, Coordinates, CalculationParams};
|
||||
use crate::store::local::{UserPreferences, load_preferences, save_preferences};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct GlobalState {
|
||||
pub current_time: ReadSignal<DateTime<Utc>>,
|
||||
pub prayer_times: ReadSignal<Option<PrayerTimes>>,
|
||||
pub preferences: ReadSignal<UserPreferences>,
|
||||
pub set_preferences: WriteSignal<UserPreferences>,
|
||||
pub routines: ReadSignal<Vec<Activity>>,
|
||||
pub set_routines: WriteSignal<Vec<Activity>>,
|
||||
pub check_update: Action<(), ()>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Activity {
|
||||
pub name: String,
|
||||
pub start_time: DateTime<Utc>, // or NaiveTime if cyclic? Start with Time roughly
|
||||
pub duration_minutes: u32,
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
pub fn provide_global_state() -> GlobalState {
|
||||
let (current_time, set_current_time) = create_signal(Utc::now());
|
||||
let (preferences, set_preferences) = create_signal(load_preferences());
|
||||
// Load routines from local storage (TODO: Add load_routines)
|
||||
let (routines, set_routines) = create_signal(Vec::<Activity>::new());
|
||||
|
||||
// Derived signal or effect for Prayer Times
|
||||
// Default Istanbul Coords for MVP if not set: 41.0082, 28.9784
|
||||
let coords = Coordinates { latitude: 41.0082, longitude: 28.9784 };
|
||||
|
||||
let (prayer_times, set_prayer_times) = create_signal(None);
|
||||
|
||||
// Ticker
|
||||
create_effect(move |_| {
|
||||
let handle = Interval::new(1000, move || {
|
||||
set_current_time.set(Utc::now());
|
||||
});
|
||||
move || drop(handle) // Cleanup
|
||||
});
|
||||
|
||||
// Update prayer times when day changes
|
||||
create_effect(move |_| {
|
||||
let now = current_time.get();
|
||||
// Simply recalc every update for MVP or check date change
|
||||
// Optimization: checked only if date changed?
|
||||
// For now, let's calc. It's cheap.
|
||||
let today = now.date_naive();
|
||||
let pt = calculate_prayer_times(today, coords, CalculationParams::default());
|
||||
set_prayer_times.set(Some(pt));
|
||||
});
|
||||
|
||||
// Save prefs when changed
|
||||
create_effect(move |_| {
|
||||
save_preferences(&preferences.get());
|
||||
});
|
||||
|
||||
// Dummy action
|
||||
let check_update = create_action(move |_| async {});
|
||||
|
||||
let state = GlobalState {
|
||||
current_time,
|
||||
prayer_times,
|
||||
preferences,
|
||||
set_preferences,
|
||||
routines,
|
||||
set_routines,
|
||||
check_update
|
||||
};
|
||||
|
||||
provide_context(state.clone());
|
||||
state
|
||||
}
|
||||
|
||||
pub fn use_global_state() -> GlobalState {
|
||||
use_context::<GlobalState>().expect("Global State missing")
|
||||
}
|
||||
88
style.css
Normal file
88
style.css
Normal file
@@ -0,0 +1,88 @@
|
||||
:root {
|
||||
/* Color Palette - Premium HSL optimized */
|
||||
--hue-primary: 250;
|
||||
/* Deep Indigo/Purple */
|
||||
--hue-secondary: 190;
|
||||
/* Cyan/Teal */
|
||||
|
||||
/* Light Mode Variables */
|
||||
--bg-app: hsl(220, 20%, 97%);
|
||||
--bg-surface: hsla(0, 0%, 100%, 0.7);
|
||||
--bg-surface-2: hsla(0, 0%, 100%, 0.5);
|
||||
--text-primary: hsl(220, 40%, 10%);
|
||||
--text-secondary: hsl(220, 20%, 40%);
|
||||
--border-color: hsla(220, 20%, 80%, 0.5);
|
||||
--accent: hsl(var(--hue-primary), 60%, 50%);
|
||||
--accent-glow: hsla(var(--hue-primary), 60%, 50%, 0.3);
|
||||
|
||||
/* Neumorphic/Glassmorphic Shadows */
|
||||
--shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
|
||||
--shadow-glass:
|
||||
0 8px 32px 0 rgba(31, 38, 135, 0.05),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
/* Dark Mode overrides (Media Query OR .dark class) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-app: hsl(222, 47%, 11%);
|
||||
--bg-surface: hsla(217, 33%, 17%, 0.4);
|
||||
--bg-surface-2: hsla(217, 33%, 25%, 0.3);
|
||||
--text-primary: hsl(210, 40%, 98%);
|
||||
--text-secondary: hsl(215, 20%, 65%);
|
||||
--border-color: hsla(217, 33%, 50%, 0.1);
|
||||
--shadow-glass:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Force Dark Mode via class */
|
||||
body.dark {
|
||||
--bg-app: hsl(222, 47%, 11%);
|
||||
--bg-surface: hsla(217, 33%, 17%, 0.4);
|
||||
--bg-surface-2: hsla(217, 33%, 25%, 0.3);
|
||||
--text-primary: hsl(210, 40%, 98%);
|
||||
--text-secondary: hsl(215, 20%, 65%);
|
||||
--border-color: hsla(217, 33%, 50%, 0.1);
|
||||
--shadow-glass:
|
||||
0 8px 32px 0 rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Global Styles */
|
||||
body {
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
transition: background-color 0.5s ease, color 0.5s ease;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Glassmorphism Utilities */
|
||||
.glass-panel {
|
||||
background: var(--bg-surface);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--shadow-glass);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 15px var(--accent-glow);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px var(--accent-glow);
|
||||
}
|
||||
Reference in New Issue
Block a user