Skip to content

Instantly share code, notes, and snippets.

@jacksonfdam
Forked from Kyriakos-Georgiopoulos/ClockScreen.kt
Created March 31, 2026 15:10
Show Gist options
  • Select an option

  • Save jacksonfdam/0f98a3dbf17f5b360e6aa702cd517079 to your computer and use it in GitHub Desktop.

Select an option

Save jacksonfdam/0f98a3dbf17f5b360e6aa702cd517079 to your computer and use it in GitHub Desktop.
/*
* 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