-
-
Save jacksonfdam/0f98a3dbf17f5b360e6aa702cd517079 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| * Copyright 2026 Kyriakos Georgiopoulos | |
| * | |
| * Licensed under the Apache License, Version 2.0 (the "License"); | |
| * you may not use this file except in compliance with the License. | |
| * You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, software | |
| * distributed under the License is distributed on an "AS IS" BASIS, | |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| * See the License for the specific language governing permissions and | |
| * limitations under the License. | |
| */ | |
| import android.app.Activity | |
| import androidx.compose.animation.AnimatedContent | |
| import androidx.compose.animation.animateColorAsState | |
| import androidx.compose.animation.core.Animatable | |
| import androidx.compose.animation.core.CubicBezierEasing | |
| import androidx.compose.animation.core.FastOutSlowInEasing | |
| import androidx.compose.animation.core.Spring | |
| import androidx.compose.animation.core.animateDpAsState | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.animation.core.tween | |
| import androidx.compose.animation.fadeIn | |
| import androidx.compose.animation.fadeOut | |
| import androidx.compose.animation.slideInVertically | |
| import androidx.compose.animation.slideOutVertically | |
| import androidx.compose.animation.togetherWith | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.border | |
| import androidx.compose.foundation.clickable | |
| import androidx.compose.foundation.gestures.detectTapGestures | |
| import androidx.compose.foundation.gestures.detectVerticalDragGestures | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.PaddingValues | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.fillMaxHeight | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.offset | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.foundation.lazy.LazyColumn | |
| import androidx.compose.foundation.lazy.items | |
| import androidx.compose.foundation.pager.HorizontalPager | |
| import androidx.compose.foundation.pager.PageSize | |
| import androidx.compose.foundation.pager.rememberPagerState | |
| import androidx.compose.foundation.shape.CircleShape | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.foundation.text.BasicTextField | |
| import androidx.compose.material.icons.Icons | |
| import androidx.compose.material.icons.rounded.Add | |
| import androidx.compose.material.icons.rounded.KeyboardArrowDown | |
| import androidx.compose.material.icons.rounded.LightMode | |
| import androidx.compose.material.icons.rounded.LocationOn | |
| import androidx.compose.material.icons.rounded.NightsStay | |
| import androidx.compose.material3.Icon | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.SideEffect | |
| import androidx.compose.runtime.derivedStateOf | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateListOf | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.rememberCoroutineScope | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.graphics.Brush | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.GraphicsLayerScope | |
| import androidx.compose.ui.graphics.PathEffect | |
| import androidx.compose.ui.graphics.SolidColor | |
| import androidx.compose.ui.graphics.StrokeCap | |
| import androidx.compose.ui.graphics.TransformOrigin | |
| import androidx.compose.ui.graphics.drawscope.Stroke | |
| import androidx.compose.ui.graphics.drawscope.rotate | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.input.pointer.pointerInput | |
| import androidx.compose.ui.platform.LocalConfiguration | |
| import androidx.compose.ui.platform.LocalDensity | |
| import androidx.compose.ui.platform.LocalView | |
| import androidx.compose.ui.text.TextStyle | |
| import androidx.compose.ui.text.drawText | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.text.rememberTextMeasurer | |
| import androidx.compose.ui.unit.IntOffset | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import androidx.core.view.WindowCompat | |
| import com.zengrip.countdown.toPx | |
| import kotlinx.coroutines.delay | |
| import kotlinx.coroutines.launch | |
| import java.time.LocalDate | |
| import java.time.LocalTime | |
| import java.time.format.DateTimeFormatter | |
| import java.util.Locale | |
| import kotlin.math.PI | |
| import kotlin.math.abs | |
| import kotlin.math.cos | |
| import kotlin.math.hypot | |
| import kotlin.math.roundToInt | |
| import kotlin.math.sin | |
| import kotlin.random.Random | |
| import java.time.format.TextStyle as TimeTextStyle | |
| // --- CONSTANTS & HELPERS --- | |
| private const val DEG_TO_RAD = (PI / 180f).toFloat() | |
| /** | |
| * Defines the core types of scheduled events, driving the color engine across the UI. | |
| */ | |
| enum class EventType { FOCUS, COLLABORATION, REST } | |
| /** | |
| * Represents a single block of time on the calendar. | |
| * | |
| * @property name The display title of the event. | |
| * @property startTime The starting boundary. | |
| * @property endTime The ending boundary. | |
| * @property type Determines the visual treatment and gradient map. | |
| * @property details Optional extended notes. | |
| */ | |
| data class ScheduleEvent( | |
| val name: String, | |
| val startTime: LocalTime, | |
| val endTime: LocalTime, | |
| val type: EventType, | |
| val details: String? = null | |
| ) { | |
| val startDecimal: Float get() = startTime.hour + (startTime.minute / 60f) | |
| val endDecimal: Float get() = endTime.hour + (endTime.minute / 60f) | |
| } | |
| /** | |
| * Generates a deterministically random schedule for a given date to populate the calendar minimap. | |
| * Reverts to a hardcoded base schedule for the current physical day. | |
| */ | |
| fun generateScheduleForDate(date: LocalDate): List<ScheduleEvent> { | |
| val seed = date.toEpochDay() | |
| val random = Random(seed) | |
| val baseEvents = listOf( | |
| ScheduleEvent( | |
| name = "Morning Coffee", | |
| startTime = LocalTime.of(8, 10), | |
| endTime = LocalTime.of(9, 0), | |
| type = EventType.REST, | |
| details = "Reviewing overnight emails." | |
| ), | |
| ScheduleEvent( | |
| name = "Deep Work Block", | |
| startTime = LocalTime.of(9, 0), | |
| endTime = LocalTime.of(10, 50), | |
| type = EventType.FOCUS, | |
| details = "No-distraction coding session." | |
| ), | |
| ScheduleEvent( | |
| name = "Team Sync", | |
| startTime = LocalTime.of(13, 10), | |
| endTime = LocalTime.of(14, 30), | |
| type = EventType.COLLABORATION, | |
| details = "Weekly standup with the design leads." | |
| ), | |
| ScheduleEvent( | |
| name = "Project Review", | |
| startTime = LocalTime.of(15, 30), | |
| endTime = LocalTime.of(16, 30), | |
| type = EventType.COLLABORATION, | |
| details = "Final sign-off on the Q3 roadmap." | |
| ), | |
| ScheduleEvent( | |
| name = "Focused Polish", | |
| startTime = LocalTime.of(17, 0), | |
| endTime = LocalTime.of(18, 30), | |
| type = EventType.FOCUS, | |
| details = "Nailing those precise animation curves." | |
| ), | |
| ScheduleEvent( | |
| name = "Evening Wind Down", | |
| startTime = LocalTime.of(20, 0), | |
| endTime = LocalTime.of(21, 0), | |
| type = EventType.REST, | |
| details = "Reading design articles and disconnecting." | |
| ) | |
| ) | |
| if (date == LocalDate.now()) return baseEvents | |
| val numEvents = random.nextInt(1, 5) | |
| return baseEvents.shuffled(random).take(numEvents).sortedBy { it.startTime } | |
| } | |
| /** | |
| * The master layout and orchestrator of the 3D Clock & Calendar ecosystem. | |
| * Manages spatial routing, state hoisting, and heavy render loop optimizations. | |
| */ | |
| @Composable | |
| fun ClockScreen() { | |
| val view = LocalView.current | |
| val scope = rememberCoroutineScope() | |
| val textMeasurer = rememberTextMeasurer() | |
| // --- 1. STATE & ORCHESTRATED ANIMATIONS --- | |
| var isDarkTheme by remember { mutableStateOf(false) } | |
| var isDetailsOpen by remember { mutableStateOf(false) } | |
| var isCalendarOpen by remember { mutableStateOf(false) } | |
| var isCreatingEvent by remember { mutableStateOf(false) } | |
| var selectedEvent by remember { mutableStateOf<ScheduleEvent?>(null) } | |
| var currentTime by remember { mutableStateOf(LocalTime.now()) } | |
| val baseTodaySchedule = remember { generateScheduleForDate(LocalDate.now()) } | |
| val todaySchedule = remember { mutableStateListOf(*baseTodaySchedule.toTypedArray()) } | |
| LaunchedEffect(Unit) { | |
| while (true) { | |
| currentTime = LocalTime.now() | |
| delay(1000) | |
| } | |
| } | |
| if (!view.isInEditMode) { | |
| SideEffect { | |
| val window = (view.context as Activity).window | |
| WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = | |
| !isDarkTheme | |
| } | |
| } | |
| LaunchedEffect(isDetailsOpen) { | |
| if (!isDetailsOpen) { | |
| selectedEvent = null | |
| isCreatingEvent = false | |
| } | |
| } | |
| LaunchedEffect(isCalendarOpen) { | |
| if (isCalendarOpen) { | |
| isDetailsOpen = false | |
| isCreatingEvent = false | |
| } | |
| } | |
| val calendarProgress by animateFloatAsState( | |
| targetValue = if (isCalendarOpen) 1f else 0f, | |
| animationSpec = tween( | |
| durationMillis = 650, | |
| easing = CubicBezierEasing(0.2f, 0.0f, 0.0f, 1.0f) | |
| ), | |
| label = "calendarSpatialProgress" | |
| ) | |
| val detailsProgress by animateFloatAsState( | |
| targetValue = if (isDetailsOpen) 1f else 0f, | |
| animationSpec = spring(dampingRatio = 0.85f, stiffness = 90f), | |
| label = "detailsSpatialProgress" | |
| ) | |
| val pagerState = rememberPagerState( | |
| initialPage = if (LocalTime.now().hour % 12 < 6) 1 else 0, | |
| pageCount = { 2 }) | |
| val initialAm = LocalTime.now().hour < 12 | |
| val rotationAnim = remember { Animatable(if (initialAm) 0f else 180f) } | |
| val pullAnim = remember { Animatable(0f) } | |
| val isAmView by remember { derivedStateOf { (rotationAnim.value % 360f) < 90f || (rotationAnim.value % 360f) > 270f } } | |
| val highlightAlpha by animateFloatAsState( | |
| targetValue = if (selectedEvent != null) 1f else 0f, | |
| animationSpec = spring(stiffness = Spring.StiffnessLow), | |
| label = "highlightAlpha" | |
| ) | |
| val targetStartAngle = selectedEvent?.let { ((it.startDecimal % 12f) * 30f) - 90f } ?: -90f | |
| val targetSweepAngle = selectedEvent?.let { (it.endDecimal - it.startDecimal) * 30f } ?: 0f | |
| val animatedStartAngle by animateFloatAsState( | |
| targetValue = targetStartAngle, | |
| animationSpec = tween(500, easing = FastOutSlowInEasing), | |
| label = "arcStart" | |
| ) | |
| val animatedSweepAngle by animateFloatAsState( | |
| targetValue = targetSweepAngle, | |
| animationSpec = tween(500, easing = FastOutSlowInEasing), | |
| label = "arcSweep" | |
| ) | |
| val isCurrentTimeInView = isAmView == (currentTime.hour < 12) | |
| val indicatorVisibility by animateFloatAsState( | |
| targetValue = if (isCurrentTimeInView) 1f else 0f, | |
| animationSpec = tween(500), | |
| label = "indicatorVisibility" | |
| ) | |
| // --- 2. DYNAMIC COLORS & CACHED RESOURCES --- | |
| val lightRadius = remember { Animatable(if (isDarkTheme) 0f else 1f) } | |
| LaunchedEffect(isDarkTheme) { | |
| lightRadius.animateTo( | |
| targetValue = if (isDarkTheme) 0f else 1f, | |
| animationSpec = tween(800, easing = FastOutSlowInEasing) | |
| ) | |
| } | |
| val gradColor1 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00F0FF) else Color(0xFF1A47FF), | |
| tween(800), | |
| label = "g1" | |
| ) | |
| val gradColor2 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF7000FF) else Color(0xFF8A2BE2), | |
| tween(800), | |
| label = "g2" | |
| ) | |
| val gradColor3 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFFFF007F) else Color(0xFFD90429), | |
| tween(800), | |
| label = "g3" | |
| ) | |
| val focusC1 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF5A189A) else Color(0xFF3C096C), | |
| tween(800), | |
| label = "fc1" | |
| ) | |
| val focusC2 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFFE0AAFF) else Color(0xFF9D4EDD), | |
| tween(800), | |
| label = "fc2" | |
| ) | |
| val focusC3 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00F0FF) else Color(0xFF48CAE4), | |
| tween(800), | |
| label = "fc3" | |
| ) | |
| val collabC1 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFFFFD166) else Color(0xFFF4A261), | |
| tween(800), | |
| label = "cc1" | |
| ) | |
| val collabC2 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFFF4A261) else Color(0xFFE76F51), | |
| tween(800), | |
| label = "cc2" | |
| ) | |
| val collabC3 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFFEF476F) else Color(0xFFE5383B), | |
| tween(800), | |
| label = "cc3" | |
| ) | |
| val restC1 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00FF87) else Color(0xFF2A9D8F), | |
| tween(800), | |
| label = "rc1" | |
| ) | |
| val restC2 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00B8FF) else Color(0xFF0077B6), | |
| tween(800), | |
| label = "rc2" | |
| ) | |
| val restC3 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF7000FF) else Color(0xFF03045E), | |
| tween(800), | |
| label = "rc3" | |
| ) | |
| val indicatorColor by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00F0FF) else Color( | |
| 0xFFEF476F | |
| ), tween(800), label = "ind" | |
| ) | |
| val trackTextColor by animateColorAsState( | |
| if (isDarkTheme) Color.White.copy(alpha = 0.35f) else Color.Black.copy( | |
| alpha = 0.4f | |
| ), tween(800), label = "ttxt" | |
| ) | |
| val baseTrackColor = if (isDarkTheme) Color.White else Color.Black | |
| val darkBgColor = Color(0xFF0F0F11) | |
| val lightBgColor = Color(0xFFF4EFE6) | |
| // Pre-allocated gradient lists to prevent GC stutter during Canvas onDraw loop | |
| val focusGradientColors = | |
| remember(focusC1, focusC2, focusC3) { listOf(focusC1, focusC2, focusC3) } | |
| val collabGradientColors = | |
| remember(collabC1, collabC2, collabC3) { listOf(collabC1, collabC2, collabC3) } | |
| val restGradientColors = remember(restC1, restC2, restC3) { listOf(restC1, restC2, restC3) } | |
| val clockGradientColors = remember(gradColor1, gradColor2, gradColor3) { | |
| listOf( | |
| gradColor1, | |
| gradColor2, | |
| gradColor3, | |
| gradColor1 | |
| ) | |
| } | |
| val textBrushColors = remember(gradColor1, gradColor2) { listOf(gradColor1, gradColor2) } | |
| val displayedSchedule = remember(isAmView, todaySchedule.size) { | |
| todaySchedule.filter { event -> if (isAmView) event.startTime.hour < 12 else event.startTime.hour >= 12 } | |
| .sortedBy { it.startTime } | |
| } | |
| // --- 3. 3D TRANSFORM ENGINE --- | |
| val apply3DTransform: GraphicsLayerScope.() -> Unit = { | |
| val cw = size.width | |
| val ch = size.height | |
| val scrollPosition = | |
| (pagerState.currentPage + pagerState.currentPageOffsetFraction).coerceIn(0f, 1f) | |
| val baseClockCenterX = (cw - 60f) - (((cw - 60f) - 60f) * scrollPosition) | |
| val clockYOffset = 4.dp.toPx() | |
| val baseRestingY = (ch / 2f) - clockYOffset | |
| val normalX = baseClockCenterX + ((cw * 0.725f - baseClockCenterX) * detailsProgress) | |
| val normalY = baseRestingY + ((140.dp.toPx() - baseRestingY) * detailsProgress) | |
| val cx = normalX + ((cw / 2f) - normalX) * pullAnim.value | |
| val cy = normalY + (baseRestingY - normalY) * pullAnim.value | |
| transformOrigin = TransformOrigin(cx / cw, cy / ch) | |
| rotationY = rotationAnim.value | |
| rotationX = pullAnim.value * -12f | |
| cameraDistance = 16f * density | |
| if (rotationAnim.value > 90f) scaleX = -1f | |
| } | |
| // --- 4. ROOT STAGE --- | |
| Box(modifier = Modifier.fillMaxSize()) { | |
| Canvas(modifier = Modifier.fillMaxSize()) { | |
| drawRect(color = darkBgColor) | |
| val maxR = hypot(size.width, size.height) | |
| val btnCenterX = size.width - (24.dp.toPx() + 24.dp.toPx()) | |
| val btnCenterY = 48.dp.toPx() + 24.dp.toPx() | |
| drawCircle( | |
| color = lightBgColor, | |
| radius = maxR * lightRadius.value, | |
| center = Offset(btnCenterX, btnCenterY) | |
| ) | |
| } | |
| // --- LAYER A: THE CLOCK WORLD --- | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .graphicsLayer { | |
| translationY = -size.height * calendarProgress | |
| scaleX = 1f - (0.05f * calendarProgress) | |
| scaleY = 1f - (0.05f * calendarProgress) | |
| alpha = 1f - (0.3f * calendarProgress) | |
| } | |
| .pointerInput(isCalendarOpen) { | |
| if (!isCalendarOpen) { | |
| detectTapGestures( | |
| onTap = { | |
| if (isCreatingEvent) { | |
| // Handled by modal overlay | |
| } else { | |
| isDetailsOpen = !isDetailsOpen | |
| if (!isDetailsOpen) selectedEvent = null | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| ) { | |
| HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { } | |
| // CLOCK ZONES BACKGROUND LAYER | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .graphicsLayer { | |
| alpha = 1f - detailsProgress | |
| apply3DTransform() | |
| } | |
| ) { | |
| val canvasWidth = size.width | |
| val canvasHeight = size.height | |
| val scrollPosition = | |
| (pagerState.currentPage + pagerState.currentPageOffsetFraction).coerceIn(0f, 1f) | |
| val baseClockCenterX = | |
| (canvasWidth - 60f) - (((canvasWidth - 60f) - 60f) * scrollPosition) | |
| val clockYOffset = 4.dp.toPx() | |
| val baseRestingY = (canvasHeight / 2f) - clockYOffset | |
| val currentNormalX = | |
| baseClockCenterX + ((canvasWidth * 0.725f - baseClockCenterX) * detailsProgress) | |
| val currentNormalY = | |
| baseRestingY + ((140.dp.toPx() - baseRestingY) * detailsProgress) | |
| val clockCenterX = | |
| currentNormalX + ((canvasWidth / 2f) - currentNormalX) * pullAnim.value | |
| val clockCenterY = currentNormalY + (baseRestingY - currentNormalY) * pullAnim.value | |
| val clockCenter = Offset(clockCenterX, clockCenterY) | |
| val baseScale = 1f - (0.55f * detailsProgress) | |
| val scale = baseScale * (1f - (0.4f * pullAnim.value)) | |
| val mainRadius = (canvasWidth * 0.54f) * scale | |
| val zoneThickness = 90f * scale | |
| val zoneSpacing = 6f * scale | |
| val zoneNames = listOf("FOCUS", "COLLABORATION", "REST") | |
| val trackAlphas = | |
| if (isDarkTheme) listOf(0.12f, 0.08f, 0.04f) else listOf(0.09f, 0.06f, 0.03f) | |
| val startOffset = mainRadius + (35f * scale) | |
| val r1 = startOffset + (zoneThickness / 2f) | |
| val r2 = r1 + zoneThickness + zoneSpacing | |
| val r3 = r2 + zoneThickness + zoneSpacing | |
| val baseZoneRadii = listOf(r1, r2, r3) | |
| val textCenterAngle = 255f + (scrollPosition * 30f) | |
| // Pre-calculate highly reused text styles outside the loops | |
| val eventTextStyle = TextStyle( | |
| fontSize = (9 * scale).sp, | |
| letterSpacing = 0.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = Color.White | |
| ) | |
| val zoneTextStyle = TextStyle( | |
| fontSize = (11 * scale).sp, | |
| letterSpacing = 0.5.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = trackTextColor | |
| ) | |
| baseZoneRadii.forEachIndexed { index, r -> | |
| val currentTrackColor = baseTrackColor.copy(alpha = trackAlphas[index]) | |
| drawCircle( | |
| color = currentTrackColor, | |
| radius = r, | |
| center = clockCenter, | |
| style = Stroke(width = zoneThickness, cap = StrokeCap.Round) | |
| ) | |
| val trackEventType = when (index) { | |
| 0 -> EventType.FOCUS; 1 -> EventType.COLLABORATION; else -> EventType.REST | |
| } | |
| displayedSchedule.filter { it.type == trackEventType }.forEach { event -> | |
| val startAngle = ((event.startDecimal % 12f) * 30f) - 90f | |
| val sweepAngle = (event.endDecimal - event.startDecimal) * 30f | |
| val startRad = startAngle * DEG_TO_RAD | |
| val endRad = (startAngle + sweepAngle) * DEG_TO_RAD | |
| val startXPos = clockCenterX + r * cos(startRad) | |
| val startYPos = clockCenterY + r * sin(startRad) | |
| val endXPos = clockCenterX + r * cos(endRad) | |
| val endYPos = clockCenterY + r * sin(endRad) | |
| val eventBrush = when (index) { | |
| 0 -> Brush.linearGradient( | |
| focusGradientColors, | |
| Offset(startXPos, startYPos), | |
| Offset(endXPos, endYPos) | |
| ) | |
| 1 -> Brush.linearGradient( | |
| collabGradientColors, | |
| Offset(startXPos, startYPos), | |
| Offset(endXPos, endYPos) | |
| ) | |
| else -> Brush.linearGradient( | |
| restGradientColors, | |
| Offset(startXPos, startYPos), | |
| Offset(endXPos, endYPos) | |
| ) | |
| } | |
| drawArc( | |
| brush = eventBrush, | |
| startAngle = startAngle, | |
| sweepAngle = sweepAngle, | |
| useCenter = false, | |
| topLeft = Offset(clockCenterX - r, clockCenterY - r), | |
| size = Size(r * 2, r * 2), | |
| style = Stroke( | |
| width = zoneThickness - (12f * scale), | |
| cap = StrokeCap.Round | |
| ) | |
| ) | |
| val eventText = event.name.uppercase() | |
| val charSpacingDegreesEvent = 1.6f | |
| val totalTextSweepAngle = (eventText.length - 1) * charSpacingDegreesEvent | |
| val midAngle = startAngle + (sweepAngle / 2f) | |
| val textStartAngle = midAngle - (totalTextSweepAngle / 2f) | |
| eventText.forEachIndexed { charIndex, char -> | |
| val charAngle = textStartAngle + (charIndex * charSpacingDegreesEvent) | |
| rotate(degrees = charAngle + 90f, pivot = clockCenter) { | |
| val charLayoutResult = textMeasurer.measure( | |
| text = char.toString(), | |
| style = eventTextStyle | |
| ) | |
| drawText( | |
| textLayoutResult = charLayoutResult, | |
| topLeft = Offset( | |
| clockCenterX - (charLayoutResult.size.width / 2f), | |
| clockCenterY - r - (charLayoutResult.size.height / 2f) | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| if (index == 1) { | |
| val innerR2 = r - (zoneThickness / 2f) | |
| for (tick in 0 until 48) { | |
| val tickAngle = ((tick * 360f / 48f) - 90f) * DEG_TO_RAD | |
| val isHour = tick % 4 == 0 | |
| val isHalf = tick % 4 == 2 | |
| val tickLength = (when { | |
| isHour -> 12f; isHalf -> 16f; else -> 8f | |
| }) * scale | |
| val tickWeight = (if (isHalf) 2.5f else 1.5f) * scale | |
| val tickAlpha = if (isHalf) 0.8f else 0.3f | |
| drawLine( | |
| color = trackTextColor.copy(alpha = tickAlpha), | |
| start = Offset( | |
| clockCenterX + innerR2 * cos(tickAngle), | |
| clockCenterY + innerR2 * sin(tickAngle) | |
| ), | |
| end = Offset( | |
| clockCenterX + (innerR2 + tickLength) * cos(tickAngle), | |
| clockCenterY + (innerR2 + tickLength) * sin(tickAngle) | |
| ), | |
| strokeWidth = tickWeight, | |
| cap = StrokeCap.Round | |
| ) | |
| } | |
| } | |
| val text = zoneNames[index] | |
| val charSpacingDegrees = 2.2f | |
| val totalSweepAngle = (text.length - 1) * charSpacingDegrees | |
| val startAngle = textCenterAngle - (totalSweepAngle / 2f) | |
| text.forEachIndexed { charIndex, char -> | |
| val charAngle = startAngle + (charIndex * charSpacingDegrees) | |
| rotate(degrees = charAngle + 90f, pivot = clockCenter) { | |
| val charLayoutResult = | |
| textMeasurer.measure(text = char.toString(), style = zoneTextStyle) | |
| drawText( | |
| textLayoutResult = charLayoutResult, | |
| topLeft = Offset( | |
| clockCenterX - (charLayoutResult.size.width / 2f), | |
| clockCenterY - r - (charLayoutResult.size.height / 2f) | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| // DIMMER FOR DETAILS PANEL | |
| if (detailsProgress > 0.01f) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.Black.copy(alpha = detailsProgress * 0.4f)) | |
| ) | |
| } | |
| // SCHEDULE DETAILS PANEL (Right Side) | |
| if (detailsProgress > 0.01f) { | |
| ScheduleDetailsPanel( | |
| schedule = displayedSchedule, | |
| isDarkTheme = isDarkTheme, | |
| progress = detailsProgress, | |
| selectedEvent = selectedEvent, | |
| onEventClick = { clickedEvent -> | |
| selectedEvent = if (selectedEvent == clickedEvent) null else clickedEvent | |
| }, | |
| modifier = Modifier | |
| .fillMaxHeight() | |
| .fillMaxWidth(0.55f) | |
| .align(Alignment.CenterEnd) | |
| ) | |
| } | |
| // THE AMBIENT DASHBOARD (Left Side) | |
| if (detailsProgress > 0.01f) { | |
| AmbientDashboard( | |
| isDarkTheme = isDarkTheme, | |
| progress = detailsProgress, | |
| onAddClick = { isCreatingEvent = true }, | |
| modifier = Modifier | |
| .fillMaxHeight() | |
| .fillMaxWidth(0.45f) | |
| .align(Alignment.CenterStart) | |
| ) | |
| } | |
| // MAIN CLOCK FACE & MINIMAP (Foreground Canvas) | |
| Canvas( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .graphicsLayer { apply3DTransform() } | |
| ) { | |
| val canvasWidth = size.width | |
| val canvasHeight = size.height | |
| val scrollPosition = | |
| (pagerState.currentPage + pagerState.currentPageOffsetFraction).coerceIn(0f, 1f) | |
| val baseClockCenterX = | |
| (canvasWidth - 60f) - (((canvasWidth - 60f) - 60f) * scrollPosition) | |
| val clockYOffset = 4.dp.toPx() | |
| val baseRestingY = (canvasHeight / 2f) - clockYOffset | |
| val currentNormalX = | |
| baseClockCenterX + ((canvasWidth * 0.725f - baseClockCenterX) * detailsProgress) | |
| val currentNormalY = | |
| baseRestingY + ((140.dp.toPx() - baseRestingY) * detailsProgress) | |
| val clockCenterX = | |
| currentNormalX + ((canvasWidth / 2f) - currentNormalX) * pullAnim.value | |
| val clockCenterY = currentNormalY + (baseRestingY - currentNormalY) * pullAnim.value | |
| val clockCenter = Offset(clockCenterX, clockCenterY) | |
| val baseScale = 1f - (0.55f * detailsProgress) | |
| val scale = baseScale * (1f - (0.4f * pullAnim.value)) | |
| val mainRadius = (canvasWidth * 0.54f) * scale | |
| val textRadius = mainRadius * 0.82f | |
| val globalDimAlpha = 1f - (0.7f * highlightAlpha) | |
| // Optimized Brush allocations | |
| val clockBrush = Brush.sweepGradient(clockGradientColors, clockCenter) | |
| val textBrush = Brush.linearGradient(textBrushColors) | |
| drawCircle( | |
| brush = clockBrush, | |
| radius = mainRadius, | |
| center = clockCenter, | |
| style = Stroke(width = 3f * scale), | |
| alpha = 0.8f * globalDimAlpha | |
| ) | |
| val zoneThickness = 90f * scale | |
| val zoneSpacing = 6f * scale | |
| val startOffset = mainRadius + (35f * scale) | |
| val outermostRadius = | |
| startOffset + (zoneThickness / 2f) + (zoneThickness * 2) + (zoneSpacing * 2) + (zoneThickness / 2f) | |
| // Pre-allocated PathEffect to prevent GC stutter inside the 48-tick loop | |
| val dashEffect = | |
| PathEffect.dashPathEffect(floatArrayOf(12f * scale, 12f * scale), 0f) | |
| val hourTextStyle = TextStyle( | |
| fontSize = (26 * scale).sp, | |
| fontWeight = FontWeight.Bold, | |
| brush = textBrush | |
| ) | |
| for (tick in 0 until 48) { | |
| val angleDegree = (tick * 7.5f) - 90f | |
| val angleRad = angleDegree * DEG_TO_RAD | |
| val isHourMark = tick % 4 == 0 | |
| val isHalfHour = tick % 4 == 2 | |
| val tickLength = (when { | |
| isHourMark -> 25f; isHalfHour -> 16f; else -> 8f | |
| }) * scale | |
| val tickWeight = (when { | |
| isHourMark -> 4f; isHalfHour -> 3f; else -> 2f | |
| }) * scale | |
| val tickAlpha = when { | |
| isHourMark -> 1f; isHalfHour -> if (isDarkTheme) 0.6f else 0.8f; else -> if (isDarkTheme) 0.3f else 0.5f | |
| } | |
| val startRadius = mainRadius - tickLength | |
| drawLine( | |
| brush = clockBrush, | |
| start = Offset( | |
| clockCenterX + startRadius * cos(angleRad), | |
| clockCenterY + startRadius * sin(angleRad) | |
| ), | |
| end = Offset( | |
| clockCenterX + mainRadius * cos(angleRad), | |
| clockCenterY + mainRadius * sin(angleRad) | |
| ), | |
| strokeWidth = tickWeight, | |
| cap = StrokeCap.Round, | |
| alpha = tickAlpha * globalDimAlpha | |
| ) | |
| if (isHourMark) { | |
| val dashAlpha = 0.15f * (1f - detailsProgress) | |
| if (dashAlpha > 0f) { | |
| drawLine( | |
| color = trackTextColor.copy(alpha = dashAlpha), | |
| start = Offset( | |
| clockCenterX + mainRadius * cos(angleRad), | |
| clockCenterY + mainRadius * sin(angleRad) | |
| ), | |
| end = Offset( | |
| clockCenterX + outermostRadius * cos(angleRad), | |
| clockCenterY + outermostRadius * sin(angleRad) | |
| ), | |
| strokeWidth = 2f * scale, | |
| pathEffect = dashEffect | |
| ) | |
| } | |
| val hour = if (tick == 0) 12 else tick / 4 | |
| val textLayoutResult = | |
| textMeasurer.measure(text = hour.toString(), style = hourTextStyle) | |
| drawText( | |
| textLayoutResult = textLayoutResult, | |
| topLeft = Offset( | |
| clockCenterX + textRadius * cos(angleRad) - (textLayoutResult.size.width / 2), | |
| clockCenterY + textRadius * sin(angleRad) - (textLayoutResult.size.height / 2) | |
| ), | |
| alpha = globalDimAlpha | |
| ) | |
| } | |
| } | |
| if (highlightAlpha > 0.01f) { | |
| val baseGradientColors = when (selectedEvent?.type) { | |
| EventType.FOCUS -> focusGradientColors | |
| EventType.COLLABORATION -> collabGradientColors | |
| EventType.REST -> restGradientColors | |
| null -> listOf(Color.Transparent, Color.Transparent) | |
| } | |
| val fadeGradientColors = | |
| baseGradientColors.map { color -> color.copy(alpha = highlightAlpha * color.alpha) } | |
| val startRad = animatedStartAngle * DEG_TO_RAD | |
| val endRad = (animatedStartAngle + animatedSweepAngle) * DEG_TO_RAD | |
| val animatedArcBrush = Brush.linearGradient( | |
| colors = fadeGradientColors, | |
| start = Offset( | |
| clockCenterX + mainRadius * cos(startRad), | |
| clockCenterY + mainRadius * sin(startRad) | |
| ), | |
| end = Offset( | |
| clockCenterX + mainRadius * cos(endRad), | |
| clockCenterY + mainRadius * sin(endRad) | |
| ) | |
| ) | |
| drawArc( | |
| brush = animatedArcBrush, | |
| startAngle = animatedStartAngle, | |
| sweepAngle = animatedSweepAngle, | |
| useCenter = false, | |
| topLeft = Offset(clockCenterX - mainRadius, clockCenterY - mainRadius), | |
| size = Size(mainRadius * 2, mainRadius * 2), | |
| style = Stroke(width = 40f * scale, cap = StrokeCap.Round) | |
| ) | |
| } | |
| val exactHour = | |
| (currentTime.hour % 12) + (currentTime.minute / 60f) + (currentTime.second / 3600f) | |
| val timeAngleRad = ((exactHour * 30f) - 90f) * DEG_TO_RAD | |
| drawCircle( | |
| color = indicatorColor, | |
| radius = 14f * scale, | |
| center = clockCenter, | |
| alpha = (0.5f + (0.5f * globalDimAlpha)) * indicatorVisibility | |
| ) | |
| drawLine( | |
| color = indicatorColor, | |
| start = clockCenter, | |
| end = Offset( | |
| clockCenterX + (mainRadius + (10f * scale)) * cos(timeAngleRad), | |
| clockCenterY + (mainRadius + (10f * scale)) * sin(timeAngleRad) | |
| ), | |
| strokeWidth = 5f * scale, | |
| cap = StrokeCap.Round, | |
| alpha = (0.5f + (0.5f * globalDimAlpha)) * indicatorVisibility | |
| ) | |
| val timeString = currentTime.format(DateTimeFormatter.ofPattern("HH:mm")) | |
| val timeTextResult = textMeasurer.measure( | |
| text = timeString, | |
| style = TextStyle( | |
| fontSize = (16 * scale).sp, | |
| fontWeight = FontWeight.Bold, | |
| color = indicatorColor | |
| ) | |
| ) | |
| drawText( | |
| textLayoutResult = timeTextResult, | |
| topLeft = Offset( | |
| clockCenterX + (mainRadius + (60f * scale)) * cos(timeAngleRad) - (timeTextResult.size.width / 2), | |
| clockCenterY + (mainRadius + (60f * scale)) * sin(timeAngleRad) - (timeTextResult.size.height / 2) | |
| ), | |
| alpha = (0.5f + (0.5f * globalDimAlpha)) * indicatorVisibility | |
| ) | |
| } | |
| // TOP TOGGLES | |
| Row( | |
| modifier = Modifier | |
| .align(Alignment.TopEnd) | |
| .padding(top = 48.dp, end = 24.dp) | |
| .graphicsLayer { | |
| alpha = 1f - detailsProgress | |
| translationY = -50f * detailsProgress | |
| }, | |
| horizontalArrangement = Arrangement.spacedBy(16.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| AmPmToggle( | |
| isAm = isAmView, | |
| isDarkTheme = isDarkTheme, | |
| onToggle = { | |
| if (!pullAnim.isRunning && !rotationAnim.isRunning && !isCalendarOpen) { | |
| scope.launch { | |
| selectedEvent = null | |
| val targetRot = if (isAmView) 180f else 0f | |
| val pullJob = launch { | |
| pullAnim.animateTo( | |
| 1f, | |
| tween(400, easing = CubicBezierEasing(0.2f, 0f, 0f, 1f)) | |
| ) | |
| } | |
| delay(150) | |
| launch { | |
| rotationAnim.animateTo( | |
| targetValue = targetRot, | |
| animationSpec = spring( | |
| dampingRatio = 0.55f, | |
| stiffness = 90f | |
| ) | |
| ) | |
| } | |
| pullJob.join() | |
| delay(50) | |
| launch { | |
| pullAnim.animateTo( | |
| 0f, | |
| spring(dampingRatio = 0.7f, stiffness = 120f) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| ) | |
| DayNightToggle(isDarkTheme = isDarkTheme, onToggle = { isDarkTheme = !isDarkTheme }) | |
| } | |
| MiniCalendarDock( | |
| isDarkTheme = isDarkTheme, | |
| onClick = { isCalendarOpen = true }, | |
| modifier = Modifier | |
| .align(Alignment.BottomCenter) | |
| .padding(bottom = 32.dp) | |
| .graphicsLayer { | |
| alpha = (1f - detailsProgress) * (1f - pullAnim.value) | |
| translationY = | |
| (50.dp.toPx() * detailsProgress) + (100.dp.toPx() * pullAnim.value) | |
| } | |
| ) | |
| } | |
| // --- LAYER B: THE CALENDAR WORLD --- | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .graphicsLayer { | |
| translationY = size.height * (1f - calendarProgress) | |
| alpha = if (calendarProgress == 0f) 0f else 1f | |
| } | |
| ) { | |
| CalendarView( | |
| isDarkTheme = isDarkTheme, | |
| onClose = { isCalendarOpen = false } | |
| ) | |
| } | |
| // --- LAYER C: THE GLASS FORGE (POP-UP) --- | |
| if (isCreatingEvent) { | |
| EventForgeModal( | |
| isDarkTheme = isDarkTheme, | |
| onDismiss = { isCreatingEvent = false }, | |
| onSave = { newEvent -> | |
| todaySchedule.add(newEvent) | |
| isCreatingEvent = false | |
| } | |
| ) | |
| } | |
| } | |
| } | |
| /** | |
| * Handles the display of ambient context (Date, Time, Location) on the left side of the screen | |
| * when the right-aligned detail panel is opened. | |
| */ | |
| @Composable | |
| fun AmbientDashboard( | |
| isDarkTheme: Boolean, | |
| progress: Float, | |
| onAddClick: () -> Unit, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val textColor = if (isDarkTheme) Color.White else Color(0xFF1D1D1D) | |
| val gradC1 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00F0FF) else Color(0xFF1A47FF), | |
| tween(800), | |
| label = "g1" | |
| ) | |
| val gradC2 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF7000FF) else Color(0xFF8A2BE2), | |
| tween(800), | |
| label = "g2" | |
| ) | |
| val dateGradient = Brush.linearGradient(listOf(gradC1, gradC2)) | |
| val today = LocalDate.now() | |
| val formatterMonth = DateTimeFormatter.ofPattern("MMMM") | |
| Box( | |
| modifier = modifier | |
| .graphicsLayer { | |
| translationX = -50f * (1f - progress) | |
| alpha = progress | |
| } | |
| .padding(start = 32.dp, top = 100.dp, bottom = 48.dp) | |
| ) { | |
| Column(modifier = Modifier.fillMaxSize()) { | |
| Text( | |
| text = today.dayOfMonth.toString(), | |
| style = TextStyle( | |
| fontSize = 110.sp, | |
| fontWeight = FontWeight.W900, | |
| brush = dateGradient, | |
| letterSpacing = (-4).sp | |
| ), | |
| modifier = Modifier.offset(x = (-4).dp) | |
| ) | |
| Text( | |
| text = today.format(formatterMonth).uppercase(), | |
| style = TextStyle( | |
| fontSize = 18.sp, | |
| fontWeight = FontWeight.Bold, | |
| letterSpacing = 8.sp, | |
| color = textColor | |
| ), | |
| modifier = Modifier.offset(y = (-20).dp) | |
| ) | |
| Spacer(modifier = Modifier.height(16.dp)) | |
| Box( | |
| modifier = Modifier | |
| .width(48.dp) | |
| .height(2.dp) | |
| .background(textColor.copy(alpha = 0.1f)) | |
| ) | |
| Spacer(modifier = Modifier.height(24.dp)) | |
| Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| Icon( | |
| imageVector = Icons.Rounded.LightMode, | |
| contentDescription = "Sunrise", | |
| tint = gradC1, | |
| modifier = Modifier.size(20.dp) | |
| ) | |
| Spacer(modifier = Modifier.width(12.dp)) | |
| Text( | |
| "06:42", | |
| style = TextStyle( | |
| fontSize = 15.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = textColor | |
| ) | |
| ) | |
| } | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| Icon( | |
| imageVector = Icons.Rounded.NightsStay, | |
| contentDescription = "Sunset", | |
| tint = gradC2, | |
| modifier = Modifier.size(20.dp) | |
| ) | |
| Spacer(modifier = Modifier.width(12.dp)) | |
| Text( | |
| "19:50", | |
| style = TextStyle( | |
| fontSize = 15.sp, | |
| fontWeight = FontWeight.Medium, | |
| color = textColor.copy(alpha = 0.5f) | |
| ) | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(48.dp)) | |
| Row(verticalAlignment = Alignment.CenterVertically) { | |
| Icon( | |
| imageVector = Icons.Rounded.LocationOn, | |
| contentDescription = "Location", | |
| tint = textColor.copy(alpha = 0.3f), | |
| modifier = Modifier.size(16.dp) | |
| ) | |
| Spacer(modifier = Modifier.width(8.dp)) | |
| Text( | |
| "CHALCIS, GR", | |
| style = TextStyle( | |
| fontSize = 11.sp, | |
| fontWeight = FontWeight.Bold, | |
| letterSpacing = 1.sp, | |
| color = textColor.copy(alpha = 0.3f) | |
| ) | |
| ) | |
| } | |
| Spacer(modifier = Modifier.weight(1f)) | |
| val buttonBgColor by animateColorAsState( | |
| targetValue = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, | |
| tween(500), | |
| label = "btnBg" | |
| ) | |
| val iconColor by animateColorAsState( | |
| targetValue = if (isDarkTheme) Color(0xFFFFB703) else Color( | |
| 0xFF1D1D1D | |
| ), tween(500), label = "iconColor" | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .size(64.dp) | |
| .clip(CircleShape) | |
| .background(buttonBgColor) | |
| .clickable { onAddClick() }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Icon( | |
| imageVector = Icons.Rounded.Add, | |
| contentDescription = "Add Event", | |
| tint = iconColor, | |
| modifier = Modifier.size(36.dp) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * A highly tailored custom modal allowing tactile time scheduling. Features a custom staggered | |
| * internal interpolation engine to circumvent Compose visibility limitations, guaranteeing buttery-smooth | |
| * sequential item rendering without inflating GC calls. | |
| */ | |
| @Composable | |
| fun EventForgeModal( | |
| isDarkTheme: Boolean, | |
| onDismiss: () -> Unit, | |
| onSave: (ScheduleEvent) -> Unit | |
| ) { | |
| val density = LocalDensity.current | |
| var selectedType by remember { mutableStateOf(EventType.FOCUS) } | |
| var eventTitle by remember { mutableStateOf("") } | |
| var eventDetails by remember { mutableStateOf("") } | |
| val glowColor = when (selectedType) { | |
| EventType.FOCUS -> if (isDarkTheme) Color(0xFF9D4EDD) else Color(0xFF5A189A) | |
| EventType.COLLABORATION -> if (isDarkTheme) Color(0xFFF4A261) else Color(0xFFE76F51) | |
| EventType.REST -> if (isDarkTheme) Color(0xFF00B8FF) else Color(0xFF2A9D8F) | |
| } | |
| val animatedGlow by animateColorAsState(glowColor, tween(400), label = "forgeGlow") | |
| val modalBg = if (isDarkTheme) Color(0xFF16161A) else Color.White | |
| val textColor = if (isDarkTheme) Color.White else Color(0xFF1D1D1D) | |
| // INTERCEPT ENGINE: Manages perfect enter/exit staggers without fighting Compose Visibility | |
| var isClosing by remember { mutableStateOf(false) } | |
| val scope = rememberCoroutineScope() | |
| val containerAnim = remember { Animatable(0f) } | |
| val itemAnims = remember { List(4) { Animatable(0f) } } | |
| LaunchedEffect(isClosing) { | |
| if (!isClosing) { | |
| launch { containerAnim.animateTo(1f, spring(dampingRatio = 0.8f, stiffness = 250f)) } | |
| itemAnims.forEachIndexed { index, anim -> | |
| launch { | |
| delay(60L + (index * 40L)) | |
| anim.animateTo(1f, spring(dampingRatio = 0.6f, stiffness = 150f)) | |
| } | |
| } | |
| } else { | |
| itemAnims.reversed().forEachIndexed { index, anim -> | |
| launch { | |
| delay(index * 30L) | |
| anim.animateTo(0f, tween(200, easing = FastOutSlowInEasing)) | |
| } | |
| } | |
| launch { | |
| delay(100) | |
| containerAnim.animateTo(0f, tween(250, easing = FastOutSlowInEasing)) | |
| } | |
| } | |
| } | |
| fun closeWithAction(action: () -> Unit) { | |
| if (isClosing) return | |
| isClosing = true | |
| scope.launch { | |
| delay(350) | |
| action() | |
| } | |
| } | |
| val startBaseHour = 8f | |
| val endBaseHour = 20f | |
| val totalHours = endBaseHour - startBaseHour | |
| val ribbonHeight = 180.dp | |
| val ribbonHeightPx = with(density) { ribbonHeight.toPx() } | |
| val blockDurationHours = 1.5f | |
| val blockHeightPx = (blockDurationHours / totalHours) * ribbonHeightPx | |
| var dragOffsetY by remember { mutableStateOf(0f) } | |
| val currentStartHour = remember(dragOffsetY) { | |
| val fraction = (dragOffsetY / (ribbonHeightPx - blockHeightPx)).coerceIn(0f, 1f) | |
| val rawHour = startBaseHour + (fraction * (totalHours - blockDurationHours)) | |
| (rawHour * 4).roundToInt() / 4f | |
| } | |
| val currentEndHour = currentStartHour + blockDurationHours | |
| val startHourInt = currentStartHour.toInt() | |
| val startMinInt = ((currentStartHour - startHourInt) * 60).toInt() | |
| val startTimeStr = | |
| LocalTime.of(startHourInt, startMinInt).format(DateTimeFormatter.ofPattern("HH:mm")) | |
| val endHourInt = currentEndHour.toInt() | |
| val endMinInt = ((currentEndHour - endHourInt) * 60).toInt() | |
| val endTimeStr = | |
| LocalTime.of(endHourInt, endMinInt).format(DateTimeFormatter.ofPattern("HH:mm")) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color.Black.copy(alpha = containerAnim.value * 0.6f)) | |
| .pointerInput(Unit) { detectTapGestures(onTap = { closeWithAction(onDismiss) }) }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .width(340.dp) | |
| .graphicsLayer { | |
| scaleX = 0.9f + (0.1f * containerAnim.value) | |
| scaleY = 0.9f + (0.1f * containerAnim.value) | |
| alpha = containerAnim.value | |
| translationY = 50f * (1f - containerAnim.value) | |
| } | |
| .clip(RoundedCornerShape(40.dp)) | |
| .background(modalBg) | |
| .border(1.dp, Color.White.copy(alpha = 0.1f), RoundedCornerShape(40.dp)) | |
| .pointerInput(Unit) { detectTapGestures { } } | |
| ) { | |
| Column( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(32.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| // [0] TITLE & DETAILS | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .graphicsLayer { | |
| translationY = 40f * (1f - itemAnims[0].value); alpha = | |
| itemAnims[0].value | |
| }) { | |
| Column { | |
| BasicTextField( | |
| value = eventTitle, | |
| onValueChange = { eventTitle = it }, | |
| textStyle = TextStyle( | |
| fontSize = 24.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = textColor | |
| ), | |
| cursorBrush = SolidColor(animatedGlow), | |
| decorationBox = { innerTextField -> | |
| if (eventTitle.isEmpty()) Text( | |
| "Event Title", | |
| style = TextStyle( | |
| fontSize = 24.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = textColor.copy(alpha = 0.2f) | |
| ) | |
| ) | |
| innerTextField() | |
| } | |
| ) | |
| Spacer(modifier = Modifier.height(8.dp)) | |
| BasicTextField( | |
| value = eventDetails, | |
| onValueChange = { eventDetails = it }, | |
| textStyle = TextStyle( | |
| fontSize = 14.sp, | |
| fontWeight = FontWeight.Medium, | |
| color = textColor.copy(alpha = 0.6f) | |
| ), | |
| cursorBrush = SolidColor(animatedGlow), | |
| decorationBox = { innerTextField -> | |
| if (eventDetails.isEmpty()) Text( | |
| "Add details...", | |
| style = TextStyle( | |
| fontSize = 14.sp, | |
| fontWeight = FontWeight.Medium, | |
| color = textColor.copy(alpha = 0.2f) | |
| ) | |
| ) | |
| innerTextField() | |
| } | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(32.dp)) | |
| // [1] EXPANDING PILL SELECTOR | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .graphicsLayer { | |
| translationY = 40f * (1f - itemAnims[1].value); alpha = | |
| itemAnims[1].value | |
| }, | |
| horizontalArrangement = Arrangement.spacedBy(12.dp) | |
| ) { | |
| EventType.values().forEach { type -> | |
| val isSelected = selectedType == type | |
| val typeColor = when (type) { | |
| EventType.FOCUS -> if (isDarkTheme) Color(0xFF9D4EDD) else Color( | |
| 0xFF5A189A | |
| ) | |
| EventType.COLLABORATION -> if (isDarkTheme) Color(0xFFF4A261) else Color( | |
| 0xFFE76F51 | |
| ) | |
| EventType.REST -> if (isDarkTheme) Color(0xFF00B8FF) else Color( | |
| 0xFF2A9D8F | |
| ) | |
| } | |
| val pillWidth by animateDpAsState( | |
| if (isSelected) 120.dp else 24.dp, | |
| spring(dampingRatio = 0.7f, stiffness = 150f), | |
| label = "pillWidth" | |
| ) | |
| val pillBg by animateColorAsState( | |
| if (isSelected) typeColor else Color.Transparent, | |
| tween(300), | |
| label = "pillBg" | |
| ) | |
| val pillBorder = | |
| if (isSelected) Color.Transparent else textColor.copy(alpha = 0.2f) | |
| Box( | |
| modifier = Modifier | |
| .height(32.dp) | |
| .width(pillWidth) | |
| .clip(RoundedCornerShape(16.dp)) | |
| .background(pillBg) | |
| .border(1.dp, pillBorder, RoundedCornerShape(16.dp)) | |
| .clickable { selectedType = type }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| if (isSelected) { | |
| Text( | |
| text = type.name, | |
| style = TextStyle( | |
| fontSize = 11.sp, | |
| fontWeight = FontWeight.Bold, | |
| letterSpacing = 1.sp, | |
| color = Color.White | |
| ) | |
| ) | |
| } else { | |
| Box( | |
| modifier = Modifier | |
| .size(10.dp) | |
| .clip(CircleShape) | |
| .background(typeColor) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(40.dp)) | |
| // [2] TACTILE TIME RIBBON | |
| Row( | |
| verticalAlignment = Alignment.CenterVertically, | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .graphicsLayer { | |
| translationY = 40f * (1f - itemAnims[2].value); alpha = | |
| itemAnims[2].value | |
| } | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .width(4.dp) | |
| .height(ribbonHeight) | |
| .clip(CircleShape) | |
| .background(textColor.copy(alpha = 0.1f)) | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .offset { IntOffset(0, dragOffsetY.roundToInt()) } | |
| .width(4.dp) | |
| .height(with(density) { blockHeightPx.toDp() }) | |
| .clip(CircleShape) | |
| .background(animatedGlow) | |
| ) | |
| Box( | |
| modifier = Modifier | |
| .offset { IntOffset(-40, dragOffsetY.roundToInt()) } | |
| .width(84.dp) | |
| .height(with(density) { blockHeightPx.toDp() }) | |
| .pointerInput(Unit) { | |
| detectVerticalDragGestures { change, dragAmount -> | |
| change.consume(); dragOffsetY = | |
| (dragOffsetY + dragAmount).coerceIn( | |
| 0f, | |
| ribbonHeightPx - blockHeightPx | |
| ) | |
| } | |
| } | |
| ) | |
| } | |
| Spacer(modifier = Modifier.width(32.dp)) | |
| Column { | |
| Text( | |
| text = startTimeStr, | |
| style = TextStyle( | |
| fontSize = 32.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = textColor | |
| ) | |
| ) | |
| Spacer(modifier = Modifier.height(4.dp)) | |
| Text( | |
| text = endTimeStr, | |
| style = TextStyle( | |
| fontSize = 24.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = textColor.copy(alpha = 0.3f) | |
| ) | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(40.dp)) | |
| // [3] ADD BUTTON | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(64.dp) | |
| .clip(RoundedCornerShape(20.dp)) | |
| .background(animatedGlow) | |
| .clickable { | |
| val newEvent = ScheduleEvent( | |
| name = if (eventTitle.isNotBlank()) eventTitle else "New ${ | |
| selectedType.name.lowercase() | |
| .replaceFirstChar { it.uppercase() } | |
| }", | |
| startTime = LocalTime.of(startHourInt, startMinInt), | |
| endTime = LocalTime.of(endHourInt, endMinInt), | |
| type = selectedType, | |
| details = eventDetails.ifBlank { null } | |
| ) | |
| closeWithAction { onSave(newEvent) } | |
| } | |
| .graphicsLayer { | |
| translationY = 40f * (1f - itemAnims[3].value); alpha = | |
| itemAnims[3].value | |
| }, | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Text( | |
| "ADD TO SCHEDULE", | |
| style = TextStyle( | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = Color.White, | |
| letterSpacing = 1.5.sp | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // --- SUB-COMPONENTS --- | |
| @Composable | |
| fun ScheduleDetailsPanel( | |
| schedule: List<ScheduleEvent>, | |
| isDarkTheme: Boolean, | |
| progress: Float, | |
| selectedEvent: ScheduleEvent?, | |
| onEventClick: (ScheduleEvent) -> Unit, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val textColor = if (isDarkTheme) Color.White else Color(0xFF1D1D1D) | |
| val panelBg = | |
| if (isDarkTheme) Color(0xFF16161A).copy(alpha = 0.95f) else Color.White.copy(alpha = 0.95f) | |
| Box( | |
| modifier = modifier | |
| .graphicsLayer { | |
| translationX = size.width * (1f - progress); alpha = progress | |
| } | |
| .clip(RoundedCornerShape(topStart = 48.dp, bottomStart = 48.dp)) | |
| .background(panelBg) | |
| .padding(start = 32.dp, end = 32.dp, top = 64.dp) | |
| ) { | |
| Column(modifier = Modifier.fillMaxSize()) { | |
| Spacer(modifier = Modifier.height(240.dp)) | |
| Text( | |
| text = "TODAY'S FLOW", | |
| style = TextStyle( | |
| fontSize = 28.sp, | |
| fontWeight = FontWeight.W900, | |
| letterSpacing = 1.5.sp, | |
| color = textColor | |
| ) | |
| ) | |
| Spacer(modifier = Modifier.height(24.dp)) | |
| LazyColumn( | |
| verticalArrangement = Arrangement.spacedBy(16.dp), | |
| contentPadding = PaddingValues(bottom = 48.dp), | |
| modifier = Modifier.fillMaxSize() | |
| ) { | |
| items(schedule) { event -> | |
| EventCard( | |
| event = event, | |
| isDarkTheme = isDarkTheme, | |
| isSelected = event == selectedEvent, | |
| onClick = { onEventClick(event) }) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun MiniCalendarDock(isDarkTheme: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { | |
| val dockBg by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF1A1A1C).copy(alpha = 0.85f) else Color.White.copy( | |
| alpha = 0.85f | |
| ), tween(800), label = "dockBg" | |
| ) | |
| val dockBorder by animateColorAsState( | |
| if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy( | |
| alpha = 0.05f | |
| ), tween(800), label = "dockBorder" | |
| ) | |
| val textCol by animateColorAsState( | |
| if (isDarkTheme) Color.White else Color.Black, | |
| tween(800), | |
| label = "dockText" | |
| ) | |
| val gradC1 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00F0FF) else Color(0xFF1A47FF), | |
| tween(800), | |
| label = "g1" | |
| ) | |
| val gradC2 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF7000FF) else Color(0xFF8A2BE2), | |
| tween(800), | |
| label = "g2" | |
| ) | |
| val activeGradient = Brush.linearGradient(listOf(gradC1, gradC2)) | |
| val today = remember { LocalDate.now() } | |
| val startOfWeek = remember(today) { today.minusDays(today.dayOfWeek.value.toLong() - 1) } | |
| val weekDays = remember(startOfWeek) { (0..6).map { startOfWeek.plusDays(it.toLong()) } } | |
| Column( | |
| modifier = modifier | |
| .clip(RoundedCornerShape(24.dp)) | |
| .background(dockBg) | |
| .border(1.dp, dockBorder, RoundedCornerShape(24.dp)) | |
| .clickable { onClick() } | |
| .padding(horizontal = 16.dp, vertical = 12.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| Row( | |
| horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| weekDays.forEach { date -> | |
| val isToday = date == today | |
| val dayInitial = | |
| date.dayOfWeek.getDisplayName(TimeTextStyle.NARROW, Locale.getDefault()) | |
| Column( | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| modifier = Modifier | |
| .width(34.dp) | |
| .clip(RoundedCornerShape(20.dp)) | |
| .background( | |
| if (isToday) activeGradient else Brush.linearGradient( | |
| listOf( | |
| Color.Transparent, | |
| Color.Transparent | |
| ) | |
| ) | |
| ) | |
| .padding(vertical = if (isToday) 10.dp else 6.dp) | |
| ) { | |
| Text( | |
| text = dayInitial, | |
| fontSize = 10.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = if (isToday) Color.White else textCol.copy(alpha = 0.4f) | |
| ) | |
| Spacer(modifier = Modifier.height(2.dp)) | |
| Text( | |
| text = date.dayOfMonth.toString(), | |
| fontSize = 14.sp, | |
| fontWeight = if (isToday) FontWeight.ExtraBold else FontWeight.Bold, | |
| color = if (isToday) Color.White else textCol | |
| ) | |
| if (isToday) { | |
| Spacer(modifier = Modifier.height(4.dp)) | |
| Box( | |
| modifier = Modifier | |
| .size(4.dp) | |
| .clip(CircleShape) | |
| .background(Color.White) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun CalendarView(isDarkTheme: Boolean, onClose: () -> Unit) { | |
| val bgColor by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF0F0F11) else Color(0xFFF4EFE6), | |
| tween(800), | |
| label = "bg" | |
| ) | |
| val textColor by animateColorAsState( | |
| if (isDarkTheme) Color.White else Color(0xFF1D1D1D), | |
| tween(800), | |
| label = "text" | |
| ) | |
| val gradC1 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF00F0FF) else Color(0xFF1A47FF), | |
| tween(800), | |
| label = "g1" | |
| ) | |
| val gradC2 by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF7000FF) else Color(0xFF8A2BE2), | |
| tween(800), | |
| label = "g2" | |
| ) | |
| val today = remember { LocalDate.now() } | |
| val daysCount = 60 | |
| val middleIndex = 30 | |
| val dialState = rememberPagerState(initialPage = middleIndex, pageCount = { daysCount }) | |
| val selectedDate = | |
| remember(dialState.currentPage) { today.plusDays((dialState.currentPage - middleIndex).toLong()) } | |
| val selectedEvents = remember(selectedDate) { generateScheduleForDate(selectedDate) } | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(bgColor) | |
| ) { | |
| Column(modifier = Modifier.fillMaxSize()) { | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .padding(top = 48.dp, start = 24.dp, end = 24.dp), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Column { | |
| Text( | |
| text = selectedDate.format(DateTimeFormatter.ofPattern("MMMM")).uppercase(), | |
| style = TextStyle( | |
| fontSize = 12.sp, | |
| fontWeight = FontWeight.Bold, | |
| letterSpacing = 2.sp, | |
| color = textColor.copy(alpha = 0.5f) | |
| ) | |
| ) | |
| Text( | |
| text = selectedDate.format(DateTimeFormatter.ofPattern("yyyy")), | |
| style = TextStyle( | |
| fontSize = 28.sp, | |
| fontWeight = FontWeight.W900, | |
| letterSpacing = 1.5.sp, | |
| color = textColor | |
| ) | |
| ) | |
| } | |
| Box( | |
| modifier = Modifier | |
| .size(48.dp) | |
| .clip(CircleShape) | |
| .background(if (isDarkTheme) Color(0xFF2C2C2E) else Color.White) | |
| .clickable { onClose() }, contentAlignment = Alignment.Center | |
| ) { | |
| Icon( | |
| imageVector = Icons.Rounded.KeyboardArrowDown, | |
| contentDescription = "Close Calendar", | |
| tint = textColor, | |
| modifier = Modifier.size(32.dp) | |
| ) | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(32.dp)) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .height(100.dp), | |
| contentAlignment = Alignment.Center | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .size(width = 64.dp, height = 80.dp) | |
| .clip(RoundedCornerShape(24.dp)) | |
| .background( | |
| Brush.linearGradient( | |
| listOf( | |
| gradC1.copy(alpha = 0.2f), | |
| gradC2.copy(alpha = 0.2f) | |
| ) | |
| ) | |
| ) | |
| .border( | |
| 1.dp, | |
| Brush.linearGradient(listOf(gradC1, gradC2)), | |
| RoundedCornerShape(24.dp) | |
| ) | |
| ) | |
| val screenWidth = LocalConfiguration.current.screenWidthDp.dp | |
| val itemWidth = 64.dp | |
| val horizontalPadding = (screenWidth / 2) - (itemWidth / 2) | |
| HorizontalPager( | |
| state = dialState, | |
| pageSize = PageSize.Fixed(itemWidth), | |
| contentPadding = PaddingValues(horizontal = horizontalPadding), | |
| modifier = Modifier.fillMaxWidth() | |
| ) { page -> | |
| val pageOffset = | |
| ((dialState.currentPage - page) + dialState.currentPageOffsetFraction).coerceIn( | |
| -3f, | |
| 3f | |
| ) | |
| val absOffset = abs(pageOffset) | |
| val scale = 1f - (0.2f * absOffset).coerceAtMost(0.4f) | |
| val alpha = 1f - (0.3f * absOffset).coerceAtMost(0.8f) | |
| val rotationY = pageOffset * 25f | |
| val translationY = absOffset * 10.dp.toPx() | |
| val dateForPage = today.plusDays((page - middleIndex).toLong()) | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .graphicsLayer { | |
| this.scaleX = scale; this.scaleY = scale; this.alpha = | |
| alpha; this.rotationY = rotationY; this.translationY = | |
| translationY; cameraDistance = 12f * density | |
| }, contentAlignment = Alignment.Center | |
| ) { | |
| Column(horizontalAlignment = Alignment.CenterHorizontally) { | |
| Text( | |
| text = dateForPage.dayOfWeek.getDisplayName( | |
| TimeTextStyle.SHORT, | |
| Locale.getDefault() | |
| ).uppercase(), | |
| fontSize = 11.sp, | |
| fontWeight = FontWeight.Bold, | |
| color = textColor.copy(alpha = 0.5f) | |
| ) | |
| Spacer(modifier = Modifier.height(4.dp)) | |
| Text( | |
| text = dateForPage.dayOfMonth.toString(), | |
| fontSize = 24.sp, | |
| fontWeight = FontWeight.ExtraBold, | |
| color = textColor | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| Spacer(modifier = Modifier.height(32.dp)) | |
| AnimatedContent( | |
| targetState = dialState.currentPage, | |
| transitionSpec = { | |
| val isMovingRight = targetState > initialState; | |
| val slideDirection = if (isMovingRight) 1 else -1; (slideInVertically( | |
| spring( | |
| stiffness = Spring.StiffnessLow | |
| ) | |
| ) { height -> slideDirection * (height / 4) } + fadeIn(tween(300))) togetherWith (slideOutVertically( | |
| spring(stiffness = Spring.StiffnessLow) | |
| ) { height -> -slideDirection * (height / 4) } + fadeOut(tween(300))) | |
| }, | |
| label = "AgendaCascade" | |
| ) { _ -> | |
| LazyColumn( | |
| modifier = Modifier.fillMaxSize(), | |
| contentPadding = PaddingValues(start = 24.dp, end = 24.dp, bottom = 100.dp), | |
| verticalArrangement = Arrangement.spacedBy(16.dp) | |
| ) { | |
| if (selectedEvents.isEmpty()) { | |
| item { | |
| Text( | |
| text = "No scheduled blocks. Enjoy the space.", | |
| style = TextStyle( | |
| fontSize = 16.sp, | |
| fontWeight = FontWeight.Medium, | |
| color = textColor.copy(alpha = 0.3f) | |
| ), | |
| modifier = Modifier.padding(top = 24.dp) | |
| ) | |
| } | |
| } else { | |
| items(selectedEvents) { event -> | |
| CalendarEventChip( | |
| event = event, | |
| isDarkTheme = isDarkTheme | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun CalendarEventChip(event: ScheduleEvent, isDarkTheme: Boolean) { | |
| val formatter = DateTimeFormatter.ofPattern("HH:mm") | |
| val baseColor = when (event.type) { | |
| EventType.FOCUS -> if (isDarkTheme) Color(0xFF9D4EDD) else Color(0xFF5A189A); EventType.COLLABORATION -> if (isDarkTheme) Color( | |
| 0xFFF4A261 | |
| ) else Color(0xFFE76F51); EventType.REST -> if (isDarkTheme) Color(0xFF00B8FF) else Color( | |
| 0xFF2A9D8F | |
| ) | |
| } | |
| val animatedBaseColor by animateColorAsState(baseColor, tween(800), label = "pillBase") | |
| val textColor by animateColorAsState( | |
| if (isDarkTheme) Color.White else Color.Black, | |
| tween(800), | |
| label = "pillText" | |
| ) | |
| val pillBg by animateColorAsState( | |
| if (isDarkTheme) Color(0xFF1A1A1C) else Color(0xFFEBE6DD), | |
| tween(800), | |
| label = "pillBg" | |
| ) | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .clip(RoundedCornerShape(20.dp)) | |
| .background(pillBg) | |
| .padding(16.dp), verticalAlignment = Alignment.CenterVertically | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .width(4.dp) | |
| .height(40.dp) | |
| .clip(RoundedCornerShape(4.dp)) | |
| .background(animatedBaseColor) | |
| ) | |
| Spacer(modifier = Modifier.width(16.dp)) | |
| Column { | |
| Text( | |
| text = event.name, | |
| style = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Bold, color = textColor) | |
| ) | |
| Spacer(modifier = Modifier.height(4.dp)) | |
| Text( | |
| text = "${event.startTime.format(formatter)} - ${event.endTime.format(formatter)}", | |
| style = TextStyle( | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Medium, | |
| color = textColor.copy(alpha = 0.5f) | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| @Composable | |
| fun EventCard( | |
| event: ScheduleEvent, | |
| isDarkTheme: Boolean, | |
| isSelected: Boolean, | |
| onClick: () -> Unit | |
| ) { | |
| val formatter = DateTimeFormatter.ofPattern("HH:mm") | |
| val accentColor = when (event.type) { | |
| EventType.FOCUS -> if (isDarkTheme) Color(0xFF9D4EDD) else Color(0xFF5A189A); EventType.COLLABORATION -> if (isDarkTheme) Color( | |
| 0xFFF4A261 | |
| ) else Color(0xFFE76F51); EventType.REST -> if (isDarkTheme) Color(0xFF00B8FF) else Color( | |
| 0xFF2A9D8F | |
| ) | |
| } | |
| val cardBg = if (isSelected) { | |
| if (isDarkTheme) Color(0xFF323236) else Color(0xFFE6DFD3) | |
| } else { | |
| if (isDarkTheme) Color(0xFF222226) else Color(0xFFF0EBE1) | |
| } | |
| val textColor = if (isDarkTheme) Color.White else Color.Black | |
| Row( | |
| modifier = Modifier | |
| .fillMaxWidth() | |
| .clip(RoundedCornerShape(20.dp)) | |
| .background(cardBg) | |
| .clickable { onClick() } | |
| .padding(16.dp), verticalAlignment = Alignment.Top | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .padding(top = 6.dp) | |
| .size(12.dp) | |
| .clip(CircleShape) | |
| .background(accentColor) | |
| ) | |
| Spacer(modifier = Modifier.width(16.dp)) | |
| Column { | |
| Text( | |
| text = event.name, | |
| style = TextStyle(fontSize = 18.sp, fontWeight = FontWeight.Bold, color = textColor) | |
| ) | |
| Spacer(modifier = Modifier.height(4.dp)) | |
| Text( | |
| text = "${event.startTime.format(formatter)} - ${event.endTime.format(formatter)} • ${event.type.name}", | |
| style = TextStyle( | |
| fontSize = 12.sp, | |
| fontWeight = FontWeight.Medium, | |
| color = textColor.copy(alpha = 0.5f) | |
| ) | |
| ) | |
| if (event.details != null) { | |
| Spacer(modifier = Modifier.height(8.dp)); Text( | |
| text = event.details, | |
| style = TextStyle( | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Normal, | |
| color = textColor.copy(alpha = 0.7f), | |
| lineHeight = 18.sp | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun AmPmToggle( | |
| isAm: Boolean, | |
| isDarkTheme: Boolean, | |
| onToggle: () -> Unit, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val buttonBgColor by animateColorAsState( | |
| targetValue = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, | |
| tween(500), | |
| label = "btnBg" | |
| ) | |
| val textColor by animateColorAsState( | |
| targetValue = if (isDarkTheme) Color(0xFFFFB703) else Color( | |
| 0xFF1D1D1D | |
| ), tween(500), label = "textColor" | |
| ) | |
| Box( | |
| modifier = modifier | |
| .size(48.dp) | |
| .clip(CircleShape) | |
| .background(buttonBgColor) | |
| .clickable { onToggle() }, contentAlignment = Alignment.Center | |
| ) { | |
| Text( | |
| text = if (isAm) "AM" else "PM", | |
| style = TextStyle(fontSize = 15.sp, fontWeight = FontWeight.Bold, color = textColor) | |
| ) | |
| } | |
| } | |
| @Composable | |
| fun DayNightToggle(isDarkTheme: Boolean, onToggle: () -> Unit, modifier: Modifier = Modifier) { | |
| val buttonBgColor by animateColorAsState( | |
| targetValue = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White, | |
| tween(500), | |
| label = "btnBg" | |
| ) | |
| val iconColor by animateColorAsState( | |
| targetValue = if (isDarkTheme) Color(0xFFFFB703) else Color( | |
| 0xFF1D1D1D | |
| ), tween(500), label = "iconColor" | |
| ) | |
| val morphProgress by animateFloatAsState( | |
| targetValue = if (isDarkTheme) 1f else 0f, | |
| spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), | |
| label = "morph" | |
| ) | |
| Box( | |
| modifier = modifier | |
| .size(48.dp) | |
| .clip(CircleShape) | |
| .background(buttonBgColor) | |
| .clickable { onToggle() }, contentAlignment = Alignment.Center | |
| ) { | |
| Canvas(modifier = Modifier.size(26.dp)) { | |
| val center = Offset(size.width / 2, size.height / 2) | |
| val radius = size.width / 2.5f | |
| rotate(morphProgress * -135f) { | |
| val rayAlpha = morphProgress | |
| if (rayAlpha > 0.01f) { | |
| for (i in 0 until 8) { | |
| val angle = (i * 45f) * DEG_TO_RAD | |
| val rayStart = radius + 3.dp.toPx() | |
| val rayEnd = rayStart + (5.dp.toPx() * rayAlpha) | |
| drawLine( | |
| color = iconColor.copy(alpha = rayAlpha), | |
| start = Offset( | |
| center.x + rayStart * cos(angle), | |
| center.y + rayStart * sin(angle) | |
| ), | |
| end = Offset( | |
| center.x + rayEnd * cos(angle), | |
| center.y + rayEnd * sin(angle) | |
| ), | |
| strokeWidth = 2.dp.toPx(), | |
| cap = StrokeCap.Round | |
| ) | |
| } | |
| } | |
| drawCircle(color = iconColor, radius = radius, center = center) | |
| val cutoutProgress = 1f - morphProgress | |
| if (cutoutProgress > 0.01f) { | |
| val cutoutOffset = | |
| Offset(center.x - (radius * 0.35f), center.y - (radius * 0.45f)) | |
| drawCircle( | |
| color = buttonBgColor, | |
| radius = radius * 0.95f * cutoutProgress, | |
| center = cutoutOffset | |
| ) | |
| } | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment