├── .gitignore ├── .idea ├── .gitignore ├── AndroidProjectSystem.xml ├── compiler.xml ├── deploymentTargetSelector.xml ├── gradle.xml ├── inspectionProfiles │ └── Project_Default.xml ├── kotlinc.xml ├── migrations.xml ├── misc.xml ├── runConfigurations.xml └── vcs.xml ├── README.md ├── app ├── .gitignore ├── build.gradle.kts ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── binissa │ │ └── calendar │ │ └── ExampleInstrumentedTest.kt │ ├── main │ ├── AndroidManifest.xml │ ├── java │ │ └── com │ │ │ └── binissa │ │ │ └── calendar │ │ │ ├── App.kt │ │ │ ├── MainActivity.kt │ │ │ ├── calendar │ │ │ ├── CalendarContract.kt │ │ │ ├── CalendarCoordinator.kt │ │ │ ├── CalendarRoute.kt │ │ │ ├── CalendarScreen.kt │ │ │ ├── CalendarViewModel.kt │ │ │ ├── TaskItem.kt │ │ │ └── components │ │ │ │ ├── AnimatedCalendarHeader.kt │ │ │ │ ├── AnimatedTaskItem.kt │ │ │ │ ├── BlurTextReveal.kt │ │ │ │ ├── SwipeToDeleteContainer.kt │ │ │ │ ├── TaskDialog.kt │ │ │ │ ├── TaskItemCard.kt │ │ │ │ ├── TaskTypeButton.kt │ │ │ │ ├── WeekCalendar.kt │ │ │ │ └── morph │ │ │ │ ├── CharacterTransition.kt │ │ │ │ ├── DayNameMorph.kt │ │ │ │ ├── DirectionalTextTransition.kt │ │ │ │ ├── DirectionalTransitionConfig.kt │ │ │ │ └── TransitionControlPanel.kt │ │ │ └── ui │ │ │ └── theme │ │ │ ├── Color.kt │ │ │ ├── Theme.kt │ │ │ └── Type.kt │ └── res │ │ ├── drawable │ │ ├── ic_launcher_background.xml │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values │ │ ├── colors.xml │ │ ├── strings.xml │ │ └── themes.xml │ │ └── xml │ │ ├── backup_rules.xml │ │ └── data_extraction_rules.xml │ └── test │ └── java │ └── com │ └── binissa │ └── calendar │ └── ExampleUnitTest.kt ├── build.gradle.kts ├── gradle.properties ├── gradle ├── libs.versions.toml └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | local.properties 16 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | -------------------------------------------------------------------------------- /.idea/AndroidProjectSystem.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/deploymentTargetSelector.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 18 | 19 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 61 | -------------------------------------------------------------------------------- /.idea/kotlinc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/migrations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 17 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | https://github.com/user-attachments/assets/502cff83-03ba-4ca4-b4b3-b1ca4b135311 4 | 5 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /app/build.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | alias(libs.plugins.android.application) 3 | alias(libs.plugins.kotlin.android) 4 | alias(libs.plugins.kotlin.compose) 5 | alias(libs.plugins.ksp) 6 | alias(libs.plugins.hilt) 7 | } 8 | 9 | android { 10 | namespace = "com.binissa.calendar" 11 | compileSdk = 35 12 | 13 | defaultConfig { 14 | applicationId = "com.binissa.calendar" 15 | minSdk = 24 16 | targetSdk = 35 17 | versionCode = 1 18 | versionName = "1.0" 19 | 20 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 21 | } 22 | 23 | buildTypes { 24 | release { 25 | isMinifyEnabled = false 26 | proguardFiles( 27 | getDefaultProguardFile("proguard-android-optimize.txt"), 28 | "proguard-rules.pro" 29 | ) 30 | } 31 | } 32 | compileOptions { 33 | sourceCompatibility = JavaVersion.VERSION_11 34 | targetCompatibility = JavaVersion.VERSION_11 35 | } 36 | kotlinOptions { 37 | jvmTarget = "11" 38 | } 39 | buildFeatures { 40 | compose = true 41 | } 42 | } 43 | 44 | dependencies { 45 | 46 | implementation(libs.androidx.core.ktx) 47 | implementation(libs.androidx.lifecycle.runtime.ktx) 48 | implementation(libs.androidx.activity.compose) 49 | implementation(platform(libs.androidx.compose.bom)) 50 | implementation(libs.androidx.ui) 51 | implementation(libs.androidx.ui.graphics) 52 | implementation(libs.androidx.ui.tooling.preview) 53 | implementation(libs.androidx.material3) 54 | testImplementation(libs.junit) 55 | androidTestImplementation(libs.androidx.junit) 56 | androidTestImplementation(libs.androidx.espresso.core) 57 | androidTestImplementation(platform(libs.androidx.compose.bom)) 58 | androidTestImplementation(libs.androidx.ui.test.junit4) 59 | debugImplementation(libs.androidx.ui.tooling) 60 | debugImplementation(libs.androidx.ui.test.manifest) 61 | 62 | 63 | //Hilt 64 | implementation (libs.hilt.android) 65 | implementation (libs.androidx.hilt.navigation.compose) 66 | 67 | ksp(libs.dagger.compiler) 68 | ksp(libs.hilt.compiler) 69 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/androidTest/java/com/binissa/calendar/ExampleInstrumentedTest.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar 2 | 3 | import androidx.test.platform.app.InstrumentationRegistry 4 | import androidx.test.ext.junit.runners.AndroidJUnit4 5 | 6 | import org.junit.Test 7 | import org.junit.runner.RunWith 8 | 9 | import org.junit.Assert.* 10 | 11 | /** 12 | * Instrumented test, which will execute on an Android device. 13 | * 14 | * See [testing documentation](http://d.android.com/tools/testing). 15 | */ 16 | @RunWith(AndroidJUnit4::class) 17 | class ExampleInstrumentedTest { 18 | @Test 19 | fun useAppContext() { 20 | // Context of the app under test. 21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext 22 | assertEquals("com.binissa.calendar", appContext.packageName) 23 | } 24 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 16 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/App.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class App: Application() { 8 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.activity.enableEdgeToEdge 7 | import androidx.compose.foundation.layout.fillMaxSize 8 | import androidx.compose.foundation.layout.padding 9 | import androidx.compose.material3.Scaffold 10 | import androidx.compose.runtime.getValue 11 | import androidx.compose.runtime.mutableStateOf 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.setValue 14 | import androidx.compose.ui.Modifier 15 | import com.binissa.calendar.calendar.CalendarRoute 16 | import com.binissa.calendar.calendar.components.morph.DirectionalTextTransition 17 | import com.binissa.calendar.calendar.components.morph.DirectionalTransitionConfig 18 | import com.binissa.calendar.calendar.components.morph.TransitionControlPanel 19 | import com.binissa.calendar.ui.theme.CalendarTheme 20 | import dagger.hilt.android.AndroidEntryPoint 21 | 22 | @AndroidEntryPoint 23 | class MainActivity : ComponentActivity() { 24 | override fun onCreate(savedInstanceState: Bundle?) { 25 | super.onCreate(savedInstanceState) 26 | enableEdgeToEdge() 27 | setContent { 28 | CalendarTheme { 29 | var currentConfig by remember { mutableStateOf(DirectionalTransitionConfig()) } 30 | 31 | Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> 32 | TransitionControlPanel( 33 | modifier = Modifier 34 | .padding(innerPadding) 35 | .fillMaxSize(), 36 | onConfigChanged = { currentConfig = it } 37 | ) 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/CalendarContract.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar 2 | 3 | import java.time.LocalDate 4 | 5 | /** 6 | * UI State that represents CalendarScreen 7 | **/ 8 | data class CalendarState( 9 | val selectedDate: LocalDate = LocalDate.now(), 10 | val tasks: List = emptyList(), 11 | val isAddingTask: Boolean = false, 12 | val newTaskTitle: String = "", 13 | val editingTask: TaskItem? = null, 14 | val selectedDateEvents: Boolean = false 15 | ) 16 | 17 | /** 18 | * Calendar Actions emitted from the UI Layer 19 | * passed to the coordinator to handle 20 | **/ 21 | data class CalendarActions( 22 | val onDateSelected: (LocalDate) -> Unit = {}, 23 | val onTaskCompleteToggle: (TaskItem) -> Unit = {}, 24 | val onAddTaskClick: () -> Unit = {}, 25 | val onDismissAddTask: () -> Unit = {}, 26 | val onNewTaskTitleChange: (String) -> Unit = {}, 27 | val onSaveNewTask: (TaskType) -> Unit = {}, 28 | val onTaskClick: (TaskItem) -> Unit = {}, 29 | val onDeleteTask: (TaskItem) -> Unit = {}, 30 | val onSwipeToNextDay: () -> Unit = {}, 31 | val onSwipeToPreviousDay: () -> Unit = {} 32 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/CalendarCoordinator.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.remember 5 | import androidx.hilt.navigation.compose.hiltViewModel 6 | import java.time.LocalDate 7 | 8 | /** 9 | * Screen's coordinator which is responsible for handling actions from the UI layer 10 | * and one-shot actions based on the new UI state 11 | */ 12 | class CalendarCoordinator( 13 | val viewModel: CalendarViewModel 14 | ) { 15 | val screenStateFlow = viewModel.stateFlow 16 | 17 | fun onDateSelected(date: LocalDate) { 18 | viewModel.selectDate(date) 19 | } 20 | 21 | fun onTaskCompleteToggle(task: TaskItem) { 22 | viewModel.toggleTaskCompleted(task) 23 | } 24 | 25 | fun onAddTaskClick() { 26 | viewModel.showAddTask() 27 | } 28 | 29 | fun onDismissAddTask() { 30 | viewModel.dismissAddTask() 31 | } 32 | 33 | fun onNewTaskTitleChange(title: String) { 34 | viewModel.updateNewTaskTitle(title) 35 | } 36 | 37 | fun onSaveNewTask(type: TaskType) { 38 | viewModel.saveNewTask(type) 39 | } 40 | 41 | fun onTaskClick(task: TaskItem) { 42 | viewModel.editTask(task) 43 | } 44 | 45 | fun onDeleteTask(task: TaskItem) { 46 | viewModel.deleteTask(task) 47 | } 48 | 49 | fun onSwipeToNextDay() { 50 | viewModel.navigateToNextDay() 51 | } 52 | 53 | fun onSwipeToPreviousDay() { 54 | viewModel.navigateToPreviousDay() 55 | } 56 | } 57 | 58 | @Composable 59 | fun rememberCalendarCoordinator( 60 | viewModel: CalendarViewModel = hiltViewModel() 61 | ): CalendarCoordinator { 62 | return remember(viewModel) { 63 | CalendarCoordinator( 64 | viewModel = viewModel 65 | ) 66 | } 67 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/CalendarRoute.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.collectAsState 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.remember 7 | 8 | 9 | @Composable 10 | fun CalendarRoute( 11 | coordinator: CalendarCoordinator = rememberCalendarCoordinator() 12 | ) { 13 | // State observing and declarations 14 | val uiState by coordinator.screenStateFlow.collectAsState(CalendarState()) 15 | 16 | // UI Actions 17 | val actions = rememberCalendarActions(coordinator) 18 | 19 | // UI Rendering 20 | CalendarScreen(uiState, actions) 21 | } 22 | 23 | 24 | @Composable 25 | fun rememberCalendarActions(coordinator: CalendarCoordinator): CalendarActions { 26 | return remember(coordinator) { 27 | CalendarActions( 28 | onDateSelected = coordinator::onDateSelected, 29 | onTaskCompleteToggle = coordinator::onTaskCompleteToggle, 30 | onAddTaskClick = coordinator::onAddTaskClick, 31 | onDismissAddTask = coordinator::onDismissAddTask, 32 | onNewTaskTitleChange = coordinator::onNewTaskTitleChange, 33 | onSaveNewTask = coordinator::onSaveNewTask, 34 | onTaskClick = coordinator::onTaskClick, 35 | onDeleteTask = coordinator::onDeleteTask, 36 | onSwipeToNextDay = coordinator::onSwipeToNextDay, 37 | onSwipeToPreviousDay = coordinator::onSwipeToPreviousDay 38 | ) 39 | } 40 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/CalendarScreen.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar 2 | 3 | import android.os.Build 4 | import androidx.annotation.RequiresApi 5 | import androidx.compose.animation.animateColorAsState 6 | import androidx.compose.animation.core.tween 7 | import androidx.compose.foundation.background 8 | import androidx.compose.foundation.border 9 | import androidx.compose.foundation.clickable 10 | import androidx.compose.foundation.gestures.detectHorizontalDragGestures 11 | import androidx.compose.foundation.layout.Arrangement 12 | import androidx.compose.foundation.layout.Box 13 | import androidx.compose.foundation.layout.Column 14 | import androidx.compose.foundation.layout.PaddingValues 15 | import androidx.compose.foundation.layout.Row 16 | import androidx.compose.foundation.layout.Spacer 17 | import androidx.compose.foundation.layout.aspectRatio 18 | import androidx.compose.foundation.layout.fillMaxSize 19 | import androidx.compose.foundation.layout.fillMaxWidth 20 | import androidx.compose.foundation.layout.height 21 | import androidx.compose.foundation.layout.padding 22 | import androidx.compose.foundation.lazy.LazyColumn 23 | import androidx.compose.foundation.lazy.grid.GridCells 24 | import androidx.compose.foundation.lazy.grid.LazyVerticalGrid 25 | import androidx.compose.foundation.lazy.grid.items 26 | import androidx.compose.foundation.lazy.items 27 | import androidx.compose.foundation.shape.CircleShape 28 | import androidx.compose.foundation.shape.RoundedCornerShape 29 | import androidx.compose.material.icons.Icons 30 | import androidx.compose.material.icons.filled.Add 31 | import androidx.compose.material3.Card 32 | import androidx.compose.material3.CardDefaults 33 | import androidx.compose.material3.FloatingActionButton 34 | import androidx.compose.material3.Icon 35 | import androidx.compose.material3.MaterialTheme 36 | import androidx.compose.material3.Scaffold 37 | import androidx.compose.material3.Surface 38 | import androidx.compose.material3.Text 39 | import androidx.compose.material3.TextButton 40 | import androidx.compose.runtime.Composable 41 | import androidx.compose.runtime.getValue 42 | import androidx.compose.runtime.mutableFloatStateOf 43 | import androidx.compose.runtime.mutableStateOf 44 | import androidx.compose.runtime.remember 45 | import androidx.compose.runtime.setValue 46 | import androidx.compose.ui.Alignment 47 | import androidx.compose.ui.Modifier 48 | import androidx.compose.ui.draw.clip 49 | import androidx.compose.ui.graphics.Brush 50 | import androidx.compose.ui.graphics.Color 51 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 52 | import androidx.compose.ui.input.pointer.pointerInput 53 | import androidx.compose.ui.platform.LocalHapticFeedback 54 | import androidx.compose.ui.text.font.FontWeight 55 | import androidx.compose.ui.text.style.TextAlign 56 | import androidx.compose.ui.tooling.preview.Preview 57 | import androidx.compose.ui.unit.dp 58 | import com.binissa.calendar.calendar.components.AnimatedTaskItem 59 | import com.binissa.calendar.calendar.components.SwipeToDeleteContainer 60 | import com.binissa.calendar.calendar.components.TaskDialog 61 | import com.binissa.calendar.calendar.components.WeekCalendar 62 | import java.time.DayOfWeek 63 | import java.time.LocalDate 64 | import java.time.YearMonth 65 | import java.time.format.TextStyle 66 | import java.time.temporal.ChronoUnit 67 | import java.time.temporal.TemporalAdjusters 68 | import java.util.Locale 69 | 70 | @RequiresApi(Build.VERSION_CODES.O) 71 | @Composable 72 | fun CalendarScreen( 73 | state: CalendarState, actions: CalendarActions 74 | ) { 75 | val haptic = LocalHapticFeedback.current 76 | var dragOffset by remember { mutableFloatStateOf(0f) } 77 | 78 | Scaffold( 79 | floatingActionButton = { 80 | FloatingActionButton( 81 | onClick = { actions.onAddTaskClick() }, containerColor = Color(0xFFFF5252) 82 | ) { 83 | Icon( 84 | imageVector = Icons.Default.Add, 85 | contentDescription = "Add Task", 86 | tint = Color.White 87 | ) 88 | } 89 | }) { paddingValues -> 90 | Surface( 91 | modifier = Modifier 92 | .fillMaxSize() 93 | .padding(paddingValues) 94 | .pointerInput(Unit) { 95 | detectHorizontalDragGestures(onDragEnd = { 96 | if (dragOffset > 100) { 97 | actions.onSwipeToPreviousDay() 98 | haptic.performHapticFeedback(HapticFeedbackType.LongPress) 99 | } else if (dragOffset < -100) { 100 | actions.onSwipeToNextDay() 101 | haptic.performHapticFeedback(HapticFeedbackType.LongPress) 102 | } 103 | dragOffset = 0f 104 | }, onDragCancel = { dragOffset = 0f }, onHorizontalDrag = { _, dragAmount -> 105 | dragOffset += dragAmount 106 | }) 107 | }, color = MaterialTheme.colorScheme.background 108 | ) { 109 | LazyColumn( 110 | modifier = Modifier 111 | .fillMaxSize() 112 | .padding(16.dp), 113 | contentPadding = PaddingValues(bottom = 80.dp) 114 | ) { 115 | item { 116 | AnimatedCalendarHeader( 117 | selectedDate = state.selectedDate, 118 | onPreviousDayClick = actions.onSwipeToPreviousDay, 119 | onNextDayClick = actions.onSwipeToNextDay 120 | ) 121 | } 122 | 123 | item { 124 | Spacer(modifier = Modifier.height(24.dp)) 125 | WeekCalendar( 126 | selectedDate = state.selectedDate, onDateSelected = actions.onDateSelected 127 | ) 128 | Spacer(modifier = Modifier.height(24.dp)) 129 | } 130 | 131 | if (state.tasks.isEmpty()) { 132 | item { 133 | Box( 134 | modifier = Modifier 135 | .fillMaxWidth() 136 | .padding(32.dp), 137 | contentAlignment = Alignment.Center 138 | ) { 139 | Text( 140 | text = "No tasks for this day", 141 | color = Color.Gray, 142 | style = MaterialTheme.typography.bodyLarge 143 | ) 144 | } 145 | } 146 | } else { 147 | items(state.tasks) { taskItem -> 148 | SwipeToDeleteContainer( 149 | onDelete = { actions.onDeleteTask(taskItem) }) { 150 | AnimatedTaskItem( 151 | task = taskItem, 152 | onClick = { actions.onTaskClick(taskItem) }, 153 | onCheckboxClick = { actions.onTaskCompleteToggle(taskItem) }) 154 | } 155 | 156 | Spacer(modifier = Modifier.height(8.dp)) 157 | } 158 | } 159 | } 160 | 161 | // Add/Edit Task Dialog 162 | if (state.isAddingTask) { 163 | TaskDialog( 164 | taskTitle = state.newTaskTitle, 165 | isEditing = state.editingTask != null, 166 | onDismiss = actions.onDismissAddTask, 167 | onTitleChange = actions.onNewTaskTitleChange, 168 | onSave = actions.onSaveNewTask 169 | ) 170 | } 171 | } 172 | } 173 | } 174 | 175 | 176 | @RequiresApi(Build.VERSION_CODES.O) 177 | @Composable 178 | @Preview(name = "Calendar") 179 | private fun CalendarScreenPreview() { 180 | MaterialTheme { 181 | CalendarScreen( 182 | state = CalendarState( 183 | selectedDate = LocalDate.now(), tasks = listOf( 184 | TaskItem(id = "1", title = "Daria's 20th Birthday", type = TaskType.BIRTHDAY), 185 | TaskItem(id = "2", title = "Wake up", time = "09:00", type = TaskType.STAR), 186 | TaskItem( 187 | id = "3", title = "Design Crit", time = "10:00", type = TaskType.CHECKBOX 188 | ) 189 | ) 190 | ), actions = CalendarActions() 191 | ) 192 | } 193 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/CalendarViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar 2 | 3 | import androidx.lifecycle.SavedStateHandle 4 | import androidx.lifecycle.ViewModel 5 | import androidx.lifecycle.viewModelScope 6 | import dagger.hilt.android.lifecycle.HiltViewModel 7 | import java.time.LocalDate 8 | import javax.inject.Inject 9 | import kotlinx.coroutines.flow.StateFlow 10 | import kotlinx.coroutines.flow.MutableStateFlow 11 | import kotlinx.coroutines.flow.asStateFlow 12 | import kotlinx.coroutines.flow.update 13 | import kotlinx.coroutines.launch 14 | import java.util.UUID 15 | 16 | @HiltViewModel 17 | class CalendarViewModel @Inject constructor( 18 | savedStateHandle: SavedStateHandle 19 | ) : ViewModel() { 20 | 21 | private val _stateFlow: MutableStateFlow = MutableStateFlow(CalendarState( 22 | selectedDate = LocalDate.now(), 23 | tasks = getSampleTaskItems() 24 | )) 25 | 26 | val stateFlow: StateFlow = _stateFlow.asStateFlow() 27 | 28 | fun selectDate(date: LocalDate) { 29 | _stateFlow.update { currentState -> 30 | currentState.copy( 31 | selectedDate = date, 32 | // In a real app, you would fetch tasks for this date from a repository 33 | tasks = getSampleTaskItems().filter { 34 | LocalDate.now().dayOfMonth == date.dayOfMonth 35 | } 36 | ) 37 | } 38 | } 39 | 40 | fun toggleTaskCompleted(task: TaskItem) { 41 | _stateFlow.update { currentState -> 42 | val updatedTasks = currentState.tasks.map { 43 | if (it.id == task.id) it.copy(isCompleted = !it.isCompleted) else it 44 | } 45 | currentState.copy(tasks = updatedTasks) 46 | } 47 | } 48 | 49 | fun showAddTask() { 50 | _stateFlow.update { it.copy(isAddingTask = true, newTaskTitle = "", editingTask = null) } 51 | } 52 | 53 | fun dismissAddTask() { 54 | _stateFlow.update { it.copy(isAddingTask = false, newTaskTitle = "", editingTask = null) } 55 | } 56 | 57 | fun updateNewTaskTitle(title: String) { 58 | _stateFlow.update { it.copy(newTaskTitle = title) } 59 | } 60 | 61 | fun saveNewTask(type: TaskType) { 62 | val currentState = _stateFlow.value 63 | 64 | if (currentState.newTaskTitle.isBlank()) return 65 | 66 | val newTask = TaskItem( 67 | id = UUID.randomUUID().toString(), 68 | title = currentState.newTaskTitle, 69 | type = type, 70 | date = currentState.selectedDate 71 | ) 72 | 73 | _stateFlow.update { state -> 74 | state.copy( 75 | tasks = state.tasks + newTask, 76 | isAddingTask = false, 77 | newTaskTitle = "" 78 | ) 79 | } 80 | } 81 | 82 | fun editTask(task: TaskItem) { 83 | _stateFlow.update { it.copy(editingTask = task, isAddingTask = true, newTaskTitle = task.title) } 84 | } 85 | 86 | fun deleteTask(task: TaskItem) { 87 | _stateFlow.update { currentState -> 88 | currentState.copy(tasks = currentState.tasks.filter { it.id != task.id }) 89 | } 90 | } 91 | 92 | fun navigateToNextDay() { 93 | _stateFlow.update { currentState -> 94 | val nextDay = currentState.selectedDate.plusDays(1) 95 | currentState.copy(selectedDate = nextDay) 96 | } 97 | } 98 | 99 | fun navigateToPreviousDay() { 100 | _stateFlow.update { currentState -> 101 | val previousDay = currentState.selectedDate.minusDays(1) 102 | currentState.copy(selectedDate = previousDay) 103 | } 104 | } 105 | 106 | private fun getSampleTaskItems(): List { 107 | return listOf( 108 | TaskItem(id = "1", title = "Daria's 20th Birthday", type = TaskType.BIRTHDAY, date = LocalDate.now()), 109 | TaskItem(id = "2", title = "Wake up", time = "09:00", type = TaskType.STAR, date = LocalDate.now()), 110 | TaskItem(id = "3", title = "Design Crit", time = "10:00", type = TaskType.CHECKBOX, date = LocalDate.now()), 111 | TaskItem(id = "4", title = "Haircut with Vincent", time = "13:00", type = TaskType.CHECKBOX, date = LocalDate.now()), 112 | TaskItem(id = "5", title = "Make pasta", type = TaskType.CHECKBOX, date = LocalDate.now()), 113 | TaskItem(id = "6", title = "Pushups x100", type = TaskType.CHECKBOX, isCompleted = true, date = LocalDate.now()), 114 | TaskItem(id = "7", title = "Wind down", time = "21:00", type = TaskType.MOON, date = LocalDate.now()) 115 | ) 116 | } 117 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/TaskItem.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar 2 | 3 | import java.time.LocalDate 4 | 5 | data class TaskItem( 6 | val id: String, 7 | val title: String, 8 | val time: String? = null, 9 | val type: TaskType = TaskType.CHECKBOX, 10 | val isCompleted: Boolean = false, 11 | val isHighlighted: Boolean = false, 12 | val date: LocalDate = LocalDate.now() 13 | ) 14 | 15 | enum class TaskType { 16 | BIRTHDAY, STAR, CHECKBOX, MOON 17 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/AnimatedCalendarHeader.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar 2 | 3 | import android.os.Build 4 | import androidx.annotation.RequiresApi 5 | import androidx.compose.foundation.BorderStroke 6 | import androidx.compose.foundation.layout.Arrangement 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.rememberScrollState 12 | import androidx.compose.foundation.verticalScroll 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft 15 | import androidx.compose.material.icons.rounded.KeyboardArrowRight 16 | import androidx.compose.material3.Button 17 | import androidx.compose.material3.Card 18 | import androidx.compose.material3.CardDefaults 19 | import androidx.compose.material3.Icon 20 | import androidx.compose.material3.IconButton 21 | import androidx.compose.material3.MaterialTheme 22 | import androidx.compose.material3.Slider 23 | import androidx.compose.material3.Text 24 | import androidx.compose.runtime.Composable 25 | import androidx.compose.runtime.LaunchedEffect 26 | import androidx.compose.runtime.getValue 27 | import androidx.compose.runtime.mutableStateOf 28 | import androidx.compose.runtime.remember 29 | import androidx.compose.runtime.setValue 30 | import androidx.compose.ui.Alignment 31 | import androidx.compose.ui.Modifier 32 | import androidx.compose.ui.graphics.Color 33 | import androidx.compose.ui.text.font.FontWeight 34 | import androidx.compose.ui.unit.dp 35 | import com.binissa.calendar.calendar.components.morph.AnimatedDayName 36 | import com.binissa.calendar.calendar.components.morph.DirectionalTransitionConfig 37 | import java.time.LocalDate 38 | import java.time.format.DateTimeFormatter 39 | 40 | /** 41 | * A Material 3 styled calendar header with smooth transitioning animations. 42 | * 43 | * @param selectedDate The currently selected date 44 | * @param onPreviousDayClick Callback when navigating to previous day 45 | * @param onNextDayClick Callback when navigating to next day 46 | * @param accentColor The accent color for the current day indicator (defaults to primary) 47 | * @param modifier Modifier for the composable 48 | */ 49 | @RequiresApi(Build.VERSION_CODES.O) 50 | @Composable 51 | fun AnimatedCalendarHeader( 52 | selectedDate: LocalDate, 53 | onPreviousDayClick: () -> Unit, 54 | onNextDayClick: () -> Unit, 55 | accentColor: Color = MaterialTheme.colorScheme.primary, 56 | modifier: Modifier = Modifier 57 | ) { 58 | // Animation state to coordinate date transitions 59 | val transitionConfig = remember { 60 | DirectionalTransitionConfig( 61 | transitionDuration = 650, 62 | staggerDelay = 80, 63 | staggerFactor = 1.4f, 64 | trailCount = 7, 65 | maxVerticalOffset = 10.dp, 66 | maxHorizontalOffset = 2.dp, 67 | maxBlur = 4.dp, 68 | rotationAmount = 4f, 69 | scaleRange = Pair(0.2f, 1.1f), 70 | bunching = 2.5f 71 | ) 72 | } 73 | 74 | Row( 75 | modifier = modifier.padding(vertical = 8.dp), 76 | horizontalArrangement = Arrangement.SpaceBetween, 77 | verticalAlignment = Alignment.CenterVertically 78 | ) { 79 | Row(verticalAlignment = Alignment.CenterVertically) { 80 | IconButton(onClick = onPreviousDayClick) { 81 | Icon( 82 | imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowLeft, 83 | contentDescription = "Previous Day", 84 | tint = MaterialTheme.colorScheme.onSurfaceVariant 85 | ) 86 | } 87 | 88 | Row( 89 | verticalAlignment = Alignment.CenterVertically, 90 | horizontalArrangement = Arrangement.SpaceBetween 91 | ) { 92 | AnimatedDayName( 93 | date = selectedDate, 94 | config = transitionConfig, 95 | textStyle = MaterialTheme.typography.headlineLarge, 96 | color = MaterialTheme.colorScheme.onSurface 97 | ) 98 | } 99 | 100 | IconButton(onClick = onNextDayClick) { 101 | Icon( 102 | imageVector = Icons.Rounded.KeyboardArrowRight, 103 | contentDescription = "Next Day", 104 | tint = MaterialTheme.colorScheme.onSurfaceVariant 105 | ) 106 | } 107 | } 108 | 109 | // Year with subtle animation 110 | Text( 111 | text = selectedDate.format(DateTimeFormatter.ofPattern("yyyy")), 112 | style = MaterialTheme.typography.bodyLarge, 113 | color = MaterialTheme.colorScheme.onSurfaceVariant 114 | ) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/AnimatedTaskItem.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.interaction.MutableInteractionSource 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Check 14 | import androidx.compose.material.icons.outlined.CheckCircle 15 | import androidx.compose.material.icons.rounded.Star 16 | import androidx.compose.material3.Card 17 | import androidx.compose.material3.CardDefaults 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Text 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.clip 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.unit.dp 30 | import androidx.compose.ui.unit.sp 31 | import com.binissa.calendar.calendar.TaskItem 32 | import com.binissa.calendar.calendar.TaskType 33 | 34 | @Composable 35 | fun AnimatedTaskItem( 36 | task: TaskItem, 37 | onClick: () -> Unit, 38 | onCheckboxClick: () -> Unit, 39 | isVisible: Boolean = true, 40 | index: Int = 0 41 | ) { 42 | val animatedElevation by animateFloatAsState( 43 | targetValue = if (task.isCompleted) 0f else 4f, label = "cardElevation" 44 | ) 45 | 46 | // Animation state 47 | val revealConfig = BlurTextRevealConfig( 48 | direction = RevealDirection.START_TO_END, 49 | initialBlur = 6f, 50 | initialOffset = 24f, 51 | blurDuration = 700, 52 | alphaDuration = 500 53 | ) 54 | 55 | Card( 56 | modifier = Modifier 57 | .fillMaxWidth() 58 | .clickable(onClick = onClick), 59 | elevation = CardDefaults.cardElevation(defaultElevation = animatedElevation.dp), 60 | colors = CardDefaults.cardColors( 61 | containerColor = if (task.isCompleted) Color.LightGray.copy(alpha = 0.3f) 62 | else MaterialTheme.colorScheme.surface 63 | ), 64 | shape = RoundedCornerShape(12.dp) 65 | ) { 66 | Row( 67 | modifier = Modifier 68 | .fillMaxWidth() 69 | .padding(16.dp), 70 | verticalAlignment = Alignment.CenterVertically 71 | ) { 72 | // Icon based on task type with animated transitions 73 | when (task.type) { 74 | TaskType.BIRTHDAY -> { 75 | Text( 76 | text = "✹", 77 | color = Color(0xFFFF5252), 78 | fontSize = 20.sp, 79 | modifier = Modifier.padding(end = 16.dp) 80 | ) 81 | } 82 | 83 | TaskType.STAR -> { 84 | Icon( 85 | imageVector = Icons.Rounded.Star, 86 | contentDescription = "Star", 87 | tint = Color(0xFFFFD700), 88 | modifier = Modifier.padding(end = 16.dp) 89 | ) 90 | } 91 | 92 | TaskType.CHECKBOX -> { 93 | Box( 94 | modifier = Modifier 95 | .clip(CircleShape) 96 | .clickable( 97 | interactionSource = remember { MutableInteractionSource() }, 98 | indication = null, 99 | onClick = onCheckboxClick 100 | ) 101 | .padding(end = 16.dp) 102 | ) { 103 | if (task.isCompleted) { 104 | Icon( 105 | imageVector = Icons.Default.Check, 106 | contentDescription = "Completed", 107 | tint = Color.Gray 108 | ) 109 | } else { 110 | Icon( 111 | imageVector = Icons.Outlined.CheckCircle, 112 | contentDescription = "Not completed", 113 | tint = Color.Gray 114 | ) 115 | } 116 | } 117 | } 118 | 119 | TaskType.MOON -> { 120 | Text( 121 | text = "🌙", 122 | fontSize = 20.sp, 123 | color = Color(0xFF7E57C2), 124 | modifier = Modifier.padding(end = 16.dp) 125 | ) 126 | } 127 | } 128 | 129 | // Task title with blur reveal animation 130 | BlurTextReveal( 131 | text = task.title, 132 | isTriggered = isVisible, 133 | color = if (task.isCompleted) Color.Gray else Color.Black, 134 | config = revealConfig, 135 | textStyle = MaterialTheme.typography.bodyLarge.copy( 136 | fontWeight = if (task.isHighlighted) FontWeight.Bold else FontWeight.Normal, 137 | fontSize = 16.sp 138 | ), 139 | staggerDelay = 15L, 140 | modifier = Modifier.weight(1f) 141 | ) 142 | 143 | // Time if available 144 | if (task.time != null) { 145 | BlurTextReveal( 146 | text = task.time, 147 | isTriggered = isVisible, 148 | color = Color.Gray, 149 | config = revealConfig.copy( 150 | direction = RevealDirection.TOP_TO_BOTTOM, blurDuration = 600 151 | ), 152 | textStyle = MaterialTheme.typography.bodyMedium.copy( 153 | fontSize = 14.sp 154 | ), 155 | staggerDelay = 20L 156 | ) 157 | } 158 | } 159 | } 160 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/BlurTextReveal.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components 2 | 3 | 4 | import androidx.compose.animation.core.Animatable 5 | import androidx.compose.animation.core.FastOutSlowInEasing 6 | import androidx.compose.animation.core.Spring 7 | import androidx.compose.animation.core.spring 8 | import androidx.compose.animation.core.tween 9 | import androidx.compose.foundation.layout.Arrangement 10 | import androidx.compose.foundation.layout.Box 11 | import androidx.compose.foundation.layout.Row 12 | import androidx.compose.foundation.layout.offset 13 | import androidx.compose.material3.MaterialTheme 14 | import androidx.compose.material3.Text 15 | import androidx.compose.runtime.Composable 16 | import androidx.compose.runtime.LaunchedEffect 17 | import androidx.compose.runtime.Stable 18 | import androidx.compose.runtime.key 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.ui.Alignment 21 | import androidx.compose.ui.Modifier 22 | import androidx.compose.ui.draw.BlurredEdgeTreatment 23 | import androidx.compose.ui.draw.blur 24 | import androidx.compose.ui.graphics.Color 25 | import androidx.compose.ui.graphics.graphicsLayer 26 | import androidx.compose.ui.text.TextStyle 27 | import androidx.compose.ui.text.font.FontFamily 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.unit.dp 30 | import kotlinx.coroutines.delay 31 | import kotlinx.coroutines.launch 32 | 33 | enum class RevealDirection { 34 | BOTTOM_TO_TOP, TOP_TO_BOTTOM, START_TO_END, END_TO_START 35 | } 36 | 37 | @Stable 38 | data class BlurTextRevealConfig( 39 | val initialBlur: Float = 8f, 40 | val initialOffset: Float = 4f, 41 | val blurDuration: Int = 600, 42 | val alphaDuration: Int = 400, 43 | val glowBlur: Float = 16f, 44 | val glowAlpha: Float = 0.3f, 45 | val direction: RevealDirection = RevealDirection.BOTTOM_TO_TOP 46 | ) 47 | 48 | 49 | @Composable 50 | private fun BlurCharReveal( 51 | char: Char, 52 | color: Color, 53 | delayMillis: Long, 54 | isTriggered: Boolean, 55 | config: BlurTextRevealConfig = BlurTextRevealConfig(), 56 | modifier: Modifier = Modifier, 57 | textStyle: TextStyle = MaterialTheme.typography.labelLarge, 58 | fontWeight: FontWeight? = null, 59 | fontFamily: FontFamily? = null, 60 | ) { 61 | val blur = remember { Animatable(config.initialBlur) } 62 | val alpha = remember { Animatable(0f) } 63 | val offsetX = remember { Animatable(0f) } 64 | val offsetY = remember { Animatable(0f) } 65 | 66 | // Reset animations when trigger is false 67 | LaunchedEffect(isTriggered) { 68 | if (!isTriggered) { 69 | blur.snapTo(config.initialBlur) 70 | alpha.snapTo(0f) 71 | when (config.direction) { 72 | RevealDirection.BOTTOM_TO_TOP -> offsetY.snapTo(config.initialOffset) 73 | RevealDirection.TOP_TO_BOTTOM -> offsetY.snapTo(-config.initialOffset) 74 | RevealDirection.START_TO_END -> offsetX.snapTo(-config.initialOffset) 75 | RevealDirection.END_TO_START -> offsetX.snapTo(config.initialOffset) 76 | } 77 | } 78 | } 79 | 80 | // Start animations when triggered 81 | LaunchedEffect(isTriggered) { 82 | if (isTriggered) { 83 | delay(delayMillis) 84 | 85 | launch { 86 | alpha.animateTo( 87 | targetValue = 1f, animationSpec = tween( 88 | durationMillis = config.alphaDuration, easing = FastOutSlowInEasing 89 | ) 90 | ) 91 | } 92 | 93 | launch { 94 | blur.animateTo( 95 | targetValue = 0f, animationSpec = tween( 96 | durationMillis = config.blurDuration, easing = FastOutSlowInEasing 97 | ) 98 | ) 99 | } 100 | 101 | launch { 102 | offsetX.animateTo( 103 | targetValue = 0f, animationSpec = spring( 104 | dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow 105 | ) 106 | ) 107 | } 108 | 109 | launch { 110 | offsetY.animateTo( 111 | targetValue = 0f, animationSpec = spring( 112 | dampingRatio = Spring.DampingRatioNoBouncy, stiffness = Spring.StiffnessLow 113 | ) 114 | ) 115 | } 116 | } 117 | } 118 | 119 | Box { 120 | // Glow effect 121 | Text(text = char.toString(), 122 | style = textStyle, 123 | color = color.copy(alpha = config.glowAlpha), 124 | modifier = modifier 125 | .graphicsLayer { 126 | this.alpha = alpha.value 127 | } 128 | .offset(x = offsetX.value.dp, y = offsetY.value.dp) 129 | .blur( 130 | config.glowBlur.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded 131 | )) 132 | 133 | // Main character 134 | Text(text = char.toString(), 135 | style = textStyle, 136 | color = color, 137 | fontWeight = fontWeight, 138 | fontFamily = fontFamily, 139 | modifier = modifier 140 | .graphicsLayer { 141 | this.alpha = alpha.value 142 | } 143 | .offset(x = offsetX.value.dp, y = offsetY.value.dp) 144 | .blur( 145 | blur.value.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded 146 | )) 147 | } 148 | } 149 | 150 | @Composable 151 | fun BlurTextReveal( 152 | text: String, 153 | color: Color = Color.Unspecified, 154 | isTriggered: Boolean, 155 | modifier: Modifier = Modifier, 156 | config: BlurTextRevealConfig = BlurTextRevealConfig(), 157 | staggerDelay: Long = 50L, 158 | textStyle: TextStyle = MaterialTheme.typography.displayLarge, 159 | fontWeight: FontWeight? = null, 160 | fontFamily: FontFamily? = null, 161 | ) { 162 | Row( 163 | modifier = modifier, 164 | horizontalArrangement = Arrangement.Start, 165 | verticalAlignment = Alignment.CenterVertically 166 | ) { 167 | text.forEachIndexed { index, char -> 168 | key(index) { 169 | BlurCharReveal( 170 | char = char, 171 | color = color, 172 | delayMillis = index * staggerDelay, 173 | isTriggered = isTriggered, 174 | config = config, 175 | textStyle = textStyle, 176 | fontWeight = fontWeight, 177 | fontFamily = fontFamily 178 | ) 179 | } 180 | } 181 | } 182 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/SwipeToDeleteContainer.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.gestures.Orientation 5 | import androidx.compose.foundation.gestures.draggable 6 | import androidx.compose.foundation.gestures.rememberDraggableState 7 | import androidx.compose.foundation.layout.Box 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.height 10 | import androidx.compose.foundation.layout.offset 11 | import androidx.compose.foundation.layout.padding 12 | import androidx.compose.foundation.shape.RoundedCornerShape 13 | import androidx.compose.material.icons.Icons 14 | import androidx.compose.material.icons.filled.Delete 15 | import androidx.compose.material3.Icon 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.runtime.getValue 18 | import androidx.compose.runtime.mutableFloatStateOf 19 | import androidx.compose.runtime.remember 20 | import androidx.compose.runtime.setValue 21 | import androidx.compose.ui.Alignment 22 | import androidx.compose.ui.Modifier 23 | import androidx.compose.ui.draw.clip 24 | import androidx.compose.ui.graphics.Color 25 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType 26 | import androidx.compose.ui.platform.LocalDensity 27 | import androidx.compose.ui.platform.LocalHapticFeedback 28 | import androidx.compose.ui.unit.IntOffset 29 | import androidx.compose.ui.unit.dp 30 | import kotlin.math.roundToInt 31 | 32 | @Composable 33 | fun SwipeToDeleteContainer( 34 | onDelete: () -> Unit, content: @Composable () -> Unit 35 | ) { 36 | val width = 200.dp 37 | val widthPx = with(LocalDensity.current) { width.toPx() } 38 | var offsetX by remember { mutableFloatStateOf(0f) } 39 | val haptic = LocalHapticFeedback.current 40 | 41 | Box( 42 | modifier = Modifier.fillMaxWidth() 43 | ) { 44 | // Delete background 45 | Box( 46 | modifier = Modifier 47 | .fillMaxWidth() 48 | .height(56.dp) 49 | .clip(RoundedCornerShape(12.dp)) 50 | .background(Color.Red.copy(alpha = 0.8f)), contentAlignment = Alignment.CenterEnd 51 | ) { 52 | Icon( 53 | imageVector = Icons.Default.Delete, 54 | contentDescription = "Delete", 55 | tint = Color.White, 56 | modifier = Modifier.padding(end = 16.dp) 57 | ) 58 | } 59 | 60 | // Foreground content (draggable) 61 | Box(modifier = Modifier 62 | .offset { IntOffset(offsetX.roundToInt(), 0) } 63 | .draggable( 64 | orientation = Orientation.Horizontal, 65 | state = rememberDraggableState { delta -> 66 | offsetX = (offsetX + delta).coerceAtMost(0f) 67 | }, 68 | onDragStopped = { 69 | if (offsetX < -widthPx * 0.5f) { 70 | haptic.performHapticFeedback(HapticFeedbackType.LongPress) 71 | onDelete() 72 | offsetX = 0f 73 | } else { 74 | offsetX = 0f 75 | } 76 | })) { 77 | content() 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/TaskDialog.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Column 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.Spacer 7 | import androidx.compose.foundation.layout.fillMaxWidth 8 | import androidx.compose.foundation.layout.height 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.shape.RoundedCornerShape 11 | import androidx.compose.material3.Card 12 | import androidx.compose.material3.MaterialTheme 13 | import androidx.compose.material3.OutlinedTextField 14 | import androidx.compose.material3.Text 15 | import androidx.compose.material3.TextButton 16 | import androidx.compose.runtime.Composable 17 | import androidx.compose.ui.Alignment 18 | import androidx.compose.ui.Modifier 19 | import androidx.compose.ui.graphics.Color 20 | import androidx.compose.ui.text.font.FontWeight 21 | import androidx.compose.ui.unit.dp 22 | import androidx.compose.ui.window.Dialog 23 | import com.binissa.calendar.calendar.TaskType 24 | 25 | @Composable 26 | fun TaskDialog( 27 | taskTitle: String, 28 | isEditing: Boolean, 29 | onDismiss: () -> Unit, 30 | onTitleChange: (String) -> Unit, 31 | onSave: (TaskType) -> Unit 32 | ) { 33 | Dialog(onDismissRequest = onDismiss) { 34 | Card( 35 | modifier = Modifier 36 | .fillMaxWidth() 37 | .padding(16.dp), 38 | shape = RoundedCornerShape(16.dp) 39 | ) { 40 | Column( 41 | modifier = Modifier 42 | .fillMaxWidth() 43 | .padding(20.dp), 44 | horizontalAlignment = Alignment.CenterHorizontally 45 | ) { 46 | Text( 47 | text = if (isEditing) "Edit Task" else "Add New Task", 48 | style = MaterialTheme.typography.headlineSmall, 49 | fontWeight = FontWeight.Bold 50 | ) 51 | 52 | Spacer(modifier = Modifier.height(16.dp)) 53 | 54 | OutlinedTextField( 55 | value = taskTitle, 56 | onValueChange = onTitleChange, 57 | label = { Text("Task Title") }, 58 | modifier = Modifier.fillMaxWidth() 59 | ) 60 | 61 | Spacer(modifier = Modifier.height(16.dp)) 62 | 63 | Text( 64 | text = "Task Type", 65 | style = MaterialTheme.typography.bodyLarge 66 | ) 67 | 68 | Spacer(modifier = Modifier.height(8.dp)) 69 | 70 | Row( 71 | modifier = Modifier.fillMaxWidth(), 72 | horizontalArrangement = Arrangement.SpaceEvenly 73 | ) { 74 | TaskTypeButton( 75 | text = "✹", 76 | color = Color(0xFFFF5252), 77 | onClick = { onSave(TaskType.BIRTHDAY) } 78 | ) 79 | 80 | TaskTypeButton( 81 | text = "★", 82 | color = Color(0xFFFFD700), 83 | onClick = { onSave(TaskType.STAR) } 84 | ) 85 | 86 | TaskTypeButton( 87 | text = "☐", 88 | color = Color.Gray, 89 | onClick = { onSave(TaskType.CHECKBOX) } 90 | ) 91 | 92 | TaskTypeButton( 93 | text = "🌙", 94 | color = Color(0xFF7E57C2), 95 | onClick = { onSave(TaskType.MOON) } 96 | ) 97 | } 98 | 99 | Spacer(modifier = Modifier.height(16.dp)) 100 | 101 | Row( 102 | modifier = Modifier.fillMaxWidth(), 103 | horizontalArrangement = Arrangement.End 104 | ) { 105 | TextButton(onClick = onDismiss) { 106 | Text("Cancel") 107 | } 108 | } 109 | } 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/TaskItemCard.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components 2 | 3 | import androidx.compose.animation.core.animateFloatAsState 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.interaction.MutableInteractionSource 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Row 8 | import androidx.compose.foundation.layout.fillMaxWidth 9 | import androidx.compose.foundation.layout.padding 10 | import androidx.compose.foundation.shape.CircleShape 11 | import androidx.compose.foundation.shape.RoundedCornerShape 12 | import androidx.compose.material.icons.Icons 13 | import androidx.compose.material.icons.filled.Check 14 | import androidx.compose.material.icons.outlined.CheckCircle 15 | import androidx.compose.material.icons.rounded.Star 16 | import androidx.compose.material3.Card 17 | import androidx.compose.material3.CardDefaults 18 | import androidx.compose.material3.Icon 19 | import androidx.compose.material3.MaterialTheme 20 | import androidx.compose.material3.Text 21 | import androidx.compose.runtime.Composable 22 | import androidx.compose.runtime.getValue 23 | import androidx.compose.runtime.remember 24 | import androidx.compose.ui.Alignment 25 | import androidx.compose.ui.Modifier 26 | import androidx.compose.ui.draw.clip 27 | import androidx.compose.ui.graphics.Color 28 | import androidx.compose.ui.text.font.FontWeight 29 | import androidx.compose.ui.unit.dp 30 | import androidx.compose.ui.unit.sp 31 | import com.binissa.calendar.calendar.TaskItem 32 | import com.binissa.calendar.calendar.TaskType 33 | 34 | @Composable 35 | fun TaskItemCard( 36 | task: TaskItem, onClick: () -> Unit, onCheckboxClick: () -> Unit 37 | ) { 38 | val animatedElevation by animateFloatAsState( 39 | targetValue = if (task.isCompleted) 0f else 4f, label = "cardElevation" 40 | ) 41 | 42 | Card( 43 | modifier = Modifier 44 | .fillMaxWidth() 45 | .clickable(onClick = onClick), 46 | elevation = CardDefaults.cardElevation(defaultElevation = animatedElevation.dp), 47 | colors = CardDefaults.cardColors( 48 | containerColor = if (task.isCompleted) Color.LightGray.copy(alpha = 0.3f) 49 | else MaterialTheme.colorScheme.surface 50 | ), 51 | shape = RoundedCornerShape(12.dp) 52 | ) { 53 | Row( 54 | modifier = Modifier 55 | .fillMaxWidth() 56 | .padding(16.dp), 57 | verticalAlignment = Alignment.CenterVertically 58 | ) { 59 | // Icon based on task type with animated transitions 60 | when (task.type) { 61 | TaskType.BIRTHDAY -> { 62 | Text( 63 | text = "✹", 64 | color = Color(0xFFFF5252), 65 | fontSize = 20.sp, 66 | modifier = Modifier.padding(end = 16.dp) 67 | ) 68 | } 69 | 70 | TaskType.STAR -> { 71 | Icon( 72 | imageVector = Icons.Rounded.Star, 73 | contentDescription = "Star", 74 | tint = Color(0xFFFFD700), 75 | modifier = Modifier.padding(end = 16.dp) 76 | ) 77 | } 78 | 79 | TaskType.CHECKBOX -> { 80 | Box( 81 | modifier = Modifier 82 | .clip(CircleShape) 83 | .clickable( 84 | interactionSource = remember { MutableInteractionSource() }, 85 | indication = null, 86 | onClick = onCheckboxClick 87 | ) 88 | .padding(end = 16.dp) 89 | ) { 90 | if (task.isCompleted) { 91 | Icon( 92 | imageVector = Icons.Default.Check, 93 | contentDescription = "Completed", 94 | tint = Color.Gray 95 | ) 96 | } else { 97 | Icon( 98 | imageVector = Icons.Outlined.CheckCircle, 99 | contentDescription = "Not completed", 100 | tint = Color.Gray 101 | ) 102 | } 103 | } 104 | } 105 | 106 | TaskType.MOON -> { 107 | Text( 108 | text = "🌙", 109 | fontSize = 20.sp, 110 | color = Color(0xFF7E57C2), 111 | modifier = Modifier.padding(end = 16.dp) 112 | ) 113 | } 114 | } 115 | 116 | // Task title 117 | Text( 118 | text = task.title, 119 | fontSize = 16.sp, 120 | color = if (task.isCompleted) Color.Gray else Color.Black, 121 | style = MaterialTheme.typography.bodyLarge.copy( 122 | fontWeight = if (task.isHighlighted) FontWeight.Bold else FontWeight.Normal 123 | ), 124 | modifier = Modifier.weight(1f) 125 | ) 126 | 127 | // Time if available 128 | if (task.time != null) { 129 | Text( 130 | text = task.time, fontSize = 14.sp, color = Color.Gray 131 | ) 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/TaskTypeButton.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Box 6 | import androidx.compose.foundation.layout.size 7 | import androidx.compose.foundation.shape.CircleShape 8 | import androidx.compose.material3.Text 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | import androidx.compose.ui.draw.clip 13 | import androidx.compose.ui.graphics.Color 14 | import androidx.compose.ui.unit.dp 15 | import androidx.compose.ui.unit.sp 16 | 17 | @Composable 18 | fun TaskTypeButton( 19 | text: String, 20 | color: Color, 21 | onClick: () -> Unit 22 | ) { 23 | Box( 24 | modifier = Modifier 25 | .size(48.dp) 26 | .clip(CircleShape) 27 | .background(color.copy(alpha = 0.2f)) 28 | .clickable(onClick = onClick), 29 | contentAlignment = Alignment.Center 30 | ) { 31 | Text( 32 | text = text, 33 | color = color, 34 | fontSize = 24.sp 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/WeekCalendar.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components 2 | 3 | import androidx.compose.foundation.background 4 | import androidx.compose.foundation.clickable 5 | import androidx.compose.foundation.layout.Arrangement 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.Column 8 | import androidx.compose.foundation.layout.Row 9 | import androidx.compose.foundation.layout.fillMaxWidth 10 | import androidx.compose.foundation.layout.padding 11 | import androidx.compose.foundation.layout.size 12 | import androidx.compose.foundation.shape.CircleShape 13 | import androidx.compose.material3.Text 14 | import androidx.compose.runtime.Composable 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.draw.clip 18 | import androidx.compose.ui.graphics.Color 19 | import androidx.compose.ui.text.font.FontWeight 20 | import androidx.compose.ui.unit.dp 21 | import androidx.compose.ui.unit.sp 22 | import java.time.LocalDate 23 | import java.time.format.TextStyle 24 | import java.util.Locale 25 | 26 | @Composable 27 | fun WeekCalendar( 28 | selectedDate: LocalDate, 29 | onDateSelected: (LocalDate) -> Unit 30 | ) { 31 | // Calculate first day of week (Monday) for the week containing selectedDate 32 | val firstDayOfWeek = selectedDate.minusDays(selectedDate.dayOfWeek.value - 1L) 33 | 34 | Row( 35 | modifier = Modifier.fillMaxWidth(), 36 | horizontalArrangement = Arrangement.SpaceBetween 37 | ) { 38 | for (i in 0..6) { 39 | val date = firstDayOfWeek.plusDays(i.toLong()) 40 | val isSelected = date.equals(selectedDate) 41 | val isToday = date.equals(LocalDate.now()) 42 | 43 | Column( 44 | horizontalAlignment = Alignment.CenterHorizontally, 45 | modifier = Modifier 46 | .clip(CircleShape) 47 | .clickable { onDateSelected(date) } 48 | .padding(vertical = 4.dp) 49 | ) { 50 | Text( 51 | text = date.dayOfWeek.getDisplayName(TextStyle.SHORT, Locale.getDefault()) 52 | .take(3).uppercase(), 53 | fontSize = 12.sp, 54 | color = Color.Gray 55 | ) 56 | 57 | Box( 58 | modifier = Modifier 59 | .padding(4.dp) 60 | .size(36.dp) 61 | .clip(CircleShape) 62 | .background( 63 | when { 64 | isSelected -> Color(0xFFFF5252) 65 | isToday -> Color(0xFFFF5252).copy(alpha = 0.3f) 66 | else -> Color.Transparent 67 | } 68 | ), 69 | contentAlignment = Alignment.Center 70 | ) { 71 | Text( 72 | text = date.dayOfMonth.toString(), 73 | fontSize = 14.sp, 74 | color = if (isSelected) Color.White else Color.Black, 75 | fontWeight = if (isSelected || isToday) FontWeight.Bold else FontWeight.Normal 76 | ) 77 | } 78 | } 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/morph/CharacterTransition.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components.morph 2 | 3 | import androidx.compose.animation.core.Animatable 4 | import androidx.compose.animation.core.CubicBezierEasing 5 | import androidx.compose.animation.core.tween 6 | import androidx.compose.foundation.layout.Box 7 | import androidx.compose.foundation.layout.offset 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.LaunchedEffect 12 | import androidx.compose.runtime.remember 13 | import androidx.compose.runtime.rememberCoroutineScope 14 | import androidx.compose.ui.Alignment 15 | import androidx.compose.ui.Modifier 16 | import androidx.compose.ui.draw.BlurredEdgeTreatment 17 | import androidx.compose.ui.draw.alpha 18 | import androidx.compose.ui.draw.blur 19 | import androidx.compose.ui.draw.rotate 20 | import androidx.compose.ui.draw.scale 21 | import androidx.compose.ui.graphics.Color 22 | import androidx.compose.ui.platform.LocalDensity 23 | import androidx.compose.ui.text.TextStyle 24 | import androidx.compose.ui.text.font.FontWeight 25 | import androidx.compose.ui.text.style.TextAlign 26 | import androidx.compose.ui.unit.dp 27 | import androidx.compose.ui.unit.sp 28 | import kotlinx.coroutines.delay 29 | import kotlinx.coroutines.launch 30 | import kotlin.math.pow 31 | import kotlin.math.sin 32 | 33 | /** 34 | * Animates a single character transition with ghost trail effect. 35 | */ 36 | @Composable 37 | fun CharacterTransition( 38 | fromChar: Char, 39 | toChar: Char, 40 | isAnimating: Boolean, 41 | direction: TransitionDirection, 42 | config: DirectionalTransitionConfig, 43 | delayMillis: Long = 0, 44 | textStyle: TextStyle = MaterialTheme.typography.titleLarge.copy( 45 | fontWeight = FontWeight.Bold, 46 | fontSize = 32.sp, 47 | ), 48 | color: Color = Color.White, 49 | letterIndex: Int = 0, 50 | onComplete: () -> Unit = {} 51 | ) { 52 | // Animation state 53 | val progress = remember { Animatable(0f) } 54 | val scope = rememberCoroutineScope() 55 | 56 | // Direction multiplier for vertical movement 57 | val directionMultiplier = if (direction == TransitionDirection.FORWARD) 1f else -1f 58 | 59 | // Subtle horizontal movement (slightly different for each character) 60 | val horizontalSway = remember { (letterIndex % 2) * 2 - 1 } // Alternates between -1 and 1 61 | 62 | // Convert Dp values to pixels for calculations 63 | val maxVerticalOffsetPx = with(LocalDensity.current) { config.maxVerticalOffset.toPx() } 64 | val maxHorizontalOffsetPx = with(LocalDensity.current) { 65 | (config.maxHorizontalOffset.toPx() + (letterIndex % 3)) 66 | } 67 | val maxBlurPx = with(LocalDensity.current) { config.maxBlur.toPx() } 68 | 69 | // Reset and trigger animations when isAnimating changes 70 | LaunchedEffect(isAnimating, fromChar, toChar) { 71 | if (isAnimating && fromChar != toChar) { 72 | progress.snapTo(0f) 73 | delay(delayMillis) 74 | scope.launch { 75 | progress.animateTo( 76 | targetValue = 1f, animationSpec = tween( 77 | durationMillis = config.transitionDuration, easing = config.easingCurve 78 | ) 79 | ) 80 | delay(100) // Small delay before signaling completion for visual polish 81 | onComplete() 82 | } 83 | } 84 | } 85 | 86 | // Enhanced easing curves for more dramatic animation 87 | val incomingEasing = CubicBezierEasing(0.34f, 1.56f, 0.64f, 1f) // Slight overshoot 88 | val outgoingEasing = CubicBezierEasing(0.36f, 0f, 0.66f, -0.56f) // Dramatic exit 89 | 90 | // Derived progress values 91 | val outgoingProgress = outgoingEasing.transform(progress.value) 92 | val incomingProgress = incomingEasing.transform(progress.value) 93 | 94 | Box( 95 | contentAlignment = Alignment.Center, 96 | ) { 97 | val (minScale, maxScale) = config.scaleRange 98 | val toCharScale = when { 99 | incomingProgress < 0.7f -> minScale + (incomingProgress * 1.3f * (1f - minScale)) 100 | else -> maxScale - ((incomingProgress - 0.7f) * (maxScale - 1f) / 0.3f) 101 | } 102 | 103 | // Position with directional movement 104 | val toCharVerticalOffset = 105 | ((1f - incomingProgress) * maxVerticalOffsetPx * -directionMultiplier).dp 106 | val toCharHorizontalOffset = 107 | (sin(incomingProgress * Math.PI) * maxHorizontalOffsetPx * horizontalSway).dp 108 | val toCharRotation = (1f - incomingProgress) * directionMultiplier * -config.rotationAmount 109 | 110 | // Alpha with quick fade-in 111 | val toCharAlpha = when { 112 | incomingProgress < 0.3f -> incomingProgress * 3.33f 113 | else -> 1f 114 | } 115 | 116 | // Blur effect that clears quickly 117 | val toCharBlur = (maxBlurPx * (1f - incomingProgress).pow(2f)).dp 118 | 119 | // Render incoming character 120 | Text( 121 | text = toChar.toString(), 122 | style = textStyle, 123 | color = color, 124 | textAlign = TextAlign.Center, 125 | modifier = Modifier 126 | .offset(x = toCharHorizontalOffset, y = toCharVerticalOffset) 127 | .alpha(toCharAlpha) 128 | .blur(toCharBlur, BlurredEdgeTreatment.Unbounded) 129 | .scale(toCharScale) 130 | .rotate(toCharRotation) 131 | ) 132 | 133 | // OUTGOING CHARACTER ANIMATION (MAIN) 134 | // ------------------------------- 135 | val fromCharMainAlpha = (1f - outgoingProgress.pow(0.7f)).coerceIn(0f, 1f) 136 | val fromCharMainOffset = 137 | (outgoingProgress.pow(1.2f) * maxVerticalOffsetPx * directionMultiplier).dp 138 | val fromCharMainHOffset = 139 | (sin(outgoingProgress * Math.PI) * -maxHorizontalOffsetPx * horizontalSway).dp 140 | val fromCharMainBlur = (outgoingProgress.pow(0.8f) * maxBlurPx * 0.6f).dp 141 | 142 | // Scaling effect 143 | val fromCharMainScale = when { 144 | outgoingProgress < 0.2f -> 1f + (outgoingProgress * 0.1f) 145 | else -> 1.02f - ((outgoingProgress - 0.2f) * 0.3f) 146 | } 147 | 148 | // Rotation effect 149 | val fromCharMainRotation = outgoingProgress * directionMultiplier * config.rotationAmount 150 | 151 | // Render main outgoing character if visible 152 | if (fromCharMainAlpha > 0.01f) { 153 | Text( 154 | text = fromChar.toString(), 155 | style = textStyle, 156 | color = color, 157 | textAlign = TextAlign.Center, 158 | modifier = Modifier 159 | .offset(x = fromCharMainHOffset, y = fromCharMainOffset) 160 | .alpha(fromCharMainAlpha) 161 | .blur(fromCharMainBlur, BlurredEdgeTreatment.Unbounded) 162 | .scale(fromCharMainScale) 163 | .rotate(fromCharMainRotation) 164 | ) 165 | } 166 | 167 | // GHOST TRAIL EFFECT 168 | // ----------------- 169 | for (i in 1..config.trailCount) { 170 | // Calculate trail position 171 | val trailPosition = i.toFloat() / config.trailCount 172 | 173 | // Apply non-linear distribution for bunching effect 174 | val trailCurve = 1f - (1f - trailPosition).pow(config.bunching) 175 | 176 | // Staggered appearance threshold 177 | val trailThreshold = trailPosition * (0.6f + letterIndex * 0.05f) 178 | 179 | if (outgoingProgress >= trailThreshold) { 180 | // Calculate ghost trail parameters 181 | val trailProgress = (outgoingProgress - trailThreshold) / (1f - trailThreshold) 182 | val trailOffsetBase = outgoingProgress * maxVerticalOffsetPx * directionMultiplier 183 | val trailOffsetVariation = 184 | sin(trailCurve * Math.PI * 0.5f) * 3f * directionMultiplier 185 | val trailOffset = 186 | (trailOffsetBase * (0.85f + trailCurve * 0.15f) + trailOffsetVariation).dp 187 | 188 | // Horizontal movement with slight variation 189 | val trailHorizontalOffset = 190 | (sin(outgoingProgress * Math.PI) * -maxHorizontalOffsetPx * horizontalSway * (1f - trailCurve * 0.3f)).dp 191 | 192 | // Opacity calculation 193 | val trailFadeMultiplier = (1f - trailCurve).pow(1.7f) 194 | val trailAlpha = fromCharMainAlpha * trailFadeMultiplier * 0.85f 195 | 196 | // Blur calculation - increases with trail position 197 | val trailBlur = (maxBlurPx * (0.6f + trailCurve * 0.6f)).dp 198 | 199 | // Scale and rotation effects 200 | val trailScale = fromCharMainScale * (1f - 0.15f * trailCurve) 201 | val trailRotation = fromCharMainRotation * (1f + trailCurve * 0.4f) 202 | 203 | // Only render visible trail instances 204 | if (trailAlpha > 0.01f) { 205 | Text( 206 | text = fromChar.toString(), 207 | style = textStyle, 208 | color = color, 209 | textAlign = TextAlign.Center, 210 | modifier = Modifier 211 | .offset(x = trailHorizontalOffset, y = trailOffset) 212 | .alpha(trailAlpha) 213 | .blur(trailBlur, BlurredEdgeTreatment.Unbounded) 214 | .scale(trailScale) 215 | .rotate(trailRotation) 216 | ) 217 | } 218 | } 219 | } 220 | } 221 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/morph/DayNameMorph.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components.morph 2 | 3 | import android.os.Build 4 | import androidx.annotation.RequiresApi 5 | import androidx.compose.material3.LocalContentColor 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.runtime.Composable 8 | import androidx.compose.runtime.LaunchedEffect 9 | import androidx.compose.runtime.getValue 10 | import androidx.compose.runtime.mutableStateOf 11 | import androidx.compose.runtime.remember 12 | import androidx.compose.runtime.setValue 13 | import androidx.compose.ui.Modifier 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.text.TextStyle 16 | import androidx.compose.ui.text.font.FontWeight 17 | import androidx.compose.ui.unit.sp 18 | import java.time.LocalDate 19 | import java.util.Locale 20 | import java.time.format.TextStyle as JavaTextStyle 21 | 22 | /** 23 | * A specialized implementation of [CharacterMorph] for transitioning between day names. 24 | * This component handles extracting day names from dates and animating the character transitions. 25 | * 26 | * @param currentDate The current selected date 27 | * @param previousDate The previous date (for determining the transition) 28 | * @param isAnimating Whether the animation is currently running 29 | * @param direction The direction of the animation (FORWARD or BACKWARD) 30 | * @param config Configuration options for the animation 31 | * @param textStyle Text style to apply to the day name 32 | * @param color Text color (defaults to LocalContentColor) 33 | * @param modifier Modifier for the composable 34 | * @param onAnimationComplete Callback when animation finishes 35 | */ 36 | @RequiresApi(Build.VERSION_CODES.O) 37 | @Composable 38 | fun DayNameDirectionalTransition( 39 | currentDate: LocalDate, 40 | previousDate: LocalDate?, 41 | isAnimating: Boolean, 42 | direction: TransitionDirection = TransitionDirection.FORWARD, 43 | config: DirectionalTransitionConfig = DirectionalTransitionConfig(), 44 | textStyle: TextStyle = MaterialTheme.typography.displaySmall.copy( 45 | fontSize = 32.sp, 46 | fontWeight = FontWeight.Bold 47 | ), 48 | color: Color = LocalContentColor.current, 49 | modifier: Modifier = Modifier, 50 | onAnimationComplete: () -> Unit = {} 51 | ) { 52 | // Get day names 53 | val currentDayName = 54 | currentDate.dayOfWeek.getDisplayName(JavaTextStyle.SHORT, Locale.getDefault()) 55 | val previousDayName = previousDate?.dayOfWeek?.getDisplayName( 56 | JavaTextStyle.SHORT, 57 | Locale.getDefault() 58 | ) ?: currentDayName 59 | 60 | // Use the character morph animation 61 | DirectionalTextTransition( 62 | initialText = previousDayName, 63 | targetText = currentDayName, 64 | isAnimating = isAnimating, 65 | direction = direction, 66 | config = config, 67 | textStyle = textStyle, 68 | color = color, 69 | modifier = modifier, 70 | onAnimationComplete = onAnimationComplete, 71 | ) 72 | } 73 | 74 | /** 75 | * A higher-level component that handles date transitions and applies the day name morphing effect. 76 | * This component automatically determines the animation direction and manages animation state. 77 | * 78 | * @param date The current date to display 79 | * @param onDateChanged Callback when date changes (useful for updating other UI elements) 80 | * @param config Configuration options for the animation 81 | * @param textStyle Text style to apply to the day name 82 | * @param color Text color 83 | * @param modifier Modifier for the composable 84 | */ 85 | @RequiresApi(Build.VERSION_CODES.O) 86 | @Composable 87 | fun AnimatedDayName( 88 | date: LocalDate, 89 | onDateChanged: (LocalDate, LocalDate) -> Unit = { _, _ -> }, 90 | config: DirectionalTransitionConfig = DirectionalTransitionConfig(), 91 | textStyle: TextStyle = MaterialTheme.typography.displaySmall.copy( 92 | fontSize = 32.sp, 93 | fontWeight = FontWeight.Bold 94 | ), 95 | color: Color = LocalContentColor.current, 96 | modifier: Modifier = Modifier 97 | ) { 98 | // Track previous date and animation state 99 | var previousDate by remember { mutableStateOf(null) } 100 | var isAnimating by remember { mutableStateOf(false) } 101 | var morphDirection by remember { mutableStateOf(TransitionDirection.FORWARD) } 102 | 103 | // When date changes, trigger animation 104 | LaunchedEffect(date) { 105 | if (previousDate != null && previousDate != date) { 106 | // Determine animation direction based on days of week 107 | morphDirection = determineTransitionDirection(date, previousDate!!) 108 | 109 | // Notify about date change 110 | onDateChanged(date, previousDate!!) 111 | 112 | // Start animation 113 | isAnimating = true 114 | } else if (previousDate == null) { 115 | previousDate = date 116 | } 117 | } 118 | 119 | DayNameDirectionalTransition( 120 | currentDate = date, 121 | previousDate = previousDate, 122 | isAnimating = isAnimating, 123 | direction = morphDirection, 124 | config = config, 125 | textStyle = textStyle, 126 | color = color, 127 | modifier = modifier, 128 | onAnimationComplete = { 129 | // Update previous date after animation 130 | previousDate = date 131 | isAnimating = false 132 | } 133 | ) 134 | } 135 | 136 | /** 137 | * Determines the correct animation direction when transitioning between dates. 138 | * This considers the calendar flow (forward in time = bottom to top, backward = top to bottom). 139 | */ 140 | @RequiresApi(Build.VERSION_CODES.O) 141 | fun determineTransitionDirection(newDate: LocalDate, oldDate: LocalDate): TransitionDirection { 142 | // Handle special case of week wrap-around 143 | if (newDate.dayOfWeek.value == 1 && oldDate.dayOfWeek.value == 7) { 144 | return TransitionDirection.FORWARD // Sunday to Monday is moving forward 145 | } 146 | 147 | if (newDate.dayOfWeek.value == 7 && oldDate.dayOfWeek.value == 1) { 148 | return TransitionDirection.BACKWARD // Monday to Sunday is moving backward 149 | } 150 | 151 | // Regular weekday progression 152 | return if (newDate.dayOfWeek.value > oldDate.dayOfWeek.value) { 153 | TransitionDirection.FORWARD 154 | } else { 155 | TransitionDirection.BACKWARD 156 | } 157 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/morph/DirectionalTextTransition.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components.morph 2 | 3 | import androidx.compose.foundation.layout.Arrangement 4 | import androidx.compose.foundation.layout.Box 5 | import androidx.compose.foundation.layout.Row 6 | import androidx.compose.foundation.layout.width 7 | import androidx.compose.foundation.layout.wrapContentWidth 8 | import androidx.compose.material3.MaterialTheme 9 | import androidx.compose.material3.Text 10 | import androidx.compose.runtime.Composable 11 | import androidx.compose.runtime.LaunchedEffect 12 | import androidx.compose.runtime.mutableIntStateOf 13 | import androidx.compose.runtime.mutableStateOf 14 | import androidx.compose.runtime.remember 15 | import androidx.compose.ui.Alignment 16 | import androidx.compose.ui.Modifier 17 | import androidx.compose.ui.graphics.Color 18 | import androidx.compose.ui.platform.LocalDensity 19 | import androidx.compose.ui.text.TextStyle 20 | import androidx.compose.ui.text.style.TextAlign 21 | import androidx.compose.ui.unit.dp 22 | 23 | /** 24 | * A composable that animates text transitions with a directional smearing/motion blur effect. 25 | * Characters that change will display a ghost trail effect in the specified direction. 26 | * 27 | * @param initialText The initial text state. 28 | * @param targetText The target text state to transition to. 29 | * @param isAnimating Whether the animation is currently active. 30 | * @param direction Direction of the animation. 31 | * @param config Configuration parameters for the animation effect. 32 | * @param modifier Modifier for the composable. 33 | * @param textStyle Text style for the characters. 34 | * @param color Color of the text. 35 | * @param onAnimationComplete Callback invoked when animation completes. 36 | */ 37 | @Composable 38 | fun DirectionalTextTransition( 39 | initialText: String, 40 | targetText: String, 41 | isAnimating: Boolean, 42 | direction: TransitionDirection = TransitionDirection.FORWARD, 43 | config: DirectionalTransitionConfig = DirectionalTransitionConfig(), 44 | modifier: Modifier = Modifier, 45 | textStyle: TextStyle = MaterialTheme.typography.displaySmall, 46 | color: Color = Color.White, 47 | onAnimationComplete: () -> Unit = {} 48 | ) { 49 | // Determine max length to ensure stable layout 50 | val maxLength = maxOf(initialText.length, targetText.length) 51 | 52 | // Create list of character transitions 53 | val charPairs = List(maxLength) { index -> 54 | val fromChar = if (index < initialText.length) initialText[index] else ' ' 55 | val toChar = if (index < targetText.length) targetText[index] else ' ' 56 | Pair(fromChar, toChar) 57 | } 58 | 59 | // Track overall animation completion 60 | val animationsInProgress = remember { mutableIntStateOf(0) } 61 | 62 | LaunchedEffect(isAnimating) { 63 | if (isAnimating) { 64 | animationsInProgress.intValue = charPairs.count { it.first != it.second } 65 | } 66 | } 67 | 68 | // Overall animation completion tracking 69 | fun onCharAnimationComplete() { 70 | animationsInProgress.intValue = (animationsInProgress.intValue - 1).coerceAtLeast(0) 71 | if (animationsInProgress.intValue == 0) { 72 | onAnimationComplete() 73 | } 74 | } 75 | 76 | Row( 77 | modifier = modifier, 78 | verticalAlignment = Alignment.CenterVertically, 79 | ) { 80 | charPairs.forEachIndexed { index, (fromChar, toChar) -> 81 | Box( 82 | // modifier = Modifier.wrapContentWidth(), 83 | contentAlignment = Alignment.Center 84 | ) { 85 | if (fromChar == toChar) { 86 | // If characters are the same, just show the character without animation 87 | Text( 88 | text = toChar.toString(), 89 | style = textStyle, 90 | color = color, 91 | textAlign = TextAlign.Center 92 | ) 93 | } else { 94 | // Calculate staggered delay for this character 95 | val staggerDelay = (config.staggerDelay * index * config.staggerFactor).toLong() 96 | 97 | // Animate transitioning characters 98 | CharacterTransition( 99 | fromChar = fromChar, 100 | toChar = toChar, 101 | isAnimating = isAnimating, 102 | direction = direction, 103 | config = config, 104 | delayMillis = staggerDelay, 105 | textStyle = textStyle, 106 | color = color, 107 | letterIndex = index, 108 | onComplete = { onCharAnimationComplete() } 109 | ) 110 | } 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/morph/DirectionalTransitionConfig.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components.morph 2 | 3 | import androidx.compose.animation.core.* 4 | import androidx.compose.foundation.layout.* 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.Text 7 | import androidx.compose.runtime.* 8 | import androidx.compose.ui.Alignment 9 | import androidx.compose.ui.Modifier 10 | import androidx.compose.ui.draw.alpha 11 | import androidx.compose.ui.draw.blur 12 | import androidx.compose.ui.draw.rotate 13 | import androidx.compose.ui.draw.scale 14 | import androidx.compose.ui.graphics.Color 15 | import androidx.compose.ui.platform.LocalDensity 16 | import androidx.compose.ui.text.TextStyle 17 | import androidx.compose.ui.text.style.TextAlign 18 | import androidx.compose.ui.unit.Dp 19 | import androidx.compose.ui.unit.dp 20 | import kotlinx.coroutines.delay 21 | import kotlinx.coroutines.launch 22 | import kotlin.math.pow 23 | import kotlin.math.sin 24 | 25 | /** 26 | * Configuration for the DirectionalTextTransition effect. 27 | * 28 | * @param transitionDuration Duration of the transition animation in milliseconds. 29 | * @param staggerDelay Base delay between character animations in milliseconds. 30 | * @param staggerFactor Multiplier for stagger delay between subsequent characters. 31 | * @param trailCount Number of ghost trail instances for each character. 32 | * @param maxVerticalOffset Maximum vertical movement distance during animation. 33 | * @param maxHorizontalOffset Maximum horizontal sway during animation. 34 | * @param maxBlur Maximum blur amount applied to trailing instances. 35 | * @param rotationAmount Maximum rotation angle in degrees. 36 | * @param scaleRange Pair of (minimum, maximum) scale values during transition. 37 | * @param charSpacing Horizontal spacing between characters. 38 | * @param bunching Power factor for trail bunching (higher = more bunched). 39 | * @param easingCurve Easing curve for the animation. 40 | */ 41 | data class DirectionalTransitionConfig( 42 | val transitionDuration: Int = 750, 43 | val staggerDelay: Long = 120, 44 | val staggerFactor: Float = 1.3f, 45 | val trailCount: Int = 8, 46 | val maxVerticalOffset: Dp = 48.dp, 47 | val maxHorizontalOffset: Dp = 3.dp, 48 | val maxBlur: Dp = 10.dp, 49 | val rotationAmount: Float = 6f, 50 | val scaleRange: Pair = Pair(0.6f, 1.15f), 51 | val charSpacing: Dp = 2.dp, 52 | val bunching: Float = 2.7f, 53 | val easingCurve: Easing = FastOutSlowInEasing 54 | ) 55 | 56 | /** 57 | * Direction of the transition animation. 58 | */ 59 | enum class TransitionDirection { 60 | FORWARD, BACKWARD 61 | } 62 | -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/calendar/components/morph/TransitionControlPanel.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.calendar.components.morph 2 | 3 | 4 | import android.os.Build 5 | import androidx.annotation.RequiresApi 6 | import androidx.compose.animation.AnimatedVisibility 7 | import androidx.compose.animation.core.animateFloatAsState 8 | import androidx.compose.animation.expandVertically 9 | import androidx.compose.animation.fadeIn 10 | import androidx.compose.animation.fadeOut 11 | import androidx.compose.animation.shrinkVertically 12 | import androidx.compose.foundation.BorderStroke 13 | import androidx.compose.foundation.background 14 | import androidx.compose.foundation.clickable 15 | import androidx.compose.foundation.layout.Arrangement 16 | import androidx.compose.foundation.layout.Box 17 | import androidx.compose.foundation.layout.Column 18 | import androidx.compose.foundation.layout.Row 19 | import androidx.compose.foundation.layout.Spacer 20 | import androidx.compose.foundation.layout.fillMaxWidth 21 | import androidx.compose.foundation.layout.height 22 | import androidx.compose.foundation.layout.padding 23 | import androidx.compose.foundation.layout.size 24 | import androidx.compose.foundation.layout.width 25 | import androidx.compose.foundation.rememberScrollState 26 | import androidx.compose.foundation.shape.CircleShape 27 | import androidx.compose.foundation.shape.RoundedCornerShape 28 | import androidx.compose.foundation.verticalScroll 29 | import androidx.compose.material.icons.Icons 30 | import androidx.compose.material.icons.filled.Add 31 | import androidx.compose.material.icons.filled.Close 32 | import androidx.compose.material.icons.filled.Info 33 | import androidx.compose.material.icons.filled.KeyboardArrowDown 34 | import androidx.compose.material.icons.filled.KeyboardArrowUp 35 | import androidx.compose.material.icons.rounded.Check 36 | import androidx.compose.material.icons.rounded.Edit 37 | import androidx.compose.material.icons.rounded.Refresh 38 | import androidx.compose.material.icons.rounded.Star 39 | import androidx.compose.material3.Button 40 | import androidx.compose.material3.ButtonDefaults 41 | import androidx.compose.material3.Card 42 | import androidx.compose.material3.CardDefaults 43 | import androidx.compose.material3.Divider 44 | import androidx.compose.material3.FilledIconButton 45 | import androidx.compose.material3.FilledTonalButton 46 | import androidx.compose.material3.FilledTonalIconButton 47 | import androidx.compose.material3.Icon 48 | import androidx.compose.material3.IconButton 49 | import androidx.compose.material3.MaterialTheme 50 | import androidx.compose.material3.OutlinedCard 51 | import androidx.compose.material3.Slider 52 | import androidx.compose.material3.SliderDefaults 53 | import androidx.compose.material3.Surface 54 | import androidx.compose.material3.Switch 55 | import androidx.compose.material3.Text 56 | import androidx.compose.material3.TextButton 57 | import androidx.compose.material3.TooltipDefaults 58 | import androidx.compose.material3.surfaceColorAtElevation 59 | import androidx.compose.runtime.Composable 60 | import androidx.compose.runtime.LaunchedEffect 61 | import androidx.compose.runtime.getValue 62 | import androidx.compose.runtime.mutableFloatStateOf 63 | import androidx.compose.runtime.mutableIntStateOf 64 | import androidx.compose.runtime.mutableStateOf 65 | import androidx.compose.runtime.remember 66 | import androidx.compose.runtime.rememberCoroutineScope 67 | import androidx.compose.runtime.setValue 68 | import androidx.compose.ui.Alignment 69 | import androidx.compose.ui.Modifier 70 | import androidx.compose.ui.draw.alpha 71 | import androidx.compose.ui.draw.clip 72 | import androidx.compose.ui.graphics.Color 73 | import androidx.compose.ui.graphics.vector.ImageVector 74 | import androidx.compose.ui.text.font.FontWeight 75 | import androidx.compose.ui.text.style.TextAlign 76 | import androidx.compose.ui.unit.dp 77 | import androidx.compose.ui.unit.sp 78 | import com.binissa.calendar.calendar.components.morph.AnimatedDayName 79 | import com.binissa.calendar.calendar.components.morph.DirectionalTransitionConfig 80 | import com.binissa.calendar.calendar.components.morph.TransitionDirection 81 | import kotlinx.coroutines.delay 82 | import kotlinx.coroutines.launch 83 | import java.time.DayOfWeek 84 | import java.time.LocalDate 85 | import java.time.format.DateTimeFormatter 86 | import java.time.format.TextStyle 87 | import java.util.Locale 88 | 89 | @RequiresApi(Build.VERSION_CODES.O) 90 | @Composable 91 | fun TransitionControlPanel( 92 | modifier: Modifier = Modifier, 93 | initialConfig: DirectionalTransitionConfig = DirectionalTransitionConfig(), 94 | onConfigChanged: (DirectionalTransitionConfig) -> Unit 95 | ) { 96 | var config by remember { mutableStateOf(initialConfig) } 97 | var currentDate by remember { mutableStateOf(LocalDate.now()) } 98 | var expandedSection by remember { mutableStateOf("none") } 99 | val scope = rememberCoroutineScope() 100 | 101 | // Define presets with visual indicators 102 | val presets = remember { 103 | listOf( 104 | TransitionPreset( 105 | name = "Subtle", 106 | description = "Gentle motion with minimal blur", 107 | visualTags = listOf("Clean", "Simple"), 108 | config = DirectionalTransitionConfig( 109 | transitionDuration = 400, 110 | staggerDelay = 70, 111 | trailCount = 2, 112 | maxBlur = 4.dp, 113 | rotationAmount = 0f, 114 | maxVerticalOffset = 12.dp, 115 | maxHorizontalOffset = 1.dp, 116 | bunching = 1.2f 117 | ) 118 | ), 119 | TransitionPreset( 120 | name = "Classic Fade", 121 | description = "Traditional text crossfade with motion", 122 | visualTags = listOf("Smooth", "Professional"), 123 | config = DirectionalTransitionConfig( 124 | transitionDuration = 500, 125 | staggerDelay = 80, 126 | trailCount = 3, 127 | maxBlur = 8.dp, 128 | rotationAmount = 0f, 129 | maxVerticalOffset = 20.dp, 130 | maxHorizontalOffset = 0.dp, 131 | bunching = 1.5f 132 | ) 133 | ), 134 | TransitionPreset( 135 | name = "Smear", 136 | description = "Dramatic motion blur effect", 137 | visualTags = listOf("Dynamic", "Bold"), 138 | config = DirectionalTransitionConfig( 139 | transitionDuration = 650, 140 | staggerDelay = 60, 141 | trailCount = 7, 142 | maxBlur = 16.dp, 143 | rotationAmount = 3f, 144 | maxVerticalOffset = 30.dp, 145 | maxHorizontalOffset = 2.dp, 146 | bunching = 2.2f 147 | ) 148 | ), 149 | TransitionPreset( 150 | name = "Ghostly", 151 | description = "Extended blur trails with transparency", 152 | visualTags = listOf("Ethereal", "Artistic"), 153 | config = DirectionalTransitionConfig( 154 | transitionDuration = 800, 155 | staggerDelay = 90, 156 | trailCount = 9, 157 | maxBlur = 20.dp, 158 | rotationAmount = 0f, 159 | maxVerticalOffset = 35.dp, 160 | maxHorizontalOffset = 0.dp, 161 | bunching = 3.0f 162 | ) 163 | ), 164 | TransitionPreset( 165 | name = "Playful", 166 | description = "Bouncy motion with rotation", 167 | visualTags = listOf("Fun", "Energetic"), 168 | config = DirectionalTransitionConfig( 169 | transitionDuration = 700, 170 | staggerDelay = 100, 171 | trailCount = 5, 172 | maxBlur = 10.dp, 173 | rotationAmount = 10f, 174 | maxVerticalOffset = 25.dp, 175 | maxHorizontalOffset = 5.dp, 176 | bunching = 1.8f, 177 | scaleRange = Pair(0.7f, 1.2f) 178 | ) 179 | ), 180 | TransitionPreset( 181 | name = "Techno", 182 | description = "Sharp, digital appearance", 183 | visualTags = listOf("Modern", "Precise"), 184 | config = DirectionalTransitionConfig( 185 | transitionDuration = 550, 186 | staggerDelay = 50, 187 | trailCount = 4, 188 | maxBlur = 6.dp, 189 | rotationAmount = 2f, 190 | maxVerticalOffset = 28.dp, 191 | maxHorizontalOffset = 1.dp, 192 | bunching = 0.8f, 193 | scaleRange = Pair(0.9f, 1.1f) 194 | ) 195 | ) 196 | ) 197 | } 198 | 199 | var selectedPreset by remember { mutableStateOf(presets.first()) } 200 | var isCustom by remember { mutableStateOf(false) } 201 | 202 | LaunchedEffect(config) { 203 | onConfigChanged(config) 204 | 205 | // Check if config matches a preset 206 | val matchingPreset = presets.firstOrNull { it.config == config } 207 | if (matchingPreset != null) { 208 | selectedPreset = matchingPreset 209 | isCustom = false 210 | } else { 211 | isCustom = true 212 | } 213 | } 214 | 215 | // Animate the date change for preview 216 | var isAnimating by remember { mutableStateOf(false) } 217 | fun animatePreview(newDate: LocalDate) { 218 | scope.launch { 219 | isAnimating = true 220 | currentDate = newDate 221 | delay(config.transitionDuration.toLong() + 200) 222 | isAnimating = false 223 | } 224 | } 225 | 226 | Column( 227 | modifier = modifier 228 | .padding(16.dp) 229 | .verticalScroll(rememberScrollState()), 230 | verticalArrangement = Arrangement.spacedBy(16.dp) 231 | ) { 232 | // Preview section with elegant card 233 | Card( 234 | modifier = Modifier.fillMaxWidth(), 235 | elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), 236 | colors = CardDefaults.cardColors( 237 | containerColor = MaterialTheme.colorScheme.surfaceVariant, 238 | ), 239 | shape = RoundedCornerShape(16.dp) 240 | ) { 241 | Column( 242 | modifier = Modifier.padding(16.dp), 243 | horizontalAlignment = Alignment.CenterHorizontally, 244 | verticalArrangement = Arrangement.spacedBy(16.dp) 245 | ) { 246 | Text( 247 | text = "Preview", 248 | style = MaterialTheme.typography.titleMedium, 249 | color = MaterialTheme.colorScheme.onSurfaceVariant 250 | ) 251 | 252 | // Animation preview 253 | Box( 254 | modifier = Modifier 255 | .fillMaxWidth() 256 | .padding(vertical = 24.dp), 257 | contentAlignment = Alignment.Center 258 | ) { 259 | AnimatedDayName( 260 | date = currentDate, 261 | config = config, 262 | textStyle = MaterialTheme.typography.displayMedium.copy( 263 | fontWeight = FontWeight.Bold 264 | ), 265 | color = MaterialTheme.colorScheme.primary 266 | ) 267 | } 268 | 269 | // Day selection buttons in an elegant row 270 | Row( 271 | modifier = Modifier.fillMaxWidth(), 272 | horizontalArrangement = Arrangement.SpaceEvenly 273 | ) { 274 | // Previous/Next buttons 275 | FilledTonalIconButton( 276 | onClick = { animatePreview(currentDate.minusDays(1)) }, 277 | enabled = !isAnimating 278 | ) { 279 | Icon( 280 | imageVector = Icons.Default.Close, 281 | contentDescription = "Previous Day" 282 | ) 283 | } 284 | 285 | // Days of week buttons 286 | DayOfWeek.values().take(3).forEach { day -> 287 | DayButton( 288 | day = day, 289 | isSelected = currentDate.dayOfWeek == day, 290 | onClick = { animatePreview(LocalDate.now().with(day)) }, 291 | enabled = !isAnimating 292 | ) 293 | } 294 | 295 | FilledTonalIconButton( 296 | onClick = { animatePreview(currentDate.plusDays(1)) }, 297 | enabled = !isAnimating 298 | ) { 299 | Icon( 300 | imageVector = Icons.Default.Add, 301 | contentDescription = "Next Day" 302 | ) 303 | } 304 | } 305 | 306 | Row( 307 | modifier = Modifier.fillMaxWidth(), 308 | horizontalArrangement = Arrangement.Center 309 | ) { 310 | DayOfWeek.values().drop(3).forEach { day -> 311 | DayButton( 312 | day = day, 313 | isSelected = currentDate.dayOfWeek == day, 314 | onClick = { animatePreview(LocalDate.now().with(day)) }, 315 | enabled = !isAnimating 316 | ) 317 | } 318 | } 319 | } 320 | } 321 | 322 | // Presets selector with visual indicators 323 | SectionHeader( 324 | title = "Effect Style", 325 | icon = Icons.Rounded.Star, 326 | expanded = expandedSection == "presets", 327 | onExpandToggle = { expandedSection = if (expandedSection == "presets") "none" else "presets" } 328 | ) 329 | 330 | AnimatedVisibility( 331 | visible = expandedSection == "presets", 332 | enter = fadeIn() + expandVertically(), 333 | exit = fadeOut() + shrinkVertically() 334 | ) { 335 | PresetSelector( 336 | presets = presets, 337 | selectedPreset = if (isCustom) null else selectedPreset, 338 | onPresetSelected = { preset -> 339 | config = preset.config 340 | selectedPreset = preset 341 | isCustom = false 342 | } 343 | ) 344 | } 345 | 346 | // Quick controls for most common adjustments 347 | SectionHeader( 348 | title = "Quick Adjustments", 349 | icon = Icons.Rounded.Edit, 350 | expanded = expandedSection == "quick", 351 | onExpandToggle = { expandedSection = if (expandedSection == "quick") "none" else "quick" } 352 | ) 353 | 354 | AnimatedVisibility( 355 | visible = expandedSection == "quick", 356 | enter = fadeIn() + expandVertically(), 357 | exit = fadeOut() + shrinkVertically() 358 | ) { 359 | QuickAdjustments( 360 | config = config, 361 | onConfigChanged = { newConfig -> 362 | config = newConfig 363 | isCustom = true 364 | } 365 | ) 366 | } 367 | 368 | // Advanced controls 369 | SectionHeader( 370 | title = "Advanced Settings", 371 | icon = Icons.Rounded.Edit, 372 | expanded = expandedSection == "advanced", 373 | onExpandToggle = { expandedSection = if (expandedSection == "advanced") "none" else "advanced" } 374 | ) 375 | 376 | AnimatedVisibility( 377 | visible = expandedSection == "advanced", 378 | enter = fadeIn() + expandVertically(), 379 | exit = fadeOut() + shrinkVertically() 380 | ) { 381 | AdvancedControls( 382 | config = config, 383 | onConfigChanged = { newConfig -> 384 | config = newConfig 385 | isCustom = true 386 | } 387 | ) 388 | } 389 | 390 | // Reset/Save button row 391 | Row( 392 | modifier = Modifier 393 | .fillMaxWidth() 394 | .padding(vertical = 8.dp), 395 | horizontalArrangement = Arrangement.SpaceBetween 396 | ) { 397 | TextButton( 398 | onClick = { 399 | config = DirectionalTransitionConfig() 400 | isCustom = false 401 | } 402 | ) { 403 | Icon( 404 | imageVector = Icons.Rounded.Refresh, 405 | contentDescription = "Reset", 406 | modifier = Modifier.size(18.dp) 407 | ) 408 | Spacer(modifier = Modifier.width(4.dp)) 409 | Text("Reset") 410 | } 411 | 412 | if (isCustom) { 413 | Button( 414 | onClick = { 415 | // Save as custom preset (in a real app) 416 | } 417 | ) { 418 | Text("Save Custom Preset") 419 | } 420 | } 421 | } 422 | } 423 | } 424 | 425 | @Composable 426 | private fun SectionHeader( 427 | title: String, 428 | icon: ImageVector, 429 | expanded: Boolean, 430 | onExpandToggle: () -> Unit 431 | ) { 432 | Row( 433 | modifier = Modifier 434 | .fillMaxWidth() 435 | .clickable(onClick = onExpandToggle) 436 | .padding(vertical = 8.dp), 437 | verticalAlignment = Alignment.CenterVertically, 438 | horizontalArrangement = Arrangement.SpaceBetween 439 | ) { 440 | Row( 441 | verticalAlignment = Alignment.CenterVertically 442 | ) { 443 | Icon( 444 | imageVector = icon, 445 | contentDescription = null, 446 | tint = MaterialTheme.colorScheme.primary, 447 | modifier = Modifier.size(24.dp) 448 | ) 449 | Spacer(modifier = Modifier.width(8.dp)) 450 | Text( 451 | text = title, 452 | style = MaterialTheme.typography.titleMedium, 453 | fontWeight = FontWeight.Bold, 454 | color = MaterialTheme.colorScheme.onSurface 455 | ) 456 | } 457 | 458 | Icon( 459 | imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown, 460 | contentDescription = if (expanded) "Collapse" else "Expand", 461 | tint = MaterialTheme.colorScheme.onSurfaceVariant 462 | ) 463 | } 464 | Divider(color = MaterialTheme.colorScheme.outlineVariant) 465 | } 466 | 467 | @Composable 468 | private fun PresetSelector( 469 | presets: List, 470 | selectedPreset: TransitionPreset?, 471 | onPresetSelected: (TransitionPreset) -> Unit 472 | ) { 473 | Column( 474 | verticalArrangement = Arrangement.spacedBy(8.dp), 475 | modifier = Modifier.padding(vertical = 8.dp) 476 | ) { 477 | presets.chunked(2).forEach { rowPresets -> 478 | Row( 479 | horizontalArrangement = Arrangement.spacedBy(8.dp), 480 | modifier = Modifier.fillMaxWidth() 481 | ) { 482 | rowPresets.forEach { preset -> 483 | val isSelected = preset == selectedPreset 484 | 485 | PresetCard( 486 | preset = preset, 487 | isSelected = isSelected, 488 | onClick = { onPresetSelected(preset) }, 489 | modifier = Modifier.weight(1f) 490 | ) 491 | } 492 | 493 | // Add empty space if odd number 494 | if (rowPresets.size == 1) { 495 | Spacer(modifier = Modifier.weight(1f)) 496 | } 497 | } 498 | } 499 | } 500 | } 501 | 502 | @Composable 503 | private fun PresetCard( 504 | preset: TransitionPreset, 505 | isSelected: Boolean, 506 | onClick: () -> Unit, 507 | modifier: Modifier = Modifier 508 | ) { 509 | val borderColor = if (isSelected) { 510 | MaterialTheme.colorScheme.primary 511 | } else { 512 | MaterialTheme.colorScheme.outline.copy(alpha = 0.2f) 513 | } 514 | 515 | val backgroundColor = if (isSelected) { 516 | MaterialTheme.colorScheme.primaryContainer 517 | } else { 518 | MaterialTheme.colorScheme.surfaceVariant 519 | } 520 | 521 | val textColor = if (isSelected) { 522 | MaterialTheme.colorScheme.onPrimaryContainer 523 | } else { 524 | MaterialTheme.colorScheme.onSurfaceVariant 525 | } 526 | 527 | OutlinedCard( 528 | onClick = onClick, 529 | modifier = modifier, 530 | colors = CardDefaults.outlinedCardColors( 531 | containerColor = backgroundColor, 532 | contentColor = textColor 533 | ), 534 | border = BorderStroke(2.dp, borderColor) 535 | ) { 536 | Column( 537 | modifier = Modifier.padding(12.dp), 538 | verticalArrangement = Arrangement.spacedBy(4.dp) 539 | ) { 540 | Row( 541 | verticalAlignment = Alignment.CenterVertically, 542 | horizontalArrangement = Arrangement.SpaceBetween, 543 | modifier = Modifier.fillMaxWidth() 544 | ) { 545 | Text( 546 | text = preset.name, 547 | style = MaterialTheme.typography.titleMedium, 548 | fontWeight = FontWeight.Bold 549 | ) 550 | 551 | if (isSelected) { 552 | Icon( 553 | imageVector = Icons.Rounded.Check, 554 | contentDescription = "Selected", 555 | tint = MaterialTheme.colorScheme.primary, 556 | modifier = Modifier.size(18.dp) 557 | ) 558 | } 559 | } 560 | 561 | Text( 562 | text = preset.description, 563 | style = MaterialTheme.typography.bodySmall, 564 | color = if (isSelected) { 565 | MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) 566 | } else { 567 | MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) 568 | } 569 | ) 570 | 571 | Spacer(modifier = Modifier.height(4.dp)) 572 | 573 | // Visual tags 574 | Row( 575 | horizontalArrangement = Arrangement.spacedBy(4.dp) 576 | ) { 577 | preset.visualTags.forEach { tag -> 578 | PresetTag(tag = tag, isSelected = isSelected) 579 | } 580 | } 581 | } 582 | } 583 | } 584 | 585 | @Composable 586 | private fun PresetTag(tag: String, isSelected: Boolean) { 587 | val backgroundColor = if (isSelected) { 588 | MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) 589 | } else { 590 | MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.1f) 591 | } 592 | 593 | val textColor = if (isSelected) { 594 | MaterialTheme.colorScheme.primary 595 | } else { 596 | MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) 597 | } 598 | 599 | Box( 600 | modifier = Modifier 601 | .clip(RoundedCornerShape(4.dp)) 602 | .background(backgroundColor) 603 | .padding(horizontal = 6.dp, vertical = 2.dp) 604 | ) { 605 | Text( 606 | text = tag, 607 | style = MaterialTheme.typography.labelSmall, 608 | color = textColor 609 | ) 610 | } 611 | } 612 | 613 | @Composable 614 | private fun QuickAdjustments( 615 | config: DirectionalTransitionConfig, 616 | onConfigChanged: (DirectionalTransitionConfig) -> Unit 617 | ) { 618 | Column( 619 | verticalArrangement = Arrangement.spacedBy(16.dp), 620 | modifier = Modifier.padding(vertical = 8.dp) 621 | ) { 622 | // Intensity slider 623 | var intensity by remember { mutableFloatStateOf(calculateIntensity(config)) } 624 | 625 | LabeledSlider( 626 | label = "Effect Intensity", 627 | value = intensity, 628 | range = 0f..1f, 629 | steps = 10, 630 | valueDisplay = { "%.0f%%".format(it * 100) }, 631 | onValueChange = { 632 | intensity = it 633 | onConfigChanged(applyIntensity(config, intensity)) 634 | } 635 | ) 636 | 637 | // Blur amount 638 | LabeledSlider( 639 | label = "Blur Amount", 640 | value = config.maxBlur.value, 641 | range = 0f..20f, 642 | valueDisplay = { "%.1f".format(it) }, 643 | onValueChange = { onConfigChanged(config.copy(maxBlur = it.dp)) } 644 | ) 645 | 646 | // Trail count with tooltip 647 | SliderWithHelp( 648 | label = "Trail Count", 649 | value = config.trailCount.toFloat(), 650 | range = 1f..12f, 651 | steps = 11, 652 | valueDisplay = { "${it.toInt()}" }, 653 | helpText = "Number of trailing ghost instances. Higher values create more pronounced motion blur effect.", 654 | onValueChange = { onConfigChanged(config.copy(trailCount = it.toInt())) } 655 | ) 656 | 657 | // Duration slider 658 | LabeledSlider( 659 | label = "Duration (ms)", 660 | value = config.transitionDuration.toFloat(), 661 | range = 200f..1000f, 662 | steps = 16, 663 | valueDisplay = { "${it.toInt()}" }, 664 | onValueChange = { onConfigChanged(config.copy(transitionDuration = it.toInt())) } 665 | ) 666 | } 667 | } 668 | 669 | @Composable 670 | private fun AdvancedControls( 671 | config: DirectionalTransitionConfig, 672 | onConfigChanged: (DirectionalTransitionConfig) -> Unit 673 | ) { 674 | Column( 675 | verticalArrangement = Arrangement.spacedBy(16.dp), 676 | modifier = Modifier.padding(vertical = 8.dp) 677 | ) { 678 | // Group sliders into categories 679 | SectionSubheader("Animation Timing") 680 | 681 | LabeledSlider( 682 | label = "Stagger Delay (ms)", 683 | value = config.staggerDelay.toFloat(), 684 | range = 0f..200f, 685 | steps = 20, 686 | valueDisplay = { "${it.toInt()}" }, 687 | onValueChange = { onConfigChanged(config.copy(staggerDelay = it.toLong())) } 688 | ) 689 | 690 | LabeledSlider( 691 | label = "Stagger Factor", 692 | value = config.staggerFactor, 693 | range = 0.5f..3f, 694 | valueDisplay = { "%.1f".format(it) }, 695 | onValueChange = { onConfigChanged(config.copy(staggerFactor = it)) } 696 | ) 697 | 698 | SectionSubheader("Visual Effects") 699 | 700 | SliderWithHelp( 701 | label = "Bunching Factor", 702 | value = config.bunching, 703 | range = 0.5f..5f, 704 | valueDisplay = { "%.1f".format(it) }, 705 | helpText = "Controls how trails bunch together. Higher values create a more concentrated smearing effect at the end of the trail.", 706 | onValueChange = { onConfigChanged(config.copy(bunching = it)) } 707 | ) 708 | 709 | LabeledSlider( 710 | label = "Rotation (°)", 711 | value = config.rotationAmount, 712 | range = 0f..20f, 713 | valueDisplay = { "%.1f°".format(it) }, 714 | onValueChange = { onConfigChanged(config.copy(rotationAmount = it)) } 715 | ) 716 | 717 | SectionSubheader("Movement") 718 | 719 | LabeledSlider( 720 | label = "Vertical Offset", 721 | value = config.maxVerticalOffset.value, 722 | range = 0f..60f, 723 | valueDisplay = { "%.0f".format(it) }, 724 | onValueChange = { onConfigChanged(config.copy(maxVerticalOffset = it.dp)) } 725 | ) 726 | 727 | LabeledSlider( 728 | label = "Horizontal Offset", 729 | value = config.maxHorizontalOffset.value, 730 | range = 0f..10f, 731 | valueDisplay = { "%.1f".format(it) }, 732 | onValueChange = { onConfigChanged(config.copy(maxHorizontalOffset = it.dp)) } 733 | ) 734 | 735 | SectionSubheader("Scale Effects") 736 | 737 | LabeledSlider( 738 | label = "Min Scale", 739 | value = config.scaleRange.first, 740 | range = 0.5f..1f, 741 | valueDisplay = { "%.2f".format(it) }, 742 | onValueChange = { 743 | onConfigChanged(config.copy( 744 | scaleRange = Pair(it, config.scaleRange.second) 745 | )) 746 | } 747 | ) 748 | 749 | LabeledSlider( 750 | label = "Max Scale", 751 | value = config.scaleRange.second, 752 | range = 1f..1.5f, 753 | valueDisplay = { "%.2f".format(it) }, 754 | onValueChange = { 755 | onConfigChanged(config.copy( 756 | scaleRange = Pair(config.scaleRange.first, it) 757 | )) 758 | } 759 | ) 760 | } 761 | } 762 | 763 | @Composable 764 | private fun SectionSubheader(text: String) { 765 | Text( 766 | text = text, 767 | style = MaterialTheme.typography.titleSmall, 768 | color = MaterialTheme.colorScheme.primary, 769 | modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) 770 | ) 771 | } 772 | 773 | @Composable 774 | private fun LabeledSlider( 775 | label: String, 776 | value: Float, 777 | range: ClosedFloatingPointRange, 778 | steps: Int = 0, 779 | valueDisplay: (Float) -> String = { "%.1f".format(it) }, 780 | onValueChange: (Float) -> Unit 781 | ) { 782 | Column { 783 | Row( 784 | horizontalArrangement = Arrangement.SpaceBetween, 785 | modifier = Modifier.fillMaxWidth(), 786 | verticalAlignment = Alignment.CenterVertically 787 | ) { 788 | Text( 789 | text = label, 790 | style = MaterialTheme.typography.bodyMedium, 791 | color = MaterialTheme.colorScheme.onSurface 792 | ) 793 | 794 | Text( 795 | text = valueDisplay(value), 796 | style = MaterialTheme.typography.bodySmall, 797 | color = MaterialTheme.colorScheme.onSurfaceVariant 798 | ) 799 | } 800 | 801 | Slider( 802 | value = value, 803 | onValueChange = onValueChange, 804 | valueRange = range, 805 | steps = steps, 806 | modifier = Modifier.fillMaxWidth(), 807 | colors = SliderDefaults.colors( 808 | thumbColor = MaterialTheme.colorScheme.primary, 809 | activeTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) 810 | ) 811 | ) 812 | } 813 | } 814 | 815 | @Composable 816 | private fun SliderWithHelp( 817 | label: String, 818 | value: Float, 819 | range: ClosedFloatingPointRange, 820 | steps: Int = 0, 821 | valueDisplay: (Float) -> String = { "%.1f".format(it) }, 822 | helpText: String, 823 | onValueChange: (Float) -> Unit 824 | ) { 825 | Column { 826 | Row( 827 | horizontalArrangement = Arrangement.SpaceBetween, 828 | modifier = Modifier.fillMaxWidth(), 829 | verticalAlignment = Alignment.CenterVertically 830 | ) { 831 | Row( 832 | verticalAlignment = Alignment.CenterVertically 833 | ) { 834 | Text( 835 | text = label, 836 | style = MaterialTheme.typography.bodyMedium, 837 | color = MaterialTheme.colorScheme.onSurface 838 | ) 839 | } 840 | 841 | Text( 842 | text = valueDisplay(value), 843 | style = MaterialTheme.typography.bodySmall, 844 | color = MaterialTheme.colorScheme.onSurfaceVariant 845 | ) 846 | } 847 | 848 | Slider( 849 | value = value, 850 | onValueChange = onValueChange, 851 | valueRange = range, 852 | steps = steps, 853 | modifier = Modifier.fillMaxWidth(), 854 | colors = SliderDefaults.colors( 855 | thumbColor = MaterialTheme.colorScheme.primary, 856 | activeTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) 857 | ) 858 | ) 859 | } 860 | } 861 | 862 | @RequiresApi(Build.VERSION_CODES.O) 863 | @Composable 864 | private fun DayButton( 865 | day: DayOfWeek, 866 | isSelected: Boolean, 867 | onClick: () -> Unit, 868 | enabled: Boolean = true 869 | ) { 870 | val dayName = day.getDisplayName(TextStyle.SHORT, Locale.getDefault()) 871 | val buttonColors = if (isSelected) { 872 | ButtonDefaults.filledTonalButtonColors( 873 | containerColor = MaterialTheme.colorScheme.primaryContainer, 874 | contentColor = MaterialTheme.colorScheme.onPrimaryContainer 875 | ) 876 | } else { 877 | ButtonDefaults.filledTonalButtonColors() 878 | } 879 | 880 | FilledTonalButton( 881 | onClick = onClick, 882 | modifier = Modifier.padding(horizontal = 4.dp), 883 | enabled = enabled, 884 | colors = buttonColors 885 | ) { 886 | Text( 887 | text = dayName.take(1), 888 | fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal 889 | ) 890 | } 891 | } 892 | 893 | // Helper functions to calculate and apply intensity 894 | private fun calculateIntensity(config: DirectionalTransitionConfig): Float { 895 | // Simplified - in reality would need more advanced normalization 896 | val blurFactor = config.maxBlur.value / 20f 897 | val trailFactor = config.trailCount / 12f 898 | val verticalFactor = config.maxVerticalOffset.value / 60f 899 | 900 | return (blurFactor + trailFactor + verticalFactor) / 3f 901 | } 902 | 903 | private fun applyIntensity(config: DirectionalTransitionConfig, intensity: Float): DirectionalTransitionConfig { 904 | // Scale key parameters based on intensity 905 | return config.copy( 906 | maxBlur = (intensity * 20f).dp, 907 | trailCount = (1 + intensity * 11).toInt(), 908 | maxVerticalOffset = (intensity * 60f).dp, 909 | bunching = 1f + intensity * 4f 910 | ) 911 | } 912 | 913 | // Data class for presets 914 | data class TransitionPreset( 915 | val name: String, 916 | val description: String, 917 | val visualTags: List, 918 | val config: DirectionalTransitionConfig 919 | ) -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/ui/theme/Color.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.ui.theme 2 | 3 | import androidx.compose.ui.graphics.Color 4 | 5 | val Purple80 = Color(0xFFD0BCFF) 6 | val PurpleGrey80 = Color(0xFFCCC2DC) 7 | val Pink80 = Color(0xFFEFB8C8) 8 | 9 | val Purple40 = Color(0xFF6650a4) 10 | val PurpleGrey40 = Color(0xFF625b71) 11 | val Pink40 = Color(0xFF7D5260) -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/ui/theme/Theme.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.ui.theme 2 | 3 | import android.app.Activity 4 | import android.os.Build 5 | import androidx.compose.foundation.isSystemInDarkTheme 6 | import androidx.compose.material3.MaterialTheme 7 | import androidx.compose.material3.darkColorScheme 8 | import androidx.compose.material3.dynamicDarkColorScheme 9 | import androidx.compose.material3.dynamicLightColorScheme 10 | import androidx.compose.material3.lightColorScheme 11 | import androidx.compose.runtime.Composable 12 | import androidx.compose.ui.platform.LocalContext 13 | 14 | private val DarkColorScheme = darkColorScheme( 15 | primary = Purple80, 16 | secondary = PurpleGrey80, 17 | tertiary = Pink80 18 | ) 19 | 20 | private val LightColorScheme = lightColorScheme( 21 | primary = Purple40, 22 | secondary = PurpleGrey40, 23 | tertiary = Pink40 24 | 25 | /* Other default colors to override 26 | background = Color(0xFFFFFBFE), 27 | surface = Color(0xFFFFFBFE), 28 | onPrimary = Color.White, 29 | onSecondary = Color.White, 30 | onTertiary = Color.White, 31 | onBackground = Color(0xFF1C1B1F), 32 | onSurface = Color(0xFF1C1B1F), 33 | */ 34 | ) 35 | 36 | @Composable 37 | fun CalendarTheme( 38 | darkTheme: Boolean = isSystemInDarkTheme(), 39 | // Dynamic color is available on Android 12+ 40 | dynamicColor: Boolean = true, 41 | content: @Composable () -> Unit 42 | ) { 43 | val colorScheme = when { 44 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { 45 | val context = LocalContext.current 46 | if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) 47 | } 48 | 49 | darkTheme -> DarkColorScheme 50 | else -> LightColorScheme 51 | } 52 | 53 | MaterialTheme( 54 | colorScheme = colorScheme, 55 | typography = Typography, 56 | content = content 57 | ) 58 | } -------------------------------------------------------------------------------- /app/src/main/java/com/binissa/calendar/ui/theme/Type.kt: -------------------------------------------------------------------------------- 1 | package com.binissa.calendar.ui.theme 2 | 3 | import androidx.compose.material3.Typography 4 | import androidx.compose.ui.text.TextStyle 5 | import androidx.compose.ui.text.font.FontFamily 6 | import androidx.compose.ui.text.font.FontWeight 7 | import androidx.compose.ui.unit.sp 8 | 9 | // Set of Material typography styles to start with 10 | val Typography = Typography( 11 | bodyLarge = TextStyle( 12 | fontFamily = FontFamily.Default, 13 | fontWeight = FontWeight.Normal, 14 | fontSize = 16.sp, 15 | lineHeight = 24.sp, 16 | letterSpacing = 0.5.sp 17 | ) 18 | /* Other default text styles to override 19 | titleLarge = TextStyle( 20 | fontFamily = FontFamily.Default, 21 | fontWeight = FontWeight.Normal, 22 | fontSize = 22.sp, 23 | lineHeight = 28.sp, 24 | letterSpacing = 0.sp 25 | ), 26 | labelSmall = TextStyle( 27 | fontFamily = FontFamily.Default, 28 | fontWeight = FontWeight.Medium, 29 | fontSize = 11.sp, 30 | lineHeight = 16.sp, 31 | letterSpacing = 0.5.sp 32 | ) 33 | */ 34 | ) -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MahmoudElsayedEssa/MorphTextTransition/4ffaaca4a1c9b598addbbd22bebe6a2232e2c891/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #FFBB86FC 4 | #FF6200EE 5 | #FF3700B3 6 | #FF03DAC5 7 | #FF018786 8 | #FF000000 9 | #FFFFFFFF 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Calendar 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |