diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 635f933..cf6c71a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -100,6 +100,9 @@ dependencies { implementation(libs.androidx.documentfile) implementation(libs.androidx.work.runtime.ktx) implementation(libs.androidx.compose.foundation.layout) + implementation(libs.androidx.compose.adaptive) + implementation(libs.androidx.compose.foundation) + implementation(libs.androidx.navigation.common.ktx) // Hilt ksp(libs.hilt.android.compiler) diff --git a/app/src/main/java/com/flux/MainActivity.kt b/app/src/main/java/com/flux/MainActivity.kt index 8eb0f44..130ff97 100644 --- a/app/src/main/java/com/flux/MainActivity.kt +++ b/app/src/main/java/com/flux/MainActivity.kt @@ -22,20 +22,24 @@ import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.rememberNavController import com.flux.navigation.AppNavHost import com.flux.navigation.Loader import com.flux.other.Constants.Other import com.flux.other.createNotificationChannel import com.flux.ui.effects.ScreenEffect +import com.flux.ui.state.States import com.flux.ui.theme.FluxTheme import com.flux.ui.viewModel.BackupViewModel import com.flux.ui.viewModel.EventViewModel import com.flux.ui.viewModel.HabitViewModel import com.flux.ui.viewModel.JournalViewModel +import com.flux.ui.viewModel.LabelViewModel import com.flux.ui.viewModel.NotesViewModel import com.flux.ui.viewModel.ProgressBoardViewModel import com.flux.ui.viewModel.SettingsViewModel import com.flux.ui.viewModel.TodoViewModel +import com.flux.ui.viewModel.ViewModels import com.flux.ui.viewModel.WorkspaceViewModel import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.Flow @@ -68,6 +72,7 @@ class MainActivity : AppCompatActivity() { val todoViewModel: TodoViewModel = hiltViewModel() val journalViewModel: JournalViewModel = hiltViewModel() val backupViewModel: BackupViewModel = hiltViewModel() + val labelViewModel: LabelViewModel = hiltViewModel() val progressBoardViewModel: ProgressBoardViewModel = hiltViewModel() // States @@ -78,6 +83,7 @@ class MainActivity : AppCompatActivity() { val habitState by habitViewModel.state.collectAsStateWithLifecycle() val todoState by todoViewModel.state.collectAsStateWithLifecycle() val journalState by journalViewModel.state.collectAsStateWithLifecycle() + val labelState by labelViewModel.state.collectAsStateWithLifecycle() val progressBoardState by progressBoardViewModel.state.collectAsStateWithLifecycle() // Stop splash screen when settings are loaded @@ -96,24 +102,31 @@ class MainActivity : AppCompatActivity() { color = MaterialTheme.colorScheme.surfaceContainerLow ) { AppNavHost( + navController = rememberNavController(), snackbarHostState = snackBarHostState, - settingsViewModel = settingsViewModel, - notesViewModel = notesViewModel, - workspaceViewModel = workspaceViewModel, - eventViewModel = eventViewModel, - habitViewModel = habitViewModel, - todoViewModel = todoViewModel, - journalViewModel = journalViewModel, - backupViewModel = backupViewModel, - progressBoardViewModel = progressBoardViewModel, - settings = settings, - notesState = notesState, - workspaceState = workspaceState, - eventState = eventState, - habitState = habitState, - todoState = todoState, - journalState = journalState, - progressBoardState = progressBoardState + viewModels = ViewModels( + notesViewModel, + eventViewModel, + todoViewModel, + habitViewModel, + workspaceViewModel, + journalViewModel, + settingsViewModel, + backupViewModel, + labelViewModel, + progressBoardViewModel + ), + states = States( + notesState, + eventState, + habitState, + todoState, + workspaceState, + journalState, + progressBoardState, + labelState, + settings + ) ) } } diff --git a/app/src/main/java/com/flux/data/dao/EventDao.kt b/app/src/main/java/com/flux/data/dao/EventDao.kt index fa9fe53..51279e5 100644 --- a/app/src/main/java/com/flux/data/dao/EventDao.kt +++ b/app/src/main/java/com/flux/data/dao/EventDao.kt @@ -22,8 +22,8 @@ interface EventDao { @Query("Delete FROM EventModel where workspaceId = :workspaceId") suspend fun deleteAllWorkspaceEvents(workspaceId: String) - @Query("SELECT * FROM EventModel WHERE workspaceId = :workspaceId") - fun loadAllEvents(workspaceId: String): Flow> + @Query("SELECT * FROM EventModel") + fun loadEventData(): Flow> @Query("SELECT * FROM EventModel") suspend fun loadAllEvents(): List diff --git a/app/src/main/java/com/flux/data/dao/EventInstanceDao.kt b/app/src/main/java/com/flux/data/dao/EventInstanceDao.kt index e158df1..b35559a 100644 --- a/app/src/main/java/com/flux/data/dao/EventInstanceDao.kt +++ b/app/src/main/java/com/flux/data/dao/EventInstanceDao.kt @@ -25,8 +25,8 @@ interface EventInstanceDao { @Query("DELETE FROM EventInstanceModel WHERE workspaceId = :workspaceId") suspend fun deleteAllWorkspaceInstance(workspaceId: String) - @Query("SELECT * FROM EventInstanceModel where workspaceId in (:workspaceId)") - fun loadAllWorkspaceInstances(workspaceId: String): Flow> + @Query("SELECT * FROM EventInstanceModel") + fun loadEventInstanceData(): Flow> @Query("SELECT * FROM EventInstanceModel") suspend fun getAll(): List diff --git a/app/src/main/java/com/flux/data/dao/HabitInstanceDao.kt b/app/src/main/java/com/flux/data/dao/HabitInstanceDao.kt index 7d5c218..c9da486 100644 --- a/app/src/main/java/com/flux/data/dao/HabitInstanceDao.kt +++ b/app/src/main/java/com/flux/data/dao/HabitInstanceDao.kt @@ -19,14 +19,17 @@ interface HabitInstanceDao { @Query("DELETE FROM HabitInstanceModel WHERE habitId IN (:habitId)") suspend fun deleteAllInstances(habitId: String) + @Query("SELECT * FROM HabitInstanceModel WHERE habitId = :habitId AND instanceDate = :date LIMIT 1") + suspend fun getHabitInstance(habitId: String, date: Long): HabitInstanceModel? + @Delete suspend fun deleteInstance(habitInstance: HabitInstanceModel) @Query("DELETE FROM HabitInstanceModel WHERE workspaceId = :workspaceId") suspend fun deleteAllWorkspaceInstance(workspaceId: String) - @Query("SELECT * FROM HabitInstanceModel where workspaceId in (:workspaceId)") - fun loadAllInstances(workspaceId: String): Flow> + @Query("SELECT * FROM HabitInstanceModel") + fun loadHabitInstanceData(): Flow> @Query("SELECT * FROM HabitInstanceModel") suspend fun loadAllInstances(): List diff --git a/app/src/main/java/com/flux/data/dao/HabitsDao.kt b/app/src/main/java/com/flux/data/dao/HabitsDao.kt index 503afbc..12e13fd 100644 --- a/app/src/main/java/com/flux/data/dao/HabitsDao.kt +++ b/app/src/main/java/com/flux/data/dao/HabitsDao.kt @@ -22,9 +22,9 @@ interface HabitsDao { @Query("DELETE FROM HabitModel WHERE workspaceId = :workspaceId") suspend fun deleteAllWorkspaceHabit(workspaceId: String) - @Query("SELECT * FROM HabitModel WHERE workspaceId = :workspaceId") - fun loadAllHabitsOfWorkspace(workspaceId: String): Flow> + @Query("SELECT * FROM HabitModel") + fun loadHabitData(): Flow> @Query("Select * FROM HabitModel") - suspend fun loadAllHabits(): List + fun loadAllHabits(): List } diff --git a/app/src/main/java/com/flux/data/dao/JournalDao.kt b/app/src/main/java/com/flux/data/dao/JournalDao.kt index 97286ae..53d8664 100644 --- a/app/src/main/java/com/flux/data/dao/JournalDao.kt +++ b/app/src/main/java/com/flux/data/dao/JournalDao.kt @@ -22,14 +22,8 @@ interface JournalDao { @Query("Delete FROM JournalModel where workspaceId = :workspaceId") suspend fun deleteAllWorkspaceEntries(workspaceId: String) - @Query( - """ - SELECT * FROM JournalModel - WHERE workspaceId = :workspaceId - ORDER BY dateTime DESC - """ - ) - fun loadAllEntries(workspaceId: String): Flow> + @Query("SELECT * FROM JournalModel ORDER BY dateTime DESC") + fun loadJournalData(): Flow> @Query("SELECT * FROM JournalModel") suspend fun loadAllEntries(): List diff --git a/app/src/main/java/com/flux/data/dao/LabelDao.kt b/app/src/main/java/com/flux/data/dao/LabelDao.kt index 5777df0..154ddc3 100644 --- a/app/src/main/java/com/flux/data/dao/LabelDao.kt +++ b/app/src/main/java/com/flux/data/dao/LabelDao.kt @@ -22,8 +22,8 @@ interface LabelDao { @Query("DELETE FROM LabelModel WHERE workspaceId = :workspaceId") suspend fun deleteAllWorkspaceLabels(workspaceId: String) - @Query("SELECT * FROM LabelModel where workspaceId IN (:workspaceId)") - fun loadAllLabels(workspaceId: String): Flow> + @Query("SELECT * FROM LabelModel") + fun loadAllLabels(): Flow> @Query("SELECT * FROM LabelModel") suspend fun getAll(): List diff --git a/app/src/main/java/com/flux/data/dao/NotesDao.kt b/app/src/main/java/com/flux/data/dao/NotesDao.kt index c7be3b7..1abbb54 100644 --- a/app/src/main/java/com/flux/data/dao/NotesDao.kt +++ b/app/src/main/java/com/flux/data/dao/NotesDao.kt @@ -28,8 +28,8 @@ interface NotesDao { @Query("DELETE FROM NotesModel WHERE workspaceId = :workspaceId") suspend fun deleteAllWorkspaceNotes(workspaceId: String) - @Query("SELECT * FROM NotesModel where workspaceId IN (:workspaceId)") - fun loadAllNotes(workspaceId: String): Flow> + @Query("SELECT * FROM NotesModel ORDER by lastEdited DESC") + fun loadNotesData(): Flow> @Query("SELECT * FROM NotesModel") fun loadAllNotes(): List diff --git a/app/src/main/java/com/flux/data/dao/ProgressBoardDao.kt b/app/src/main/java/com/flux/data/dao/ProgressBoardDao.kt index 61d8a83..2d96ba9 100644 --- a/app/src/main/java/com/flux/data/dao/ProgressBoardDao.kt +++ b/app/src/main/java/com/flux/data/dao/ProgressBoardDao.kt @@ -13,8 +13,8 @@ interface ProgressBoardDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertBoardItem(item: ProgressBoardModel) - @Query("SELECT * FROM ProgressBoardModel WHERE workspaceId = :workspaceId") - fun getBoardItemsByWorkspace(workspaceId: String): Flow> + @Query("SELECT * FROM ProgressBoardModel") + fun getProgressBoardData(): Flow> @Delete suspend fun deleteBoardItem(item: ProgressBoardModel) @@ -27,5 +27,4 @@ interface ProgressBoardDao { @Query("SELECT * FROM ProgressBoardModel") fun getAllBoardItems(): List -} - +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/dao/TodoDao.kt b/app/src/main/java/com/flux/data/dao/TodoDao.kt index b86cbf3..3224475 100644 --- a/app/src/main/java/com/flux/data/dao/TodoDao.kt +++ b/app/src/main/java/com/flux/data/dao/TodoDao.kt @@ -22,8 +22,8 @@ interface TodoDao { @Query("DELETE FROM TodoModel WHERE workspaceId = :workspaceId") fun deleteAllWorkspaceLists(workspaceId: String) - @Query("SELECT * FROM TodoModel where workspaceId IN (:workspaceId)") - fun loadAllLists(workspaceId: String): Flow> + @Query("SELECT * FROM TodoModel") + fun loadTodoData(): Flow> @Query("SELECT * FROM TodoModel") suspend fun loadAllLists(): List diff --git a/app/src/main/java/com/flux/data/database/FluxDatabase.kt b/app/src/main/java/com/flux/data/database/FluxDatabase.kt index 741028f..a4c44f5 100644 --- a/app/src/main/java/com/flux/data/database/FluxDatabase.kt +++ b/app/src/main/java/com/flux/data/database/FluxDatabase.kt @@ -36,7 +36,7 @@ import java.util.UUID @Database( entities = [EventModel::class, LabelModel::class, EventInstanceModel::class, SettingsModel::class, NotesModel::class, HabitModel::class, HabitInstanceModel::class, WorkspaceModel::class, TodoModel::class, JournalModel::class, ProgressBoardModel::class], - version = 7, + version = 11, exportSchema = false ) @TypeConverters(Converter::class) @@ -260,4 +260,99 @@ val MIGRATION_6_7 = object : Migration(6, 7) { if (!db.columnExists("SettingsModel", "useSystemTimeFormat")) db.safeExec("ALTER TABLE SettingsModel ADD COLUMN useSystemTimeFormat INTEGER NOT NULL DEFAULT 0") } +} + +val MIGRATION_7_8 = object : Migration(7, 8) { + override fun migrate(db: SupportSQLiteDatabase) { + + // ---------------------- HabitModel rebuild ---------------------- + if (!db.tableExists("HabitModel_new")) { + + db.safeExec(""" + CREATE TABLE HabitModel_new ( + id TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + recurrence TEXT NOT NULL, + startDateTime INTEGER NOT NULL, + endDateTime INTEGER NOT NULL, + notificationOffset INTEGER NOT NULL, + workspaceId TEXT NOT NULL, + habitConfig TEXT NOT NULL + ) + """) + + // Default HabitConfig → Simple + val defaultConfig = """{"type":"Simple"}""" + + db.safeExec(""" + INSERT INTO HabitModel_new ( + id, title, description, recurrence, + startDateTime, endDateTime, + notificationOffset, workspaceId, habitConfig + ) + SELECT + id, title, description, recurrence, + startDateTime, endDateTime, + notificationOffset, workspaceId, + '$defaultConfig' + FROM HabitModel + """) + + db.safeExec("DROP TABLE HabitModel") + db.safeExec("ALTER TABLE HabitModel_new RENAME TO HabitModel") + } + } +} + +val Migration_8_9 = object : Migration(8, 9){ + override fun migrate(db: SupportSQLiteDatabase) { + db.safeExec("ALTER TABLE HabitInstanceModel ADD COLUMN completed INTEGER NOT NULL DEFAULT 0") + db.safeExec("ALTER TABLE HabitInstanceModel ADD COLUMN timeSpent INTEGER NOT NULL DEFAULT 0") + db.safeExec("ALTER TABLE HabitInstanceModel ADD COLUMN isRunning INTEGER NOT NULL DEFAULT 0") + db.safeExec("ALTER TABLE HabitInstanceModel ADD COLUMN count INTEGER NOT NULL DEFAULT 0") + } +} + +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + + if (!db.tableExists("WorkspaceModel_new")) { + db.execSQL(""" + CREATE TABLE WorkspaceModel_new ( + workspaceId TEXT NOT NULL PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + colorInd INTEGER NOT NULL, + cover TEXT NOT NULL, + icon INTEGER NOT NULL, + passKey TEXT, + isPinned INTEGER NOT NULL, + selectedSpaces TEXT NOT NULL + ) + """.trimIndent()) + + db.execSQL(""" + INSERT INTO WorkspaceModel_new ( + workspaceId, title, description, colorInd, + cover, icon, passKey, isPinned, selectedSpaces + ) + SELECT + workspaceId, title, description, colorInd, + cover, icon, + CASE WHEN passKey = '' THEN NULL ELSE passKey END, + isPinned, selectedSpaces + FROM WorkspaceModel + """.trimIndent()) + + db.execSQL("DROP TABLE WorkspaceModel") + db.execSQL("ALTER TABLE WorkspaceModel_new RENAME TO WorkspaceModel") + } + } +} + +val MIGRATION_10_11 = object : Migration(10, 11) { + override fun migrate(db: SupportSQLiteDatabase) { + db.safeExec("ALTER TABLE JournalModel ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'") + } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/model/Converter.kt b/app/src/main/java/com/flux/data/model/Converter.kt index 0da2ed9..98f1d8b 100644 --- a/app/src/main/java/com/flux/data/model/Converter.kt +++ b/app/src/main/java/com/flux/data/model/Converter.kt @@ -11,6 +11,7 @@ class Converter { encodeDefaults = true ignoreUnknownKeys = true coerceInputValues = true + classDiscriminator = "type" } @TypeConverter @@ -84,6 +85,21 @@ class Converter { val listType = object : TypeToken>() {}.type return Gson().fromJson(value, listType) } + + @TypeConverter + fun fromConfig(config: HabitConfig): String { + return json.encodeToString(config) + } + + @TypeConverter + fun toConfig(value: String): HabitConfig { + return try { + json.decodeFromString(value) + } catch ( + _: Exception) { + HabitConfig.Simple // fallback (critical) + } + } } // Helper class for parsing old data format (without id field) diff --git a/app/src/main/java/com/flux/data/model/EventModel.kt b/app/src/main/java/com/flux/data/model/EventModel.kt index c6e2851..7c21646 100644 --- a/app/src/main/java/com/flux/data/model/EventModel.kt +++ b/app/src/main/java/com/flux/data/model/EventModel.kt @@ -12,18 +12,15 @@ import java.time.temporal.ChronoUnit @Serializable @Entity data class EventModel( - @PrimaryKey - override val id: String = UUID.randomUUID().toString(), - override val title: String = "", - override val description: String = "", - override val recurrence: RecurrenceRule = RecurrenceRule.Custom(), - override val startDateTime: Long = System.currentTimeMillis(), - override val endDateTime: Long = -1L, - override val notificationOffset: Long = 0L, - override val workspaceId: String = "" -) : ReminderItem { - override val type: ReminderType get() = ReminderType.EVENT -} + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val title: String = "", + val description: String = "", + val recurrence: RecurrenceRule = RecurrenceRule.Custom(), + val startDateTime: Long = System.currentTimeMillis(), + val endDateTime: Long = -1L, + val notificationOffset: Long = 0L, + val workspaceId: String = "" +) @Serializable @Entity(primaryKeys = ["eventId", "instanceDate"]) @@ -33,6 +30,11 @@ data class EventInstanceModel( val instanceDate: Long = LocalDate.now().toEpochDay() ) +fun EventModel.isLive(): Boolean { + if (endDateTime == -1L) return true + return endDateTime > System.currentTimeMillis() +} + fun EventModel.occursOn(date: LocalDate): Boolean { val eventStart = Instant.ofEpochMilli(startDateTime) .atZone(ZoneId.systemDefault()) diff --git a/app/src/main/java/com/flux/data/model/HabitConfig.kt b/app/src/main/java/com/flux/data/model/HabitConfig.kt new file mode 100644 index 0000000..c76bb57 --- /dev/null +++ b/app/src/main/java/com/flux/data/model/HabitConfig.kt @@ -0,0 +1,26 @@ +package com.flux.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +sealed class HabitConfig { + + @Serializable + @SerialName("Simple") + object Simple : HabitConfig() + + @Serializable + @SerialName("Counted") + data class Counted( + val goal: Int = 2, + val unit: String = "", + val intervalMillis: Long = 60_000L, // reminder interval + val activeStartTime: Long = System.currentTimeMillis(), // millis of day + val activeEndTime: Long = System.currentTimeMillis() + ) : HabitConfig() + + @Serializable + @SerialName("Timed") + data class Timed(val durationMillis: Long = 60_000L) : HabitConfig() +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/model/HabitModel.kt b/app/src/main/java/com/flux/data/model/HabitModel.kt index b9cf20e..b9ae58d 100644 --- a/app/src/main/java/com/flux/data/model/HabitModel.kt +++ b/app/src/main/java/com/flux/data/model/HabitModel.kt @@ -9,29 +9,46 @@ import kotlinx.serialization.Serializable @Serializable @Entity data class HabitModel( - @PrimaryKey - override val id: String = UUID.randomUUID().toString(), - override val title: String = "", - override val description: String = "", - override val recurrence: RecurrenceRule = RecurrenceRule.Weekly(), - override val startDateTime: Long = System.currentTimeMillis(), - override val endDateTime: Long = -1L, - override val notificationOffset: Long = 0L, - override val workspaceId: String = "", - val bestStreak: Long = 0L -) : ReminderItem { - override val type: ReminderType get() = ReminderType.HABIT -} + @PrimaryKey val id: String = UUID.randomUUID().toString(), + val title: String = "", + val description: String = "", + val recurrence: RecurrenceRule = RecurrenceRule.Weekly(), + val startDateTime: Long = System.currentTimeMillis(), + val endDateTime: Long = -1L, + val notificationOffset: Long = 0L, + val workspaceId: String = "", + val habitConfig: HabitConfig = HabitConfig.Simple +) @Serializable @Entity(primaryKeys = ["habitId", "instanceDate"]) data class HabitInstanceModel( val habitId: String = "", val workspaceId: String = "", - val instanceDate: Long = LocalDate.now().toEpochDay() + val instanceDate: Long = LocalDate.now().toEpochDay(), + + // timed + val timeSpent: Long = 0L, + val isRunning: Boolean = false, + + // counted + val count: Int = 0 ) fun HabitModel.isLive(): Boolean { if (endDateTime == -1L) return true return endDateTime > System.currentTimeMillis() +} + +val HabitModel.isTimed get() = habitConfig is HabitConfig.Timed +val HabitModel.isCounted get() = habitConfig is HabitConfig.Counted + +fun HabitInstanceModel.isCompleted(habit: HabitModel): Boolean { + return when (val config = habit.habitConfig) { + is HabitConfig.Simple -> true + + is HabitConfig.Counted -> count >= config.goal + + is HabitConfig.Timed -> timeSpent >= config.durationMillis + } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/model/JournalModel.kt b/app/src/main/java/com/flux/data/model/JournalModel.kt index a081a4a..3d0df95 100644 --- a/app/src/main/java/com/flux/data/model/JournalModel.kt +++ b/app/src/main/java/com/flux/data/model/JournalModel.kt @@ -15,7 +15,8 @@ data class JournalModel( val journalId: String = UUID.randomUUID().toString(), val workspaceId: String = "", val text: String = "", - val dateTime: Long = System.currentTimeMillis() + val dateTime: Long = System.currentTimeMillis(), + val labels: List = emptyList() ) fun JournalModel.writtenOnDate(date: LocalDate): Boolean { diff --git a/app/src/main/java/com/flux/data/model/RecurrenceRule.kt b/app/src/main/java/com/flux/data/model/RecurrenceRule.kt index 8234b2f..119ab91 100644 --- a/app/src/main/java/com/flux/data/model/RecurrenceRule.kt +++ b/app/src/main/java/com/flux/data/model/RecurrenceRule.kt @@ -2,8 +2,6 @@ package com.flux.data.model import kotlinx.serialization.Serializable -enum class ReminderType { EVENT, HABIT } - @Serializable sealed class RecurrenceRule { @Serializable @@ -20,16 +18,4 @@ sealed class RecurrenceRule { @Serializable data class Custom(val everyXDays: Int = 1) : RecurrenceRule() -} - -interface ReminderItem { - val id: String - val title: String - val description: String - val recurrence: RecurrenceRule - val type: ReminderType - val startDateTime: Long - val endDateTime: Long - val workspaceId: String - val notificationOffset: Long } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/model/ScheduleRequest.kt b/app/src/main/java/com/flux/data/model/ScheduleRequest.kt new file mode 100644 index 0000000..4717762 --- /dev/null +++ b/app/src/main/java/com/flux/data/model/ScheduleRequest.kt @@ -0,0 +1,92 @@ +package com.flux.data.model + +import android.content.Context +import android.content.Intent +import com.flux.other.ReminderReceiver +import kotlinx.serialization.json.Json + +enum class ReminderType { EVENT, HABIT } + +data class ScheduleRequest( + val itemId: String, + val itemType: ReminderType, + val title: String, + val description: String, + val recurrence: RecurrenceRule, + val startDateTime: Long, + val endDateTime: Long, + val notificationOffset: Long, + val workspaceId: String, + val habitConfig: HabitConfig? = null +) { + companion object { + fun fromIntent(intent: Intent): ScheduleRequest? { + return try { + ScheduleRequest( + itemId = intent.getStringExtra("itemId") ?: return null, + itemType = ReminderType.valueOf( + intent.getStringExtra("itemType") ?: return null + ), + title = intent.getStringExtra("title") ?: "", + description = intent.getStringExtra("description") ?: "", + recurrence = Json.decodeFromString( + intent.getStringExtra("recurrence") ?: return null + ), + startDateTime = intent.getLongExtra("startDateTime", -1), + endDateTime = intent.getLongExtra("endDateTime", -1), + notificationOffset = intent.getLongExtra("notificationOffset", 0), + workspaceId = intent.getStringExtra("workspaceId") ?: "", + habitConfig = intent.getStringExtra("habitConfig") + ?.let { Json.decodeFromString(it) } + ) + } catch (_: Exception) { null } + } + } +} + +private const val DAY_MILLIS = + 24L * 60L * 60L * 1000L + +private fun Int.minutesToMillis(): Long { + return this * 60L * 1000L +} + +fun HabitModel.toScheduleRequest() = ScheduleRequest( + itemId = id, + itemType = ReminderType.HABIT, + title = title, + description = description, + recurrence = recurrence, + startDateTime = startDateTime, + endDateTime = endDateTime, + notificationOffset = notificationOffset, + workspaceId = workspaceId, + habitConfig = habitConfig +) + +fun EventModel.toScheduleRequest() = ScheduleRequest( + itemId = id, + itemType = ReminderType.EVENT, + title = title, + description = description, + recurrence = recurrence, + startDateTime = startDateTime, + endDateTime = endDateTime, + notificationOffset = notificationOffset, + workspaceId = workspaceId +) + +fun ScheduleRequest.toIntent(context: Context): Intent { + return Intent(context, ReminderReceiver::class.java).apply { + putExtra("itemId", itemId) + putExtra("itemType", itemType.name) + putExtra("title", title) + putExtra("description", description) + putExtra("recurrence", Json.encodeToString(recurrence)) + putExtra("startDateTime", startDateTime) + putExtra("endDateTime", endDateTime) + putExtra("notificationOffset", notificationOffset) + putExtra("workspaceId", workspaceId) + habitConfig?.let { putExtra("habitConfig", Json.encodeToString(it)) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/model/WorkspaceModel.kt b/app/src/main/java/com/flux/data/model/WorkspaceModel.kt index b97c8ef..7f4fe7c 100644 --- a/app/src/main/java/com/flux/data/model/WorkspaceModel.kt +++ b/app/src/main/java/com/flux/data/model/WorkspaceModel.kt @@ -27,7 +27,7 @@ data class WorkspaceModel( val colorInd: Int = 0, val cover: String = "", val icon: Int = 48, - val passKey: String = "", + val passKey: String? = null, val isPinned: Boolean = false, val selectedSpaces: List = emptyList() ) diff --git a/app/src/main/java/com/flux/data/repository/EventRepository.kt b/app/src/main/java/com/flux/data/repository/EventRepository.kt index ac287d4..f2d0f64 100644 --- a/app/src/main/java/com/flux/data/repository/EventRepository.kt +++ b/app/src/main/java/com/flux/data/repository/EventRepository.kt @@ -11,6 +11,6 @@ interface EventRepository { suspend fun deleteEventInstance(eventInstanceModel: EventInstanceModel) suspend fun upsertEventInstance(eventInstanceModel: EventInstanceModel) suspend fun loadAllEvents(): List - fun loadAllWorkspaceEvents(workspaceId: String): Flow> - fun loadAllEventInstances(workspaceId: String): Flow> + fun loadEventData(): Flow> + fun loadEventInstanceData(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/EventRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/EventRepositoryImpl.kt index 97133c8..84bf299 100644 --- a/app/src/main/java/com/flux/data/repository/EventRepositoryImpl.kt +++ b/app/src/main/java/com/flux/data/repository/EventRepositoryImpl.kt @@ -29,12 +29,12 @@ class EventRepositoryImpl @Inject constructor( return eventDao.loadAllEvents() } - override fun loadAllWorkspaceEvents(workspaceId: String): Flow> { - return eventDao.loadAllEvents(workspaceId) + override fun loadEventData(): Flow> { + return eventDao.loadEventData() } - override fun loadAllEventInstances(workspaceId: String): Flow> { - return eventInstanceDao.loadAllWorkspaceInstances(workspaceId) + override fun loadEventInstanceData(): Flow> { + return eventInstanceDao.loadEventInstanceData() } override suspend fun deleteEvent(event: EventModel) { diff --git a/app/src/main/java/com/flux/data/repository/HabitRepository.kt b/app/src/main/java/com/flux/data/repository/HabitRepository.kt index d95486d..59fcf55 100644 --- a/app/src/main/java/com/flux/data/repository/HabitRepository.kt +++ b/app/src/main/java/com/flux/data/repository/HabitRepository.kt @@ -5,12 +5,13 @@ import com.flux.data.model.HabitModel import kotlinx.coroutines.flow.Flow interface HabitRepository { + suspend fun getHabitInstance(habitId: String, instanceDate: Long): HabitInstanceModel? suspend fun upsertHabit(habit: HabitModel) suspend fun deleteHabit(habit: HabitModel) suspend fun deleteAllWorkspaceHabit(workspaceId: String) suspend fun deleteInstance(habitInstance: HabitInstanceModel) suspend fun upsertHabitInstance(habitInstance: HabitInstanceModel) suspend fun loadAllHabits(): List - fun loadAllHabitsOfWorkspace(workspaceId: String): Flow> - fun loadAllHabitInstance(workspaceId: String): Flow> + fun loadHabitData(): Flow> + fun loadHabitInstanceData(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/HabitRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/HabitRepositoryImpl.kt index 12823dc..cfb4695 100644 --- a/app/src/main/java/com/flux/data/repository/HabitRepositoryImpl.kt +++ b/app/src/main/java/com/flux/data/repository/HabitRepositoryImpl.kt @@ -13,6 +13,11 @@ class HabitRepositoryImpl @Inject constructor( private val dao: HabitsDao, private val instanceDao: HabitInstanceDao ) : HabitRepository { + + override suspend fun getHabitInstance(habitId: String, instanceDate: Long): HabitInstanceModel? { + return withContext(Dispatchers.IO) { instanceDao.getHabitInstance(habitId, instanceDate) } + } + override suspend fun upsertHabit(habit: HabitModel) { return withContext(Dispatchers.IO) { dao.upsertHabit(habit) } } @@ -29,12 +34,12 @@ class HabitRepositoryImpl @Inject constructor( return withContext(Dispatchers.IO) { dao.loadAllHabits() } } - override fun loadAllHabitInstance(workspaceId: String): Flow> { - return instanceDao.loadAllInstances(workspaceId) + override fun loadHabitInstanceData(): Flow> { + return instanceDao.loadHabitInstanceData() } - override fun loadAllHabitsOfWorkspace(workspaceId: String): Flow> { - return dao.loadAllHabitsOfWorkspace(workspaceId) + override fun loadHabitData(): Flow> { + return dao.loadHabitData() } override suspend fun deleteHabit(habit: HabitModel) { diff --git a/app/src/main/java/com/flux/data/repository/JournalRepository.kt b/app/src/main/java/com/flux/data/repository/JournalRepository.kt index bc320d5..ecb7baa 100644 --- a/app/src/main/java/com/flux/data/repository/JournalRepository.kt +++ b/app/src/main/java/com/flux/data/repository/JournalRepository.kt @@ -7,5 +7,5 @@ interface JournalRepository { suspend fun upsertEntry(entry: JournalModel) suspend fun deleteEntry(entry: JournalModel) suspend fun deleteAllWorkspaceEntry(workspaceId: String) - fun loadAllEntries(workspaceId: String): Flow> + fun loadJournalData(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/JournalRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/JournalRepositoryImpl.kt index 6e1d6bb..6e395cf 100644 --- a/app/src/main/java/com/flux/data/repository/JournalRepositoryImpl.kt +++ b/app/src/main/java/com/flux/data/repository/JournalRepositoryImpl.kt @@ -22,7 +22,7 @@ class JournalRepositoryImpl @Inject constructor( return withContext(Dispatchers.IO) { dao.deleteAllWorkspaceEntries(workspaceId) } } - override fun loadAllEntries(workspaceId: String): Flow> { - return dao.loadAllEntries(workspaceId) + override fun loadJournalData(): Flow> { + return dao.loadJournalData() } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/LabelRepository.kt b/app/src/main/java/com/flux/data/repository/LabelRepository.kt new file mode 100644 index 0000000..0bf3676 --- /dev/null +++ b/app/src/main/java/com/flux/data/repository/LabelRepository.kt @@ -0,0 +1,11 @@ +package com.flux.data.repository + +import com.flux.data.model.LabelModel +import kotlinx.coroutines.flow.Flow + +interface LabelRepository { + suspend fun upsertLabel(label: LabelModel) + suspend fun deleteLabel(label: LabelModel) + suspend fun deleteAllWorkspaceLabels(workspaceId: String) + fun loadAllLabels(): Flow> +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/LabelRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/LabelRepositoryImpl.kt new file mode 100644 index 0000000..e9f01c9 --- /dev/null +++ b/app/src/main/java/com/flux/data/repository/LabelRepositoryImpl.kt @@ -0,0 +1,28 @@ +package com.flux.data.repository + +import com.flux.data.dao.LabelDao +import com.flux.data.model.LabelModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class LabelRepositoryImpl @Inject constructor ( + private val dao: LabelDao +) : LabelRepository { + override suspend fun upsertLabel(label: LabelModel) { + return withContext(Dispatchers.IO) { dao.upsertLabel(label) } + } + + override suspend fun deleteLabel(label: LabelModel) { + return withContext(Dispatchers.IO) { dao.deleteLabel(label) } + } + + override suspend fun deleteAllWorkspaceLabels(workspaceId: String) { + return withContext(Dispatchers.IO) { dao.deleteAllWorkspaceLabels(workspaceId) } + } + + override fun loadAllLabels(): Flow> { + return dao.loadAllLabels() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/NoteRepository.kt b/app/src/main/java/com/flux/data/repository/NoteRepository.kt index fbc846f..a702413 100644 --- a/app/src/main/java/com/flux/data/repository/NoteRepository.kt +++ b/app/src/main/java/com/flux/data/repository/NoteRepository.kt @@ -1,17 +1,13 @@ package com.flux.data.repository -import com.flux.data.model.LabelModel import com.flux.data.model.NotesModel import kotlinx.coroutines.flow.Flow interface NoteRepository { suspend fun upsertNote(note: NotesModel) - suspend fun upsertLabel(label: LabelModel) suspend fun upsertNotes(notes: List) suspend fun deleteNote(note: NotesModel) - suspend fun deleteLabel(label: LabelModel) suspend fun deleteNotes(notes: List) suspend fun deleteAllWorkspaceNotes(workspaceId: String) - fun loadAllNotes(workspaceId: String): Flow> - fun loadAllLabels(workspaceId: String): Flow> + fun loadNotesData(): Flow> } diff --git a/app/src/main/java/com/flux/data/repository/NoteRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/NoteRepositoryImpl.kt index 626ee6a..95295ef 100644 --- a/app/src/main/java/com/flux/data/repository/NoteRepositoryImpl.kt +++ b/app/src/main/java/com/flux/data/repository/NoteRepositoryImpl.kt @@ -2,7 +2,6 @@ package com.flux.data.repository import com.flux.data.dao.LabelDao import com.flux.data.dao.NotesDao -import com.flux.data.model.LabelModel import com.flux.data.model.NotesModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -17,10 +16,6 @@ class NoteRepositoryImpl @Inject constructor( return withContext(Dispatchers.IO) { notesDao.upsertNote(note) } } - override suspend fun upsertLabel(label: LabelModel) { - return withContext(Dispatchers.IO) { labelDao.upsertLabel(label) } - } - override suspend fun upsertNotes(notes: List) { return withContext(Dispatchers.IO) { notesDao.upsertNotes(notes) } } @@ -29,20 +24,12 @@ class NoteRepositoryImpl @Inject constructor( return withContext(Dispatchers.IO) { notesDao.deleteNote(note) } } - override suspend fun deleteLabel(label: LabelModel) { - return withContext(Dispatchers.IO) { labelDao.deleteLabel(label) } - } - override suspend fun deleteNotes(notes: List) { return withContext(Dispatchers.IO) { notesDao.deleteNotes(notes) } } - override fun loadAllNotes(workspaceId: String): Flow> { - return notesDao.loadAllNotes(workspaceId) - } - - override fun loadAllLabels(workspaceId: String): Flow> { - return labelDao.loadAllLabels(workspaceId) + override fun loadNotesData(): Flow> { + return notesDao.loadNotesData() } override suspend fun deleteAllWorkspaceNotes(workspaceId: String) { diff --git a/app/src/main/java/com/flux/data/repository/ProgressBoardRepository.kt b/app/src/main/java/com/flux/data/repository/ProgressBoardRepository.kt index 0d39cea..9292897 100644 --- a/app/src/main/java/com/flux/data/repository/ProgressBoardRepository.kt +++ b/app/src/main/java/com/flux/data/repository/ProgressBoardRepository.kt @@ -7,5 +7,5 @@ interface ProgressBoardRepository { suspend fun upsertBoardItem(item: ProgressBoardModel) suspend fun deleteBoardItem(item: ProgressBoardModel) suspend fun deleteBoardItemsByWorkspace(workspaceId: String) - fun getBoardItemsByWorkspace(workspaceId: String): Flow> + fun getProgressBoardData(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/ProgressBoardRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/ProgressBoardRepositoryImpl.kt index 871b11e..7da0f84 100644 --- a/app/src/main/java/com/flux/data/repository/ProgressBoardRepositoryImpl.kt +++ b/app/src/main/java/com/flux/data/repository/ProgressBoardRepositoryImpl.kt @@ -22,7 +22,7 @@ class ProgressBoardRepositoryImpl @Inject constructor( return withContext(Dispatchers.IO) { dao.deleteBoardItemsByWorkspace(workspaceId) } } - override fun getBoardItemsByWorkspace(workspaceId: String): Flow> { - return dao.getBoardItemsByWorkspace(workspaceId) + override fun getProgressBoardData(): Flow> { + return dao.getProgressBoardData() } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/TodoRepository.kt b/app/src/main/java/com/flux/data/repository/TodoRepository.kt index 97e1f75..962c138 100644 --- a/app/src/main/java/com/flux/data/repository/TodoRepository.kt +++ b/app/src/main/java/com/flux/data/repository/TodoRepository.kt @@ -7,5 +7,5 @@ interface TodoRepository { suspend fun upsertList(list: TodoModel) suspend fun deleteList(list: TodoModel) suspend fun deleteAllWorkspaceLists(workspaceId: String) - fun loadAllLists(workspaceId: String): Flow> + fun loadTodoData(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt b/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt index 91a6842..c59e6d7 100644 --- a/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt +++ b/app/src/main/java/com/flux/data/repository/TodoRepositoryImpl.kt @@ -10,8 +10,8 @@ import javax.inject.Inject class TodoRepositoryImpl @Inject constructor( val dao: TodoDao ) : TodoRepository { - override fun loadAllLists(workspaceId: String): Flow> { - return dao.loadAllLists(workspaceId) + override fun loadTodoData(): Flow> { + return dao.loadTodoData() } override suspend fun upsertList(list: TodoModel) { diff --git a/app/src/main/java/com/flux/di/DataModule.kt b/app/src/main/java/com/flux/di/DataModule.kt index 782f828..fa086b7 100644 --- a/app/src/main/java/com/flux/di/DataModule.kt +++ b/app/src/main/java/com/flux/di/DataModule.kt @@ -15,12 +15,16 @@ import com.flux.data.dao.SettingsDao import com.flux.data.dao.TodoDao import com.flux.data.dao.WorkspaceDao import com.flux.data.database.FluxDatabase +import com.flux.data.database.MIGRATION_10_11 import com.flux.data.database.MIGRATION_1_2 import com.flux.data.database.MIGRATION_2_3 import com.flux.data.database.MIGRATION_3_4 import com.flux.data.database.MIGRATION_4_5 import com.flux.data.database.MIGRATION_5_6 import com.flux.data.database.MIGRATION_6_7 +import com.flux.data.database.MIGRATION_7_8 +import com.flux.data.database.MIGRATION_9_10 +import com.flux.data.database.Migration_8_9 import com.flux.other.BackupManager import dagger.Module import dagger.Provides @@ -41,7 +45,17 @@ object DataModule { FluxDatabase::class.java, "FluxDatabase" ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7) + .addMigrations(MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, + MIGRATION_6_7, + MIGRATION_7_8, + Migration_8_9, + MIGRATION_9_10, + MIGRATION_10_11 + ) .build() @Singleton diff --git a/app/src/main/java/com/flux/di/RepositoryModule.kt b/app/src/main/java/com/flux/di/RepositoryModule.kt index 7b01152..7732b61 100644 --- a/app/src/main/java/com/flux/di/RepositoryModule.kt +++ b/app/src/main/java/com/flux/di/RepositoryModule.kt @@ -6,6 +6,8 @@ import com.flux.data.repository.HabitRepository import com.flux.data.repository.HabitRepositoryImpl import com.flux.data.repository.JournalRepository import com.flux.data.repository.JournalRepositoryImpl +import com.flux.data.repository.LabelRepository +import com.flux.data.repository.LabelRepositoryImpl import com.flux.data.repository.NoteRepository import com.flux.data.repository.NoteRepositoryImpl import com.flux.data.repository.ProgressBoardRepository @@ -73,4 +75,10 @@ abstract class RepositoryModule { abstract fun bindProgressBoardRepository( impl: ProgressBoardRepositoryImpl ): ProgressBoardRepository + + @Binds + @Singleton + abstract fun bindLabelRepository( + impl: LabelRepositoryImpl + ): LabelRepository } \ No newline at end of file diff --git a/app/src/main/java/com/flux/navigation/AnimatedComposable.kt b/app/src/main/java/com/flux/navigation/AnimatedComposable.kt index 97fdeec..539d4cf 100644 --- a/app/src/main/java/com/flux/navigation/AnimatedComposable.kt +++ b/app/src/main/java/com/flux/navigation/AnimatedComposable.kt @@ -7,12 +7,12 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDeepLink import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable -import com.flux.ui.components.defaultScreenEnterAnimation -import com.flux.ui.components.defaultScreenExitAnimation -import com.flux.ui.components.slideFromBottomEnter -import com.flux.ui.components.slideScreenEnterAnimation -import com.flux.ui.components.slideScreenExitAnimation -import com.flux.ui.components.slideToBottomExit +import com.flux.ui.common.defaultScreenEnterAnimation +import com.flux.ui.common.defaultScreenExitAnimation +import com.flux.ui.common.slideFromBottomEnter +import com.flux.ui.common.slideScreenEnterAnimation +import com.flux.ui.common.slideScreenExitAnimation +import com.flux.ui.common.slideToBottomExit fun NavGraphBuilder.animatedComposable( diff --git a/app/src/main/java/com/flux/navigation/NavHost.kt b/app/src/main/java/com/flux/navigation/NavHost.kt index fbcbe7e..0ded2eb 100644 --- a/app/src/main/java/com/flux/navigation/NavHost.kt +++ b/app/src/main/java/com/flux/navigation/NavHost.kt @@ -8,51 +8,13 @@ import androidx.navigation.NamedNavArgument import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost -import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.flux.ui.state.EventState -import com.flux.ui.state.HabitState -import com.flux.ui.state.JournalState -import com.flux.ui.state.NotesState -import com.flux.ui.state.ProgressBoardState -import com.flux.ui.state.Settings import com.flux.ui.state.States -import com.flux.ui.state.TodoState -import com.flux.ui.state.WorkspaceState -import com.flux.ui.viewModel.BackupViewModel -import com.flux.ui.viewModel.EventViewModel -import com.flux.ui.viewModel.HabitViewModel -import com.flux.ui.viewModel.JournalViewModel -import com.flux.ui.viewModel.NotesViewModel -import com.flux.ui.viewModel.ProgressBoardViewModel -import com.flux.ui.viewModel.SettingsViewModel -import com.flux.ui.viewModel.TodoViewModel import com.flux.ui.viewModel.ViewModels -import com.flux.ui.viewModel.WorkspaceViewModel @RequiresApi(Build.VERSION_CODES.TIRAMISU) @Composable -fun AppNavHost( - navController: NavHostController = rememberNavController(), - snackbarHostState: SnackbarHostState, - settingsViewModel: SettingsViewModel, - notesViewModel: NotesViewModel, - workspaceViewModel: WorkspaceViewModel, - eventViewModel: EventViewModel, - habitViewModel: HabitViewModel, - todoViewModel: TodoViewModel, - journalViewModel: JournalViewModel, - backupViewModel: BackupViewModel, - progressBoardViewModel: ProgressBoardViewModel, - settings: Settings, - notesState: NotesState, - workspaceState: WorkspaceState, - eventState: EventState, - habitState: HabitState, - todoState: TodoState, - journalState: JournalState, - progressBoardState: ProgressBoardState -) { +fun AppNavHost(navController: NavHostController, snackbarHostState: SnackbarHostState, viewModels: ViewModels, states: States) { NavHost(navController, startDestination = NavRoutes.AuthScreen.route) { NotesScreens.forEach { (route, screen) -> val arguments = mutableListOf() @@ -79,27 +41,8 @@ fun AppNavHost( navController, notesId, workspaceId, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ) + states, + viewModels ) } } @@ -108,16 +51,7 @@ fun AppNavHost( animatedComposable(route) { screen( navController, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ) + states ) } } @@ -126,27 +60,8 @@ fun AppNavHost( animatedComposable(route) { screen( navController, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ) + states, + viewModels ) } } @@ -185,27 +100,8 @@ fun AppNavHost( journalId, journalDateTime, workspaceId, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ) + states, + viewModels ) } } @@ -235,27 +131,8 @@ fun AppNavHost( navController, listId, workspaceId, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ) + states, + viewModels ) } } @@ -285,88 +162,20 @@ fun AppNavHost( navController, habitId, workspaceId, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ) + states, + viewModels ) } } SettingsScreens.forEach { (route, screen) -> - if (route == NavRoutes.Settings.route) { - slideInComposable(route) { - screen( - navController, - snackbarHostState, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ) - ) - } - } else { - animatedComposable(route) { - screen( - navController, - snackbarHostState, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ) - ) - } + animatedComposable(route) { + screen( + navController, + snackbarHostState, + states, + viewModels + ) } } @@ -408,25 +217,8 @@ fun AppNavHost( val eventDate = entry.arguments?.getLong("eventDate") ?: 0L screen(navController, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel), + states, + viewModels, eventId, workspaceId, instanceDate, @@ -449,27 +241,8 @@ fun AppNavHost( screen( navController, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ), + states, + viewModels, workspaceId ) } @@ -490,31 +263,22 @@ fun AppNavHost( screen( navController, snackbarHostState, - States( - notesState, - eventState, - habitState, - todoState, - workspaceState, - journalState, - progressBoardState, - settings - ), - ViewModels( - notesViewModel, - eventViewModel, - todoViewModel, - habitViewModel, - workspaceViewModel, - journalViewModel, - settingsViewModel, - backupViewModel, - progressBoardViewModel - ), + states, + viewModels, id ) } } + + SearchScreens.forEach { (route, screen) -> + animatedComposable(route) { + screen( + navController, + states, + viewModels + ) + } + } } } diff --git a/app/src/main/java/com/flux/navigation/NavRoutes.kt b/app/src/main/java/com/flux/navigation/NavRoutes.kt index 6a7f49d..b842e6b 100644 --- a/app/src/main/java/com/flux/navigation/NavRoutes.kt +++ b/app/src/main/java/com/flux/navigation/NavRoutes.kt @@ -17,21 +17,24 @@ import com.flux.ui.screens.events.NewEvent import com.flux.ui.screens.habits.HabitDetails import com.flux.ui.screens.habits.NewHabit import com.flux.ui.screens.journal.EditJournal -import com.flux.ui.screens.notes.EditLabels +import com.flux.ui.screens.labels.EditLabels import com.flux.ui.screens.notes.NoteDetails +import com.flux.ui.screens.search.SearchScreen import com.flux.ui.screens.settings.About import com.flux.ui.screens.settings.Contact import com.flux.ui.screens.settings.Customize import com.flux.ui.screens.settings.Data import com.flux.ui.screens.settings.Editor import com.flux.ui.screens.settings.Languages +import com.flux.ui.screens.settings.Mode import com.flux.ui.screens.settings.Privacy import com.flux.ui.screens.settings.Settings import com.flux.ui.screens.settings.StorageSelectionScreen import com.flux.ui.screens.settings.Themes import com.flux.ui.screens.todo.TodoDetail -import com.flux.ui.screens.workspaces.NormalModeDetails -import com.flux.ui.screens.workspaces.WorkSpaces +import com.flux.ui.screens.workspaces.NewWorkspaceScreen +import com.flux.ui.screens.workspaces.WorkspaceDetails +import com.flux.ui.screens.workspaces.WorkspaceHomeScreen import com.flux.ui.state.States import com.flux.ui.viewModel.ViewModels @@ -39,6 +42,7 @@ sealed class NavRoutes(val route: String) { data object AuthScreen : NavRoutes("biometric") // auth screen data object StorageSelection : NavRoutes("storageSelection") // Storage Selection data object Workspace : NavRoutes("workspace") // workspaces + data object NewWorkspace: NavRoutes("workspace/edit") // edit workspace data object WorkspaceHome : NavRoutes("workspace/details") data object EditLabels : NavRoutes("workspace/labels/edit") //Labels data object NoteDetails : NavRoutes("workspace/note/details") // Notes @@ -48,6 +52,8 @@ sealed class NavRoutes(val route: String) { data object TodoDetail : NavRoutes("workspace/todo/details") // TodoList data object EditJournal : NavRoutes("workspace/journal/edit") // Journal data object NewEvent : NavRoutes("workspace/event/edit") // new event + data object Analytics : NavRoutes("Analytics") + data object Search : NavRoutes("workspace/search") // Settings data object Settings : NavRoutes("settings") @@ -59,6 +65,7 @@ sealed class NavRoutes(val route: String) { data object Contact : NavRoutes("settings/contact") data object Backup : NavRoutes("setting/backup") data object Editor : NavRoutes("setting/editor") + data object Mode : NavRoutes("setting/mode") fun withArgs(vararg args: Any): String { return buildString { @@ -83,6 +90,12 @@ val StorageSelectionScreen = mapOf Unit>( + NavRoutes.Search.route to { navController, states, viewModels -> + SearchScreen(navController, states, viewModels) + } +) + val NotesScreens = mapOf Unit>( NavRoutes.NoteDetails.route + "/{workspaceId}" + "/{notesId}" to { navController, notesId, workspaceId, states, viewModel -> @@ -98,7 +111,7 @@ val NotesScreens = states.notesState.allNotes.find { it.notesId == notesId } ?: NotesModel(workspaceId = workspaceId), states.settings.data.storageRootUri, - states.notesState.allLabels.filter { it.workspaceId == workspaceId }, + states.labelState.allLabels, viewModel.settingsViewModel, viewModel.notesViewModel, viewModel.notesViewModel::onEvent @@ -146,7 +159,7 @@ val JournalScreens = NavRoutes.EditJournal.route + "/{workspaceId}" + "/{journalId}" + "/{journalDateTime}" to { navController, journalId, journalDateTime, workspaceId, states, viewModel -> EditJournal( navController, - states.journalState.allEntries.find { it.journalId == journalId } ?: JournalModel(workspaceId = workspaceId, dateTime = journalDateTime), + states.journalState.data.find { it.journalId == journalId } ?: JournalModel(workspaceId = workspaceId, dateTime = journalDateTime), states.journalState.outline, states.journalState.textState, states.settings.data.isDarkMode, @@ -154,6 +167,7 @@ val JournalScreens = states.settings.data.isLineNumbersVisible, states.settings.data.startWithReadView, states.settings.data.storageRootUri, + states.labelState.allLabels.filter { it.workspaceId==workspaceId }, viewModel.journalViewModel, viewModel.settingsViewModel, viewModel.journalViewModel::onEvent @@ -190,6 +204,9 @@ val SettingsScreens = }, NavRoutes.Theme.route to { navController, _, states, viewModels -> Themes(navController, states.settings, viewModels.settingsViewModel::onEvent) + }, + NavRoutes.Mode.route to { navController, _, states, viewModels -> + Mode(navController, states.settings, viewModels.settingsViewModel::onEvent) } ) @@ -220,27 +237,28 @@ val EventScreens = val WorkspaceScreens = mapOf Unit>( NavRoutes.Workspace.route to { navController, snackbarHostState, states, viewModels, _ -> - WorkSpaces( + WorkspaceHomeScreen( snackbarHostState, navController, states, viewModels ) }, + + NavRoutes.NewWorkspace.route + "/{workspaceId}" to { navController, _, states, viewModels, workspaceId -> + NewWorkspaceScreen ( + navController, + states.workspaceState.allWorkspaces.find { it.workspaceId==workspaceId }?: WorkspaceModel(), + viewModels.workspaceViewModel::onEvent + ) + }, + NavRoutes.WorkspaceHome.route + "/{workspaceId}" to { navController, _, states, viewModels, workspaceId -> - NormalModeDetails( + WorkspaceDetails( navController, states, states.workspaceState.allWorkspaces.find { it.workspaceId==workspaceId }?: WorkspaceModel(workspaceId=workspaceId), - viewModels.settingsViewModel, - viewModels.workspaceViewModel::onEvent, - viewModels.notesViewModel::onEvent, - viewModels.eventViewModel::onEvent, - viewModels.habitViewModel::onEvent, - viewModels.todoViewModel::onEvent, - viewModels.journalViewModel::onEvent, - viewModels.settingsViewModel::onEvent, - viewModels.progressBoardViewModel::onEvent + viewModels ) } ) @@ -250,10 +268,10 @@ val LabelScreens = NavRoutes.EditLabels.route + "/{workspaceId}" to { navController, states, viewModels, workspaceId -> EditLabels( navController, - states.notesState.isLabelsLoading, + states.labelState.isLoading, workspaceId, - states.notesState.allLabels, - viewModels.notesViewModel::onEvent + states.labelState.allLabels.filter { it.workspaceId==workspaceId }, + viewModels.labelViewModel::onEvent ) } ) diff --git a/app/src/main/java/com/flux/other/BootReceiver.kt b/app/src/main/java/com/flux/other/BootReceiver.kt index 1cb96f8..0165211 100644 --- a/app/src/main/java/com/flux/other/BootReceiver.kt +++ b/app/src/main/java/com/flux/other/BootReceiver.kt @@ -3,8 +3,9 @@ package com.flux.other import android.content.BroadcastReceiver import android.content.Context import android.content.Intent -import com.flux.data.model.ReminderItem +import com.flux.data.model.ScheduleRequest import com.flux.data.model.isLive +import com.flux.data.model.toScheduleRequest import com.flux.data.repository.EventRepository import com.flux.data.repository.HabitRepository import dagger.hilt.EntryPoint @@ -16,38 +17,47 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class BootReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { - if (intent.action == Intent.ACTION_BOOT_COMPLETED || - intent.action == Intent.ACTION_LOCKED_BOOT_COMPLETED - ) { - val pendingResult = goAsync() - CoroutineScope(Dispatchers.IO).launch { + if (intent.action != Intent.ACTION_BOOT_COMPLETED && + intent.action != Intent.ACTION_LOCKED_BOOT_COMPLETED + ) return + + val pendingResult = goAsync() + + CoroutineScope(Dispatchers.IO).launch { + try { val reminders = getStoredReminders(context) - // Reschedule each reminder at its next occurrence - reminders.forEach { reminder -> - scheduleNextReminder(context, reminder) + reminders.forEach { request -> + scheduleNextReminder(context, request) } + } catch (e: Exception) { + e.printStackTrace() + } finally { pendingResult.finish() } } } - // --- Load reminders from repositories --- - private suspend fun getStoredReminders(context: Context): List { + // ---------------------- LOAD + CONVERT ---------------------- + + private suspend fun getStoredReminders(context: Context): List { + val entryPoint = EntryPointAccessors.fromApplication( context, ReceiverEntryPoint::class.java ) - val habitRepository = entryPoint.habitRepository() - val eventRepository = entryPoint.eventRepository() - val habits = habitRepository.loadAllHabits() - val events = eventRepository.loadAllEvents() + val habitRepo = entryPoint.habitRepository() + val eventRepo = entryPoint.eventRepository() + + val habits = habitRepo.loadAllHabits().filter { it.isLive() }.map { it.toScheduleRequest() } + val events = eventRepo.loadAllEvents().filter { it.isLive() }.map { it.toScheduleRequest() } - return habits.filter { it.isLive() } + events + return habits + events } } diff --git a/app/src/main/java/com/flux/other/Constants.kt b/app/src/main/java/com/flux/other/Constants.kt index 0791efb..1312e56 100644 --- a/app/src/main/java/com/flux/other/Constants.kt +++ b/app/src/main/java/com/flux/other/Constants.kt @@ -24,6 +24,7 @@ object Constants { object Other { const val SIDE_EFFECT_KEY = "side_effect" + const val ACTION_MARK_DONE = "flux.action.MARK_DONE" } object Editor { @@ -200,15 +201,15 @@ val HEADER_LINE_STYLES = listOf( val HEADER_STYLES = listOf( SpanStyle( - fontSize = 32.sp, + fontSize = 22.sp, fontWeight = FontWeight.Black, fontSynthesis = FontSynthesis.Weight ), SpanStyle( - fontSize = 24.sp, + fontSize = 20.sp, fontWeight = FontWeight.ExtraBold, fontSynthesis = FontSynthesis.Weight ), SpanStyle( - fontSize = 18.72.sp, + fontSize = 18.sp, fontWeight = FontWeight.ExtraBold, fontSynthesis = FontSynthesis.Weight ), SpanStyle( @@ -216,11 +217,11 @@ val HEADER_STYLES = listOf( fontWeight = FontWeight.Bold, fontSynthesis = FontSynthesis.Weight ), SpanStyle( - fontSize = 13.28.sp, + fontSize = 14.sp, fontWeight = FontWeight.Bold, fontSynthesis = FontSynthesis.Weight ), SpanStyle( - fontSize = 12.sp, + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, fontSynthesis = FontSynthesis.Weight ) diff --git a/app/src/main/java/com/flux/other/ReminderReceiver.kt b/app/src/main/java/com/flux/other/ReminderReceiver.kt index 950c652..4974f16 100644 --- a/app/src/main/java/com/flux/other/ReminderReceiver.kt +++ b/app/src/main/java/com/flux/other/ReminderReceiver.kt @@ -13,7 +13,6 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build -import android.os.Handler import android.provider.Settings import android.widget.Toast import androidx.annotation.RequiresApi @@ -23,268 +22,270 @@ import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.flux.R import com.flux.data.model.EventInstanceModel +import com.flux.data.model.HabitConfig import com.flux.data.model.HabitInstanceModel import com.flux.data.model.RecurrenceRule -import com.flux.data.model.ReminderItem import com.flux.data.model.ReminderType +import com.flux.data.model.ScheduleRequest +import com.flux.data.model.toIntent +import com.flux.other.Constants.Other.ACTION_MARK_DONE import dagger.hilt.android.EntryPointAccessors import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import java.time.Duration +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.Instant class ReminderReceiver : BroadcastReceiver() { - - companion object { - const val ACTION_MARK_DONE = "flux.action.MARK_DONE" - } - override fun onReceive(context: Context, intent: Intent) { - // ───────────────────────────────────────────── - // MARK DONE ACTION - // ───────────────────────────────────────────── if (intent.action == ACTION_MARK_DONE) { - val id = intent.getStringExtra("ID") ?: return - val type = intent.getStringExtra("TYPE") ?: return - val workspaceId = intent.getStringExtra("WORKSPACE_ID") ?: return - - markDone(context, type, id, workspaceId) + MarkDoneHandler.handle(context, intent) return } - // ───────────────────────────────────────────── - // EXTRACT PAYLOAD - // ───────────────────────────────────────────── - val title = intent.getStringExtra("TITLE") ?: "Reminder" - - val description = intent.getStringExtra("DESCRIPTION") - ?: "It's time to complete pending things" + val request = ScheduleRequest.fromIntent(intent) ?: return - val id = intent.getStringExtra("ID") ?: return - val workspaceId = intent.getStringExtra("WORKSPACE_ID") ?: "" - val type = intent.getStringExtra("TYPE") ?: "EVENT" + NotificationDispatcher.notify(context, request) - val endTimeInMillis = intent.getLongExtra("ENDTIME", -1L) - val startDateTime = intent.getLongExtra("START_TIME", -1L) - val offset = intent.getLongExtra("OFFSET", 0L) + if (request.recurrence !is RecurrenceRule.Once) { + scheduleNextReminder(context, request) + } + } +} - if (startDateTime <= 0L) return +object MarkDoneHandler { + fun handle(context: Context, intent: Intent) { + val itemId = intent.getStringExtra("itemId") ?: return + val itemType = intent.getStringExtra("itemType") ?: return + val workspaceId = intent.getStringExtra("workspaceId") ?: return + + val notificationId = (itemId + itemType).hashCode() + + CoroutineScope(Dispatchers.IO).launch { + try { + val entryPoint = EntryPointAccessors + .fromApplication(context, ReceiverEntryPoint::class.java) + + when (itemType) { + "EVENT" -> entryPoint.eventRepository() + .upsertEventInstance(EventInstanceModel(itemId, workspaceId)) + + "HABIT" -> { + val config = intent.getStringExtra("habitConfig") + ?.let { runCatching { Json.decodeFromString(it) }.getOrNull() } + + val repo = entryPoint.habitRepository() + + if (config is HabitConfig.Counted) { + val today = LocalDate.now().toEpochDay() + val existing = repo.getHabitInstance(itemId, today) + val updated = existing + ?.copy(count = existing.count + 1) + ?: HabitInstanceModel(habitId = itemId, workspaceId = workspaceId, instanceDate = today, count = 1) + repo.upsertHabitInstance(updated) + } else { repo.upsertHabitInstance(HabitInstanceModel(itemId, workspaceId)) } + } + } + + withContext(Dispatchers.Main) { + Toast.makeText(context, "Marked Done", Toast.LENGTH_SHORT).show() + } + + } catch (_: Exception) { + withContext(Dispatchers.Main) { + Toast.makeText(context, "Failed", Toast.LENGTH_LONG).show() + } + } + } - // ───────────────────────────────────────────── - // TYPE SAFE CHECK - // ───────────────────────────────────────────── - val reminderType = ReminderType.valueOf(type) - val isHabit = reminderType == ReminderType.HABIT + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.cancel(notificationId) + } +} - // ───────────────────────────────────────────── - // DESERIALIZE RECURRENCE - // ───────────────────────────────────────────── - val recurrenceJson = intent.getStringExtra("RECURRENCE") +object NotificationDispatcher { - val recurrence = recurrenceJson?.let { - Json.decodeFromString(it) - } ?: RecurrenceRule.Once + private const val CHANNEL_ID = "notification_channel" - // ───────────────────────────────────────────── - // NOTIFICATION SETUP - // ───────────────────────────────────────────── - val icon = - if (reminderType == ReminderType.EVENT) - R.drawable.check_list - else - R.drawable.calendar_check + fun notify(context: Context, request: ScheduleRequest) { + ensureChannel(context) - val notificationId = getUniqueRequestCode(type, id) + val isHabit = request.itemType == ReminderType.HABIT val doneIntent = Intent(context, ReminderReceiver::class.java).apply { action = ACTION_MARK_DONE - putExtra("ID", id) - putExtra("TYPE", type) - putExtra("WORKSPACE_ID", workspaceId) + putExtra("itemId", request.itemId) + putExtra("itemType", request.itemType.name) + putExtra("workspaceId", request.workspaceId) + request.habitConfig?.let { // ADD + putExtra("habitConfig", Json.encodeToString(it)) + } } val donePendingIntent = PendingIntent.getBroadcast( context, - notificationId, + getUniqueRequestCode(request.itemType.name, request.itemId), doneIntent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) - val builder = NotificationCompat.Builder(context, "notification_channel") - .setSmallIcon(icon) - .setContentTitle(title) - .setContentText(description) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setCategory(NotificationCompat.CATEGORY_REMINDER) - - // ───────────────────────────────────────────── - // CONDITIONAL STICKY BEHAVIOR - // ───────────────────────────────────────────── - if (isHabit) { - builder - .setOngoing(true) // sticky - .setAutoCancel(false) // cannot auto dismiss - } else { - builder - .setOngoing(false) - .setAutoCancel(true) // normal dismiss - } - - val notification = builder - .addAction( - R.drawable.check_list, - "Done", - donePendingIntent + val builder = NotificationCompat.Builder(context, CHANNEL_ID) + .setContentTitle(request.title) + .setContentText(request.description) + .setSmallIcon( + if (isHabit) R.drawable.calendar_check + else R.drawable.check_list ) - .build() - - val manager = - context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - manager.notify(notificationId, notification) - - // ───────────────────────────────────────────── - // BUILD REMINDER ITEM - // ───────────────────────────────────────────── - val item = object : ReminderItem { - override val id = id - override val title = title - override val description = description - override val recurrence = recurrence - override val type = reminderType - override val startDateTime = startDateTime - override val endDateTime = endTimeInMillis - override val workspaceId = workspaceId - override val notificationOffset = offset - } + .setPriority(NotificationCompat.PRIORITY_HIGH) + + // persistent notification + builder.setOngoing(true).setAutoCancel(false) + + val notification = builder.addAction(R.drawable.check_list, "Done", donePendingIntent).build() - // ───────────────────────────────────────────── - // RESCHEDULE NEXT OCCURRENCE - // ───────────────────────────────────────────── - scheduleNextReminder(context, item) + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify((request.itemId + request.itemType.name).hashCode(), notification) } -} -private fun markDone( - context: Context, - type: String, - id: String, - workspaceId: String -) { - CoroutineScope(Dispatchers.IO).launch { - try { - val entryPoint = - EntryPointAccessors.fromApplication( - context, - ReceiverEntryPoint::class.java - ) - - when (type) { - - "EVENT" -> - entryPoint.eventRepository() - .upsertEventInstance( - EventInstanceModel( - eventId = id, - workspaceId = workspaceId - ) - ) - - else -> - entryPoint.habitRepository() - .upsertHabitInstance( - HabitInstanceModel( - habitId = id, - workspaceId = workspaceId - ) - ) - } + private fun ensureChannel(context: Context) { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (manager.getNotificationChannel(CHANNEL_ID) != null) return - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "Done!", - Toast.LENGTH_SHORT - ).show() - } + val channel = NotificationChannel( + CHANNEL_ID, + "Reminders", + NotificationManager.IMPORTANCE_HIGH + ).apply { + enableVibration(true) + } - } catch (e: Exception) { + manager.createNotificationChannel(channel) + } +} - e.printStackTrace() +fun scheduleReminder(context: Context, request: ScheduleRequest, triggerAt: Long) { + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - withContext(Dispatchers.Main) { - Toast.makeText( - context, - "Failed to mark done", - Toast.LENGTH_LONG - ).show() - } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if (!alarmManager.canScheduleExactAlarms()) { + requestExactAlarmPermission(context) + return } } - val manager = - context.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager + val intent = request.toIntent(context) - manager.cancel( - getUniqueRequestCode(type, id) + val pendingIntent = PendingIntent.getBroadcast( + context, + getUniqueRequestCode(request.itemType.name, request.itemId), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerAt, + pendingIntent ) } +private val zone: ZoneId get() = ZoneId.systemDefault() + fun scheduleNextReminder( context: Context, - item: ReminderItem + request: ScheduleRequest +) { + when (val config = request.habitConfig) { + is HabitConfig.Counted -> { scheduleNextCountedReminder(context, request, config) } + else -> { scheduleStandardReminder(context, request) } + } +} + +private fun scheduleStandardReminder( + context: Context, + request: ScheduleRequest ) { val now = System.currentTimeMillis() + var candidate = getNextOccurrence(request.recurrence, request.startDateTime) ?: return + var finalTime: Long - if (item.startDateTime <= 0L) return + while (true) { + val alarmTime = candidate - request.notificationOffset + if (alarmTime > now) { finalTime = alarmTime; break } + candidate = getNextOccurrence(request.recurrence, candidate) ?: return + } - var candidate = - getNextOccurrence( - item.recurrence, - item.startDateTime - ) ?: return + if (request.endDateTime != -1L && finalTime > request.endDateTime) return + scheduleReminder(context, request, finalTime) +} - var finalTime: Long? +private fun scheduleNextCountedReminder( + context: Context, + request: ScheduleRequest, + config: HabitConfig.Counted +) { + val now = ZonedDateTime.now(zone) + val nextTrigger = calculateNextCountedTrigger(now, request, config) ?: run { return } - // Catch-up loop (handles missed alarms / backups) - while (true) { + if (request.endDateTime != -1L) { + val endDate = Instant.ofEpochMilli(request.endDateTime).atZone(zone).toLocalDate() + val finalAllowed = ZonedDateTime.of(endDate, millisToLocalTime(config.activeEndTime), zone) - val alarmTime = - candidate - item.notificationOffset + if (nextTrigger.isAfter(finalAllowed)) { return } + } + + scheduleReminder(context, request, nextTrigger.toInstant().toEpochMilli()) +} - if (alarmTime > now) { - finalTime = alarmTime - break +private fun calculateNextCountedTrigger( + now: ZonedDateTime, + request: ScheduleRequest, + config: HabitConfig.Counted +): ZonedDateTime? { + val recurrence = request.recurrence as? RecurrenceRule.Weekly ?: run { return null } + + var currentDate = now.toLocalDate() + + repeat(14) { + // Monday=0 ... Sunday=6 + val currentDayOfWeek = currentDate.dayOfWeek.value - 1 + val isValidDay = currentDayOfWeek in recurrence.daysOfWeek + + if (isValidDay) { + val startTime = millisToLocalTime(config.activeStartTime) + val endTime = millisToLocalTime(config.activeEndTime) + + val windowStart = ZonedDateTime.of(currentDate, startTime, zone) + val windowEnd = ZonedDateTime.of(currentDate, endTime, zone) + + when { + now.isBefore(windowStart) || now.isEqual(windowStart) -> { return windowStart } + now.isBefore(windowEnd) -> { + val elapsedMillis = Duration.between(windowStart, now).toMillis() + val intervalsPassed = elapsedMillis / config.intervalMillis + 1 + val nextSlot = windowStart.plus(Duration.ofMillis(intervalsPassed * config.intervalMillis)) + if (nextSlot.isBefore(windowEnd) || nextSlot.isEqual(windowEnd)) { + return nextSlot + } + } + } } - candidate = - getNextOccurrence( - item.recurrence, - candidate - ) ?: return + currentDate = currentDate.plusDays(1) } - if (item.endDateTime != -1L && - finalTime > item.endDateTime - ) return - - scheduleReminder( - context = context, - id = item.id, - type = item.type.toString(), - recurrence = item.recurrence, - timeInMillis = finalTime, - endTimeInMillis = item.endDateTime, - startDateTime = item.startDateTime, - notificationOffset = item.notificationOffset, - workspaceId = item.workspaceId, - title = item.title, - description = item.description - ) + return null +} + +private fun millisToLocalTime(millis: Long): LocalTime { + return Instant.ofEpochMilli(millis).atZone(zone).toLocalTime() } @RequiresApi(Build.VERSION_CODES.TIRAMISU) @@ -297,6 +298,7 @@ fun createNotificationChannel(context: Context) { val channel = NotificationChannel("notification_channel", "Reminders", importance) channel.enableVibration(true) channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager manager.createNotificationChannel(channel) } @@ -326,98 +328,15 @@ fun canScheduleReminder(context: Context): Boolean { } } -fun scheduleReminder( - context: Context, - id: String, - type: String, - recurrence: RecurrenceRule, - timeInMillis: Long, - endTimeInMillis: Long, - startDateTime: Long, - notificationOffset: Long, - workspaceId: String, - title: String, - description: String -) { - try { - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - val intent = createReminderIntent(context, id, type, title, description, workspaceId, startDateTime, notificationOffset, endTimeInMillis, recurrence) - val requestCode = getUniqueRequestCode(type, id) - - val pendingIntent = PendingIntent.getBroadcast( - context, - requestCode, - intent, - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT - ) - - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent) - } catch (e: SecurityException) { - e.printStackTrace() - Handler(context.mainLooper).post { - Toast.makeText(context, context.getString(R.string.Error_Schedule_Alarm_Failed), Toast.LENGTH_LONG).show() - } - } -} - -fun createReminderIntent( - context: Context, - id: String, - type: String, - title: String, - description: String, - workspaceId: String, - startDateTime: Long, - notificationOffset: Long, - endTimeInMillis: Long, - recurrence: RecurrenceRule -): Intent { - return Intent(context, ReminderReceiver::class.java).apply { - putExtra("TITLE", title) - putExtra("DESCRIPTION", description) - putExtra("ID", id) - putExtra("TYPE", type) - putExtra("RECURRENCE", Json.encodeToString(recurrence)) - putExtra("ENDTIME", endTimeInMillis) - putExtra("WORKSPACE_ID", workspaceId) - putExtra("START_TIME", startDateTime) - putExtra("OFFSET", notificationOffset) - } -} - -fun getUniqueRequestCode(type: String, uuid: String): Int { - return (type + uuid).hashCode() -} - fun cancelReminder( context: Context, - id: String, - type: String, - title: String, - description: String, - workspaceId: String, - endTimeInMillis: Long, - startDateTime: Long, - notificationOffset: Long, - recurrence: RecurrenceRule + request: ScheduleRequest ) { try { - val intent = createReminderIntent( - context = context, - id = id, - type = type, - title = title, - description = description, - recurrence = recurrence, - workspaceId = workspaceId, - endTimeInMillis = endTimeInMillis, - startDateTime = startDateTime, - notificationOffset = notificationOffset - ) - val requestCode = getUniqueRequestCode(type, id) + val intent = request.toIntent(context) val pendingIntent = PendingIntent.getBroadcast( context, - requestCode, + getUniqueRequestCode(request.itemType.name, request.itemId), intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) @@ -452,4 +371,8 @@ fun openAppNotificationSettings(context: Context) { putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) } context.startActivity(intent) +} + +fun getUniqueRequestCode(type: String, uuid: String): Int { + return (type + uuid).hashCode() } \ No newline at end of file diff --git a/app/src/main/java/com/flux/other/Utils.kt b/app/src/main/java/com/flux/other/Utils.kt index b013004..a55bfa9 100644 --- a/app/src/main/java/com/flux/other/Utils.kt +++ b/app/src/main/java/com/flux/other/Utils.kt @@ -59,6 +59,11 @@ import org.commonmark.node.Text import org.commonmark.parser.IncludeSourceSpans import org.commonmark.parser.Parser import androidx.core.net.toUri +import com.flux.data.model.EventModel +import com.flux.data.model.occursOn +import java.time.LocalDate +import java.time.YearMonth +import java.time.temporal.ChronoUnit fun Int.toHexColor(): String { return String.format("#%06X", 0xFFFFFF and this) @@ -752,4 +757,19 @@ fun parseMarkdownContent(text: String): AnnotatedString { } append(textWithoutProperties) } +} + +fun computeMonthlyEventDates( + events: List, + yearMonth: YearMonth +): Map { + val monthStart = yearMonth.atDay(1) + val monthEnd = yearMonth.atEndOfMonth() + + if (monthEnd < monthStart) return emptyMap() + + return (0..ChronoUnit.DAYS.between(monthStart, monthEnd)) + .map { monthStart.plusDays(it) } + .associateWith { date -> events.count { event -> event.occursOn(date) } } + .filter { (_, count) -> count > 0 } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/Animations.kt b/app/src/main/java/com/flux/ui/common/Animations.kt similarity index 98% rename from app/src/main/java/com/flux/ui/components/Animations.kt rename to app/src/main/java/com/flux/ui/common/Animations.kt index 0af6c68..8335c37 100644 --- a/app/src/main/java/com/flux/ui/components/Animations.kt +++ b/app/src/main/java/com/flux/ui/common/Animations.kt @@ -1,4 +1,4 @@ -package com.flux.ui.components +package com.flux.ui.common import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition diff --git a/app/src/main/java/com/flux/ui/common/AppBars.kt b/app/src/main/java/com/flux/ui/common/AppBars.kt new file mode 100644 index 0000000..11d964b --- /dev/null +++ b/app/src/main/java/com/flux/ui/common/AppBars.kt @@ -0,0 +1,631 @@ +package com.flux.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Deselect +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.RemoveRedEye +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.SearchOff +import androidx.compose.material.icons.filled.SelectAll +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Summarize +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.PushPin +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.navigation.NavController +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.compose.currentBackStackEntryAsState +import coil.compose.AsyncImage +import com.flux.R +import com.flux.navigation.NavRoutes +import com.flux.other.icons + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NoteDetailsTopBar( + isPinned: Boolean, + isSearching: Boolean, + isReadView: Boolean, + onBackPressed: () -> Unit, + onOutlineClicked: () -> Unit, + onReadClick: () -> Unit, + onEditClick: ()->Unit, + onDelete: () -> Unit, + onAddLabel: () -> Unit, + onSearchClick: () -> Unit, + onTogglePinned: () -> Unit, + onAboutClicked: () -> Unit, + onShareNote: () -> Unit, + onSaveNote: () -> Unit, + onPrintNote: () -> Unit, +) { + CenterAlignedTopAppBar( + title = { + Row { + Box( + modifier = Modifier + .background( + if (!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( + 6.dp + ), + RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp) + ) + .clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) + .clickable { onEditClick() } + .padding(8.dp) + ) { + Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.width(1.dp)) + Box( + modifier = Modifier + .background( + if (isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( + 6.dp + ), + RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp) + ) + .clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) + .clickable { onReadClick() } + .padding(8.dp) + ) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } }, + actions = { + if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } } + IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) } + DropdownMenuWithDetails(isPinned, onTogglePinned, onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete) } + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JournalDetailsTopBar( + isSearching: Boolean, + isReadView: Boolean, + onBackPressed: () -> Unit, + onOutlineClicked: () -> Unit, + onReadClick: () -> Unit, + onEditClick: ()->Unit, + onDelete: () -> Unit, + onAddLabel: () -> Unit, + onSearchClick: () -> Unit, + onAboutClicked: () -> Unit, + onShareNote: () -> Unit, + onSaveNote: () -> Unit, + onPrintNote: () -> Unit, +) { + CenterAlignedTopAppBar( + title = { + Row { + Box( + modifier = Modifier + .background( + if (!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( + 6.dp + ), + RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp) + ) + .clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) + .clickable { onEditClick() } + .padding(8.dp) + ) { + Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.width(1.dp)) + Box( + modifier = Modifier + .background( + if (isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( + 6.dp + ), + RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp) + ) + .clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) + .clickable { onReadClick() } + .padding(8.dp) + ) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } }, + actions = { + if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } } + IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) } + JournalDropdownMenu(onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete) + } + ) +} + + +@Composable +fun SelectedToolBarRow( + showDeleteOption: Boolean = true, + selectionCount: Int, + isAllSelected: Boolean, + isAllPinned: Boolean, + onClear: () -> Unit, + onDelete: () -> Unit = {}, + onTogglePin: () -> Unit, + onToggleSelection: () -> Unit, +){ + val shape = if (!showDeleteOption) RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp) else RectangleShape + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClear){ + Icon( + Icons.Filled.Clear, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + Text(selectionCount.toString()) + } + + Row(Modifier.padding(end = 8.dp)) { + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp) + ) + .clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) + .clickable { onTogglePin() } + .padding(8.dp) + ) { + Icon( + if(isAllPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + + Spacer(Modifier.width(1.dp)) + Box( + modifier = Modifier + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + shape + ) + .clip(shape) + .clickable { onToggleSelection() } + .padding(8.dp) + ) { + Icon( + if (isAllSelected) Icons.Default.Deselect else Icons.Default.SelectAll, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + + if (showDeleteOption){ + Spacer(Modifier.width(1.dp)) + Box(Modifier + .background( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp) + ) + .clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) + .clickable { onDelete() } + .padding(8.dp) + ) { + Icon( + Icons.Default.DeleteOutline, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} + +@Composable +fun CompactCard(icon: ImageVector, title: String, onClick: () -> Unit){ + Card( + modifier = Modifier.clip(RoundedCornerShape(50)), + shape = RoundedCornerShape(50), + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Row( + Modifier.padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton( + onClick = onClick, + modifier = Modifier.size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { + Icon( + icon, + null, + Modifier.size(18.dp) + ) + } + + Text( + title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(end = 8.dp) + .widthIn(max = 150.dp), + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold) + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SpaceTopBar( + scrollBehavior: TopAppBarScrollBehavior, + title: String, + description: String, + cover: String, + icon: Int, + isLocked: Boolean = false, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onEditWorkspace: () -> Unit, + onBackPressed: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit +) { + val hasCover = cover.isNotBlank() + val hasDescription = description.isNotBlank() + val density = LocalDensity.current + + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val coverHeightDp = 180.dp + val toolbarContentDp = 48.dp + val toolbarHeight = toolbarContentDp + statusBarHeight + + // Derive title section height purely from typography — no measurement callback needed. + // Layout: vertical padding (6dp top + 6dp bottom) + icon/title row + optional (2dp spacer + description row) + val titleStyle = MaterialTheme.typography.titleLarge + val bodyStyle = MaterialTheme.typography.bodyMedium + val titleLineHeightDp = with(density) { titleStyle.lineHeight.toDp() } + val bodyLineHeightDp = with(density) { bodyStyle.lineHeight.toDp() } + val titleRowDp = maxOf(24.dp, titleLineHeightDp) // Icon default size is 24.dp + val titleSectionHeightDp = 12.dp + titleRowDp + if (hasDescription) 2.dp + bodyLineHeightDp else 0.dp + + val expandedHeightDp = if (hasCover) + coverHeightDp + titleSectionHeightDp + statusBarHeight - 32.dp + else + toolbarHeight + titleSectionHeightDp + 24.dp + + val expandedPx = with(density) { expandedHeightDp.toPx() } + val collapsedPx = with(density) { toolbarHeight.toPx() } + + SideEffect { + val limit = collapsedPx - expandedPx + if (scrollBehavior.state.heightOffsetLimit != limit) { + scrollBehavior.state.heightOffsetLimit = limit + } + } + + val fraction = scrollBehavior.state.collapsedFraction + val currentHeight = lerp(expandedHeightDp, toolbarHeight, fraction) + val coverAlpha = (1f - fraction * 1.5f).coerceIn(0f, 1f) + val expandedTitleAlpha = (1f - fraction * 2f).coerceIn(0f, 1f) + val collapsedTitleAlpha = ((fraction - 0.5f) * 2f).coerceIn(0f, 1f) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(currentHeight) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + ) { + // ── Cover + title/description stacked flush ─────────────────── + Column(Modifier.fillMaxWidth()) { + if (hasCover) { + AsyncImage( + model = cover, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxWidth() + .height(lerp(coverHeightDp, 0.dp, fraction)) + .alpha(coverAlpha) + ) + } else { + Spacer(Modifier.height(toolbarHeight)) + } + + // Title + description — always visible, fades on scroll + Column( + modifier = Modifier + .fillMaxWidth() + .alpha(expandedTitleAlpha) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icons[icon], null) + Text( + text = title, + style = titleStyle, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + } + if (hasDescription) { + Text( + text = description, + style = bodyStyle, + fontWeight = FontWeight.ExtraLight, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + } + + // ── Toolbar — overlaid, always on top ───────────────────────── + Row( + modifier = Modifier + .fillMaxWidth() + .height(toolbarHeight) + .padding(top = statusBarHeight, start = 8.dp, end = 8.dp) + .align(Alignment.TopStart), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + IconButton( + onClick = onBackPressed, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + contentColor = MaterialTheme.colorScheme.onSurface + ) + ) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + .alpha(collapsedTitleAlpha) + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + if (hasDescription) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.ExtraLight, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + WorkspaceMore( + isCoverAdded = hasCover, + isLocked = isLocked, + onDelete = onDeleteWorkspace, + onEditDetails = onEditWorkspace, + onRemoveCover = onRemoveCover, + onAddCover = onAddCover, + onToggleLock = onToggleLock + ) + } + } +} + +sealed class Destination( + val title: String, + val route: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector, +) { + data object Home : Destination(title = "Home", route = NavRoutes.Workspace.route, selectedIcon = Icons.Filled.Home, unselectedIcon = Icons.Outlined.Home) + data object Search : Destination(title = "Search", route = NavRoutes.Search.route, selectedIcon = Icons.Filled.Search, unselectedIcon = Icons.Outlined.Search) + data object Settings : Destination(title = "Settings", route = NavRoutes.Settings.route, selectedIcon = Icons.Filled.Settings, unselectedIcon = Icons.Outlined.Settings) +} + +@Composable +fun BottomBar(modifier: Modifier, navController: NavController) { + val screens = listOf(Destination.Home, Destination.Search, Destination.Settings) + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + Modifier + .height(48.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primary), + verticalAlignment = Alignment.CenterVertically + ) { + screens.forEach { screen-> + BottomBarCard( + screen = screen, + currentDestination = currentDestination, + navController = navController + ) + } + } + Spacer(Modifier.width(8.dp)) + IconButton( + {navController.navigate(NavRoutes.NewWorkspace.withArgs(""))}, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { Icon(Icons.Default.Add, null) } + } +} + +@Composable +fun BottomBarCard( + screen: Destination, + currentDestination: NavDestination?, + navController: NavController +){ + val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true + + val containerColor = if(selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary + val contentColor = if(selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary + + Card( + modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), + onClick = { if (currentDestination?.route != screen.route) { navController.navigate(screen.route) } }, + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor + ), + elevation= CardDefaults.cardElevation(0.dp), + shape = RoundedCornerShape(50) + ) { + Row( + modifier = Modifier.padding(6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(if(selected) screen.selectedIcon else screen.unselectedIcon, null) + if(selected) Text(screen.title, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)) + } + } +} + +@Composable +fun SpaceSearchBar( + query: String, + onQueryChange: (String) -> Unit, + onCloseClicked: () -> Unit, + modifier: Modifier = Modifier, + placeholder: String = stringResource(R.string.Search_Here) +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .height(56.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + ) { + Row( + modifier = Modifier.fillMaxSize(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton({}){ + Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.width(6.dp)) + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onSurface + ), + modifier = Modifier.weight(1f).padding(vertical = 2.dp), + decorationBox = { innerTextField -> + if (query.isEmpty()) { + Text( + text = placeholder, + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.primary + ) + ) + } + innerTextField() + } + ) + + IconButton( { + onCloseClicked() + onQueryChange("") + }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Clear", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/app/src/main/java/com/flux/ui/common/BottomSheets.kt b/app/src/main/java/com/flux/ui/common/BottomSheets.kt new file mode 100644 index 0000000..0031165 --- /dev/null +++ b/app/src/main/java/com/flux/ui/common/BottomSheets.kt @@ -0,0 +1,338 @@ +package com.flux.ui.common + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.flux.R +import com.flux.data.model.RecurrenceRule +import com.flux.other.icons +import com.flux.other.workspaceIconList +import com.flux.ui.screens.events.formatCustom +import com.flux.ui.screens.events.formatMonthly +import com.flux.ui.screens.events.formatOnce +import com.flux.ui.screens.events.formatYearly +import java.util.Calendar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChangeIconSheet( + isVisible: Boolean, + sheetState: SheetState, + onDismiss: () -> Unit, + onConfirm: (Int) -> Unit +) { + if (isVisible) { + ModalBottomSheet( + onDismissRequest = { onDismiss() }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + LazyColumn( + Modifier + .fillMaxWidth() + .heightIn(max = 500.dp) + ) { + items(workspaceIconList) { item -> + Text( + item.title, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + ) + FlowRow( + modifier = Modifier.padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + item.icons.forEach { index -> + IconButton( + { + onConfirm(index) + onDismiss() + } + ) { + Icon(icons[index], null) + } + } + } + } + } + } + } +} + +@Composable +fun RecurrenceRule.label(): String = when (this) { + is RecurrenceRule.Once -> stringResource(R.string.Once) + is RecurrenceRule.Custom -> stringResource(R.string.Custom) + is RecurrenceRule.Weekly -> stringResource(R.string.Weekly) + is RecurrenceRule.Monthly -> stringResource(R.string.Monthly) + is RecurrenceRule.Yearly -> stringResource(R.string.Yearly) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecurrenceBottomSheet( + isVisible: Boolean, + sheetState: SheetState, + startDateTime: Long, + onDismiss: () -> Unit, + currentRule: RecurrenceRule, + onRuleChange: (RecurrenceRule, Long) -> Unit +) { + if (!isVisible) return + var showDatePicker by remember { mutableStateOf(false) } + var selectedDateTime by remember { mutableLongStateOf(startDateTime) } + var tempRule by remember(currentRule) { mutableStateOf(currentRule) } + + val options = listOf( + RecurrenceRule.Once, + RecurrenceRule.Weekly(), + RecurrenceRule.Monthly, + RecurrenceRule.Yearly, + RecurrenceRule.Custom() + ) + + if (showDatePicker) { + DatePickerModal(onDateSelected = { newDateMillis -> + if (newDateMillis != null) { + val timeOfDay = selectedDateTime % DateUtils.DAY_IN_MILLIS + selectedDateTime = newDateMillis + timeOfDay + } + }, onDismiss = { showDatePicker = false }) + } + + ModalBottomSheet( + modifier = Modifier.heightIn(min = 300.dp), + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + LazyColumn(modifier = Modifier + .fillMaxWidth() + .padding(8.dp)) { + item { + Row( + Modifier.padding(start = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Repeat, null) + Text( + stringResource(R.string.repeat), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + } + + item { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + items(options.size) { index -> + val option = options[index] + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = option::class == tempRule::class, + onClick = { tempRule = option } + ) + Text(option.label(), modifier = Modifier.padding(start = 4.dp)) + } + } + } + } + + item { + when (val rule = tempRule) { + is RecurrenceRule.Once -> { + Row(Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatOnce(selectedDateTime)) + IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer + )) { + Icon(Icons.Default.Edit, null) + } + } + } + + is RecurrenceRule.Custom -> { + OutlinedTextField( + value = rule.everyXDays.toString(), + onValueChange = { new -> new.toIntOrNull()?.let { tempRule = rule.copy(everyXDays = it) } }, + modifier = Modifier.padding(start = 8.dp), + label = { Text(formatCustom(rule)) }, + singleLine = true + ) + } + + is RecurrenceRule.Weekly -> { + val weekdays = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + Row ( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + weekdays.forEachIndexed { index, day -> + val isSelected = index in rule.daysOfWeek + Card( + onClick = { + val newDays = rule.daysOfWeek.toMutableList() + if (index in newDays) newDays.remove(index) else newDays.add(index) + tempRule = rule.copy(daysOfWeek = newDays) + }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 2.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + text = day, + modifier = Modifier + .padding(6.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } + } + + is RecurrenceRule.Monthly -> { + Row(Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatMonthly(selectedDateTime)) + IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) + )) { + Icon(Icons.Default.Edit, null) + } + } + } + + is RecurrenceRule.Yearly -> { + Row(Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { + Text(formatYearly(selectedDateTime)) + IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) + )) { + Icon(Icons.Default.Edit, null) + } + } + } + } + } + + // Buttons + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton(onClick = onDismiss) { + Text(stringResource(R.string.Dismiss)) + } + Spacer(Modifier.width(8.dp)) + FilledTonalButton( + onClick = { + onRuleChange(tempRule, adjustStartDate(tempRule, selectedDateTime)) + onDismiss() + } + ) { + Text(stringResource(R.string.Confirm)) + } + } + } + } + } + } + + fun adjustStartDate(rule: RecurrenceRule, startDateTime: Long): Long { + return when (rule) { + is RecurrenceRule.Once -> startDateTime + is RecurrenceRule.Monthly -> startDateTime + is RecurrenceRule.Yearly -> startDateTime + is RecurrenceRule.Custom -> startDateTime + is RecurrenceRule.Weekly -> { + val cal = Calendar.getInstance().apply { timeInMillis = startDateTime } + + // Calendar days: Sunday = 1, Monday = 2, ... Saturday = 7 + val today = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7 // shift so Monday=0, Sunday=6 + + if (today in rule.daysOfWeek) { + startDateTime + } else { + // find next closest match + var offset = 1 + while (true) { + val nextDay = (today + offset) % 7 + if (nextDay in rule.daysOfWeek) { + cal.add(Calendar.DAY_OF_YEAR, offset) + return cal.timeInMillis + } + offset++ + } + } + } + } as Long + } diff --git a/app/src/main/java/com/flux/ui/components/Buttons.kt b/app/src/main/java/com/flux/ui/common/Buttons.kt similarity index 94% rename from app/src/main/java/com/flux/ui/components/Buttons.kt rename to app/src/main/java/com/flux/ui/common/Buttons.kt index 7d8fa07..4fc404e 100644 --- a/app/src/main/java/com/flux/ui/components/Buttons.kt +++ b/app/src/main/java/com/flux/ui/common/Buttons.kt @@ -1,4 +1,4 @@ -package com.flux.ui.components +package com.flux.ui.common import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close diff --git a/app/src/main/java/com/flux/ui/common/Dialog.kt b/app/src/main/java/com/flux/ui/common/Dialog.kt new file mode 100644 index 0000000..28f7a21 --- /dev/null +++ b/app/src/main/java/com/flux/ui/common/Dialog.kt @@ -0,0 +1,261 @@ +package com.flux.ui.common + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.EditCalendar +import androidx.compose.material.icons.filled.FontDownload +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimeInput +import androidx.compose.material3.TimePicker +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.flux.R +import com.flux.ui.theme.FONTS +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +fun convertMillisToDate(millis: Long): String { + val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) + return formatter.format(Date(millis)) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DatePickerModal( + onDateSelected: (Long?) -> Unit, + onDismiss: () -> Unit +) { + val datePickerState = rememberDatePickerState() + + DatePickerDialog( + onDismissRequest = onDismiss, + confirmButton = { + TextButton(onClick = { + onDateSelected(datePickerState.selectedDateMillis) + onDismiss() + }) { + Text(stringResource(R.string.Set)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.Cancel)) + } + } + ) { + DatePicker(state = datePickerState) + } +} + +fun convertMillisToTime(millis: Long, is24Hour: Boolean = false): String { + val pattern = if (is24Hour) "HH:mm" else "hh:mm a" + val formatter = SimpleDateFormat(pattern, Locale.getDefault()) + return formatter.format(Date(millis)) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TimePicker( + initialTime: Long, + is24Hour: Boolean = false, + onConfirm: (Long) -> Unit, + onDismiss: () -> Unit, +) { + val currentTime = Calendar.getInstance().apply { timeInMillis = initialTime } + val timePickerState = rememberTimePickerState( + initialHour = currentTime.get(Calendar.HOUR_OF_DAY), + initialMinute = currentTime.get(Calendar.MINUTE), + is24Hour = is24Hour, + ) + var showDial by remember { mutableStateOf(true) } + val toggleIcon = if (showDial) { Icons.Filled.EditCalendar } else { Icons.Filled.AccessTime } + + TimePickerDialog( + onDismiss = onDismiss, + onConfirm = { + val calendar = Calendar.getInstance().apply { + timeInMillis = initialTime + set(Calendar.HOUR_OF_DAY, timePickerState.hour) + set(Calendar.MINUTE, timePickerState.minute) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + onConfirm(calendar.timeInMillis) + onDismiss() + }, + toggle = { + IconButton(onClick = { showDial = !showDial }) { + Icon( + imageVector = toggleIcon, + contentDescription = "Time picker type toggle", + ) + } + }, + ) { + if (showDial) { + TimePicker(timePickerState) + } else { + TimeInput(timePickerState) + } + } +} + +// Remove the onDismiss() call from TimePickerDialog's onConfirm button +@Composable +fun TimePickerDialog( + title: String = stringResource(R.string.Select_Time), + onDismiss: () -> Unit, + onConfirm: () -> Unit, + toggle: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + shape = MaterialTheme.shapes.extraLarge, + tonalElevation = 6.dp, + modifier = + Modifier + .width(IntrinsicSize.Min) + .height(IntrinsicSize.Min) + .background( + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 20.dp), + text = title, + style = MaterialTheme.typography.labelMedium + ) + content() + Row( + modifier = Modifier + .height(40.dp) + .fillMaxWidth() + ) { + toggle() + Spacer(modifier = Modifier.weight(1f)) + TextButton(onClick = onDismiss) { Text(stringResource(R.string.Cancel)) } + TextButton(onClick = onConfirm) { Text(stringResource(R.string.Set)) } + } + } + } + } +} + +@Composable +fun DeleteAlert( + onDismissRequest: () -> Unit, + onConfirmation: () -> Unit, + dialogTitle: String = stringResource(R.string.deleteDialogTitle), + dialogText: String = stringResource(R.string.deleteDialogText), + icon: ImageVector = Icons.Default.DeleteOutline, +) { + AlertDialog( + icon = { + Icon( + icon, + contentDescription = "Delete Icon", + tint = MaterialTheme.colorScheme.error + ) + }, + title = { Text(text = dialogTitle) }, + text = { Text(text = dialogText) }, + onDismissRequest = { onDismissRequest() }, + confirmButton = { TextButton(onClick = { onConfirmation() }) { Text(stringResource(R.string.Confirm)) } }, + dismissButton = { TextButton(onClick = { onDismissRequest() }) { Text(stringResource(R.string.Dismiss)) } } + ) +} + +@Composable +fun FontDialog( + selectedFont: Int, + onSelectFont: (Int)->Unit, + onDismissRequest: () -> Unit +){ + Dialog(onDismissRequest = onDismissRequest) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainerLow) + ) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Default.FontDownload, null, modifier = Modifier.size(36.dp)) + Text(stringResource(R.string.Fonts), style = MaterialTheme.typography.titleLarge) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + FONTS.forEachIndexed { index, font -> + val containerColor = + if (selectedFont == index) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh + val contentColor = + if (selectedFont == index) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + Card( + onClick = { + onSelectFont(index) + onDismissRequest() + }, + shape = + if (selectedFont == index) RoundedCornerShape(50) + else RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor + ) + ) { + Text(font, modifier = Modifier.padding(8.dp)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/DropDownMenu.kt b/app/src/main/java/com/flux/ui/common/DropDownMenu.kt similarity index 77% rename from app/src/main/java/com/flux/ui/components/DropDownMenu.kt rename to app/src/main/java/com/flux/ui/common/DropDownMenu.kt index 00fa4f4..5cfea61 100644 --- a/app/src/main/java/com/flux/ui/components/DropDownMenu.kt +++ b/app/src/main/java/com/flux/ui/common/DropDownMenu.kt @@ -1,4 +1,4 @@ -package com.flux.ui.components +package com.flux.ui.common import androidx.compose.foundation.layout.Box import androidx.compose.foundation.shape.RoundedCornerShape @@ -6,7 +6,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Notes import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.RemoveCircleOutline @@ -150,6 +149,7 @@ fun DropdownMenuWithDetails( @Composable fun JournalDropdownMenu( + onAddLabel: () -> Unit, onAboutClicked: () -> Unit, onShareNote: () -> Unit, onSaveNote: () -> Unit, @@ -169,6 +169,22 @@ fun JournalDropdownMenu( expanded = expanded, onDismissRequest = { expanded = false } ) { + + DropdownMenuItem( + text = { Text(stringResource(R.string.Labels)) }, + leadingIcon = { + Icon( + Icons.AutoMirrored.Outlined.Label, + contentDescription = null + ) + }, + trailingIcon = { Icon(Icons.Default.Add, contentDescription = null) }, + onClick = { + expanded = false + onAddLabel() + } + ) + HorizontalDivider() DropdownMenuItem( text = { Text(stringResource(R.string.share)) }, leadingIcon = { Icon(Icons.Outlined.Share, contentDescription = null) }, @@ -309,21 +325,15 @@ fun SpacesMenu( @Composable fun WorkspaceMore( - isCompactMode: Boolean = false, isLocked: Boolean = false, isCoverAdded: Boolean = false, - showEditLabel: Boolean = false, - isPinned: Boolean = false, onEditDetails: () -> Unit, - onEditLabel: () -> Unit, onRemoveCover: () -> Unit = {}, onAddCover: () -> Unit = {}, onDelete: () -> Unit, - onTogglePinned: () -> Unit = {}, onToggleLock: () -> Unit ) { var expanded by remember { mutableStateOf(false) } - Box(modifier = Modifier) { IconButton( onClick = { expanded = true }, colors = IconButtonDefaults.iconButtonColors( @@ -345,71 +355,36 @@ fun WorkspaceMore( ) DropdownMenuItem( - text = { Text(if (isPinned) stringResource(R.string.Unpin) else stringResource(R.string.Pin)) }, + text = { Text(stringResource(R.string.Change_Cover)) }, leadingIcon = { Icon( - if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, + Icons.Outlined.PhotoSizeSelectActual, contentDescription = null ) }, onClick = { expanded = false - onTogglePinned() + onAddCover() } ) - - if(!isCompactMode) { - DropdownMenuItem( - text = { Text(stringResource(R.string.Change_Cover)) }, - leadingIcon = { - Icon( - Icons.Outlined.PhotoSizeSelectActual, - contentDescription = null - ) - }, - onClick = { - expanded = false - onAddCover() - } - ) - if (isCoverAdded) { - DropdownMenuItem( - colors = MenuDefaults.itemColors( - leadingIconColor = MaterialTheme.colorScheme.error, - textColor = MaterialTheme.colorScheme.error - ), - text = { Text(stringResource(R.string.Remove_Cover)) }, - leadingIcon = { - Icon( - Icons.Default.RemoveCircleOutline, - contentDescription = null - ) - }, - onClick = { - expanded = false - onRemoveCover() - } - ) - } - } - - if (showEditLabel) { - HorizontalDivider() + if (isCoverAdded) { DropdownMenuItem( - text = { Text(stringResource(R.string.Labels)) }, + colors = MenuDefaults.itemColors( + leadingIconColor = MaterialTheme.colorScheme.error, + textColor = MaterialTheme.colorScheme.error + ), + text = { Text(stringResource(R.string.Remove_Cover)) }, leadingIcon = { Icon( - Icons.AutoMirrored.Outlined.Label, + Icons.Default.RemoveCircleOutline, contentDescription = null ) }, - trailingIcon = { Icon(Icons.Default.KeyboardDoubleArrowRight, null) }, onClick = { expanded = false - onEditLabel() + onRemoveCover() } ) - HorizontalDivider() } DropdownMenuItem( @@ -445,81 +420,4 @@ fun WorkspaceMore( ) } } -} - -@Composable -fun ExtremeCompactModeDropDown( - expanded: Boolean =false, - isLocked: Boolean = false, - showEditLabel: Boolean = false, - onEditDetails: () -> Unit, - onEditLabel: () -> Unit, - onDelete: () -> Unit, - onToggleLock: () -> Unit, - onDismiss: () -> Unit -){ - DropdownMenu( - expanded = expanded, - onDismissRequest = onDismiss - ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.Edit_Details)) }, - leadingIcon = { Icon(Icons.Outlined.Edit, contentDescription = null) }, - onClick = { - onDismiss() - onEditDetails() - } - ) - - if (showEditLabel) { - HorizontalDivider() - DropdownMenuItem( - text = { Text(stringResource(R.string.Labels)) }, - leadingIcon = { - Icon( - Icons.AutoMirrored.Outlined.Label, - contentDescription = null - ) - }, - trailingIcon = { Icon(Icons.Default.KeyboardDoubleArrowRight, null) }, - onClick = { - onDismiss() - onEditLabel() - } - ) - HorizontalDivider() - } - - DropdownMenuItem( - text = { - Text( - if (isLocked) stringResource(R.string.Unlock_Workspace) else stringResource( - R.string.Lock_Workspace - ) - ) - }, - leadingIcon = { - Icon( - if (isLocked) Icons.Outlined.LockOpen else Icons.Outlined.Lock, - contentDescription = null - ) - }, - onClick = { - onDismiss() - onToggleLock() - } - ) - DropdownMenuItem( - colors = MenuDefaults.itemColors( - leadingIconColor = MaterialTheme.colorScheme.error, - textColor = MaterialTheme.colorScheme.error - ), - text = { Text(stringResource(R.string.Delete_Workspace)) }, - leadingIcon = { Icon(Icons.Outlined.Delete, contentDescription = null) }, - onClick = { - onDismiss() - onDelete() - } - ) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/common/EmptySpace.kt b/app/src/main/java/com/flux/ui/common/EmptySpace.kt new file mode 100644 index 0000000..bbcdd09 --- /dev/null +++ b/app/src/main/java/com/flux/ui/common/EmptySpace.kt @@ -0,0 +1,166 @@ +package com.flux.ui.common + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.AutoStories +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.QuestionMark +import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material.icons.filled.Workspaces +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.flux.R + +@Composable +fun EmptyNotes() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Notes, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text(stringResource(R.string.Empty_Notes)) + } +} + +@Composable +fun EmptyLabels() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Label, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text(stringResource(R.string.Empty_Labels)) + } +} + +@Composable +fun EmptyHabits() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon(Icons.Default.EventAvailable, null, modifier = Modifier.size(48.dp)) + Text(stringResource(R.string.Empty_Habits)) + } +} + +@Composable +fun EmptySpaces() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Workspaces, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text(stringResource(R.string.Empty_Workspace)) + } +} + +@Composable +fun EmptyJournal() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.AutoStories, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text(stringResource(R.string.Empty_Journal)) + } +} + +@Composable +fun EmptyEvents() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.TaskAlt, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text(stringResource(R.string.Empty_Events)) + } +} + +@Composable +fun EmptyTodoList() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Checklist, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text(stringResource(R.string.Empty_Lists)) + } +} + +@Composable +fun EmptyProgressItems() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.HourglassEmpty, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text("Empty! No Data Found") + } +} + +@Composable +fun EmptyLanguage() { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + modifier = Modifier.size(48.dp) + ) + Text("Oops! No Language Found") + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/common/Filter.kt b/app/src/main/java/com/flux/ui/common/Filter.kt new file mode 100644 index 0000000..ddce534 --- /dev/null +++ b/app/src/main/java/com/flux/ui/common/Filter.kt @@ -0,0 +1,158 @@ +package com.flux.ui.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flux.data.model.RecurrenceRule + +enum class SelectionType { + SINGLE, + MULTIPLE, + DATE, + RECURRENCE +} + +data class FilterOption( + val id: String, + val label: String, + val date: Long?= null, + val recurrenceRule: RecurrenceRule? = null +) + +data class FilterCategory( + val name: String, + val options: List, + val type: SelectionType +) + +data class SearchFilterOption( + val id: String, + val label: String +) + +data class SearchFilterCategory( + val name: String, + val options: List, + val type: SelectionType +) + +@Composable +fun CategoryRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .then( + if (isSelected) + Modifier.drawLeftBorder( + MaterialTheme.colorScheme.primary, + 4.dp + ) + else Modifier + ) + .padding(12.dp) + ) { + Text( + text = label, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal + ) + } +} + +@Composable +fun OptionRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, Modifier.weight(1f)) + RadioButton(selected = isSelected, onClick = onClick) + } +} + +@Composable +fun DateOptionRow( + date: Long?, + label: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(label) + if(date!=null) Text(convertMillisToDate(date), style = MaterialTheme.typography.labelSmall) + } + + IconButton(onClick) { + Icon( + Icons.Default.DateRange, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + } +} + +@Composable +fun MultiOptionRow( + label: String, + isSelected: Boolean, + onClick: () -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, Modifier.weight(1f)) + Checkbox( + checked = isSelected, + onCheckedChange = { onClick() } + ) + } +} + +fun Modifier.drawLeftBorder(color: Color, width: Dp): Modifier = this.drawWithContent { + drawContent() + drawLine( + color = color, + start = Offset(0f, 0f), + end = Offset(0f, size.height), + strokeWidth = width.toPx() + ) +} diff --git a/app/src/main/java/com/flux/ui/components/Scaffold.kt b/app/src/main/java/com/flux/ui/common/Scaffold.kt similarity index 63% rename from app/src/main/java/com/flux/ui/components/Scaffold.kt rename to app/src/main/java/com/flux/ui/common/Scaffold.kt index 8d2fea3..16e08b0 100644 --- a/app/src/main/java/com/flux/ui/components/Scaffold.kt +++ b/app/src/main/java/com/flux/ui/common/Scaffold.kt @@ -1,11 +1,10 @@ -package com.flux.ui.components +package com.flux.ui.common -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.filled.Done import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -16,10 +15,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -36,10 +31,7 @@ fun BasicScaffold( title = { Text(title) }, navigationIcon = { IconButton(onClick = onBackClicked) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" - ) + Icon(Icons.AutoMirrored.Filled.ArrowBack, null) } } ) @@ -51,8 +43,6 @@ fun BasicScaffold( @OptIn(ExperimentalMaterial3Api::class) @Composable fun HabitScaffold( - title: String, - description: String, onDeleteClicked: () -> Unit, onBackPressed: () -> Unit, onEditClicked: () -> Unit, @@ -63,26 +53,7 @@ fun HabitScaffold( topBar = { TopAppBar( colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow), - title = { - Column { - Text( - title, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(end = 3.dp) - ) - if(description.isNotBlank()){ - Text( - description, - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Light), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(end = 3.dp) - ) - } - } - }, + title = {}, navigationIcon = { IconButton(onBackPressed) { Icon( @@ -106,3 +77,26 @@ fun HabitScaffold( content = content ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EditorScaffold( + title: String, + canSave: Boolean = false, + onBackPressed: () -> Unit, + onDone: () -> Unit, + content: @Composable (PaddingValues) -> Unit +) { + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow), + title = { Text(title) }, + navigationIcon = { IconButton(onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } }, + actions = { IconButton(onDone, enabled = canSave) { Icon(Icons.Default.Done, null) } } + ) + }, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/SearchBar.kt b/app/src/main/java/com/flux/ui/common/SearchBar.kt similarity index 64% rename from app/src/main/java/com/flux/ui/components/SearchBar.kt rename to app/src/main/java/com/flux/ui/common/SearchBar.kt index 0f89eeb..d8c7867 100644 --- a/app/src/main/java/com/flux/ui/components/SearchBar.kt +++ b/app/src/main/java/com/flux/ui/common/SearchBar.kt @@ -1,22 +1,10 @@ -package com.flux.ui.components +package com.flux.ui.common -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -33,7 +21,6 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource @@ -43,73 +30,6 @@ import androidx.compose.ui.semantics.traversalIndex import androidx.compose.ui.unit.dp import com.flux.R -@Composable -fun NotesSearchBar( - query: String, - onQueryChange: (String) -> Unit, - onCloseClicked: () -> Unit, - modifier: Modifier = Modifier, - placeholder: String = stringResource(R.string.Search_Here) -) { - Box( - modifier = modifier - .height(40.dp) - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) - .padding(horizontal = 12.dp) - ) { - Row( - modifier = Modifier.fillMaxSize(), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.Search, - contentDescription = "Search", - tint = MaterialTheme.colorScheme.primary - ) - - Spacer(Modifier.width(6.dp)) - - BasicTextField( - value = query, - onValueChange = onQueryChange, - singleLine = true, - textStyle = MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurface - ), - modifier = Modifier - .weight(1f) - .padding(vertical = 2.dp), // keeps text centered vertically - decorationBox = { innerTextField -> - if (query.isEmpty()) { - Text( - text = placeholder, - style = MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.primary - ) - ) - } - innerTextField() - } - ) - - IconButton( - onClick = { - onCloseClicked() - onQueryChange("") - }, - modifier = Modifier.size(24.dp) - ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Clear", - tint = MaterialTheme.colorScheme.primary - ) - } - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun GeneralSearchBar( diff --git a/app/src/main/java/com/flux/ui/components/BottomSheets.kt b/app/src/main/java/com/flux/ui/components/BottomSheets.kt deleted file mode 100644 index 7c05e18..0000000 --- a/app/src/main/java/com/flux/ui/components/BottomSheets.kt +++ /dev/null @@ -1,1085 +0,0 @@ -package com.flux.ui.components - -import android.text.format.DateUtils -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.imePadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowRight -import androidx.compose.material.icons.automirrored.filled.FormatAlignLeft -import androidx.compose.material.icons.automirrored.filled.List -import androidx.compose.material.icons.filled.Abc -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Flag -import androidx.compose.material.icons.filled.FormatQuote -import androidx.compose.material.icons.filled.Numbers -import androidx.compose.material.icons.filled.Remove -import androidx.compose.material.icons.filled.Repeat -import androidx.compose.material.icons.filled.Timelapse -import androidx.compose.material.icons.outlined.UnfoldLess -import androidx.compose.material.icons.outlined.UnfoldMore -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilledTonalButton -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton -import androidx.compose.material3.SheetState -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableLongStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.flux.R -import com.flux.data.model.ProgressBoardModel -import com.flux.data.model.RecurrenceRule -import com.flux.data.model.Space -import com.flux.data.model.WorkspaceModel -import com.flux.data.model.getSpacesList -import com.flux.other.HeaderNode -import com.flux.other.icons -import com.flux.other.workspaceIconList -import com.flux.ui.screens.events.formatCustom -import com.flux.ui.screens.events.formatMonthly -import com.flux.ui.screens.events.formatOnce -import com.flux.ui.screens.events.formatYearly -import com.flux.ui.screens.events.getTextFieldColors -import com.flux.ui.theme.completed -import com.flux.ui.theme.failed -import com.flux.ui.theme.pending -import java.util.Calendar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NewWorkspaceBottomSheet( - isEditing: Boolean = false, - workspace: WorkspaceModel = WorkspaceModel(), - isVisible: Boolean, - sheetState: SheetState, - onDismiss: () -> Unit, - onConfirm: (WorkspaceModel) -> Unit -) { - var title by remember { mutableStateOf(workspace.title) } - var description by remember { mutableStateOf(workspace.description) } - val focusRequesterDesc = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - val focusManager = LocalFocusManager.current - - if (isVisible) { - ModalBottomSheet( - onDismissRequest = { - keyboardController?.hide() - onDismiss() - title = workspace.title - description = workspace.description - }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .imePadding() - .padding(horizontal = 16.dp, vertical = 16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(if (isEditing) Icons.Default.Edit else Icons.Default.Add, null) - Text( - if (isEditing) stringResource(R.string.Edit_Workspace) else stringResource(R.string.Add_Workspace), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleLarge - ) - } - Spacer(Modifier.height(8.dp)) - TextField( - value = title, - onValueChange = { title = it }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 3.dp), - placeholder = { Text(stringResource(R.string.Title)) }, - singleLine = true, - shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedTextColor = MaterialTheme.colorScheme.primary, - focusedPlaceholderColor = MaterialTheme.colorScheme.primary - ), - keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() }) - ) - - TextField( - value = description, - onValueChange = { description = it }, - modifier = Modifier - .fillMaxWidth() - .focusRequester(focusRequesterDesc), - placeholder = { Text(stringResource(R.string.Description)) }, - singleLine = true, - shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp), - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - focusedTextColor = MaterialTheme.colorScheme.primary, - focusedPlaceholderColor = MaterialTheme.colorScheme.primary - ), - keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions( - onDone = { - keyboardController?.hide() - focusManager.clearFocus(force = true) - onConfirm(workspace.copy(title = title, description = description)) - onDismiss() - title = workspace.title - description = workspace.description - } - ) - ) - Spacer(Modifier.height(6.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton(onClick = { - keyboardController?.hide() - onDismiss() - title = workspace.title - description = workspace.description - }) { - Text(stringResource(R.string.Dismiss)) - } - Spacer(Modifier.width(8.dp)) - FilledTonalButton( - enabled = title.isNotBlank(), - onClick = { - keyboardController?.hide() - onConfirm(workspace.copy(title = title, description = description)) - onDismiss() - title = workspace.title - description = workspace.description - } - ) { - Text(stringResource(R.string.Confirm)) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ChangeIconBottomSheet( - isVisible: Boolean, - sheetState: SheetState, - onDismiss: () -> Unit, - onConfirm: (Int) -> Unit -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = { onDismiss() }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - LazyColumn( - Modifier - .fillMaxWidth() - .heightIn(max = 500.dp) - ) { - items(workspaceIconList) { item -> - Text( - item.title, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) - ) - FlowRow( - modifier = Modifier.padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - item.icons.forEach { index -> - IconButton({ onConfirm(index) }) { Icon(icons[index], null) } - } - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddNewSpacesBottomSheet( - isVisible: Boolean, - sheetState: SheetState, - selectedSpaces: List, - onDismiss: () -> Unit, - onRemove: (Int) -> Unit, - onSelect: (Int) -> Unit -) { - var showDeleteDialog by remember { mutableStateOf(false) } - var removeSpace by remember { mutableIntStateOf(-1) } - val spacesList = getSpacesList() - if (showDeleteDialog) { - DeleteAlert(onConfirmation = { - onRemove(removeSpace) - removeSpace = -1 - showDeleteDialog = false - }, onDismissRequest = { - showDeleteDialog = false - }) - } - - if (isVisible) { - ModalBottomSheet( - onDismissRequest = { onDismiss() }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - LazyColumn(Modifier - .fillMaxWidth() - .padding(vertical = 16.dp)) { - if (selectedSpaces.isNotEmpty()) { - item { - Text( - stringResource(R.string.Current), - modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - } - } - item { - FlowRow( - modifier = Modifier.padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - selectedSpaces.forEach { space -> - SpaceCard(space, true, { onSelect(space.id) }, { - removeSpace = space.id - showDeleteDialog = true - }) - } - } - } - if (selectedSpaces.size != spacesList.size) { - item { - Text( - stringResource(R.string.Available_Spaces), - modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), - fontWeight = FontWeight.Bold - ) - } - } - item { - FlowRow( - modifier = Modifier.padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - spacesList.filterNot { id -> selectedSpaces.contains(id) } - .forEach { space -> - SpaceCard(space, false, { onSelect(space.id) }, { }) - } - } - } - } - } - } -} - -@Composable -fun SpaceCard(space: Space, isSelected: Boolean, onSelect: () -> Unit, onRemove: () -> Unit) { - val cardContainerColor = - if (isSelected) MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) else MaterialTheme.colorScheme.surfaceContainerHigh - val cardContentColor = - if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface - val iconContainerColor = - if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface - val iconContentColor = - if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface - - Card( - modifier = Modifier.clip(RoundedCornerShape(50)), - shape = RoundedCornerShape(50), - onClick = onSelect, - colors = CardDefaults.cardColors( - containerColor = cardContainerColor, - contentColor = cardContentColor - ) - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = onSelect, - colors = IconButtonDefaults.iconButtonColors( - containerColor = iconContainerColor, - contentColor = iconContentColor - ) - ) { Icon(space.icon, null) } - Text(space.title, modifier = Modifier.padding(end = if (isSelected) 0.dp else 16.dp)) - if (isSelected) { - IconButton(onRemove) { Icon(Icons.Default.Remove, null) } - } - } - } -} - -@Composable -fun RecurrenceRule.label(): String = when (this) { - is RecurrenceRule.Once -> stringResource(R.string.Once) - is RecurrenceRule.Custom -> stringResource(R.string.Custom) - is RecurrenceRule.Weekly -> stringResource(R.string.Weekly) - is RecurrenceRule.Monthly -> stringResource(R.string.Monthly) - is RecurrenceRule.Yearly -> stringResource(R.string.Yearly) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RecurrenceBottomSheet( - isVisible: Boolean, - sheetState: SheetState, - startDateTime: Long, - onDismiss: () -> Unit, - currentRule: RecurrenceRule, - onRuleChange: (RecurrenceRule, Long) -> Unit -) { - if (!isVisible) return - var showDatePicker by remember { mutableStateOf(false) } - var selectedDateTime by remember { mutableLongStateOf(startDateTime) } - var tempRule by remember(currentRule) { mutableStateOf(currentRule) } - - val options = listOf( - RecurrenceRule.Once, - RecurrenceRule.Weekly(), - RecurrenceRule.Monthly, - RecurrenceRule.Yearly, - RecurrenceRule.Custom() - ) - - if (showDatePicker) { - DatePickerModal(onDateSelected = { newDateMillis -> - if (newDateMillis != null) { - val timeOfDay = selectedDateTime % DateUtils.DAY_IN_MILLIS - selectedDateTime = newDateMillis + timeOfDay - } - }, onDismiss = { showDatePicker = false }) - } - - ModalBottomSheet( - modifier = Modifier.heightIn(min = 300.dp), - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - LazyColumn(modifier = Modifier - .fillMaxWidth() - .padding(8.dp)) { - item { - Row( - Modifier.padding(start = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(Icons.Default.Repeat, null) - Text( - stringResource(R.string.repeat), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - ) - } - } - - item { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(vertical = 8.dp) - ) { - items(options.size) { index -> - val option = options[index] - Row( - modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = option::class == tempRule::class, - onClick = { tempRule = option } - ) - Text(option.label(), modifier = Modifier.padding(start = 4.dp)) - } - } - } - } - - item { - when (val rule = tempRule) { - is RecurrenceRule.Once -> { - Row(Modifier - .padding(horizontal = 12.dp) - .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(formatOnce(selectedDateTime)) - IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primaryContainer - )) { - Icon(Icons.Default.Edit, null) - } - } - } - - is RecurrenceRule.Custom -> { - OutlinedTextField( - value = rule.everyXDays.toString(), - onValueChange = { new -> new.toIntOrNull()?.let { tempRule = rule.copy(everyXDays = it) } }, - modifier = Modifier.padding(start = 8.dp), - label = { Text(formatCustom(rule)) }, - singleLine = true - ) - } - - is RecurrenceRule.Weekly -> { - val weekdays = listOf( - stringResource(R.string.monday_short), - stringResource(R.string.tuesday_short), - stringResource(R.string.wednesday_short), - stringResource(R.string.thursday_short), - stringResource(R.string.friday_short), - stringResource(R.string.saturday_short), - stringResource(R.string.sunday_short) - ) - Row ( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp) - ) { - weekdays.forEachIndexed { index, day -> - val isSelected = index in rule.daysOfWeek - Card( - onClick = { - val newDays = rule.daysOfWeek.toMutableList() - if (index in newDays) newDays.remove(index) else newDays.add(index) - tempRule = rule.copy(daysOfWeek = newDays) - }, - modifier = Modifier - .weight(1f) - .padding(horizontal = 2.dp), - colors = CardDefaults.cardColors( - containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), - contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface - ) - ) { - Text( - text = day, - modifier = Modifier - .padding(6.dp) - .fillMaxWidth(), - textAlign = TextAlign.Center - ) - } - } - } - } - - is RecurrenceRule.Monthly -> { - Row(Modifier - .padding(horizontal = 12.dp) - .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(formatMonthly(selectedDateTime)) - IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) - )) { - Icon(Icons.Default.Edit, null) - } - } - } - - is RecurrenceRule.Yearly -> { - Row(Modifier - .padding(horizontal = 12.dp) - .fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Text(formatYearly(selectedDateTime)) - IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) - )) { - Icon(Icons.Default.Edit, null) - } - } - } - } - } - - // Buttons - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - OutlinedButton(onClick = onDismiss) { - Text(stringResource(R.string.Dismiss)) - } - Spacer(Modifier.width(8.dp)) - FilledTonalButton( - onClick = { - onRuleChange(tempRule, adjustStartDate(tempRule, selectedDateTime)) - onDismiss() - } - ) { - Text(stringResource(R.string.Confirm)) - } - } - } - } - } - } - - fun adjustStartDate(rule: RecurrenceRule, startDateTime: Long): Long { - return when (rule) { - is RecurrenceRule.Once -> startDateTime - is RecurrenceRule.Monthly -> startDateTime - is RecurrenceRule.Yearly -> startDateTime - is RecurrenceRule.Custom -> startDateTime - is RecurrenceRule.Weekly -> { - val cal = Calendar.getInstance().apply { timeInMillis = startDateTime } - - // Calendar days: Sunday = 1, Monday = 2, ... Saturday = 7 - val today = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7 // shift so Monday=0, Sunday=6 - - if (today in rule.daysOfWeek) { - startDateTime - } else { - // find next closest match - var offset = 1 - while (true) { - val nextDay = (today + offset) % 7 - if (nextDay in rule.daysOfWeek) { - cal.add(Calendar.DAY_OF_YEAR, offset) - return cal.timeInMillis - } - offset++ - } - } - } - } as Long - } - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun OutlineBottomSheet( - isVisible: Boolean, - outline: HeaderNode, - sheetState: SheetState, - onHeaderClick: (IntRange) -> Unit, - onDismiss: () -> Unit -) { - - var isAllExpanded by rememberSaveable { mutableStateOf(true) } - - if (isVisible) { - ModalBottomSheet( - onDismissRequest = { onDismiss() }, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - LazyColumn(Modifier - .fillMaxWidth() - .heightIn(300.dp)) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 16.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(R.string.Outline), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Bold - ) - - IconButton( - onClick = { isAllExpanded = !isAllExpanded } - ) { - Icon( - imageVector = if (isAllExpanded) Icons.Outlined.UnfoldLess - else Icons.Outlined.UnfoldMore, - contentDescription = "fold", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - HorizontalDivider() - } - items(outline.children) { header -> - HeaderItem( - header = header, - depth = 0, - onHeaderClick = onHeaderClick, - parentExpanded = isAllExpanded - ) - } - } - } - } -} - -@Composable -fun HeaderItem( - header: HeaderNode, - depth: Int, - parentExpanded: Boolean, - onHeaderClick: (IntRange) -> Unit -) { - var expanded by rememberSaveable { mutableStateOf(true) } - - LaunchedEffect(parentExpanded) { expanded = parentExpanded } - Row( - modifier = Modifier - .padding(start = (depth * 8).dp) - .fillMaxWidth() - .heightIn(min = 32.dp) - .clickable { - onHeaderClick(header.range) - }, - verticalAlignment = Alignment.CenterVertically - ) { - if (header.children.isNotEmpty()) { - IconButton( - modifier = Modifier.size(32.dp), - onClick = { - if (header.children.isNotEmpty()) { - expanded = !expanded - } - } - ) { - Icon( - imageVector = if (expanded) Icons.Default.ArrowDropDown - else Icons.AutoMirrored.Filled.ArrowRight, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - contentDescription = null - ) - } - } else { - Spacer(modifier = Modifier.width(32.dp)) - } - - Text( - text = header.title, - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyLarge - ) - } - - if (expanded) { - header.children.forEach { child -> - HeaderItem( - header = child, - depth = depth + 1, - onHeaderClick = onHeaderClick, - parentExpanded = parentExpanded - ) - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NotesInfoBottomSheet( - words: Int, - lines: Int, - wordsWithoutPunctuations: Int, - paragraph: Int, - characters: Int, - lastEdited: String, - isVisible: Boolean, - sheetState: SheetState, - onDismiss: () -> Unit -) { - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - LazyColumn( - Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - item { - SettingOption( - radius = shapeManager(isFirst = true, radius = 32), - icon = Icons.Default.Edit, - title = stringResource(R.string.Last_Edited), - description = lastEdited, - actionType = ActionType.None - ) - } - - item { - SettingOption( - radius = shapeManager(radius = 32), - icon = Icons.Default.Numbers, - title = stringResource(R.string.Word_Count), - description = words.toString(), - actionType = ActionType.None - ) - } - - item { - SettingOption( - radius = shapeManager(radius = 32), - icon = Icons.Default.FormatQuote, - title = stringResource(R.string.words_excluding_punctuations), - description = wordsWithoutPunctuations.toString(), - actionType = ActionType.None - ) - } - - item { - SettingOption( - radius = shapeManager(radius = 32), - icon = Icons.Default.Abc, - title = stringResource(R.string.Character_Count), - description = characters.toString(), - actionType = ActionType.None - ) - } - - item { - SettingOption( - radius = shapeManager(radius = 32), - icon = Icons.AutoMirrored.Filled.List, - title = stringResource(R.string.lines), - description = lines.toString(), - actionType = ActionType.None - ) - } - - item { - SettingOption( - radius = shapeManager(radius = 32, isLast = true), - icon = Icons.AutoMirrored.Filled.FormatAlignLeft, - title = stringResource(R.string.paragraph), - description = paragraph.toString(), - actionType = ActionType.None - ) - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NewBoardItemSheet( - isVisible: Boolean, - sheetState: SheetState, - progressBoardItem: ProgressBoardModel, - onDismiss: () -> Unit, - onConfirm: (ProgressBoardModel) -> Unit, - onDelete: (ProgressBoardModel) -> Unit -) { - val focusRequesterDesc = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - var selectedStatus by remember(progressBoardItem) { mutableIntStateOf(progressBoardItem.status) } - var startDate by remember(progressBoardItem) { mutableLongStateOf(progressBoardItem.startDate) } - var endDate by remember(progressBoardItem) { mutableLongStateOf(progressBoardItem.endDate) } - var showDateSelector by remember { mutableStateOf(false) } - var isSelectingStartDate by remember { mutableStateOf(true) } - var title by remember(progressBoardItem) { mutableStateOf(progressBoardItem.title) } - var description by remember(progressBoardItem) { mutableStateOf(progressBoardItem.description ) } - var isChangeIcon by remember { mutableStateOf(false) } - val iconSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var newIcon by remember(progressBoardItem) { mutableIntStateOf(progressBoardItem.icon) } - - if(showDateSelector){ - DatePickerModal({ - if (isSelectingStartDate) startDate=it?:-1L - else endDate=it?:-1L - }) { - showDateSelector=false - } - } - - ChangeIconBottomSheet(isChangeIcon, iconSheetState, { isChangeIcon=false }) { - newIcon=it - isChangeIcon=false - } - - if (isVisible) { - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { - IconButton( - { isChangeIcon=true }, - modifier = Modifier.align(Alignment.CenterHorizontally).size(56.dp), - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - contentColor = MaterialTheme.colorScheme.primary - ) - ) { - Icon(icons[newIcon], null, modifier = Modifier.size(40.dp)) - } - Spacer(Modifier.height(8.dp)) - TextField( - value = title, - onValueChange = { title = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(stringResource(R.string.Title)) }, - singleLine = true, - textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), - colors = getTextFieldColors(), - keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() }) - ) - - TextField( - value = description, - onValueChange = { description=it }, - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp, bottom = 16.dp) - .focusRequester(focusRequesterDesc), - placeholder = { Text(stringResource(R.string.Description)) }, - singleLine = true, - shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp), - colors = getTextFieldColors(), - keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) - ) - - LazyRow(Modifier.fillMaxWidth().padding(start = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - item { - BoardStatusItem(selectedStatus==0, stringResource(R.string.not_started), failed) { - selectedStatus=0 - } - } - item { - BoardStatusItem(selectedStatus==1, stringResource(R.string.in_progress), pending){ - selectedStatus=1 - } - } - item { - BoardStatusItem(selectedStatus==2, stringResource(R.string.Completed), completed){ - selectedStatus=2 - } - } - } - - Row( - modifier = Modifier.fillMaxWidth().padding(start = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - BoardTimeCard(Icons.Default.Timelapse, stringResource(R.string.start)) { - showDateSelector=true - isSelectingStartDate=true - } - Spacer(Modifier.width(2.dp)) - Text( - if(startDate==-1L) stringResource(R.string.empty) else convertMillisToDate(startDate), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.clip(RoundedCornerShape(50)) - .clickable{ - showDateSelector=true - isSelectingStartDate=true - } - .padding(vertical = 4.dp, horizontal = 8.dp)) - } - - Row( - modifier = Modifier.fillMaxWidth().padding(start = 4.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - BoardTimeCard(Icons.Default.Flag, stringResource(R.string.target)) { - showDateSelector=true - isSelectingStartDate=false - } - Text( - if(endDate==-1L) stringResource(R.string.empty) else convertMillisToDate(endDate), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.clip(RoundedCornerShape(50)) - .clickable{ - showDateSelector=true - isSelectingStartDate=false - } - .padding(vertical = 4.dp, horizontal = 8.dp)) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - if(progressBoardItem.title.isNotBlank()){ - FilledTonalButton( - onClick = { - keyboardController?.hide() - onDismiss() - onDelete(progressBoardItem) - }, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) - ) { - Text(stringResource(R.string.delete)) - } - } - Spacer(Modifier.width(8.dp)) - FilledTonalButton( - enabled = title.isNotBlank(), - onClick = { - keyboardController?.hide() - onConfirm( - progressBoardItem.copy( - title= title, - description = description, - icon = newIcon, - startDate = startDate, - endDate = endDate, - status = selectedStatus - ) - ) - onDismiss() - } - ) { - Text(stringResource(R.string.Confirm)) - } - } - } - } - } -} - -@Composable -fun BoardStatusItem(isSelected: Boolean, status: String, color: Color, onClick: () -> Unit){ - Card( - onClick = onClick, - shape = RoundedCornerShape(50), - colors = CardDefaults.cardColors( - containerColor = if(isSelected) color.copy(0.5f) else color.copy(0.1f) - ) - ) { - Row( - Modifier.padding(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .padding(start = 2.dp) - .size(12.dp) - .clip(RoundedCornerShape(50)) - .then( - if (isSelected) { - Modifier.background(color) - } else { - Modifier.border(2.dp, color, RoundedCornerShape(50)) - } - ) - ) - Text( - status, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(4.dp) - ) - } - } -} - -@Composable -fun BoardTimeCard(icon: ImageVector, title: String, onClick: () -> Unit){ - Card( - onClick = onClick, - shape = RoundedCornerShape(50), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceContainer - ) - ) { - Row( - Modifier.padding(vertical = 2.dp, horizontal = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon(icon, null, modifier = Modifier.size(18.dp).alpha(0.75f)) - Text( - title, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(4.dp).alpha(0.8f) - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/CalendarComponents.kt b/app/src/main/java/com/flux/ui/components/CalendarComponents.kt deleted file mode 100644 index 0fe9f39..0000000 --- a/app/src/main/java/com/flux/ui/components/CalendarComponents.kt +++ /dev/null @@ -1,308 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBackIos -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import com.flux.R -import java.time.LocalDate -import java.time.YearMonth - -@Composable -fun DailyViewDateCard(date: Long, day: String, isSelected: Boolean, onClick: () -> Unit) { - val localDate = LocalDate.ofEpochDay(date) - - val containerColor = - if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceContainerHighest - val contentColor = - if (isSelected) MaterialTheme.colorScheme.onPrimary - else MaterialTheme.colorScheme.onSurface - - Card( - modifier = Modifier.width(60.dp), - colors = CardDefaults.cardColors( - containerColor = containerColor.copy(alpha = 0.6f), - contentColor = contentColor - ), - shape = RoundedCornerShape(8.dp), - onClick = onClick - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - day, - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraLight), - modifier = Modifier.padding(top = 4.dp) - ) - ElevatedCard( - Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = containerColor, - contentColor = contentColor - ), - shape = RoundedCornerShape(8.dp) - ) { - Text( - localDate.dayOfMonth.toString(), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - ) - } - } - } -} - -@Composable -fun MonthlyViewDateCard(date: Long, count: Int, maxCount: Int = 0, isSelected: Boolean, onClick: () -> Unit) { - val localDate = LocalDate.ofEpochDay(date) - val fraction = if (maxCount > 0 && count > 0) count.toFloat() / maxCount.toFloat() else 0f - val containerColor = - if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surfaceContainerLow - val contentColor = - if (isSelected) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) - val primaryColor = MaterialTheme.colorScheme.primary - - Box( - modifier = Modifier - .size(48.dp) - .clip(RoundedCornerShape(50)) - .drawBehind { - if (fraction > 0f && !isSelected) { - drawCircle( - brush = Brush.radialGradient( - colors = listOf( - primaryColor.copy(alpha = 0.35f * fraction), - Color.Transparent - ), - center = center, - radius = size.minDimension / 2f - ) - ) - } - } - .clickable { onClick() }, - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = localDate.dayOfMonth.toString(), - style = MaterialTheme.typography.bodyLarge, - color = contentColor - ) - - if (isSelected) { - Box( - modifier = Modifier - .size(8.dp) - .clip(CircleShape) - .background(containerColor) - .padding(top = 2.dp) - ) - } - } - } -} - -@Composable -fun DailyViewCalendar( - selectedMonth: YearMonth, - selectedDate: Long, - onDateChange: (Long) -> Unit -) { - val daysInMonth = selectedMonth.lengthOfMonth() - val dateList = (1..daysInMonth).map { day -> selectedMonth.atDay(day) } - val listState = rememberLazyListState() - - LaunchedEffect(selectedMonth, selectedDate) { - val todayIndex = dateList.indexOfFirst { it.toEpochDay() == selectedDate } - if (todayIndex >= 0) { - listState.animateScrollToItem( - index = maxOf(0, todayIndex - 2) - ) - } - } - - Column(modifier = Modifier.fillMaxWidth()) { - LazyRow( - horizontalArrangement = Arrangement.spacedBy(6.dp), - state = listState - ) { - items(dateList) { date -> - val dayName = date.dayOfWeek.name - .take(3) - .lowercase() - .replaceFirstChar { it.uppercaseChar() } - - DailyViewDateCard( - date = date.toEpochDay(), - day = dayName, - isSelected = date.toEpochDay() == selectedDate, - onClick = { onDateChange(date.toEpochDay()) } - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun MonthlyViewCalendar( - currentMonth: YearMonth, - selectedDate: Long, - monthlyJournalCount: Map = emptyMap(), - onMonthChange: (YearMonth) -> Unit, - onDateChange: (Long) -> Unit -) { - val daysOfWeek = listOf( - stringResource(R.string.monday_short), - stringResource(R.string.tuesday_short), - stringResource(R.string.wednesday_short), - stringResource(R.string.thursday_short), - stringResource(R.string.friday_short), - stringResource(R.string.saturday_short), - stringResource(R.string.sunday_short) - ) - val firstDayOfMonth = currentMonth.atDay(1) - val firstDayOffset = (firstDayOfMonth.dayOfWeek.value - 1) % 7 - val daysInMonth = currentMonth.lengthOfMonth() - - val allDates = buildList { - repeat(firstDayOffset) { add(null) } - for (day in 1..daysInMonth) { - add(currentMonth.atDay(day).toEpochDay()) - } - } - - Column(Modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box(modifier = Modifier.weight(1f)) { - Text( - text = currentMonth.month.name.lowercase() - .replaceFirstChar { it.uppercaseChar() } + ", ${currentMonth.year}", - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary - ) - } - - IconButton(onClick = { - val prevMonth = currentMonth.minusMonths(1) - onMonthChange(prevMonth) - onDateChange(prevMonth.atDay(1).toEpochDay()) - }) { - Icon( - Icons.AutoMirrored.Default.ArrowBackIos, - tint = MaterialTheme.colorScheme.primary, - contentDescription = "Previous month", - modifier = Modifier - .size(18.dp) - .alpha(0.5f) - ) - } - - IconButton(onClick = { - val nextMonth = currentMonth.plusMonths(1) - onMonthChange(nextMonth) - onDateChange(nextMonth.atDay(1).toEpochDay()) - }) { - Icon( - Icons.AutoMirrored.Default.ArrowForwardIos, - tint = MaterialTheme.colorScheme.primary, - contentDescription = "Next month", - modifier = Modifier - .size(18.dp) - .alpha(0.5f) - ) - } - } - - // Weekday Row - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - daysOfWeek.forEach { - Text( - text = it, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) - ) - } - } - Spacer(Modifier.height(4.dp)) - - // Calendar Grid - LazyVerticalGrid( - columns = GridCells.Fixed(7), - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 100.dp, max = 300.dp), - userScrollEnabled = false - ) { - items(allDates) { date -> - if (date == null) { - Box(modifier = Modifier.size(48.dp)) - } else { - MonthlyViewDateCard( - date = date, - isSelected = selectedDate == date, - count = monthlyJournalCount[LocalDate.ofEpochDay(date)] ?: 0, - maxCount = monthlyJournalCount.values.maxOrNull() ?: 0, - onClick = { onDateChange(date) } - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/CurlyCornerShape.kt b/app/src/main/java/com/flux/ui/components/CurlyCornerShape.kt deleted file mode 100644 index a865849..0000000 --- a/app/src/main/java/com/flux/ui/components/CurlyCornerShape.kt +++ /dev/null @@ -1,102 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.foundation.shape.CornerBasedShape -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.ZeroCornerSize -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Outline -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.unit.LayoutDirection -import kotlin.math.cos -import kotlin.math.sin - -/** - * A custom shape with curly edges, resembling a circle with sinusoidal indentations. - * - * This shape creates an outline that appears as a circle with "curly" or "wavy" - * edges, achieved by applying a sine wave function to the circle's radius. The - * amplitude and the number of curls (waves) can be adjusted. - * - * @property curlAmplitude The amplitude of the sine wave, controlling the depth of the curls. - * A higher value results in deeper curls. Defaults to 16.0. - * @property curlCount The number of curls (waves) around the circle's circumference. - * A higher count results in more frequent curls. Defaults to 12. - */ -class CurlyCornerShape( - private val curlAmplitude: Double = 16.0, - private val curlCount: Int = 12, -) : CornerBasedShape( - topStart = ZeroCornerSize, - topEnd = ZeroCornerSize, - bottomEnd = ZeroCornerSize, - bottomStart = ZeroCornerSize -) { - - /** - * Calculates the x and y coordinates of a point on the curly circle at a given angle. - * - * @param centerX The x-coordinate of the center of the circle. - * @param centerY The y-coordinate of the center of the circle. - * @param baseRadius The base radius of the circle. - * @param amplitude The amplitude of the sine wave. - * @param angle The angle in radians. - * @return A Pair containing the x and y coordinates of the point. - */ - private fun calculateCurlyCirclePoint( - centerX: Double, - centerY: Double, - baseRadius: Double, - amplitude: Double, - angle: Double, - ): Pair { - // Calculate the radius with the sine wave applied. - val radius = baseRadius + amplitude * sin(curlCount * angle) - // Calculate x and y coordinates using polar coordinates. - val x = centerX + radius * cos(angle) - val y = centerY + radius * sin(angle) - return Pair(x, y) - } - - override fun createOutline( - size: Size, - topStart: Float, - topEnd: Float, - bottomEnd: Float, - bottomStart: Float, - layoutDirection: LayoutDirection - ): Outline { - val centerX = size.width / 2.0 - val centerY = size.height / 2.0 - val baseRadius = centerX - curlAmplitude - val path = Path() - - // Start at the rightmost point - val startPoint = calculateCurlyCirclePoint(centerX, centerY, baseRadius, curlAmplitude, 0.0) - path.moveTo(startPoint.first.toFloat(), startPoint.second.toFloat()) - - // Iterate through 360 degrees to draw the curly circle - for (angleDegrees in 1..360) { - // Convert the angle to radians. - val angleRadians = Math.toRadians(angleDegrees.toDouble()) - - // calculate the current point - val currentPoint = - calculateCurlyCirclePoint(centerX, centerY, baseRadius, curlAmplitude, angleRadians) - - path.lineTo(currentPoint.first.toFloat(), currentPoint.second.toFloat()) - } - - path.close() - return Outline.Generic(path) - } - - override fun copy( - topStart: CornerSize, - topEnd: CornerSize, - bottomEnd: CornerSize, - bottomStart: CornerSize, - ): CurlyCornerShape = CurlyCornerShape( - curlAmplitude = this.curlAmplitude, - curlCount = this.curlCount - ) -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/EditorRow.kt b/app/src/main/java/com/flux/ui/components/EditorRow.kt deleted file mode 100644 index 436c048..0000000 --- a/app/src/main/java/com/flux/ui/components/EditorRow.kt +++ /dev/null @@ -1,392 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.outlined.FormatIndentDecrease -import androidx.compose.material.icons.automirrored.outlined.FormatIndentIncrease -import androidx.compose.material.icons.automirrored.outlined.Label -import androidx.compose.material.icons.automirrored.outlined.List -import androidx.compose.material.icons.outlined.AddChart -import androidx.compose.material.icons.outlined.AudioFile -import androidx.compose.material.icons.outlined.CheckBox -import androidx.compose.material.icons.outlined.Code -import androidx.compose.material.icons.outlined.DataArray -import androidx.compose.material.icons.outlined.DataObject -import androidx.compose.material.icons.outlined.Feedback -import androidx.compose.material.icons.outlined.FormatBold -import androidx.compose.material.icons.outlined.FormatItalic -import androidx.compose.material.icons.outlined.FormatPaint -import androidx.compose.material.icons.outlined.FormatQuote -import androidx.compose.material.icons.outlined.FormatUnderlined -import androidx.compose.material.icons.outlined.HorizontalRule -import androidx.compose.material.icons.outlined.Image -import androidx.compose.material.icons.outlined.Info -import androidx.compose.material.icons.outlined.Lightbulb -import androidx.compose.material.icons.outlined.Link -import androidx.compose.material.icons.outlined.Mic -import androidx.compose.material.icons.outlined.ReportGmailerrorred -import androidx.compose.material.icons.outlined.StrikethroughS -import androidx.compose.material.icons.outlined.TableChart -import androidx.compose.material.icons.outlined.Title -import androidx.compose.material.icons.outlined.VideoFile -import androidx.compose.material.icons.outlined.Warning -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.dp -import com.flux.R -import com.flux.other.Constants - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CustomIconButton( - enabled: Boolean = true, - imageVector: ImageVector? = null, - painter: Int? = null, - contentDescription: String?=null, - onClick: () -> Unit -) { - IconButton( - onClick = onClick, - enabled = enabled, - ) { - if (imageVector != null) { - Icon( - imageVector = imageVector, - contentDescription = contentDescription - ) - } else { - Icon( - painter = painterResource(id = painter!!), - contentDescription = contentDescription - ) - } - } -} - -@Composable -fun MarkdownEditorRow( - canUndo: Boolean, - canRedo: Boolean, - onEdit: (String) -> Unit, - onTableButtonClick: () -> Unit, - onListButtonClick: () -> Unit, - onTaskButtonClick: () -> Unit, - onLinkButtonClick: () -> Unit, - onImageButtonClick: () -> Unit, - onAudioButtonClick: () -> Unit, - onRecordAudioClick: () -> Unit, - onVideoButtonClick: () -> Unit -) { - - var isExpanded by rememberSaveable { mutableStateOf(false) } - var isAlertExpanded by rememberSaveable { mutableStateOf(false) } - - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp) - .navigationBarsPadding() - .height(48.dp) - .border(BorderStroke(0.5.dp, MaterialTheme.colorScheme.primary), shape = RoundedCornerShape(50)) - .clip(RoundedCornerShape(50)) - .horizontalScroll(rememberScrollState()) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), - verticalAlignment = Alignment.CenterVertically - ) { - CustomIconButton( - enabled = canUndo, - painter = R.drawable.undo, - contentDescription = "Undo" - ) { - onEdit(Constants.Editor.UNDO) - } - - CustomIconButton( - enabled = canRedo, - painter = R.drawable.redo, - contentDescription = "Redo" - ) { - onEdit(Constants.Editor.REDO) - } - - CustomIconButton( - imageVector = Icons.Outlined.Title, - contentDescription = "Heading Level" - ) { - isExpanded = !isExpanded - } - - AnimatedVisibility(visible = isExpanded) { - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - - CustomIconButton( - painter = R.drawable.format_h1, - contentDescription = "H1" - ) { - onEdit(Constants.Editor.H1) - } - - CustomIconButton( - painter = R.drawable.format_h2, - contentDescription = "H2" - ) { - onEdit(Constants.Editor.H2) - } - - CustomIconButton( - painter = R.drawable.format_h3, - contentDescription = "H3" - ) { - onEdit(Constants.Editor.H3) - } - - CustomIconButton( - painter = R.drawable.format_h4, - contentDescription = "H4" - ) { - onEdit(Constants.Editor.H4) - } - - CustomIconButton( - painter = R.drawable.format_h5, - contentDescription = "H5" - ) { - onEdit(Constants.Editor.H5) - } - - CustomIconButton( - painter = R.drawable.format_h6, - contentDescription = "H6" - ) { - onEdit(Constants.Editor.H6) - } - } - } - - CustomIconButton( - imageVector = Icons.Outlined.FormatBold, - contentDescription = "Bold" - ) { - onEdit(Constants.Editor.BOLD) - } - - CustomIconButton( - imageVector = Icons.Outlined.FormatItalic, - contentDescription = "Italic" - ) { - onEdit(Constants.Editor.ITALIC) - } - - CustomIconButton( - imageVector = Icons.Outlined.FormatUnderlined, - contentDescription = "Underline" - ) { - onEdit(Constants.Editor.UNDERLINE) - } - - CustomIconButton( - imageVector = Icons.Outlined.StrikethroughS, - contentDescription = "Strike Through" - ) { - onEdit(Constants.Editor.STRIKETHROUGH) - } - - CustomIconButton( - imageVector = Icons.Outlined.FormatPaint, - contentDescription = "Mark" - ) { - onEdit(Constants.Editor.MARK) - } - - CustomIconButton( - imageVector = Icons.Outlined.Code, - contentDescription = "Code" - ) { - onEdit(Constants.Editor.INLINE_CODE) - } - - CustomIconButton( - imageVector = Icons.Outlined.DataArray, - contentDescription = "Brackets" - ) { - onEdit(Constants.Editor.INLINE_BRACKETS) - } - - CustomIconButton( - imageVector = Icons.Outlined.DataObject, - contentDescription = "Braces" - ) { - onEdit(Constants.Editor.INLINE_BRACES) - } - - CustomIconButton( - imageVector = Icons.AutoMirrored.Outlined.FormatIndentIncrease, - contentDescription = "Tab" - ) { - onEdit(Constants.Editor.TAB) - } - - CustomIconButton( - imageVector = Icons.AutoMirrored.Outlined.FormatIndentDecrease, - contentDescription = "unTab" - ) { - onEdit(Constants.Editor.UN_TAB) - } - - CustomIconButton( - painter = R.drawable.function, - contentDescription = "Math" - ) { - onEdit(Constants.Editor.INLINE_MATH) - } - - CustomIconButton( - imageVector = Icons.Outlined.FormatQuote, - contentDescription = "Quote" - ) { - onEdit(Constants.Editor.QUOTE) - } - - CustomIconButton( - imageVector = Icons.AutoMirrored.Outlined.Label, - contentDescription = "Alert", - ) { - isAlertExpanded = !isAlertExpanded - } - - AnimatedVisibility(visible = isAlertExpanded) { - Row( - modifier = Modifier.fillMaxHeight(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start - ) { - CustomIconButton( - imageVector = Icons.Outlined.Info, - contentDescription = "Note Alert", - ) { - onEdit(Constants.Editor.NOTE) - } - - CustomIconButton( - imageVector = Icons.Outlined.Lightbulb, - contentDescription = "Tip Alert", - ) { - onEdit(Constants.Editor.TIP) - } - - CustomIconButton( - imageVector = Icons.Outlined.Feedback, - contentDescription = "Important Alert", - ) { - onEdit(Constants.Editor.IMPORTANT) - - } - CustomIconButton( - imageVector = Icons.Outlined.Warning, - contentDescription = "Warning Alert", - ) { - onEdit(Constants.Editor.WARNING) - } - - CustomIconButton( - imageVector = Icons.Outlined.ReportGmailerrorred, - contentDescription = "Caution Alert", - ) { - onEdit(Constants.Editor.CAUTION) - } - } - } - - CustomIconButton( - imageVector = Icons.Outlined.HorizontalRule, - contentDescription = "Horizontal Rule", - ) { - onEdit(Constants.Editor.RULE) - } - - CustomIconButton( - imageVector = Icons.Outlined.TableChart, - contentDescription = "Table", - onClick = onTableButtonClick - ) - - CustomIconButton( - imageVector = Icons.Outlined.AddChart, - contentDescription = "Mermaid Diagram", - ) { - onEdit(Constants.Editor.DIAGRAM) - } - - CustomIconButton( - imageVector = Icons.AutoMirrored.Outlined.List, - contentDescription = "List", - onClick = onListButtonClick - ) - - CustomIconButton( - imageVector = Icons.Outlined.CheckBox, - contentDescription = "Task List", - onClick = onTaskButtonClick - ) - - CustomIconButton( - imageVector = Icons.Outlined.Link, - contentDescription = "Link", - onClick = onLinkButtonClick - ) - - CustomIconButton( - imageVector = Icons.Outlined.Mic, - contentDescription = "Audio", - onClick = onRecordAudioClick - ) - - CustomIconButton( - imageVector = Icons.Outlined.AudioFile, - contentDescription = "Audio", - onClick = onAudioButtonClick - ) - - CustomIconButton( - imageVector = Icons.Outlined.VideoFile, - contentDescription = "Video", - onClick = onVideoButtonClick - ) - - CustomIconButton( - imageVector = Icons.Outlined.Image, - contentDescription = "Image", - onClick = onImageButtonClick - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/HabitComponents.kt b/app/src/main/java/com/flux/ui/components/HabitComponents.kt deleted file mode 100644 index 597cfab..0000000 --- a/app/src/main/java/com/flux/ui/components/HabitComponents.kt +++ /dev/null @@ -1,1068 +0,0 @@ -package com.flux.ui.components - -import android.widget.Toast -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBackIos -import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos -import androidx.compose.material.icons.filled.Analytics -import androidx.compose.material.icons.filled.EventAvailable -import androidx.compose.material.icons.filled.LocalFireDepartment -import androidx.compose.material.icons.filled.Pending -import androidx.compose.material.icons.filled.Verified -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.CornerRadius -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.flux.R -import com.flux.data.model.HabitInstanceModel -import com.flux.data.model.HabitModel -import com.flux.data.model.RecurrenceRule -import com.flux.ui.events.HabitEvents -import com.flux.ui.screens.events.IconRadioButton -import com.flux.ui.screens.events.toFormattedDate -import com.flux.ui.screens.events.toFormattedTime -import com.flux.ui.screens.habits.isDateAllowedForHabit -import com.flux.ui.state.Settings -import java.time.DayOfWeek -import java.time.Instant -import java.time.LocalDate -import java.time.YearMonth -import java.time.ZoneId -import java.time.format.TextStyle -import java.util.Locale -import kotlin.collections.filter -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.* -import androidx.compose.material.icons.outlined.Flag -import androidx.compose.material.icons.outlined.SportsScore -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.sp - -@Composable -fun EmptyHabits() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.EventAvailable, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text(stringResource(R.string.Empty_Habits)) - } -} - -@Composable -fun HabitDateCard( - radius: Int, - isTodayDone: Boolean, - isDone: Boolean, - day: String, - date: Int, - modifier: Modifier = Modifier -) { - val containerColor = when { - isTodayDone && isDone -> MaterialTheme.colorScheme.primary - isTodayDone && !isDone -> MaterialTheme.colorScheme.primaryContainer - isDone -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - } - - val contentColor = when { - isDone -> MaterialTheme.colorScheme.onPrimary - else -> MaterialTheme.colorScheme.onSurface - } - - Card( - modifier = modifier, - shape = shapeManager(radius = radius * 2), - colors = CardDefaults.cardColors( - containerColor = containerColor, - contentColor = contentColor - ) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - day.uppercase(), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight), - modifier = Modifier.alpha(0.95f) - ) - Text( - date.toString(), - style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight), - modifier = Modifier.alpha(0.95f) - ) - } - } -} - -@Composable -fun HabitPreviewCard( - radius: Int, - habit: HabitModel, - instances: List, - settings: Settings, - onToggleDone: (Long) -> Unit, - onAnalyticsClicked: () -> Unit -) { - val todayEpoch = LocalDate.now().toEpochDay() - val isTodayDone = instances.any { it.instanceDate == todayEpoch } - - // Get Monday of this week - val mondayEpoch = LocalDate.now().with(DayOfWeek.MONDAY).toEpochDay() - val weekDates = (0L..6L).map { mondayEpoch + it } - val (currentStreak, _) = calculateStreaks(habit.recurrence, habit.startDateTime, instances) - - Card( - onClick = { onToggleDone(todayEpoch) }, - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = if (isTodayDone) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ), - shape = shapeManager(radius = radius * 2) - ) { - Column { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row { - IconRadioButton( - selected = isTodayDone, - ) { onToggleDone(todayEpoch) } - - Column(Modifier.padding(top = 8.dp)) { - Text( - habit.title, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - habit.startDateTime.toFormattedTime(settings.data.is24HourFormat), - style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.ExtraLight), - modifier = Modifier.alpha(0.9f) - ) - } - } - - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton({}) { Icon(Icons.Default.LocalFireDepartment, null) } - Text("$currentStreak") - IconButton(onAnalyticsClicked) { Icon(Icons.Default.Analytics, null) } - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically - ) { - weekDates.forEach { epochDay -> - val localDate = LocalDate.ofEpochDay(epochDay) - val isCardDone = instances.any { it.instanceDate == epochDay } - - HabitDateCard( - radius = radius, - isTodayDone=isTodayDone, - isDone = isCardDone, - day = localDate.dayOfWeek.name.take(3), - date = localDate.dayOfMonth, - modifier = Modifier.weight(1f) - ) - } - } - } - } -} - -@Composable -fun HabitCalendarCard( - radius: Int, - habitId: String, - workspaceId: String, - startDateTime: Long, - endDateTime: Long, - recurrence: RecurrenceRule, - habitInstances: List, - onHabitEvents: (HabitEvents) -> Unit -) { - val context = LocalContext.current - - val habitStartMonth = - Instant.ofEpochMilli(startDateTime).atZone(ZoneId.systemDefault()).toLocalDate() - .let { YearMonth.of(it.year, it.month) } - - var currentMonth by remember { mutableStateOf(YearMonth.now()) } - val currentYearMonth = YearMonth.now() - val endOfYear = YearMonth.of(currentYearMonth.year, 12) - - val canGoBack = currentMonth > habitStartMonth - val canGoForward = currentMonth < endOfYear - - val daysInMonth = currentMonth.lengthOfMonth() - val firstDayOfWeek = currentMonth.atDay(1).dayOfWeek.value % 7 - val dates = (1..daysInMonth).map { currentMonth.atDay(it) } - - val daysOfWeek = listOf( - stringResource(R.string.sunday_short), - stringResource(R.string.monday_short), - stringResource(R.string.tuesday_short), - stringResource(R.string.wednesday_short), - stringResource(R.string.thursday_short), - stringResource(R.string.friday_short), - stringResource(R.string.saturday_short) - - ) - val today = LocalDate.now() - val habitStartDate = - Instant.ofEpochMilli(startDateTime).atZone(ZoneId.systemDefault()).toLocalDate() - val habitEndEpochDay = if (endDateTime == -1L) null - else Instant.ofEpochMilli(endDateTime) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - .toEpochDay() - - Card( - modifier = Modifier - .fillMaxWidth() - .height(330.dp), - onClick = {}, - shape = shapeManager(radius = radius * 2), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ) - ) { - Column(Modifier.padding(16.dp)) { - // Month navigation - Row( - modifier = Modifier.fillMaxWidth().padding(4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = currentMonth.month.getDisplayName(TextStyle.FULL, Locale.getDefault()) + - " ${currentMonth.year}", - style = MaterialTheme.typography.titleMedium - ) - Row { - IconButton( - onClick = { if (canGoBack) currentMonth = currentMonth.minusMonths(1) }, - enabled = canGoBack - ) { - Icon( - Icons.AutoMirrored.Default.ArrowBackIos, - contentDescription = "Previous Month", - modifier = Modifier - .alpha(if (canGoBack) 0.8f else 0.3f) - .size(16.dp) - ) - } - IconButton( - onClick = { if (canGoForward) currentMonth = currentMonth.plusMonths(1) }, - enabled = canGoForward - ) { - Icon( - Icons.AutoMirrored.Default.ArrowForwardIos, - contentDescription = "Next Month", - modifier = Modifier - .alpha(if (canGoForward) 0.8f else 0.3f) - .size(16.dp) - ) - } - } - } - - // Days of week row - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { - daysOfWeek.forEach { - Text( - text = it, - modifier = Modifier.weight(1f), - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) - ) - } - } - - // Calendar grid - LazyVerticalGrid( - columns = GridCells.Fixed(7), - userScrollEnabled = false, - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.fillMaxWidth() - ) { - // Empty cells before 1st day - items(firstDayOfWeek) { Box(modifier = Modifier.size(32.dp)) } - - items(dates) { date -> - val epochDay = date.toEpochDay() - val instance = habitInstances.find { it.instanceDate == epochDay } - val isMarked = instance != null - val isBeforeStart = epochDay < habitStartDate.toEpochDay() - val isAfterToday = epochDay > today.toEpochDay() - val isAfterEnd = habitEndEpochDay != null && epochDay > habitEndEpochDay - val isAllowedByRecurrence = isDateAllowedForHabit(recurrence, epochDay) - - val backgroundColor = when { - isMarked -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) - isAllowedByRecurrence && !isBeforeStart && !isAfterEnd -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - else -> Color.Transparent - } - - val textColor = when { - isMarked -> MaterialTheme.colorScheme.onPrimary - isAllowedByRecurrence && !isBeforeStart && !isAfterEnd -> MaterialTheme.colorScheme.onSurface - else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) - } - - val dateAlpha = when { - isBeforeStart -> 0.2f - isAfterToday || isAfterEnd -> 0.4f - !isAllowedByRecurrence -> 0.4f - else -> 1f - } - - val habitAlreadyEnded = stringResource(R.string.habit_already_ended) - val cantMarkFutureDate = stringResource(R.string.cannot_mark_future_dates) - val habitStartsLater = stringResource(R.string.habit_starts_later) - val notInSchedule = stringResource(R.string.date_not_in_schedule) - Box( - modifier = Modifier - .size(32.dp) - .clip(RoundedCornerShape(8.dp)) - .background(backgroundColor) - .alpha(dateAlpha) - .clickable { - when { - isAfterEnd -> Toast.makeText( - context, - habitAlreadyEnded, - Toast.LENGTH_SHORT - ).show() - - isAfterToday -> Toast.makeText( - context, - cantMarkFutureDate, - Toast.LENGTH_SHORT - ).show() - - isBeforeStart -> Toast.makeText( - context, - habitStartsLater, - Toast.LENGTH_SHORT - ).show() - - !isAllowedByRecurrence -> Toast.makeText( - context, - notInSchedule, - Toast.LENGTH_SHORT - ).show() - - else -> { - if (isMarked) onHabitEvents(HabitEvents.MarkUndone(instance)) - else onHabitEvents( - HabitEvents.MarkDone( - HabitInstanceModel( - habitId = habitId, - workspaceId = workspaceId, - instanceDate = epochDay - ) - ) - ) - } - } - }, - contentAlignment = Alignment.Center - ) { - Text(date.dayOfMonth.toString(), color = textColor) - } - } - - } - } - } -} - -@Composable -fun HabitStartCard(startDateTime: Long, radius: Int) { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = {}, - shape = shapeManager(radius = radius, isFirst = true), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ) - ) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text(stringResource(R.string.Started), modifier = Modifier.alpha(0.85f)) - Text(startDateTime.toFormattedDate(), fontWeight = FontWeight.SemiBold) - } - CircleWrapper(MaterialTheme.colorScheme.primary) { - Icon( - Icons.Outlined.Flag, - null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } -} - -@Composable -fun HabitEndCard(endDateTime: Long, radius: Int) { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = {}, - shape = shapeManager(radius = radius), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ) - ) { - Row( - Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text(stringResource(R.string.ends_on), modifier = Modifier.alpha(0.85f)) - Text(endDateTime.toFormattedDate(), fontWeight = FontWeight.SemiBold) - } - CircleWrapper(MaterialTheme.colorScheme.primary) { - Icon( - Icons.Outlined.SportsScore, - null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } -} - -@Composable -fun WeeklyHabitAnalyticsCard( - radius: Int, - habitInstances: List -) { - val today = LocalDate.now() - val startOfWeek = today.with(DayOfWeek.MONDAY) - val endOfWeek = today.with(DayOfWeek.SUNDAY) - - val thisWeekInstances = habitInstances.filter { - val date = LocalDate.ofEpochDay(it.instanceDate) - date in startOfWeek..endOfWeek - } - val completedCount = thisWeekInstances.size - - val daysOfWeek = listOf( - stringResource(R.string.monday_short), - stringResource(R.string.tuesday_short), - stringResource(R.string.wednesday_short), - stringResource(R.string.thursday_short), - stringResource(R.string.friday_short), - stringResource(R.string.saturday_short), - stringResource(R.string.sunday_short) - ) - val dayStatus = daysOfWeek.mapIndexed { index, _ -> - val date = startOfWeek.plusDays(index.toLong()) - thisWeekInstances.any { it.instanceDate == date.toEpochDay() } - } - - Card( - modifier = Modifier.fillMaxWidth(), - shape = shapeManager(radius = radius * 2), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ), - onClick = {} - ) { - Column(Modifier.fillMaxSize().padding(12.dp)) { - Text( - text = stringResource(R.string.This_Week), - style = MaterialTheme.typography.titleMedium, - ) - - Text( - text = stringResource(R.string.completed_habits, completedCount), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 24.dp) - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - daysOfWeek.forEachIndexed { index, day -> - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.weight(1f) - ) { - Card( - shape = RoundedCornerShape(50), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = if(dayStatus[index]) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) - ) - ) { - Icon( - imageVector = if (dayStatus[index]) Icons.Default.Verified else Icons.Default.Pending, - contentDescription = null, - modifier = Modifier.padding(4.dp) - ) - } - Text( - text = day, - style = MaterialTheme.typography.labelLarge, - ) - } - } - } - } - } -} - -@Composable -fun MonthlyHabitAnalyticsCard( - radius: Int, - habitInstances: List -) { - val today = LocalDate.now() - val currentYearMonth = YearMonth.of(today.year, today.month) - val daysInMonth = currentYearMonth.lengthOfMonth() - - // Filter only habits from this month - val thisMonthInstances = remember(habitInstances) { - habitInstances.filter { instance -> - val date = LocalDate.ofEpochDay(instance.instanceDate) - val instanceYearMonth = YearMonth.of(date.year, date.month) - instanceYearMonth == currentYearMonth - } - } - - // Break month into week ranges (1–7, 8–14, etc.) - val weekRanges = remember(daysInMonth) { - val ranges = mutableListOf() - var start = 1 - while (start <= daysInMonth) { - val end = minOf(start + 6, daysInMonth) - ranges.add(start..end) - start = end + 1 - } - ranges - } - - // Count completed habits per week - val weekCounts = remember(thisMonthInstances) { - val counts = MutableList(weekRanges.size) { 0 } - - thisMonthInstances - .distinctBy { it.instanceDate } - .forEach { instance -> - val date = LocalDate.ofEpochDay(instance.instanceDate) - val day = date.dayOfMonth - weekRanges.forEachIndexed { index, range -> - if (day in range) { - counts[index]++ - return@forEachIndexed - } - } - } - - counts - } - - val completedHabits = weekCounts.sum() - - Card( - modifier = Modifier.fillMaxWidth(), - shape = shapeManager(radius = radius * 2), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ), - onClick = {} - ) { - Column( - Modifier - .fillMaxSize() - .padding(12.dp) - ) { - Text( - text = stringResource(R.string.This_Month), - style = MaterialTheme.typography.titleMedium, - ) - - Text( - text = stringResource(R.string.completed_habits, completedHabits), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(bottom = 24.dp) - ) - - HabitBarChart( - weekCounts = weekCounts, - weekLabels = weekRanges.map { "${it.first}-${it.last}" } - ) - } - } -} - -@Composable -fun WeeklyHabitProgressChart( - radius: Int, - habits: List, - habitInstances: List, - modifier: Modifier = Modifier -) { - val primaryColor = MaterialTheme.colorScheme.primary - val primaryAlpha = primaryColor.copy(alpha = 0.4f) - - val today = LocalDate.now() - val startOfWeek = today.with(DayOfWeek.MONDAY) - val weekDays = (0..6).map { startOfWeek.plusDays(it.toLong()) } - val dayLabels = listOf( - stringResource(R.string.monday_short), - stringResource(R.string.tuesday_short), - stringResource(R.string.wednesday_short), - stringResource(R.string.thursday_short), - stringResource(R.string.friday_short), - stringResource(R.string.saturday_short), - stringResource(R.string.sunday_short) - ) - - // Calculate percentages correctly - val percentages = weekDays.map { day -> - // Map DayOfWeek to 0=Monday ... 6=Sunday - val dayOfWeekIndex = (day.dayOfWeek.value + 6) % 7 - val scheduled = habits.count { habit -> - (habit.recurrence as? RecurrenceRule.Weekly)?.daysOfWeek?.contains(dayOfWeekIndex) == true - } - val done = habitInstances.count { LocalDate.ofEpochDay(it.instanceDate) == day } - if (scheduled > 0) (done.toFloat() / scheduled) * 100 else 0f - } - - Card( - modifier = Modifier.fillMaxWidth(), - shape = shapeManager(radius = radius * 2), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ), - onClick = {} - ) { - Column(modifier = modifier.padding(8.dp)) { - Text( - stringResource(R.string.weekly_habit_completion), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 24.dp, top = 8.dp) - ) - - Canvas( - modifier = Modifier - .fillMaxWidth() - .padding(6.dp) - .height(200.dp) - ) { - val leftPadding = 32.dp.toPx() - val bottomPadding = 32.dp.toPx() - val widthPerPoint = (size.width - leftPadding) / (weekDays.size - 1) - val height = size.height - bottomPadding - - // Vertical percentage labels - for (i in 0..4) { - val y = height - i * height / 4 - drawContext.canvas.nativeCanvas.apply { - drawText( - "${i * 25}%", - leftPadding - 14.dp.toPx(), - y, - android.graphics.Paint().apply { - color = primaryColor.toArgb() - textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.RIGHT - } - ) - } - } - - // Line path - val linePath = Path().apply { - percentages.forEachIndexed { index, percentage -> - val x = leftPadding + index * widthPerPoint - val y = height - (percentage / 100f * height) - if (index == 0) moveTo(x, y) else lineTo(x, y) - } - } - - // Shadow under line - val shadowPath = Path().apply { - addPath(linePath) - lineTo(leftPadding + (weekDays.size - 1) * widthPerPoint, height) - lineTo(leftPadding, height) - close() - } - - drawPath( - path = shadowPath, - brush = Brush.verticalGradient( - colors = listOf(primaryAlpha, primaryColor.copy(alpha = 0f)), - startY = 0f, - endY = height - ) - ) - - // Draw line - drawPath( - path = linePath, - color = primaryColor, - style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round) - ) - - // Draw dots - percentages.forEachIndexed { index, percentage -> - val x = leftPadding + index * widthPerPoint - val y = height - (percentage / 100f * height) - drawCircle( - color = primaryColor, - radius = 6.dp.toPx(), - center = Offset(x, y) - ) - } - - // Draw day labels - dayLabels.forEachIndexed { index, day -> - val x = leftPadding + index * widthPerPoint - val y = height + 24.dp.toPx() - drawContext.canvas.nativeCanvas.apply { - drawText( - day, - x, - y, - android.graphics.Paint().apply { - color = primaryColor.toArgb() - textSize = 12.sp.toPx() - textAlign = android.graphics.Paint.Align.CENTER - } - ) - } - } - } - } - } -} - -@Composable -fun HabitBarChart( - weekCounts: List, - weekLabels: List, - modifier: Modifier = Modifier -) { - val maxDaysPerWeek = 7 - val yLabels = (maxDaysPerWeek downTo 1).toList() - val primaryColor = MaterialTheme.colorScheme.primary - - Row( - modifier = modifier - .fillMaxWidth() - .height(220.dp) - .padding(bottom = 16.dp) - ) { - // Y-axis labels - Box( - modifier = Modifier - .width(20.dp) - .padding(end = 8.dp) - .fillMaxHeight() - ) { - Canvas(modifier = Modifier.fillMaxSize()) { - val stepHeight = size.height / maxDaysPerWeek - val textPaint = Paint().asFrameworkPaint().apply { - isAntiAlias = true - textSize = 32f - color = primaryColor.toArgb() - textAlign = android.graphics.Paint.Align.RIGHT - } - - yLabels.forEach { label -> - val y = size.height - (stepHeight * label) - drawContext.canvas.nativeCanvas.drawText( - label.toString(), - size.width - 10f, - y + 5f, - textPaint - ) - } - } - } - - // Bar chart canvas - Canvas( - modifier = Modifier - .weight(1f) - .fillMaxHeight() - ) { - val barWidth = size.width / (weekCounts.size * 2 + 1) - val stepHeight = size.height / maxDaysPerWeek - - weekCounts.forEachIndexed { index, count -> - val barHeight = stepHeight * count - val x = barWidth + index * (barWidth + barWidth) - val y = size.height - barHeight - - drawRoundRect( - color = primaryColor, - topLeft = Offset(x, y), - size = Size(barWidth, barHeight), - cornerRadius = CornerRadius(x = 16.dp.toPx(), y = 16.dp.toPx()) - ) - - drawIntoCanvas { canvas -> - val paint = Paint().asFrameworkPaint().apply { - isAntiAlias = true - color = primaryColor.toArgb() - textSize = 30f - textAlign = android.graphics.Paint.Align.CENTER - } - - canvas.nativeCanvas.drawText( - weekLabels[index], - x + barWidth / 2, - size.height + 40f, - paint - ) - } - } - - for (i in 1..maxDaysPerWeek) { - val y = size.height - (stepHeight * i) - drawLine( - color = Color.Gray.copy(alpha = 0.5f), - start = Offset(0f, y), - end = Offset(size.width, y), - strokeWidth = 0.25.dp.toPx() - ) - } - } - } -} - -@Composable -fun HabitStreakCard( - habit: HabitModel, - instances: List, - radius: Int -) { - val streakData = remember(habit, instances) { - calculateStreaks(habit.recurrence, habit.startDateTime, instances) - } - - Card( - modifier = Modifier.fillMaxWidth(), - onClick = {}, - shape = shapeManager(radius = radius, isLast = true), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ) - ) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - "${stringResource(R.string.Current_Streak)} ${streakData.currentStreak}", - modifier = Modifier.alpha(0.85f) - ) - Text( - "${stringResource(R.string.Best_Streak)} ${streakData.bestStreak}", - fontWeight = FontWeight.SemiBold - ) - } - CircleWrapper(MaterialTheme.colorScheme.primary) { - Icon( - Icons.Default.LocalFireDepartment, - null, - modifier = Modifier.size(40.dp), - tint = MaterialTheme.colorScheme.onPrimary - ) - } - } - } -} - -data class StreakData( - val currentStreak: Int, - val bestStreak: Int -) - -fun calculateStreaks( - recurrence: RecurrenceRule, - startDateTime: Long, - instances: List -): StreakData { - if (instances.isEmpty()) { - return StreakData(currentStreak = 0, bestStreak = 0) - } - - // Get all expected dates from habit start to today - val expectedDates = getExpectedDates(recurrence, startDateTime) - val completedDates = instances.map { it.instanceDate }.toSet() - - // Calculate current streak (working backwards from today) - val currentStreak = calculateCurrentStreak(expectedDates, completedDates) - - // Calculate best streak (longest consecutive completed sequence) - val bestStreak = calculateBestStreak(expectedDates, completedDates) - - return StreakData(currentStreak, bestStreak) -} - -private fun getExpectedDates( - recurrence: RecurrenceRule, - startDateTime: Long -): List { - - val today = LocalDate.now() - - val startDate = Instant.ofEpochMilli(startDateTime) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - - val weekly = recurrence as? RecurrenceRule.Weekly - ?: return emptyList() - - val expectedDates = mutableListOf() - - var current = startDate - while (!current.isAfter(today)) { - - // Monday = 0 ... Sunday = 6 - val dayOfWeek = (current.dayOfWeek.value + 6) % 7 - - if (dayOfWeek in weekly.daysOfWeek) { - expectedDates.add(current.toEpochDay()) - } - - current = current.plusDays(1) - } - - return expectedDates -} - -private fun calculateCurrentStreak( - expectedDates: List, - completedDates: Set -): Int { - - if (expectedDates.isEmpty()) return 0 - - var streak = 0 - - for (i in expectedDates.indices.reversed()) { - val date = expectedDates[i] - - if (date in completedDates) { - streak++ - } else { - if (streak > 0) break - } - } - - return streak -} - - -private fun calculateBestStreak(expectedDates: List, completedDates: Set): Int { - if (expectedDates.isEmpty()) return 0 - - var bestStreak = 0 - var currentStreak = 0 - - for (expectedDate in expectedDates) { - if (expectedDate in completedDates) { - currentStreak++ - bestStreak = maxOf(bestStreak, currentStreak) - } else { - currentStreak = 0 - } - } - - return bestStreak -} diff --git a/app/src/main/java/com/flux/ui/components/NotesComponents.kt b/app/src/main/java/com/flux/ui/components/NotesComponents.kt deleted file mode 100644 index 045541d..0000000 --- a/app/src/main/java/com/flux/ui/components/NotesComponents.kt +++ /dev/null @@ -1,182 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Label -import androidx.compose.material.icons.automirrored.filled.LabelImportant -import androidx.compose.material.icons.automirrored.filled.Notes -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import com.flux.R -import com.flux.data.model.NotesModel -import com.flux.other.parseMarkdownContent - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NotesPreviewCard( - modifier: Modifier = Modifier, - radius: Int, - isSelected: Boolean, - note: NotesModel, - labels: List, - onClick: (String) -> Unit, - onLongPressed: () -> Unit -) { - Card( - colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), - modifier = modifier - .clip(shapeManager(isBoth = true, radius = radius / 2)) - .combinedClickable( - onClick = { onClick(note.notesId) }, - onLongClick = onLongPressed - ), - shape = shapeManager(isBoth = true, radius = radius / 2), - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null - ) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp), - horizontalAlignment = Alignment.Start, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - ) { - Text( - text = note.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .alpha(0.75f) - .padding(horizontal = 12.dp) - .padding(top = 12.dp) - ) - - Text( - text = parseMarkdownContent(note.description), - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - maxLines = 12, - modifier = Modifier - .alpha(0.9f) - .padding(horizontal = 12.dp) - ) - - val maxVisibleLabels = 2 - val visibleLabels = labels.take(maxVisibleLabels) - val extraCount = labels.size - maxVisibleLabels - - if (visibleLabels.isNotEmpty()) { - FlowRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - visibleLabels.forEach { label -> - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.primaryContainer) - ) { - Row( - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Icon( - Icons.AutoMirrored.Default.LabelImportant, - null, - modifier = Modifier.size(15.dp), - tint = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - label, - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - - if (extraCount > 0) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.primaryContainer) - ) { - Text( - text = "+$extraCount", - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), - color = MaterialTheme.colorScheme.onPrimaryContainer, - style = MaterialTheme.typography.labelMedium - ) - } - } - } - } - } - } -} - -@Composable -fun EmptyNotes() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Notes, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text(stringResource(R.string.Empty_Notes)) - } -} - -@Composable -fun EmptyLabels() { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Label, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text(stringResource(R.string.Empty_Labels)) - } -} diff --git a/app/src/main/java/com/flux/ui/components/Render.kt b/app/src/main/java/com/flux/ui/components/Render.kt deleted file mode 100644 index d6eb041..0000000 --- a/app/src/main/java/com/flux/ui/components/Render.kt +++ /dev/null @@ -1,74 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - - -@Composable -fun RenderRadio( - enabled: Boolean, - onRadioEnabled: () -> Unit -) { - RadioButton( - selected = enabled, - onClick = { - onRadioEnabled() - }, - modifier = Modifier - .scale(0.9f) - .padding(0.dp) - ) -} - -@Composable -fun RenderCategoryTitle(title: String) { - Text( - text = title, - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) -} - -@Composable -fun RenderCategoryDescription(subTitle: String) { - if (subTitle.isNotBlank()) { - Text( - color = MaterialTheme.colorScheme.primary, - text = subTitle, - fontSize = 10.sp - ) - } -} - -@Composable -fun RenderCategoryIcon(icon: ImageVector) { - Box( - modifier = Modifier - .background( - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(50) - ), - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.surfaceContainerHigh, - modifier = Modifier - .scale(1f) - .padding(9.dp) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/SelectableColorPalette.kt b/app/src/main/java/com/flux/ui/components/SelectableColorPalette.kt deleted file mode 100644 index 5a2c856..0000000 --- a/app/src/main/java/com/flux/ui/components/SelectableColorPalette.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandIn -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkOut -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Check -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp - -@Composable -fun SelectableColorPlatte( - modifier: Modifier = Modifier, - selected: Boolean, - colorScheme: ColorScheme, - onClick: () -> Unit -) = Box(modifier = modifier.clip(MaterialTheme.shapes.large)) { - Surface( - modifier = Modifier - .clickable { onClick() } - .padding(6.dp) - .size(48.dp), - shape = CircleShape, - color = colorScheme.primary, - ) { - Box { - Surface( - modifier = Modifier - .size(48.dp) - .offset((-24).dp, 24.dp), - color = colorScheme.tertiary, - ) {} - Surface( - modifier = Modifier - .size(48.dp) - .offset(24.dp, 24.dp), - color = colorScheme.secondaryContainer, - ) {} - AnimatedVisibility( - visible = selected, - modifier = Modifier - .align(Alignment.Center) - .clip(CircleShape) - .background(colorScheme.tertiaryContainer), - enter = fadeIn() + expandIn(expandFrom = Alignment.Center), - exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() - ) { - Icon( - imageVector = Icons.Outlined.Check, - contentDescription = "Checked", - modifier = Modifier - .padding(8.dp) - .size(16.dp), - tint = MaterialTheme.colorScheme.onTertiary - ) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/SettingsCategory.kt b/app/src/main/java/com/flux/ui/components/SettingsCategory.kt deleted file mode 100644 index e6034a9..0000000 --- a/app/src/main/java/com/flux/ui/components/SettingsCategory.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp - -@Composable -fun SettingCategory( - title: String, - subTitle: String = "", - icon: ImageVector, - shape: RoundedCornerShape, - isLast: Boolean = false, - action: () -> Unit = {}, - composableAction: @Composable (() -> Unit) -> Unit = {}, -) { - var showCustomAction by remember { mutableStateOf(false) } - if (showCustomAction) composableAction { showCustomAction = !showCustomAction } - - ElevatedCard( - shape = shape, - modifier = Modifier - .clip(shape) - .clickable { - showCustomAction = showCustomAction.not() - action() - }, - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - ) - ) { - Row( - modifier = Modifier - .clip(shape) - .fillMaxSize() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RenderCategoryIcon(icon = icon) - Spacer(modifier = Modifier.width(8.dp)) - Column { - RenderCategoryTitle(title = title) - RenderCategoryDescription(subTitle = subTitle) - } - } - } - Spacer(modifier = Modifier.height(if (isLast) 12.dp else 2.dp)) -} - -fun shapeManager( - isBoth: Boolean = false, - isLast: Boolean = false, - isFirst: Boolean = false, - radius: Int -): RoundedCornerShape { - val smallerRadius: Dp = (radius / 5).dp - val defaultRadius: Dp = radius.dp - - return when { - isBoth -> RoundedCornerShape(defaultRadius) - isLast -> RoundedCornerShape(smallerRadius, smallerRadius, defaultRadius, defaultRadius) - isFirst -> RoundedCornerShape(defaultRadius, defaultRadius, smallerRadius, smallerRadius) - else -> RoundedCornerShape(smallerRadius) - } -} - -@Composable -fun CircleWrapper( - color: Color = MaterialTheme.colorScheme.background, - size: Dp = 8.dp, - content: @Composable () -> Unit -) { - Box( - modifier = Modifier - .background( - color = color, - shape = RoundedCornerShape(50) - ) - .padding(size), - ) { - content() - } -} - -@Composable -fun MaterialText( - modifier: Modifier = Modifier, - title: String, - description: String? = null, - titleSize: TextUnit = 14.sp, - descriptionSize: TextUnit = 11.sp, - center: Boolean = false, - titleColor: Color = MaterialTheme.colorScheme.onSurface, - descriptionColor: Color = MaterialTheme.colorScheme.primary -) { - Column( - modifier = modifier, - horizontalAlignment = if (center) Alignment.CenterHorizontally else Alignment.Start, - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium.copy(fontSize = titleSize), - color = titleColor, - textAlign = if (center) TextAlign.Center else TextAlign.Start - ) - if (description != null) { - Text( - text = description, - style = MaterialTheme.typography.bodySmall.copy(fontSize = descriptionSize), - color = descriptionColor, - maxLines = 6, - overflow = TextOverflow.Ellipsis - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/Timeline.kt b/app/src/main/java/com/flux/ui/components/Timeline.kt deleted file mode 100644 index 7bbd001..0000000 --- a/app/src/main/java/com/flux/ui/components/Timeline.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.runtime.Composable -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -@Composable -fun TimelineBody( - isLast: Boolean, - color: Color = MaterialTheme.colorScheme.onSurface.copy(0.3f), - thickness: Dp = 2.dp -) { - Canvas( - modifier = Modifier - .width(16.dp) - .fillMaxHeight() - ) { - if (!isLast) { - val x = size.width / 2f - drawLine( - color = color, - start = Offset(x, 0f), - end = Offset(x, size.height), - strokeWidth = thickness.toPx(), - cap = StrokeCap.Round - ) - } - } -} - diff --git a/app/src/main/java/com/flux/ui/components/ToolBar.kt b/app/src/main/java/com/flux/ui/components/ToolBar.kt deleted file mode 100644 index e6669a2..0000000 --- a/app/src/main/java/com/flux/ui/components/ToolBar.kt +++ /dev/null @@ -1,333 +0,0 @@ -package com.flux.ui.components - -import android.content.Context -import android.os.Build -import android.widget.Toast -import androidx.annotation.RequiresApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.CalendarViewDay -import androidx.compose.material.icons.filled.CalendarViewMonth -import androidx.compose.material.icons.filled.Download -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.GridView -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.ViewStream -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.VerticalDivider -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.R -import com.flux.navigation.NavRoutes -import com.flux.other.canScheduleReminder -import com.flux.other.isNotificationPermissionGranted -import com.flux.other.openAppNotificationSettings -import com.flux.other.requestExactAlarmPermission -import java.time.LocalDate -import java.time.LocalTime -import java.time.ZoneId -import java.time.ZonedDateTime - -@Composable -fun SpacesToolBar( - title: String, - icon: ImageVector, - isEmptyWorkspace: Boolean, - onMainClick: () -> Unit, - onEditClick: () -> Unit -) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)) - ) { - if (isEmptyWorkspace) { - Row( - modifier = Modifier - .clickable { onEditClick() } - .padding(vertical = 6.dp, horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Add, - contentDescription = stringResource(R.string.Add_Spaces_Content_Desc), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = stringResource(R.string.Add_Space), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary - ) - } - } else { - Row( - modifier = Modifier.height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically - ) { - Row( - modifier = Modifier - .clickable { onMainClick() } - .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Icon( - icon, - contentDescription = stringResource(R.string.Space_Content_Desc), - tint = MaterialTheme.colorScheme.primary - ) - Text( - text = title, - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.primary - ) - Icon( - Icons.Default.ArrowDropDown, - contentDescription = stringResource(R.string.Space_Content_Desc), - tint = MaterialTheme.colorScheme.primary - ) - } - - VerticalDivider(Modifier.fillMaxHeight()) - - // Right section (Edit icon) - Row( - modifier = Modifier.clickable { onEditClick() }, - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - Icons.Default.Edit, - modifier = Modifier.padding(6.dp), - contentDescription = stringResource(R.string.Edit_Space_Content_Desc), - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - } -} - -@Composable -fun TodoToolBar(navController: NavController, workspaceId: String) { - IconButton({ navController.navigate(NavRoutes.TodoDetail.withArgs(workspaceId, "")) }) { - Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) - } -} - -@Composable -fun ProgressTrackerToolBar(onClick: () -> Unit) { - IconButton(onClick) { - Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) - } -} - -@Composable -fun JournalToolBar( - navController: NavController, - workspaceId: String, - selectedEpochDay: Long, - isMonthlyView: Boolean, - onChangeView: (Boolean) -> Unit -) { - val context = LocalContext.current - val zoneId = ZoneId.systemDefault() - val selectedDate = remember(selectedEpochDay) { LocalDate.ofEpochDay(selectedEpochDay) } - val today = LocalDate.now() - val isToday = selectedDate.isEqual(today) - - Row { - IconButton(onClick = { onChangeView(!isMonthlyView) }) { - Icon( - imageVector = if (isMonthlyView) - Icons.Default.CalendarViewDay - else - Icons.Default.CalendarViewMonth, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - - IconButton( - onClick = { - if (selectedDate.isAfter(today)) { - Toast - .makeText( - context, - "You can't write journals for future dates", - Toast.LENGTH_SHORT - ) - .show() - return@IconButton - } - - val navigationEpochMillis = - if (isToday) { - System.currentTimeMillis() - } else { - selectedDate - .atStartOfDay(zoneId) - .toInstant() - .toEpochMilli() - } - - navController.navigate( - NavRoutes.EditJournal.withArgs( - workspaceId, - "", - navigationEpochMillis - ) - ) - } - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - } - } -} - - -@RequiresApi(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.S) -@Composable -fun EventToolBar(navController: NavController, workspaceId: String, context: Context, selectedDate: Long, isMonthlyView: Boolean, onClick: (Boolean) -> Unit) { - Row { - IconButton({ onClick(!isMonthlyView) }) { - Icon( - if (isMonthlyView) Icons.Default.CalendarViewDay else Icons.Default.CalendarViewMonth, - null, - tint = MaterialTheme.colorScheme.primary - ) - } - IconButton({ - if (!canScheduleReminder(context)) { - Toast.makeText( - context, - context.getText(R.string.Reminder_Permission), - Toast.LENGTH_SHORT - ).show() - requestExactAlarmPermission(context) - } - if (!isNotificationPermissionGranted(context)) { - Toast.makeText( - context, - context.getText(R.string.Notification_Permission), - Toast.LENGTH_SHORT - ).show() - openAppNotificationSettings(context) - } - if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) { - val localDate = LocalDate.ofEpochDay(selectedDate) - val currentTime = LocalTime.now() - val zonedDateTime = ZonedDateTime.of(localDate, currentTime, ZoneId.systemDefault()) - val selectedDateMillis = zonedDateTime.toInstant().toEpochMilli() - - navController.navigate(NavRoutes.NewEvent.withArgs(workspaceId, "", selectedDateMillis)) - } - }) { Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) } - } -} - -@RequiresApi(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.S) -@Composable -fun HabitToolBar(context: Context, onAddClick: () -> Unit) { - IconButton({ - if (!canScheduleReminder(context)) { - Toast.makeText( - context, - context.getText(R.string.Reminder_Permission), - Toast.LENGTH_SHORT - ).show() - requestExactAlarmPermission(context) - } - if (!isNotificationPermissionGranted(context)) { - Toast.makeText( - context, - context.getText(R.string.Notification_Permission), - Toast.LENGTH_SHORT - ).show() - openAppNotificationSettings(context) - } - if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) { - onAddClick() - } - }) { Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) } -} - -@Composable -fun NotesToolBar( - navController: NavController, - workspaceId: String, - query: String, - isGridView: Boolean, - onImportNote: () -> Unit, - onChangeView: () -> Unit, - onSearch: (String) -> Unit -) { - var onSearchClicked by remember { mutableStateOf(false) } - - if (!onSearchClicked) { - Row { - IconButton({ onSearchClicked = true }) { - Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary) - } - IconButton(onChangeView) { - val icon = when { - isGridView -> Icons.Default.ViewStream - else -> Icons.Default.GridView - } - Icon(icon, null, tint = MaterialTheme.colorScheme.primary) - } - IconButton(onImportNote) { - Icon(Icons.Default.Download, null, tint = MaterialTheme.colorScheme.primary) - } - IconButton({ - navController.navigate( - NavRoutes.NoteDetails.withArgs( - workspaceId, - "" - ) - ) - }) { - Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) - } - } - } else { - NotesSearchBar( - query = query, - onQueryChange = { onSearch(it) }, - onCloseClicked = { - onSearch("") - onSearchClicked = false }, - modifier = Modifier.width(200.dp) - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/components/TopBar.kt b/app/src/main/java/com/flux/ui/components/TopBar.kt deleted file mode 100644 index e330f74..0000000 --- a/app/src/main/java/com/flux/ui/components/TopBar.kt +++ /dev/null @@ -1,323 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.Deselect -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.PushPin -import androidx.compose.material.icons.filled.RemoveRedEye -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.SearchOff -import androidx.compose.material.icons.filled.SelectAll -import androidx.compose.material.icons.filled.Summarize -import androidx.compose.material.icons.outlined.PushPin -import androidx.compose.material3.CenterAlignedTopAppBar -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage -import com.flux.data.model.WorkspaceModel - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NoteDetailsTopBar( - isPinned: Boolean, - isSearching: Boolean, - isReadView: Boolean, - onBackPressed: () -> Unit, - onOutlineClicked: () -> Unit, - onReadClick: () -> Unit, - onEditClick: ()->Unit, - onDelete: () -> Unit, - onAddLabel: () -> Unit, - onSearchClick: () -> Unit, - onTogglePinned: () -> Unit, - onAboutClicked: () -> Unit, - onShareNote: () -> Unit, - onSaveNote: () -> Unit, - onPrintNote: () -> Unit, -) { - CenterAlignedTopAppBar( - title = { - Row { - Box( - modifier = Modifier - .background( - if (!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( - 6.dp - ), - RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp) - ) - .clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) - .clickable { onEditClick() } - .padding(8.dp) - ) { - Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) - } - Spacer(Modifier.width(1.dp)) - Box( - modifier = Modifier - .background( - if (isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( - 6.dp - ), - RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp) - ) - .clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) - .clickable { onReadClick() } - .padding(8.dp) - ) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) } - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } }, - actions = { - if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } } - IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) } - DropdownMenuWithDetails(isPinned, onTogglePinned, onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete) } - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun JournalDetailsTopBar( - isSearching: Boolean, - isReadView: Boolean, - onBackPressed: () -> Unit, - onOutlineClicked: () -> Unit, - onReadClick: () -> Unit, - onEditClick: ()->Unit, - onDelete: () -> Unit, - onSearchClick: () -> Unit, - onAboutClicked: () -> Unit, - onShareNote: () -> Unit, - onSaveNote: () -> Unit, - onPrintNote: () -> Unit, -) { - CenterAlignedTopAppBar( - title = { - Row { - Box( - modifier = Modifier - .background( - if (!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( - 6.dp - ), - RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp) - ) - .clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) - .clickable { onEditClick() } - .padding(8.dp) - ) { - Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) - } - Spacer(Modifier.width(1.dp)) - Box( - modifier = Modifier - .background( - if (isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( - 6.dp - ), - RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp) - ) - .clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) - .clickable { onReadClick() } - .padding(8.dp) - ) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) } - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } }, - actions = { - if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } } - IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) } - JournalDropdownMenu(onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete) } - ) -} - - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WorkspaceTopBar( - workspace: WorkspaceModel, - isFullView: Boolean, - onBackPressed: () -> Unit, - onDelete: () -> Unit, - onTogglePinned: () -> Unit, - onToggleLock: () -> Unit, - onAddCover: () -> Unit, - onEditDetails: () -> Unit, - onEditLabel: () -> Unit, - onRemoveCover: () -> Unit -) { - if (isFullView) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(if (workspace.cover.isNotBlank()) 160.dp else 80.dp) - ) { - // Background image - AsyncImage( - model = workspace.cover, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() - ) - } - - // Overlay TopAppBar - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - title = { }, - navigationIcon = { - IconButton( - onClick = onBackPressed, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } - }, - actions = { - WorkspaceMore( - isCompactMode = !isFullView, - isCoverAdded = workspace.cover.isNotBlank(), - isLocked = workspace.passKey.isNotBlank(), - isPinned = workspace.isPinned, - showEditLabel = workspace.selectedSpaces.contains(1), - onDelete = onDelete, - onEditDetails = onEditDetails, - onEditLabel = onEditLabel, - onRemoveCover = onRemoveCover, - onAddCover = onAddCover, - onTogglePinned = onTogglePinned, - onToggleLock = onToggleLock - ) - } - ) - } - else { - // Overlay TopAppBar - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - title = { - Column { - Text( - workspace.title, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(end = 3.dp) - ) - if (workspace.description.isNotBlank()) { - Text( - workspace.description, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(end = 3.dp) - ) - } - } }, - navigationIcon = { - IconButton( - onClick = onBackPressed, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - contentColor = MaterialTheme.colorScheme.onSurface - ) - ) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } - }, - actions = { - WorkspaceMore( - isCompactMode = !isFullView, - isCoverAdded = workspace.cover.isNotBlank(), - isLocked = workspace.passKey.isNotBlank(), - isPinned = workspace.isPinned, - showEditLabel = workspace.selectedSpaces.contains(1), - onDelete = onDelete, - onEditDetails = onEditDetails, - onEditLabel = onEditLabel, - onRemoveCover = onRemoveCover, - onAddCover = onAddCover, - onTogglePinned = onTogglePinned, - onToggleLock = onToggleLock - ) - } - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun SelectedBar( - showDeleteOption: Boolean=true, - isAllSelected: Boolean, - isAllSelectedPinned: Boolean, - selectedItemsSize: Int, - onPinClick: () -> Unit, - onDeleteClick: () -> Unit, - onSelectAllClick: () -> Unit, - onCloseClick: () -> Unit -) { - Row( - Modifier - .fillMaxWidth() - .padding(bottom = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - IconButton(onCloseClick) { - Icon( - Icons.Default.Close, - null, - tint = MaterialTheme.colorScheme.primary - ) - } - Text("$selectedItemsSize", color = MaterialTheme.colorScheme.primary) - } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - IconButton(onClick = { - onPinClick() - onCloseClick() - }) { Icon(if(isAllSelectedPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, null, tint = MaterialTheme.colorScheme.primary) } - IconButton(onSelectAllClick) { Icon(if(isAllSelected) Icons.Default.Deselect else Icons.Default.SelectAll, null, tint = MaterialTheme.colorScheme.primary) } - if(showDeleteOption) IconButton({ - onDeleteClick() - } ) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.primary) } - } - } -} diff --git a/app/src/main/java/com/flux/ui/components/WorkSpaceComponents.kt b/app/src/main/java/com/flux/ui/components/WorkSpaceComponents.kt deleted file mode 100644 index 319108b..0000000 --- a/app/src/main/java/com/flux/ui/components/WorkSpaceComponents.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.flux.ui.components - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.Workspaces -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.core.net.toUri -import coil.compose.AsyncImage -import com.flux.R -import com.flux.other.icons - -@Composable -fun WorkspaceCard( - gridColumns: Int, - radius: Int, - isLocked: Boolean = false, - cover: String, - title: String, - description: String, - iconIndex: Int, - isSelected: Boolean, - onClick: () -> Unit, - onLongPressed: ()->Unit -){ - val coverHeight = when (gridColumns) { - 1 -> 120.dp - 2 -> 100.dp - else -> 80.dp - } - - val maxTitleLines = when (gridColumns) { - 1 -> 2 - else -> 1 - } - - val maxDescriptionLines = when (gridColumns) { - 1 -> 3 - else -> 2 - } - - val paddingValues = when (gridColumns) { - 1 -> 8.dp - 2 -> 6.dp - else -> 4.dp - } - - val iconSize = when (gridColumns) { - 1 -> 28.dp - 2 -> 24.dp - else -> 18.dp - } - - val titleStyle = when (gridColumns) { - 1 -> MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - 2 -> MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) - else -> MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold) - } - - val descriptionStyle = when (gridColumns) { - 1 -> MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Normal) - 2 -> MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal) - else -> MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraLight) - } - - Card( - shape = shapeManager(radius = radius*2), - modifier = Modifier.fillMaxWidth().padding(horizontal = paddingValues) - .clip(shapeManager(radius = radius*2)) - .combinedClickable( - onClick = onClick, - onLongClick = onLongPressed - ), - colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), - border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null - ) { - Column( - Modifier - .fillMaxSize() - .padding(bottom = 4.dp) - ) { - if (cover.isBlank()) { - Box( - Modifier - .fillMaxWidth() - .height(coverHeight) - .alpha(0.125f) - .background(MaterialTheme.colorScheme.onSurface) - ) - } else { - AsyncImage( - model = cover.toUri(), - modifier = Modifier - .height(coverHeight) - .alpha(0.8f), - contentDescription = null, - contentScale = ContentScale.Crop - ) - } - Row( - modifier = Modifier.padding(vertical = 8.dp, horizontal = paddingValues), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - icons[iconIndex], - null, - Modifier.size(iconSize), - MaterialTheme.colorScheme.primary - ) - if (isLocked) Icon( - Icons.Default.Lock, - null, - Modifier.size(iconSize), - MaterialTheme.colorScheme.primary - ) - Text( - title, - modifier = Modifier.padding(start = 4.dp), - maxLines = maxTitleLines, - style = titleStyle, - overflow = TextOverflow.Clip, - color = MaterialTheme.colorScheme.primary - ) - } - - Text( - description, - style = descriptionStyle, - modifier = Modifier - .padding(bottom = 6.dp) - .padding(horizontal = paddingValues), - maxLines = maxDescriptionLines, - overflow = TextOverflow.Ellipsis - ) - } - } -} - -@Composable -fun EmptySpaces() { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.Workspaces, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text(stringResource(R.string.Empty_Workspace)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/events/HabitEvents.kt b/app/src/main/java/com/flux/ui/events/HabitEvents.kt index 4444093..b631605 100644 --- a/app/src/main/java/com/flux/ui/events/HabitEvents.kt +++ b/app/src/main/java/com/flux/ui/events/HabitEvents.kt @@ -1,14 +1,13 @@ package com.flux.ui.events import android.content.Context +import com.flux.data.model.HabitConfig import com.flux.data.model.HabitInstanceModel import com.flux.data.model.HabitModel sealed class HabitEvents { data class DeleteAllWorkspaceHabits(val workspaceId: String, val context: Context) : HabitEvents() - data class EnterWorkspace(val workspaceId: String) : HabitEvents() data class DeleteHabit(val habit: HabitModel, val context: Context) : HabitEvents() data class UpsertHabit(val context: Context, val habit: HabitModel) : HabitEvents() - data class MarkDone(val habitInstance: HabitInstanceModel) : HabitEvents() - data class MarkUndone(val habitInstance: HabitInstanceModel) : HabitEvents() + data class UpdateInstance(val habitInstance: HabitInstanceModel, val config: HabitConfig) : HabitEvents() } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/events/JournalEvents.kt b/app/src/main/java/com/flux/ui/events/JournalEvents.kt index fdb9c8d..ec98df7 100644 --- a/app/src/main/java/com/flux/ui/events/JournalEvents.kt +++ b/app/src/main/java/com/flux/ui/events/JournalEvents.kt @@ -6,19 +6,15 @@ import android.webkit.WebView import androidx.compose.foundation.text.input.TextFieldState import com.flux.data.model.JournalModel import com.flux.other.ExportType -import java.time.YearMonth sealed class JournalEvents { data class UpsertEntry(val entry: JournalModel) : JournalEvents() data class DeleteEntry(val entry: JournalModel) : JournalEvents() data class DeleteWorkspaceEntries(val workspaceId: String) : JournalEvents() - data class EnterWorkspace(val workspaceId: String) : JournalEvents() data class ImportAudio(val context: Context, val sourceUri: Uri, val contentState: TextFieldState): JournalEvents() data class ImportImages(val context: Context, val uriList: List, val contentState: TextFieldState): JournalEvents() data class ImportVideo(val context: Context, val uri: Uri, val contentState: TextFieldState): JournalEvents() data class ExportJournal(val context: Context, val type: ExportType, val title: String, val content: String, val webView: WebView?): JournalEvents() data class CalculateOutline(val content: CharSequence): JournalEvents() data class CalculateTextState(val content: CharSequence): JournalEvents() - data class ChangeMonth(val newYearMonth: YearMonth) : JournalEvents() - data class ChangeDate(val newLocalDate: Long) : JournalEvents() } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/events/LabelEvents.kt b/app/src/main/java/com/flux/ui/events/LabelEvents.kt new file mode 100644 index 0000000..5565246 --- /dev/null +++ b/app/src/main/java/com/flux/ui/events/LabelEvents.kt @@ -0,0 +1,9 @@ +package com.flux.ui.events + +import com.flux.data.model.LabelModel + +sealed class LabelEvents { + data class DeleteLabel(val data: LabelModel) : LabelEvents() + data class UpsertLabel(val data: LabelModel) : LabelEvents() + data class DeleteAllWorkspaceLabels(val workspaceId: String) : LabelEvents() +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/events/NotesEvents.kt b/app/src/main/java/com/flux/ui/events/NotesEvents.kt index d895236..2701e1f 100644 --- a/app/src/main/java/com/flux/ui/events/NotesEvents.kt +++ b/app/src/main/java/com/flux/ui/events/NotesEvents.kt @@ -4,19 +4,15 @@ import android.content.Context import android.net.Uri import android.webkit.WebView import androidx.compose.foundation.text.input.TextFieldState -import com.flux.data.model.LabelModel import com.flux.data.model.NotesModel import com.flux.other.ExportType sealed class NotesEvents { data class DeleteAllWorkspaceNotes(val workspaceId: String) : NotesEvents() - data class EnterWorkspace(val workspaceId: String) : NotesEvents() data class DeleteNote(val data: NotesModel) : NotesEvents() data class DeleteNotes(val data: List) : NotesEvents() data class TogglePinMultiple(val data: List) : NotesEvents() data class UpsertNote(val data: NotesModel) : NotesEvents() - data class DeleteLabel(val data: LabelModel) : NotesEvents() - data class UpsertLabel(val data: LabelModel) : NotesEvents() data class SelectNotes(val noteId: String) : NotesEvents() data class UnSelectNotes(val noteId: String) : NotesEvents() data class ImportAudio(val context: Context, val sourceUri: Uri, val contentState: TextFieldState): NotesEvents() diff --git a/app/src/main/java/com/flux/ui/events/ProgressBoardEvents.kt b/app/src/main/java/com/flux/ui/events/ProgressBoardEvents.kt index 489c8d1..cdb1ddc 100644 --- a/app/src/main/java/com/flux/ui/events/ProgressBoardEvents.kt +++ b/app/src/main/java/com/flux/ui/events/ProgressBoardEvents.kt @@ -4,7 +4,6 @@ import com.flux.data.model.ProgressBoardModel sealed class ProgressBoardEvents { data class DeleteBoardItemsByWorkspace(val workspaceId: String) : ProgressBoardEvents() - data class EnterWorkspace(val workspaceId: String) : ProgressBoardEvents() data class DeleteProgressItem(val data: ProgressBoardModel) : ProgressBoardEvents() data class UpsertProgressItem(val data: ProgressBoardModel) : ProgressBoardEvents() } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/events/TaskEvents.kt b/app/src/main/java/com/flux/ui/events/TaskEvents.kt index 7e4aff8..87cf901 100644 --- a/app/src/main/java/com/flux/ui/events/TaskEvents.kt +++ b/app/src/main/java/com/flux/ui/events/TaskEvents.kt @@ -6,7 +6,6 @@ import java.time.YearMonth sealed class TaskEvents { data class DeleteAllWorkspaceEvents(val workspaceId: String, val context: Context) : TaskEvents() - data class EnterWorkspace(val workspaceId: String) : TaskEvents() data class UpsertTask(val context: Context, val taskEvent: EventModel) : TaskEvents() data class DeleteTask(val taskEvent: EventModel, val context: Context) : TaskEvents() data class ToggleStatus(val markDone: Boolean, val eventId: String, val workspaceId: String, val date: Long) : TaskEvents() diff --git a/app/src/main/java/com/flux/ui/events/TodoEvents.kt b/app/src/main/java/com/flux/ui/events/TodoEvents.kt index a5eecf6..d855b99 100644 --- a/app/src/main/java/com/flux/ui/events/TodoEvents.kt +++ b/app/src/main/java/com/flux/ui/events/TodoEvents.kt @@ -4,7 +4,6 @@ import com.flux.data.model.TodoModel sealed class TodoEvents { data class DeleteAllWorkspaceLists(val workspaceId: String) : TodoEvents() - data class EnterWorkspace(val workspaceId: String) : TodoEvents() data class DeleteList(val data: TodoModel) : TodoEvents() data class UpsertList(val data: TodoModel) : TodoEvents() } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt b/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt index 3c6a4cf..8a2da70 100644 --- a/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/analytics/AnalyticsScreen.kt @@ -13,7 +13,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -27,36 +28,51 @@ import androidx.compose.material.icons.filled.LocalFireDepartment import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import com.flux.R import com.flux.data.model.EventInstanceModel import com.flux.data.model.EventModel import com.flux.data.model.HabitInstanceModel import com.flux.data.model.HabitModel import com.flux.data.model.JournalModel +import com.flux.data.model.WorkspaceModel +import com.flux.data.model.isCompleted import com.flux.data.model.occursOn -import com.flux.ui.components.ActionType -import com.flux.ui.components.SettingOption -import com.flux.ui.components.WeeklyHabitProgressChart -import com.flux.ui.components.shapeManager +import com.flux.navigation.NavRoutes +import com.flux.ui.common.SpaceTopBar +import com.flux.ui.common.SpacesMenu +import com.flux.ui.screens.habits.HabitsWeeklyProgressAnalysis +import com.flux.ui.screens.settings.ActionType +import com.flux.ui.screens.settings.SettingOption +import com.flux.ui.screens.settings.shapeManager +import com.flux.ui.screens.workspaces.SpacesToolBar +import com.flux.ui.state.States import com.flux.ui.theme.completed import com.flux.ui.theme.failed import com.flux.ui.theme.pending @@ -68,62 +84,125 @@ import java.time.ZoneId import java.time.temporal.ChronoUnit import java.time.temporal.TemporalAdjusters -fun LazyListScope.analyticsItems( +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnalyticScreen( + navController: NavController, + states: States, + workspace: WorkspaceModel, selectedSpaces: List, - radius: Int, - allHabitInstances: List, - totalHabits: Int, - totalNotes: Int, - journalEntries: List, - allHabits: List, - allEvents: List, - allEventInstances: List -) { - when { - selectedSpaces.isEmpty() -> item { EmptyAnalytics() } - else -> { - if (selectedSpaces.contains(1)){ - item { - SettingOption( - title = stringResource(R.string.Notes), - description = totalNotes.toString(), - icon = Icons.AutoMirrored.Default.Notes, - radius = shapeManager(radius = radius, isBoth = true), - actionType = ActionType.None - ) - Spacer(Modifier.height(8.dp)) - } - } - if (selectedSpaces.contains(4)) { - item { - JournalAnalytics(radius, journalEntries) - Spacer(Modifier.height(8.dp)) - } - } - if (selectedSpaces.contains(3)) { - item { - ChartCirclePie( - radius = radius, - weeklyEventStats = calculateWeeklyStats(allEvents, allEventInstances) - ) - Spacer(Modifier.height(8.dp)) - } - } - if (selectedSpaces.contains(5)){ - item { - HabitHeatMap(radius, allHabitInstances, totalHabits) - Spacer(Modifier.height(8.dp)) - } - } - if(selectedSpaces.contains(5)){ - item { - WeeklyHabitProgressChart( - radius, - habits = allHabits, - habitInstances = allHabitInstances, - modifier = Modifier.padding(horizontal = 16.dp) - ) - Spacer(Modifier.height(8.dp)) + onShowSpaceBottomSheet: () -> Unit, + onSpaceChange: (Int) -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit +){ + val workspaceId = workspace.workspaceId + val totalNotes = states.notesState.allNotes.filter { it.workspaceId == workspaceId }.size + val radius = states.settings.data.cornerRadius + val allEvents = states.eventState.allEvent.filter { it.workspaceId == workspaceId } + val allEventInstances = states.eventState.allEventInstances.filter { it.workspaceId == workspaceId } + val journalEntries = states.journalState.data.filter { it.workspaceId == workspaceId } + val allHabits = states.habitState.allHabits.filter { it.workspaceId == workspaceId } + val allHabitInstances = states.habitState.allInstances.filter { it.workspaceId == workspaceId } + val totalHabits = states.habitState.allHabits.filter { it.workspaceId == workspaceId }.size + var showSpacesMenu by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + }, + ) { innerPadding -> + when { + selectedSpaces.isEmpty() -> EmptyAnalytics() + else -> { + LazyColumn( + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + item { + Row( + Modifier.fillMaxWidth().padding(bottom=8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + SpacesToolBar( + stringResource(R.string.Analytics), + Icons.Default.Analytics, + false, + onMainClick = { showSpacesMenu = true }, + onEditClick = onShowSpaceBottomSheet + ) + SpacesMenu(showSpacesMenu, workspace, onSpaceChange) { showSpacesMenu = false } + } + } + if (selectedSpaces.contains(1)){ + item { + SettingOption( + title = stringResource(R.string.Notes), + description = totalNotes.toString(), + icon = Icons.AutoMirrored.Default.Notes, + radius = shapeManager(radius = radius, isBoth = true), + actionType = ActionType.None + ) + Spacer(Modifier.height(8.dp)) + } + } + if (selectedSpaces.contains(3)) { + item { + ChartCirclePie( + radius = radius, + weeklyEventStats = calculateWeeklyStats(allEvents, allEventInstances) + ) + Spacer(Modifier.height(8.dp)) + } + } + if (selectedSpaces.contains(4)) { + item { + JournalAnalytics(radius, journalEntries) + Spacer(Modifier.height(8.dp)) + } + item { + JournalHeatMap(radius, journalEntries) + Spacer(Modifier.height(8.dp)) + } + } + if (selectedSpaces.contains(5)){ + item { + HabitHeatMap(radius, allHabits, allHabitInstances, totalHabits) + Spacer(Modifier.height(8.dp)) + } + } + if(selectedSpaces.contains(5)){ + item { + HabitsWeeklyProgressAnalysis( + radius, + habits = allHabits, + habitInstances = allHabitInstances, + modifier = Modifier.padding(horizontal = 16.dp) + ) + Spacer(Modifier.height(8.dp)) + } + } } } } @@ -195,7 +274,10 @@ fun calculateWeeklyStats( } @Composable -fun HabitHeatMap(radius: Int, allHabitInstances: List, totalHabits: Int) { +fun JournalHeatMap( + radius: Int, + allEntries: List +){ val today = LocalDate.now() val yearStart = LocalDate.of(today.year, 1, 1) @@ -206,8 +288,92 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List, total val totalDays = ChronoUnit.DAYS.between(yearStart, today).toInt() + 1 val allDates = (0 until totalDays).map { yearStart.plusDays(it.toLong()) } - val habitMap = remember(allHabitInstances) { - allHabitInstances.groupBy { LocalDate.ofEpochDay(it.instanceDate) } + val heatMap = remember(allEntries) { + allEntries + .groupingBy { + Instant.ofEpochMilli(it.dateTime) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + } + .eachCount() + } + + // Create week columns with proper day alignment + val weekColumns = mutableListOf>() + var currentWeek = MutableList(7) { null } + + // Fill the first week with nulls for days before January 1st + for (i in 0 until offsetFromMonday) { + currentWeek[i] = null + } + + // Add all dates starting from the correct day of week + allDates.forEachIndexed { index, date -> + val dayIndex = (offsetFromMonday + index) % 7 + currentWeek[dayIndex] = date + + // When we complete a week (reach Sunday) or it's the last date + if (dayIndex == 6 || index == allDates.size - 1) { + weekColumns.add(currentWeek.toList()) + currentWeek = MutableList(7) { null } + } + } + + val boxSize = 24.dp + val lazyListState = rememberLazyListState() + + // Calculate the index of the current month's first week + val currentMonthStartIndex = remember(weekColumns) { + val currentMonth = today.month + weekColumns.indexOfFirst { week -> + week.any { date -> date?.month == currentMonth } + }.takeIf { it != -1 } ?: 0 + } + + // Auto-scroll to current month on first composition + LaunchedEffect(currentMonthStartIndex) { + if (currentMonthStartIndex > 0) { + lazyListState.scrollToItem(index = maxOf(0, currentMonthStartIndex - 2)) + } + } + + HeatMapCard( + radius, + "Journal Heat Map", + "", + boxSize, + 5, + lazyListState, + weekColumns, + heatMap.toMap() + ) +} + +@Composable +fun HabitHeatMap(radius: Int, habits: List, allHabitInstances: List, totalHabits: Int) { + val today = LocalDate.now() + val yearStart = LocalDate.of(today.year, 1, 1) + + // Calculate the offset from Monday for January 1st + val jan1DayOfWeek = yearStart.dayOfWeek.value // Monday = 1, Sunday = 7 + val offsetFromMonday = jan1DayOfWeek - 1 // 0 for Monday, 6 for Sunday + + val totalDays = ChronoUnit.DAYS.between(yearStart, today).toInt() + 1 + val allDates = (0 until totalDays).map { yearStart.plusDays(it.toLong()) } + + val habitMapById = remember(habits) { + habits.associateBy { it.id } + } + + val heatMap = remember(allHabitInstances, habits) { + allHabitInstances + .groupBy { LocalDate.ofEpochDay(it.instanceDate) } + .mapValues { (_, instances) -> + instances.count { instance -> + val habit = habitMapById[instance.habitId] ?: return@count false + instance.isCompleted(habit) + } + } } // Create week columns with proper day alignment @@ -252,6 +418,29 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List, total } } + HeatMapCard( + radius, + stringResource(R.string.HeatMap), + "${stringResource(R.string.Completed_Today)}: $todayHabit/$totalHabits", + boxSize, + totalHabits, + lazyListState, + weekColumns, + heatMap.toMap() + ) +} + +@Composable +fun HeatMapCard( + radius: Int, + title: String, + description: String, + boxSize: Dp, + intensityParam: Int, + lazyListState: LazyListState, + weekColumns: List>, + heatMap: Map +){ Card( onClick = {}, modifier = Modifier.fillMaxWidth(), @@ -267,16 +456,18 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List, total verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - stringResource(R.string.HeatMap), + title, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth() ) - Text( - "${stringResource(R.string.Completed_Today)}: $todayHabit/$totalHabits", - style = MaterialTheme.typography.labelMedium - ) + if(description.isNotBlank()){ + Text( + description, + style = MaterialTheme.typography.labelMedium + ) + } Row( modifier = Modifier .fillMaxWidth() @@ -335,9 +526,9 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List, total // Heatmap boxes columnDates.forEach { date -> if (date != null) { - val count = habitMap[date]?.size ?: 0 + val count = heatMap[date] ?: 0 val intensity = - (count / if (totalHabits > 0) totalHabits.toFloat() else 2f) + (count / if (intensityParam > 0) intensityParam.toFloat() else 2f) .coerceIn(0f, 1f) val color = lerp( MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), diff --git a/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt b/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt index c5b265b..570ce91 100644 --- a/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt +++ b/app/src/main/java/com/flux/ui/screens/auth/AuthScreen.kt @@ -42,7 +42,7 @@ import androidx.navigation.NavController import com.flux.R import com.flux.navigation.NavRoutes import com.flux.other.BiometricAuthenticator -import com.flux.ui.components.CircleWrapper +import com.flux.ui.screens.settings.CircleWrapper @Composable fun AuthScreen( diff --git a/app/src/main/java/com/flux/ui/screens/events/EventComponents.kt b/app/src/main/java/com/flux/ui/screens/events/EventComponents.kt new file mode 100644 index 0000000..3cfd476 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/events/EventComponents.kt @@ -0,0 +1,648 @@ +package com.flux.ui.screens.events + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.NotificationsActive +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.flux.R +import com.flux.data.model.RecurrenceRule +import com.flux.ui.screens.settings.CircleWrapper +import com.flux.ui.screens.settings.shapeManager +import java.text.SimpleDateFormat +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Date +import java.util.Locale +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.automirrored.filled.ArrowBackIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.text.style.TextAlign +import java.time.LocalDate +import java.time.YearMonth + +// ------------- Dialogs ------------- +@Composable +fun EventNotificationDialog( + currentOffset: Long, + onChange: (Long) -> Unit, + onCustomClick: () -> Unit, + onDismissRequest: () -> Unit +) { + val options = listOf( + 0L to stringResource(R.string.On_Time), + 5L to stringResource(R.string.five_minutes_before), + 30L to stringResource(R.string.thirty_minutes_before) + ) + + // Convert to minutes for comparison + val currentMinutes = currentOffset / 1000 / 60 + + Dialog(onDismissRequest = onDismissRequest) { + Card(Modifier.fillMaxWidth()) { + Column( + Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircleWrapper(MaterialTheme.colorScheme.primary) { + Icon( + Icons.Outlined.NotificationsActive, + contentDescription = "Notification Icon", + tint = MaterialTheme.colorScheme.onPrimary + ) + } + + Text( + stringResource(R.string.Add_Notification), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + ) + Spacer(Modifier.height(16.dp)) + options.forEach { (minutesBefore, label) -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onChange(minutesBefore * 60 * 1000) + onDismissRequest() + } + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(label) + RadioButton( + selected = currentMinutes == minutesBefore, + onClick = { + onChange(minutesBefore * 60 * 1000) + onDismissRequest() + } + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable { + onCustomClick() + onDismissRequest() + } + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(stringResource(R.string.Custom)) + RadioButton( + selected = options.none { it.first == currentMinutes }, + onClick = { + onCustomClick() + onDismissRequest() + } + ) + } + } + } + } +} + +@Composable +fun CustomNotificationDialog( + onDismissRequest: () -> Unit, + onConfirm: (offsetMillis: Long) -> Unit +) { + val timeUnits = listOf( + stringResource(R.string.minutes), + stringResource(R.string.hours), + stringResource(R.string.days) + ) + var selectedUnit by remember { mutableStateOf(timeUnits[0]) } + var amountText by remember { mutableStateOf("1") } + val amount = amountText.toIntOrNull()?.coerceAtLeast(1) ?: 1 + + val offsetMillis = when (selectedUnit) { + stringResource(R.string.minutes) -> amount * 60_000L + stringResource(R.string.hours) -> amount * 60 * 60_000L + stringResource(R.string.days) -> amount * 24 * 60 * 60_000L + else -> 0L + } + + Dialog(onDismissRequest = onDismissRequest) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp)) { + Text( + stringResource(R.string.Custom_Notification), + style = MaterialTheme.typography.titleLarge + ) + Spacer(Modifier.height(8.dp)) + + timeUnits.forEach { unit -> + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .selectable( + selected = selectedUnit == unit, + onClick = { selectedUnit = unit } + ) + .padding(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = selectedUnit == unit, + onClick = { selectedUnit = unit } + ) + Text(unit, modifier = Modifier.padding(start = 8.dp)) + } + } + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = amountText, + onValueChange = { if (it.all { c -> c.isDigit() }) amountText = it }, + label = { Text(stringResource(R.string.Amount)) }, + singleLine = true, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Number + ), + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.Cancel)) } + Spacer(Modifier.width(8.dp)) + Button(onClick = { + onConfirm(offsetMillis) + onDismissRequest() + }) { + Text(stringResource(R.string.Set)) + } + } + } + } + } +} + +@Composable +fun IconRadioButton( + modifier: Modifier = Modifier, + uncheckedTint: Color = MaterialTheme.colorScheme.onSurface, + checkedTint: Color = MaterialTheme.colorScheme.onPrimaryContainer, + selected: Boolean, + onClick: () -> Unit +) { + IconButton(modifier = modifier, onClick = onClick) { + if (selected) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Selected", + tint = checkedTint + ) + } else { + Icon( + imageVector = Icons.Default.RadioButtonUnchecked, + contentDescription = "Unselected", + tint = uncheckedTint + ) + } + } +} + +@Composable +fun EventCard( + radius: Int, + is24HourFormat: Boolean, + isPending: Boolean, + title: String, + repeat: RecurrenceRule, + startDateTime: Long, + onChangeStatus: () -> Unit, + onClick: () -> Unit +) { + val containerColor = + if (isPending) MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + else MaterialTheme.colorScheme.primaryContainer + val contentColor = + if (isPending) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onPrimaryContainer + + val context = LocalContext.current + val time = startDateTime.toFormattedTime(is24HourFormat) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor + ), + shape = shapeManager(radius = radius * 2), + onClick = onClick + ) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + ) { + IconRadioButton( + selected = !isPending, + onClick = onChangeStatus + ) + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(6.dp)) + Text( + "${getRecurrenceText(context, repeat, startDateTime)} at $time", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } +} + +fun getRecurrenceText(context: Context, repeat: RecurrenceRule, startDateTime: Long): String { + val localDate = Instant.ofEpochMilli(startDateTime) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + return when (repeat) { + is RecurrenceRule.Once -> { + context.getString( + R.string.recurrence_once, + localDate.format(DateTimeFormatter.ofPattern("MMM dd, yyyy")) + ) + } + + is RecurrenceRule.Custom -> { + if (repeat.everyXDays == 1) { + context.getString(R.string.recurrence_daily) + } else { + context.getString(R.string.recurrence_every_x_days, repeat.everyXDays) + } + } + + is RecurrenceRule.Weekly -> { + val days = listOf("M", "T", "W", "T", "F", "S", "S") + if (repeat.daysOfWeek.size == 7) { + context.getString(R.string.recurrence_daily) + } else { + val daysText = repeat.daysOfWeek.sorted().joinToString(", ") { days[it] } + context.getString(R.string.recurrence_weekly_on, daysText) + } + } + + is RecurrenceRule.Monthly -> { + context.getString(R.string.recurrence_monthly_on, localDate.dayOfMonth) + } + + is RecurrenceRule.Yearly -> { + context.getString( + R.string.recurrence_yearly_on, + localDate.format(DateTimeFormatter.ofPattern("MMM dd")) + ) + } + } +} + +fun Long.toFormattedTime(is24Hour: Boolean = false): String { + val pattern = if (is24Hour) "HH:mm" else "hh:mm a" + val formatter = SimpleDateFormat(pattern, Locale.getDefault()) + return formatter.format(Date(this)) +} + +fun Long.toFormattedDate(): String { + val date = Date(this) + val format = SimpleDateFormat("dd MMMM, yyyy", Locale.getDefault()) + return format.format(date) +} + +@Composable +fun DailyViewDateCard(date: Long, day: String, isSelected: Boolean, onClick: () -> Unit) { + val localDate = LocalDate.ofEpochDay(date) + + val containerColor = + if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceContainerHighest + val contentColor = + if (isSelected) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface + + Card( + modifier = Modifier.width(60.dp), + colors = CardDefaults.cardColors( + containerColor = containerColor.copy(alpha = 0.6f), + contentColor = contentColor + ), + shape = RoundedCornerShape(8.dp), + onClick = onClick + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + day, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraLight), + modifier = Modifier.padding(top = 4.dp) + ) + ElevatedCard( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor + ), + shape = RoundedCornerShape(8.dp) + ) { + Text( + localDate.dayOfMonth.toString(), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) + } + } + } +} + +@Composable +fun MonthlyViewDateCard(date: Long, count: Int, maxCount: Int = 0, isSelected: Boolean, onClick: () -> Unit) { + val localDate = LocalDate.ofEpochDay(date) + val fraction = if (maxCount > 0 && count > 0) count.toFloat() / maxCount.toFloat() else 0f + val containerColor = + if (isSelected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surfaceContainerLow + val contentColor = + if (isSelected) MaterialTheme.colorScheme.onSurface + else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + val primaryColor = MaterialTheme.colorScheme.primary + + Box( + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(50)) + .drawBehind { + if (fraction > 0f && !isSelected) { + drawCircle( + brush = Brush.radialGradient( + colors = listOf( + primaryColor.copy(alpha = 0.35f * fraction), + Color.Transparent + ), + center = center, + radius = size.minDimension / 2f + ) + ) + } + } + .clickable { onClick() }, + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = localDate.dayOfMonth.toString(), + style = MaterialTheme.typography.bodyLarge, + color = contentColor + ) + + if (isSelected) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(containerColor) + .padding(top = 2.dp) + ) + } + } + } +} + +@Composable +fun DailyViewCalendar( + selectedMonth: YearMonth, + selectedDate: Long, + onDateChange: (Long) -> Unit +) { + val daysInMonth = selectedMonth.lengthOfMonth() + val dateList = (1..daysInMonth).map { day -> selectedMonth.atDay(day) } + val listState = rememberLazyListState() + + LaunchedEffect(selectedMonth, selectedDate) { + val todayIndex = dateList.indexOfFirst { it.toEpochDay() == selectedDate } + if (todayIndex >= 0) { + listState.animateScrollToItem( + index = maxOf(0, todayIndex - 2) + ) + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + state = listState + ) { + items(dateList) { date -> + val dayName = date.dayOfWeek.name + .take(3) + .lowercase() + .replaceFirstChar { it.uppercaseChar() } + + DailyViewDateCard( + date = date.toEpochDay(), + day = dayName, + isSelected = date.toEpochDay() == selectedDate, + onClick = { onDateChange(date.toEpochDay()) } + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MonthlyViewCalendar( + currentMonth: YearMonth, + selectedDate: Long, + monthlyJournalCount: Map = emptyMap(), + onMonthChange: (YearMonth) -> Unit, + onDateChange: (Long) -> Unit +) { + val daysOfWeek = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + val firstDayOfMonth = currentMonth.atDay(1) + val firstDayOffset = (firstDayOfMonth.dayOfWeek.value - 1) % 7 + val daysInMonth = currentMonth.lengthOfMonth() + + val allDates = buildList { + repeat(firstDayOffset) { add(null) } + for (day in 1..daysInMonth) { + add(currentMonth.atDay(day).toEpochDay()) + } + } + + Column(Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 8.dp, end = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(modifier = Modifier.weight(1f)) { + Text( + text = currentMonth.month.name.lowercase() + .replaceFirstChar { it.uppercaseChar() } + ", ${currentMonth.year}", + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary + ) + } + + IconButton(onClick = { + val prevMonth = currentMonth.minusMonths(1) + onMonthChange(prevMonth) + onDateChange(prevMonth.atDay(1).toEpochDay()) + }) { + Icon( + Icons.AutoMirrored.Default.ArrowBackIos, + tint = MaterialTheme.colorScheme.primary, + contentDescription = "Previous month", + modifier = Modifier + .size(18.dp) + .alpha(0.5f) + ) + } + + IconButton(onClick = { + val nextMonth = currentMonth.plusMonths(1) + onMonthChange(nextMonth) + onDateChange(nextMonth.atDay(1).toEpochDay()) + }) { + Icon( + Icons.AutoMirrored.Default.ArrowForwardIos, + tint = MaterialTheme.colorScheme.primary, + contentDescription = "Next month", + modifier = Modifier + .size(18.dp) + .alpha(0.5f) + ) + } + } + + // Weekday Row + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + daysOfWeek.forEach { + Text( + text = it, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + } + Spacer(Modifier.height(4.dp)) + + // Calendar Grid + LazyVerticalGrid( + columns = GridCells.Fixed(7), + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 100.dp, max = 300.dp), + userScrollEnabled = false + ) { + items(allDates) { date -> + if (date == null) { + Box(modifier = Modifier.size(48.dp)) + } else { + MonthlyViewDateCard( + date = date, + isSelected = selectedDate == date, + count = monthlyJournalCount[LocalDate.ofEpochDay(date)] ?: 0, + maxCount = monthlyJournalCount.values.maxOrNull() ?: 0, + onClick = { onDateChange(date) } + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/events/EventDetails.kt b/app/src/main/java/com/flux/ui/screens/events/EventDetails.kt index 9f2833b..811ebf8 100644 --- a/app/src/main/java/com/flux/ui/screens/events/EventDetails.kt +++ b/app/src/main/java/com/flux/ui/screens/events/EventDetails.kt @@ -49,7 +49,7 @@ import com.flux.R import com.flux.data.model.EventModel import com.flux.data.model.RecurrenceRule import com.flux.navigation.NavRoutes -import com.flux.ui.components.DeleteAlert +import com.flux.ui.common.DeleteAlert import com.flux.ui.events.TaskEvents import com.flux.ui.state.Settings import java.time.ZoneId diff --git a/app/src/main/java/com/flux/ui/screens/events/EventHome.kt b/app/src/main/java/com/flux/ui/screens/events/EventHome.kt deleted file mode 100644 index ff551ba..0000000 --- a/app/src/main/java/com/flux/ui/screens/events/EventHome.kt +++ /dev/null @@ -1,293 +0,0 @@ -package com.flux.ui.screens.events - -import android.content.Context -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.RadioButtonUnchecked -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.TaskAlt -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.R -import com.flux.data.model.EventInstanceModel -import com.flux.data.model.EventModel -import com.flux.data.model.RecurrenceRule -import com.flux.navigation.Loader -import com.flux.navigation.NavRoutes -import com.flux.ui.components.DailyViewCalendar -import com.flux.ui.components.MonthlyViewCalendar -import com.flux.ui.components.shapeManager -import com.flux.ui.events.TaskEvents -import java.text.SimpleDateFormat -import java.time.Instant -import java.time.LocalDate -import java.time.YearMonth -import java.time.ZoneId -import java.time.format.DateTimeFormatter -import java.util.Date -import java.util.Locale - -@OptIn(ExperimentalMaterial3Api::class) -fun LazyListScope.eventHomeItems( - navController: NavController, - radius: Int, - isMonthlyView: Boolean, - is24HourFormat: Boolean, - isLoading: Boolean, - workspaceId: String, - selectedMonth: YearMonth, - selectedDate: Long, - monthlyEventCount: Map, - datedEvents: List, - allEventInstances: List, - onTaskEvents: (TaskEvents) -> Unit -) { - if (isMonthlyView) { - item { - MonthlyViewCalendar( - selectedMonth, selectedDate, monthlyEventCount, - onMonthChange = { onTaskEvents(TaskEvents.ChangeMonth(it)) }, - onDateChange = { onTaskEvents(TaskEvents.ChangeDate(it)) } - ) - } - } else { - item { - DailyViewCalendar(selectedMonth, selectedDate){ - onTaskEvents(TaskEvents.ChangeDate(it)) - } - } - } - - if (isLoading) { item { Loader() } - } else if (datedEvents.isEmpty()) { item { EmptyEvents() } } else { - item { Spacer(Modifier.height(24.dp)) } - - val pendingTasks = datedEvents.filter { task -> - val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate } - instance == null - } - - val completedTasks = datedEvents.filter { task -> - val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate } - instance != null - } - - if (pendingTasks.isNotEmpty()) { - items(pendingTasks) { task -> - EventCard( - radius = radius, - is24HourFormat = is24HourFormat, - isPending = true, - title = task.title, - repeat = task.recurrence, - startDateTime = task.startDateTime, - onChangeStatus = { onTaskEvents(TaskEvents.ToggleStatus(true, task.id, workspaceId, selectedDate)) }, - onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) } - ) - Spacer(Modifier.height(8.dp)) - } - } - if (completedTasks.isNotEmpty()) { - items(completedTasks) { task -> - EventCard( - radius = radius, - is24HourFormat = is24HourFormat, - isPending = false, - title = task.title, - repeat = task.recurrence, - startDateTime = task.startDateTime, - onChangeStatus = { onTaskEvents(TaskEvents.ToggleStatus(false, task.id, workspaceId, selectedDate)) }, - onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) } - ) - Spacer(Modifier.height(8.dp)) - } - } - } -} - -@Composable -fun EmptyEvents() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.TaskAlt, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text(stringResource(R.string.Empty_Events)) - } -} - -@Composable -fun IconRadioButton( - modifier: Modifier = Modifier, - uncheckedTint: Color = MaterialTheme.colorScheme.onSurface, - checkedTint: Color = MaterialTheme.colorScheme.onPrimaryContainer, - selected: Boolean, - onClick: () -> Unit -) { - IconButton(modifier = modifier, onClick = onClick) { - if (selected) { - Icon( - imageVector = Icons.Default.CheckCircle, - contentDescription = "Selected", - tint = checkedTint - ) - } else { - Icon( - imageVector = Icons.Default.RadioButtonUnchecked, - contentDescription = "Unselected", - tint = uncheckedTint - ) - } - } -} - -@Composable -fun EventCard( - radius: Int, - is24HourFormat: Boolean, - isPending: Boolean, - title: String, - repeat: RecurrenceRule, - startDateTime: Long, - onChangeStatus: () -> Unit, - onClick: () -> Unit -) { - val containerColor = - if (isPending) MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) - else MaterialTheme.colorScheme.primaryContainer - val contentColor = - if (isPending) MaterialTheme.colorScheme.onSurface - else MaterialTheme.colorScheme.onPrimaryContainer - - val context = LocalContext.current - val time = startDateTime.toFormattedTime(is24HourFormat) - - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = containerColor, - contentColor = contentColor - ), - shape = shapeManager(radius = radius * 2), - onClick = onClick - ) { - Row( - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - ) { - IconRadioButton( - selected = !isPending, - onClick = onChangeStatus - ) - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp, vertical = 4.dp) - ) { - Text(title, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(4.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - imageVector = Icons.Default.Refresh, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(Modifier.width(6.dp)) - Text( - "${getRecurrenceText(context, repeat, startDateTime)} at $time", - style = MaterialTheme.typography.bodySmall - ) - } - } - } - } -} - -fun getRecurrenceText(context: Context, repeat: RecurrenceRule, startDateTime: Long): String { - val localDate = Instant.ofEpochMilli(startDateTime) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - - return when (repeat) { - is RecurrenceRule.Once -> { - context.getString( - R.string.recurrence_once, - localDate.format(DateTimeFormatter.ofPattern("MMM dd, yyyy")) - ) - } - - is RecurrenceRule.Custom -> { - if (repeat.everyXDays == 1) { - context.getString(R.string.recurrence_daily) - } else { - context.getString(R.string.recurrence_every_x_days, repeat.everyXDays) - } - } - - is RecurrenceRule.Weekly -> { - val days = listOf("M", "T", "W", "T", "F", "S", "S") - if (repeat.daysOfWeek.size == 7) { - context.getString(R.string.recurrence_daily) - } else { - val daysText = repeat.daysOfWeek.sorted().joinToString(", ") { days[it] } - context.getString(R.string.recurrence_weekly_on, daysText) - } - } - - is RecurrenceRule.Monthly -> { - context.getString(R.string.recurrence_monthly_on, localDate.dayOfMonth) - } - - is RecurrenceRule.Yearly -> { - context.getString( - R.string.recurrence_yearly_on, - localDate.format(DateTimeFormatter.ofPattern("MMM dd")) - ) - } - } -} - -fun Long.toFormattedTime(is24Hour: Boolean = false): String { - val pattern = if (is24Hour) "HH:mm" else "hh:mm a" - val formatter = SimpleDateFormat(pattern, Locale.getDefault()) - return formatter.format(Date(this)) -} - -fun Long.toFormattedDate(): String { - val date = Date(this) - val format = SimpleDateFormat("dd MMMM, yyyy", Locale.getDefault()) - return format.format(date) -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/events/EventScreen.kt b/app/src/main/java/com/flux/ui/screens/events/EventScreen.kt new file mode 100644 index 0000000..379f255 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/events/EventScreen.kt @@ -0,0 +1,243 @@ +package com.flux.ui.screens.events + +import android.os.Build +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Event +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.R +import com.flux.data.model.WorkspaceModel +import com.flux.navigation.Loader +import com.flux.navigation.NavRoutes +import com.flux.other.canScheduleReminder +import com.flux.other.computeMonthlyEventDates +import com.flux.other.isNotificationPermissionGranted +import com.flux.other.openAppNotificationSettings +import com.flux.other.requestExactAlarmPermission +import com.flux.ui.common.EmptyEvents +import com.flux.ui.common.SpaceSearchBar +import com.flux.ui.common.SpaceTopBar +import com.flux.ui.common.SpacesMenu +import com.flux.ui.events.TaskEvents +import com.flux.ui.screens.workspaces.SpacesToolBar +import com.flux.ui.state.EventState +import com.flux.ui.state.Settings +import java.time.LocalDate +import java.time.LocalTime +import java.time.ZoneId +import java.time.ZonedDateTime + +@RequiresApi(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.S) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EventScreen( + navController: NavController, + state: EventState, + settings: Settings, + workspace: WorkspaceModel, + onShowSpaceBottomSheet: () -> Unit, + onSpaceChange: (Int) -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, + onEvent: (TaskEvents) -> Unit +){ + val context = LocalContext.current + val workspaceId = workspace.workspaceId + val selectedDate = state.selectedDate + val isLoading = state.isDatedEventLoading + val is24HourFormat = settings.data.is24HourFormat + val radius = settings.data.cornerRadius + val selectedMonth = state.selectedYearMonth + val isMonthlyView = settings.data.isCalendarMonthlyView + var query by remember { mutableStateOf("") } + val datedEvents = state.datedEvents + .filter { it.workspaceId==workspaceId } + .filter { it.title.contains(query, ignoreCase = true) } + val monthlyEventCount = computeMonthlyEventDates(state.allEvent.filter { it.workspaceId == workspaceId }, selectedMonth) + val allEventInstances = state.allEventInstances + var showSearchBar by remember { mutableStateOf(false) } + var showSpacesMenu by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior() + val notificationPermissionLabel = stringResource(R.string.Notification_Permission) + val reminderPermissionLabel = stringResource(R.string.Reminder_Permission) + + val pendingTasks = datedEvents.filter { task -> + val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate } + instance == null + } + + val completedTasks = datedEvents.filter { task -> + val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate } + instance != null + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + }, + floatingActionButton = { + FloatingActionButton({ + if (!canScheduleReminder(context)) { + Toast.makeText(context, reminderPermissionLabel, Toast.LENGTH_SHORT).show() + requestExactAlarmPermission(context) + } + if (!isNotificationPermissionGranted(context)) { + Toast.makeText(context, notificationPermissionLabel, Toast.LENGTH_SHORT).show() + openAppNotificationSettings(context) + } + if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) { + val localDate = LocalDate.ofEpochDay(selectedDate) + val currentTime = LocalTime.now() + val zonedDateTime = ZonedDateTime.of(localDate, currentTime, ZoneId.systemDefault()) + val selectedDateMillis = zonedDateTime.toInstant().toEpochMilli() + + navController.navigate(NavRoutes.NewEvent.withArgs(workspaceId, "", selectedDateMillis)) + } + }) { Icon(Icons.Default.Add, null) } + } + ) { innerPadding -> + when { + isLoading -> { Loader() } + else -> + LazyColumn(Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection)) + { + item { + if(showSearchBar){ SpaceSearchBar(query, { query=it }, { showSearchBar=false }) } + else { + Row( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + SpacesToolBar( + stringResource(R.string.Events), + Icons.Default.Event, + false, + onMainClick = { showSpacesMenu = true }, + onEditClick = onShowSpaceBottomSheet + ) + SpacesMenu( + showSpacesMenu, + workspace, + onSpaceChange + ) { showSpacesMenu = false } + Row { + IconButton({ showSearchBar = true }) { + Icon( + Icons.Default.Search, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton({ showSearchBar = true }) { + Icon( + Icons.Default.FilterList, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + if (isMonthlyView) { + item { + MonthlyViewCalendar( + selectedMonth, selectedDate, monthlyEventCount, + onMonthChange = { onEvent(TaskEvents.ChangeMonth(it)) }, + onDateChange = { onEvent(TaskEvents.ChangeDate(it)) } + ) + } + } else { + item { + DailyViewCalendar(selectedMonth, selectedDate){ + onEvent(TaskEvents.ChangeDate(it)) + } + } + } + if(datedEvents.isEmpty()) item { EmptyEvents() } + if (pendingTasks.isNotEmpty()) { + items(pendingTasks) { task -> + EventCard( + radius = radius, + is24HourFormat = is24HourFormat, + isPending = true, + title = task.title, + repeat = task.recurrence, + startDateTime = task.startDateTime, + onChangeStatus = { onEvent(TaskEvents.ToggleStatus(true, task.id, workspaceId, selectedDate)) }, + onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) } + ) + Spacer(Modifier.height(8.dp)) + } + } + if (completedTasks.isNotEmpty()) { + items(completedTasks) { task -> + EventCard( + radius = radius, + is24HourFormat = is24HourFormat, + isPending = false, + title = task.title, + repeat = task.recurrence, + startDateTime = task.startDateTime, + onChangeStatus = { onEvent(TaskEvents.ToggleStatus(false, task.id, workspaceId, selectedDate)) }, + onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) } + ) + Spacer(Modifier.height(8.dp)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/events/NewEvent.kt b/app/src/main/java/com/flux/ui/screens/events/NewEvent.kt index ff3bbdc..a153542 100644 --- a/app/src/main/java/com/flux/ui/screens/events/NewEvent.kt +++ b/app/src/main/java/com/flux/ui/screens/events/NewEvent.kt @@ -58,13 +58,11 @@ import androidx.navigation.NavController import com.flux.R import com.flux.data.model.EventModel import com.flux.data.model.RecurrenceRule -import com.flux.ui.components.CustomNotificationDialog -import com.flux.ui.components.DatePickerModal -import com.flux.ui.components.EventNotificationDialog -import com.flux.ui.components.RecurrenceBottomSheet -import com.flux.ui.components.TimePicker -import com.flux.ui.components.convertMillisToTime -import com.flux.ui.components.label +import com.flux.ui.common.DatePickerModal +import com.flux.ui.common.RecurrenceBottomSheet +import com.flux.ui.common.TimePicker +import com.flux.ui.common.convertMillisToTime +import com.flux.ui.common.label import com.flux.ui.events.TaskEvents import com.flux.ui.state.Settings import kotlin.math.max diff --git a/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt new file mode 100644 index 0000000..ce487df --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/habits/HabitComponents.kt @@ -0,0 +1,2190 @@ +package com.flux.ui.screens.habits + +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.icons.filled.AcUnit +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircleOutline +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.HourglassTop +import androidx.compose.material.icons.filled.LocalFireDepartment +import androidx.compose.material.icons.filled.Numbers +import androidx.compose.material.icons.filled.OutlinedFlag +import androidx.compose.material.icons.filled.Percent +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material.icons.filled.SportsScore +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material.icons.filled.TrackChanges +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material.icons.outlined.IncompleteCircle +import androidx.compose.material.icons.outlined.NotificationsActive +import androidx.compose.material.icons.outlined.StopCircle +import androidx.compose.material.icons.outlined.Timer +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.flux.R +import com.flux.data.model.HabitConfig +import com.flux.data.model.HabitInstanceModel +import com.flux.data.model.HabitModel +import com.flux.data.model.isCompleted +import com.flux.ui.common.convertMillisToDate +import com.flux.ui.common.convertMillisToTime +import com.flux.ui.events.HabitEvents +import com.flux.ui.screens.analytics.HeatMapCard +import kotlinx.coroutines.flow.distinctUntilChanged +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.icons.automirrored.filled.ArrowBackIos +import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos +import androidx.compose.material.icons.filled.Analytics +import androidx.compose.material.icons.filled.Pending +import androidx.compose.material.icons.filled.Verified +import androidx.compose.material3.IconButton +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.text.style.TextOverflow +import com.flux.data.model.RecurrenceRule +import com.flux.ui.screens.events.IconRadioButton +import java.time.DayOfWeek +import java.time.Instant +import java.time.YearMonth +import java.time.ZoneId +import java.time.format.TextStyle +import kotlin.collections.filter +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.platform.LocalContext +import com.flux.data.model.isCounted +import com.flux.data.model.isLive +import com.flux.data.model.isTimed +import com.flux.ui.common.FilterCategory +import com.flux.ui.common.FilterOption +import com.flux.ui.common.SelectionType +import com.flux.ui.screens.settings.CircleWrapper +import com.flux.ui.screens.settings.shapeManager +import androidx.compose.ui.platform.LocalLocale + +fun toMillis(hours: Int, minutes: Int): Long = + (hours * 60L * 60_000L) + (minutes * 60_000L) + +// ------------- Dialogs ------------- +@Composable +fun TimerDialog( + durationMillis: Long, + onDismiss: () -> Unit, + onConfirm: (Long) -> Unit +) { + val totalMinutes = durationMillis / 60_000L + var hours by remember { mutableIntStateOf((totalMinutes / 60).toInt()) } + var minutes by remember { mutableIntStateOf((totalMinutes % 60).toInt()) } + + // Prevent invalid state + LaunchedEffect(hours, minutes) { + if (hours == 0 && minutes == 0) { + minutes = 1 + } + } + + Dialog(onDismissRequest = onDismiss) { + Surface(shape = RoundedCornerShape(24.dp)) { + Column(Modifier.padding(16.dp).fillMaxWidth()) { + NumberPickerRow(Modifier.fillMaxWidth(), hours, minutes, { hours = it }) { + minutes = it + } + Spacer(Modifier.height(16.dp)) + FilledTonalButton( + onClick = { + onDismiss() + onConfirm(toMillis(hours, minutes)) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.Confirm)) + } + } + } + } +} + +@Composable +fun CountedHabitStatus( + radius: Int, + habit: HabitModel, + instance: HabitInstanceModel?, + onHabitEvents: (HabitEvents) -> Unit +) { + + val goal = (habit.habitConfig as HabitConfig.Counted).goal + + // Single source of truth + val currentCount = instance?.count ?: 0 + + val completion = (currentCount.toFloat() / goal.toFloat()) + .coerceIn(0f, 1f) + + val completionPercentage = (completion * 100).toInt() + + val todayEpoch = LocalDate.now().toEpochDay() + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + onClick = {} + ) { + Column( + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + + Text( + "Current Habit Status", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold + ), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + + CircularProgressIndicator( + progress = { completion }, + modifier = Modifier.size(120.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 12.dp, + trackColor = MaterialTheme.colorScheme.primary.copy(0.35f), + strokeCap = StrokeCap.Round, + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column( + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + + HabitInfoComponent( + Icons.Outlined.IncompleteCircle, + "Current", + currentCount.toString() + ) + + HabitInfoComponent( + Icons.Default.TrackChanges, + "Goal", + goal.toString() + ) + + HabitInfoComponent( + Icons.Default.Percent, + "Completion", + completionPercentage.toString() + ) + } + } + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + FilledTonalButton( + onClick = { + + val count = maxOf(currentCount - 1, 0) + + val newInstance = HabitInstanceModel( + instanceDate = todayEpoch, + habitId = habit.id, + workspaceId = habit.workspaceId, + count = count + ) + + onHabitEvents( + HabitEvents.UpdateInstance( + newInstance, + habit.habitConfig + ) + ) + }, + modifier = Modifier.weight(0.5f) + ) { + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + Icon( + Icons.Default.Remove, + null, + tint = MaterialTheme.colorScheme.primary + ) + + Text("Decrement") + } + } + + FilledTonalButton( + onClick = { + + val count = currentCount + 1 + + val newInstance = HabitInstanceModel( + instanceDate = todayEpoch, + habitId = habit.id, + workspaceId = habit.workspaceId, + count = count + ) + + onHabitEvents( + HabitEvents.UpdateInstance( + newInstance, + habit.habitConfig + ) + ) + }, + modifier = Modifier.weight(0.5f) + ) { + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + + Icon( + Icons.Default.Add, + null, + tint = MaterialTheme.colorScheme.primary + ) + + Text("Increment") + } + } + } + } + } +} + +@Composable +fun TimedHabitStatus ( + radius: Int, + habit: HabitModel, + instance: HabitInstanceModel?, + onHabitEvents: (HabitEvents) -> Unit +) { + val goal = (habit.habitConfig as HabitConfig.Timed).durationMillis + var timeSpent by remember { mutableLongStateOf(instance?.timeSpent?: 0L) } + val completion = (timeSpent.toDouble()/goal.toDouble()).toFloat() + val completionPercentage = (completion*100).toInt() + val todayEpoch = LocalDate.now().toEpochDay() + var hours by remember { mutableIntStateOf(0) } + var minutes by remember { mutableIntStateOf(0) } + + // Prevent invalid state + LaunchedEffect(hours, minutes) { + if (hours == 0 && minutes == 0) { + minutes = 1 + } + } + + var selectedIndex by remember { mutableIntStateOf(0) } + val options = listOf("Countdown", "Manual") + val icons = listOf(Icons.Default.HourglassTop, Icons.Default.Edit) + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + onClick = {} + ) { + Column( + Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Current Habit Status", + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + + Row( + Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + progress = { completion }, + modifier = Modifier.size(120.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 12.dp, + trackColor = MaterialTheme.colorScheme.primary.copy(0.35f), + strokeCap = StrokeCap.Round, + ) + Spacer(modifier = Modifier.width(12.dp)) + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + HabitInfoComponent( + Icons.Outlined.IncompleteCircle, + "Current", + formatDuration(timeSpent) + ) + HabitInfoComponent( + Icons.Default.TrackChanges, + "Goal", + formatDuration(goal) + ) + HabitInfoComponent( + Icons.Default.Percent, + "Completion", + completionPercentage.toString() + ) + } + } + + SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(vertical = 6.dp)) { + options.forEachIndexed { index, label -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = options.size + ), + onClick = { selectedIndex = index }, + selected = index == selectedIndex, + label = { Text(label) }, + icon = { Icon(icons[index], null) } + ) + } + } + + NumberPickerRow(Modifier.fillMaxWidth().padding(horizontal = 32.dp), hours, minutes, {hours=it}) { minutes=it } + + FilledTonalButton( + { + timeSpent += toMillis(hours, minutes) + + val newInstance = HabitInstanceModel( + instanceDate = todayEpoch, + habitId = habit.id, + workspaceId = habit.workspaceId, + timeSpent = timeSpent + ) + onHabitEvents(HabitEvents.UpdateInstance(newInstance, habit.habitConfig)) + }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp), + colors = ButtonDefaults.filledTonalButtonColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer + ) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ){ + Icon(Icons.Default.Add, null) + Text("Log time") + } + } + } + } +} + +@Composable +fun NumberPickerRow( + modifier: Modifier = Modifier, + hours: Int, + minutes: Int, + onHourChange: (Int)-> Unit, + onMinutesChange: (Int)->Unit +) { + Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Hours", color = MaterialTheme.colorScheme.primary) + NumberPicker( + range = 0..23, + selected = hours, + onValueChange = onHourChange + ) + } + + VerticalDivider( + modifier = Modifier + .height(150.dp) + .align(Alignment.CenterVertically), + color = Color.Gray + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Minutes", color = MaterialTheme.colorScheme.primary) + NumberPicker( + range = 0..59, + selected = minutes, + onValueChange = onMinutesChange, + invalidValue = if (hours == 0) 0 else null + ) + } + } +} + +@Composable +fun NumberPicker( + range: IntRange, + selected: Int, + onValueChange: (Int) -> Unit, + modifier: Modifier = Modifier, + invalidValue: Int? = null +) { + val count = range.count() + val startOffset = (Int.MAX_VALUE / 2) - (Int.MAX_VALUE / 2) % count + + val listState = rememberLazyListState() + val flingBehavior = rememberSnapFlingBehavior(lazyListState = listState) + + val itemHeightDp = 50.dp + val itemHeightPx = with(LocalDensity.current) { itemHeightDp.toPx() } + + val initialIndex = startOffset + (selected - range.first) + + val haptic = LocalHapticFeedback.current + + // Sync scroll with selected value + LaunchedEffect(selected, range) { + listState.scrollToItem(initialIndex) + } + + val centeredIndex by remember(listState, itemHeightPx) { + derivedStateOf { + val offset = listState.firstVisibleItemScrollOffset + if (offset > itemHeightPx / 2) + listState.firstVisibleItemIndex + 1 + else + listState.firstVisibleItemIndex + } + } + + LaunchedEffect(listState) { + snapshotFlow { listState.firstVisibleItemIndex } + .distinctUntilChanged() + .collect { + haptic.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + } + + var lastCenter by remember { mutableIntStateOf(centeredIndex) } + + LaunchedEffect(listState, range, invalidValue) { + snapshotFlow { centeredIndex } + .distinctUntilChanged() + .collect { index -> + + if (index != lastCenter) { + // stronger feedback on snap + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + lastCenter = index + } + + val normalized = + ((index - startOffset) % count + count) % count + + var value = range.first + normalized + + // prevent invalid selection BEFORE applying + if (invalidValue != null && value == invalidValue) { + value = range.first + ((normalized + 1) % count) + } + + onValueChange(value) + } + } + + LazyColumn( + state = listState, + flingBehavior = flingBehavior, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(vertical = itemHeightDp), + modifier = modifier.height(itemHeightDp * 3) + ) { + items(Int.MAX_VALUE) { index -> + val normalized = + ((index - startOffset) % count + count) % count + val value = range.first + normalized + val isSelected = index == centeredIndex + + val isInvalid = invalidValue != null && value == invalidValue + + Text( + text = value.toString().padStart(2, '0'), + fontSize = if (isSelected) 28.sp else 18.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = when { + isInvalid -> Color.LightGray + isSelected -> MaterialTheme.colorScheme.primary + else -> Color.Gray + }, + modifier = Modifier + .height(itemHeightDp) + .wrapContentHeight(Alignment.CenterVertically) + ) + } + } +} + +@Composable +fun TimedHabitDialog( + durationMillis: Long, + onDismiss: () -> Unit, + onConfirm: (Long) -> Unit +){ + val totalMinutes = durationMillis / 60_000L + var hours by remember { mutableIntStateOf((totalMinutes / 60).toInt()) } + var minutes by remember { mutableIntStateOf((totalMinutes % 60).toInt()) } + + // Prevent invalid state + LaunchedEffect(hours, minutes) { + if (hours == 0 && minutes == 0) { + minutes = 1 + } + } + + var selectedIndex by remember { mutableIntStateOf(0) } + val options = listOf("Countdown", "Manual") + val icons = listOf(Icons.Default.HourglassTop, Icons.Default.Edit) + + Dialog(onDismissRequest = onDismiss) { + Surface(shape = RoundedCornerShape(24.dp)) { + Column(Modifier.padding(16.dp).fillMaxWidth()) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(vertical = 6.dp)) { + options.forEachIndexed { index, label -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = index, + count = options.size + ), + onClick = { selectedIndex = index }, + selected = index == selectedIndex, + label = { Text(label) }, + icon = { Icon(icons[index], null) } + ) + } + } + + NumberPickerRow( + Modifier.fillMaxWidth().padding(horizontal = 32.dp), + hours, + minutes, + { hours=it } + ) { minutes=it } + Spacer(Modifier.height(16.dp)) + FilledTonalButton( + onClick = { + onDismiss() + onConfirm(toMillis(hours, minutes)) + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.Confirm)) + } + } + } + } +} + +@Composable +fun HabitDetailedInfo( + radius: Int, + habit: HabitModel, + instances: List +){ + val streakData = remember(habit, instances) { + calculateStreaks(habit.recurrence, habit.startDateTime, habit.habitConfig, instances) + } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + onClick = {} + ) { + Column( + Modifier + .fillMaxSize() + .padding(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center) + { + Icon(Icons.Default.EventAvailable, null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(4.dp)) + Text( + text = habit.title, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + } + + if(habit.description.isNotBlank()){ + Text( + text = habit.description, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Light), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + + LazyVerticalGrid( + columns = GridCells.Fixed(3), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 1000.dp) + .padding(vertical = 12.dp) + ) { + item { + HabitInfoComponent( + Icons.Default.OutlinedFlag, + stringResource(R.string.Started), + convertMillisToDate(habit.startDateTime)) + } + if(habit.endDateTime != -1L){ + item { + HabitInfoComponent( + Icons.Default.SportsScore, + stringResource(R.string.ends_on), + convertMillisToDate(habit.endDateTime) + ) + } + } + item { + HabitInfoComponent( + Icons.Default.LocalFireDepartment, + "Current ${streakData.currentStreak}", + "${stringResource(R.string.Best_Streak)} ${streakData.bestStreak}" + ) + } + + item { + val habitTypeIcon = when (habit.habitConfig) { + is HabitConfig.Simple -> Icons.Default.CheckCircleOutline + is HabitConfig.Counted -> Icons.Default.Numbers + else -> Icons.Default.Timer + } + val habitTypeText = when (habit.habitConfig) { + is HabitConfig.Timed -> "Timed" + is HabitConfig.Counted -> "Counter" + else -> "Simple" + } + HabitInfoComponent( + habitTypeIcon, + "Type", + habitTypeText + ) + } + + if(habit.habitConfig is HabitConfig.Simple || habit.habitConfig is HabitConfig.Timed) { + item { + HabitInfoComponent( + Icons.Outlined.NotificationsActive, + "Remind at", + convertMillisToTime(habit.startDateTime) + ) + } + } + + if(habit.habitConfig is HabitConfig.Timed) { + item { + HabitInfoComponent( + Icons.Outlined.Timer, + "Timer", + formatDuration(habit.habitConfig.durationMillis) + ) + } + } + + if(habit.habitConfig is HabitConfig.Counted) { + item { + HabitInfoComponent( + Icons.Outlined.Circle, + "Active from", + convertMillisToTime(habit.habitConfig.activeStartTime) + ) + } + + item { + HabitInfoComponent( + Icons.Outlined.StopCircle, + "Active uptil", + convertMillisToTime(habit.habitConfig.activeEndTime) + ) + } + + item { + HabitInfoComponent( + Icons.Outlined.NotificationsActive, + "Notify Every", + formatDuration(habit.habitConfig.intervalMillis) + ) + } + + item { + HabitInfoComponent( + Icons.Default.TrackChanges, + "Goal", + habit.habitConfig.goal.toString() + ) + } + + if(habit.habitConfig.unit.isNotBlank()){ + item { + HabitInfoComponent( + Icons.Default.AcUnit, + "Unit", + habit.habitConfig.unit + ) + } + } + } + } + } + } +} + +@Composable +fun HabitInfoComponent(icon: ImageVector, title: String, description: String){ + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + CircleWrapper(MaterialTheme.colorScheme.primaryContainer) { + Icon( + icon, + null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + } + Column { + Text(title, modifier = Modifier.alpha(0.85f), style = MaterialTheme.typography.labelMedium) + Text(description, fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelMedium) + } + } +} + +@Composable +fun SingleHabitHeatMap( + radius: Int, + habit: HabitModel, + instances: List, +){ + val today = LocalDate.now() + val yearStart = LocalDate.of(today.year, 1, 1) + + // Calculate the offset from Monday for January 1st + val jan1DayOfWeek = yearStart.dayOfWeek.value // Monday = 1, Sunday = 7 + val offsetFromMonday = jan1DayOfWeek - 1 // 0 for Monday, 6 for Sunday + + val totalDays = ChronoUnit.DAYS.between(yearStart, today).toInt() + 1 + val allDates = (0 until totalDays).map { yearStart.plusDays(it.toLong()) } + + val heatMap = remember(instances, habit) { + instances + .groupBy { LocalDate.ofEpochDay(it.instanceDate) } + .mapValues { (_, instancesForDay) -> + instancesForDay.count { instance -> + instance.isCompleted(habit) + } + } + } + + // Create week columns with proper day alignment + val weekColumns = mutableListOf>() + var currentWeek = MutableList(7) { null } + + // Fill the first week with nulls for days before January 1st + for (i in 0 until offsetFromMonday) { + currentWeek[i] = null + } + + // Add all dates starting from the correct day of week + allDates.forEachIndexed { index, date -> + val dayIndex = (offsetFromMonday + index) % 7 + currentWeek[dayIndex] = date + + // When we complete a week (reach Sunday) or it's the last date + if (dayIndex == 6 || index == allDates.size - 1) { + weekColumns.add(currentWeek.toList()) + currentWeek = MutableList(7) { null } + } + } + val boxSize = 24.dp + val lazyListState = rememberLazyListState() + + // Calculate the index of the current month's first week + val currentMonthStartIndex = remember(weekColumns) { + val currentMonth = today.month + weekColumns.indexOfFirst { week -> + week.any { date -> date?.month == currentMonth } + }.takeIf { it != -1 } ?: 0 + } + + // Auto-scroll to current month on first composition + LaunchedEffect(currentMonthStartIndex) { + if (currentMonthStartIndex > 0) { + lazyListState.scrollToItem(index = maxOf(0, currentMonthStartIndex - 2)) + } + } + + HeatMapCard( + radius, + "This Year", + "", + boxSize, + 2, + lazyListState, + weekColumns, + heatMap.toMap() + ) +} + +// ------------- Card ------------- +@Composable +fun HabitDateCard( + radius: Int, + isTodayDone: Boolean, + isDone: Boolean, + day: String, + date: Int, + modifier: Modifier = Modifier +) { + val containerColor = when { + isTodayDone && isDone -> MaterialTheme.colorScheme.primary + isTodayDone && !isDone -> MaterialTheme.colorScheme.primaryContainer + isDone -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + } + + val contentColor = when { + isDone -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface + } + + Card( + modifier = modifier, + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + day.uppercase(), + style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraLight), + modifier = Modifier.alpha(0.95f) + ) + Text( + date.toString(), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight), + modifier = Modifier.alpha(0.95f) + ) + } + } +} + +@Composable +fun HabitPreviewCard( + radius: Int, + is24HourFormat: Boolean, + habit: HabitModel, + instances: List, + onClick: (Long) -> Unit, + onAnalyticsClicked: () -> Unit +) { + val todayEpoch = LocalDate.now().toEpochDay() + val instance = instances.firstOrNull { it.instanceDate == todayEpoch } + val isTodayDone = instance?.isCompleted(habit) ?: false + + // Get Monday of this week + val mondayEpoch = LocalDate.now().with(DayOfWeek.MONDAY).toEpochDay() + val weekDates = (0L..6L).map { mondayEpoch + it } + val (currentStreak, _) = calculateStreaks(habit.recurrence, habit.startDateTime, habit.habitConfig, instances) + val habitTypeIcon = when (habit.habitConfig) { + is HabitConfig.Simple -> Icons.Default.CheckCircleOutline + is HabitConfig.Counted -> Icons.Default.Numbers + else -> Icons.Default.Timer + } + + val habitTypeText = when (habit.habitConfig) { + is HabitConfig.Timed -> formatDuration(habit.habitConfig.durationMillis) + is HabitConfig.Counted -> habit.habitConfig.goal.toString() + else -> "" + } + + val habitTimeText = when (habit.habitConfig) { + is HabitConfig.Counted -> "${convertMillisToTime(habit.habitConfig.activeStartTime, is24HourFormat)} - ${convertMillisToTime(habit.habitConfig.activeEndTime, is24HourFormat)}" + else -> convertMillisToTime(habit.startDateTime, is24HourFormat) + } + + Card( + onClick = { onClick(todayEpoch) }, + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (isTodayDone) MaterialTheme.colorScheme.primaryContainer + else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + shape = shapeManager(radius = radius * 2) + ) { + Column { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row { + IconRadioButton( + selected = isTodayDone, + ) { onClick(todayEpoch) } + + Column(Modifier.padding(top = 8.dp)) { + Text( + habit.title, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + ) + Text( + habitTimeText, + style = MaterialTheme.typography.labelSmall.copy(fontWeight = FontWeight.ExtraLight), + modifier = Modifier.alpha(0.9f) + ) + } + } + + Row(modifier = Modifier.padding(end = 2.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton({}, modifier = Modifier.size(36.dp)) { Icon(Icons.Default.LocalFireDepartment, null) } + Text("$currentStreak", modifier = Modifier.padding(end = 4.dp), style = MaterialTheme.typography.labelLarge) + IconButton({}, modifier = Modifier.size(36.dp)) { Icon(habitTypeIcon, null) } + if(habit.habitConfig is HabitConfig.Counted || habit.habitConfig is HabitConfig.Timed){ + Text(habitTypeText, modifier = Modifier.padding(end = 4.dp), style = MaterialTheme.typography.labelLarge) + } + IconButton(onAnalyticsClicked, modifier = Modifier.size(36.dp)) { Icon(Icons.Default.Analytics, null) } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + weekDates.forEach { epochDay -> + val localDate = LocalDate.ofEpochDay(epochDay) + val instance = instances.firstOrNull { it.instanceDate == epochDay } + val isCardDone = instance?.isCompleted(habit) ?: false + + when(habit.habitConfig) { + is HabitConfig.Simple -> + HabitDateCard( + radius = radius, + isTodayDone = isTodayDone, + isDone = isCardDone, + day = localDate.dayOfWeek.name.take(3), + date = localDate.dayOfMonth, + modifier = Modifier.weight(1f) + ) + else -> + OtherConfigCard( + radius = radius, + instance = instance, + isTimed = habit.isTimed, + isTodayDone=isTodayDone, + isDone = isCardDone, + day = localDate.dayOfWeek.name.take(1), + date = localDate.dayOfMonth, + modifier = Modifier.weight(1f) + ) + } + } + } + } + } +} + +@Composable +fun OtherConfigCard( + radius: Int, + isTimed: Boolean, + instance: HabitInstanceModel?, + isTodayDone: Boolean, + isDone: Boolean, + day: String, + date: Int, + modifier: Modifier = Modifier +) { + val containerColor = when { + isTodayDone && isDone -> MaterialTheme.colorScheme.primary + isTodayDone && !isDone -> MaterialTheme.colorScheme.primaryContainer + isDone -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + } + + val contentColor = when { + isDone -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface + } + + val text = if (isTimed) { formatDuration(instance?.timeSpent ?: 0L) } else { (instance?.count ?: 0).toString() } + Card( + modifier = modifier, + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = containerColor, + contentColor = contentColor + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Row{ + Text( + "$date, ", + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight), + modifier = Modifier.alpha(0.95f) + ) + + Text( + day.uppercase(), + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight), + modifier = Modifier.alpha(0.95f) + ) + } + Text( + text, + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight), + modifier = Modifier.alpha(0.85f) + ) + } + } +} + +@Composable +fun HabitCalendarCard( + radius: Int, + habit: HabitModel, + habitInstances: List, + onHabitEvents: (HabitEvents) -> Unit +) { + val context = LocalContext.current + val startDateTime = habit.startDateTime + val endDateTime = habit.endDateTime + val recurrence = habit.recurrence + val workspaceId = habit.workspaceId + + val habitStartMonth = + Instant.ofEpochMilli(startDateTime).atZone(ZoneId.systemDefault()).toLocalDate() + .let { YearMonth.of(it.year, it.month) } + + var currentMonth by remember { mutableStateOf(YearMonth.now()) } + val currentYearMonth = YearMonth.now() + val endOfYear = YearMonth.of(currentYearMonth.year, 12) + + val canGoBack = currentMonth > habitStartMonth + val canGoForward = currentMonth < endOfYear + + val daysInMonth = currentMonth.lengthOfMonth() + val firstDayOfWeek = currentMonth.atDay(1).dayOfWeek.value % 7 + val dates = (1..daysInMonth).map { currentMonth.atDay(it) } + + val daysOfWeek = listOf( + stringResource(R.string.sunday_short), + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short) + + ) + val today = LocalDate.now() + val habitStartDate = + Instant.ofEpochMilli(startDateTime).atZone(ZoneId.systemDefault()).toLocalDate() + val habitEndEpochDay = if (endDateTime == -1L) null + else Instant.ofEpochMilli(endDateTime) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toEpochDay() + val locale = LocalLocale.current.platformLocale + Card( + modifier = Modifier + .fillMaxWidth() + .height(330.dp), + onClick = {}, + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ) + ) { + Column(Modifier.padding(16.dp)) { + // Month navigation + Row( + modifier = Modifier + .fillMaxWidth() + .padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = currentMonth.month.getDisplayName(TextStyle.FULL, locale) + + " ${currentMonth.year}", + style = MaterialTheme.typography.titleMedium + ) + Row { + IconButton( + onClick = { if (canGoBack) currentMonth = currentMonth.minusMonths(1) }, + enabled = canGoBack + ) { + Icon( + Icons.AutoMirrored.Default.ArrowBackIos, + contentDescription = "Previous Month", + modifier = Modifier + .alpha(if (canGoBack) 0.8f else 0.3f) + .size(16.dp) + ) + } + IconButton( + onClick = { if (canGoForward) currentMonth = currentMonth.plusMonths(1) }, + enabled = canGoForward + ) { + Icon( + Icons.AutoMirrored.Default.ArrowForwardIos, + contentDescription = "Next Month", + modifier = Modifier + .alpha(if (canGoForward) 0.8f else 0.3f) + .size(16.dp) + ) + } + } + } + + // Days of week row + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + daysOfWeek.forEach { + Text( + text = it, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + } + + // Calendar grid + LazyVerticalGrid( + columns = GridCells.Fixed(7), + userScrollEnabled = false, + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.fillMaxWidth() + ) { + // Empty cells before 1st day + items(firstDayOfWeek) { Box(modifier = Modifier.size(32.dp)) } + + items(dates) { date -> + val epochDay = date.toEpochDay() + val instance = habitInstances.find { it.instanceDate == epochDay } + val isMarked = instance?.isCompleted(habit) ?: false + val isBeforeStart = epochDay < habitStartDate.toEpochDay() + val isAfterToday = epochDay > today.toEpochDay() + val isAfterEnd = habitEndEpochDay != null && epochDay > habitEndEpochDay + val isAllowedByRecurrence = isDateAllowedForHabit(recurrence, epochDay) + + val backgroundColor = when { + isMarked -> MaterialTheme.colorScheme.primary.copy(alpha = 0.8f) + isAllowedByRecurrence && !isBeforeStart && !isAfterEnd -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + else -> Color.Transparent + } + + val textColor = when { + isMarked -> MaterialTheme.colorScheme.onPrimary + isAllowedByRecurrence && !isBeforeStart && !isAfterEnd -> MaterialTheme.colorScheme.onSurface + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f) + } + + val dateAlpha = when { + isBeforeStart -> 0.2f + isAfterToday || isAfterEnd -> 0.4f + !isAllowedByRecurrence -> 0.4f + else -> 1f + } + + val habitAlreadyEnded = stringResource(R.string.habit_already_ended) + val cantMarkFutureDate = stringResource(R.string.cannot_mark_future_dates) + val habitStartsLater = stringResource(R.string.habit_starts_later) + val notInSchedule = stringResource(R.string.date_not_in_schedule) + Box( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(backgroundColor) + .alpha(dateAlpha) + .clickable { + when { + isAfterEnd -> Toast.makeText( + context, + habitAlreadyEnded, + Toast.LENGTH_SHORT + ).show() + + isAfterToday -> Toast.makeText( + context, + cantMarkFutureDate, + Toast.LENGTH_SHORT + ).show() + + isBeforeStart -> Toast.makeText( + context, + habitStartsLater, + Toast.LENGTH_SHORT + ).show() + + !isAllowedByRecurrence -> Toast.makeText( + context, + notInSchedule, + Toast.LENGTH_SHORT + ).show() + + else -> { + val oldInstance = + habitInstances.find { it.instanceDate == epochDay } + val count = if (habit.isCounted) { + if (oldInstance != null) { + val goal = + (habit.habitConfig as HabitConfig.Counted).goal + if (oldInstance.count == goal) 0 + else goal + } else (habit.habitConfig as HabitConfig.Counted).goal + } else 0 + + val newInstance = HabitInstanceModel( + instanceDate = epochDay, + habitId = habit.id, + workspaceId = workspaceId, + count = count + ) + onHabitEvents( + HabitEvents.UpdateInstance( + newInstance, + habit.habitConfig + ) + ) + } + } + }, + contentAlignment = Alignment.Center + ) { + Text(date.dayOfMonth.toString(), color = textColor) + } + } + + } + } + } +} + +@Composable +fun WeeklyHabitAnalyticsCard( + radius: Int, + habit: HabitModel, + instances: List +) { + val today = LocalDate.now() + val startOfWeek = today.with(DayOfWeek.MONDAY) + + val instanceMap = remember(instances) { + instances.associateBy { it.instanceDate } + } + + val daysOfWeek = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + + val dayStatus = daysOfWeek.mapIndexed { index, _ -> + val date = startOfWeek.plusDays(index.toLong()) + val epoch = date.toEpochDay() + + val instance = instanceMap[epoch] + + when (val config = habit.habitConfig) { + is HabitConfig.Simple -> { + instance?.isCompleted(habit) == true + } + + is HabitConfig.Counted -> { + (instance?.count ?: 0) >= config.goal + } + + is HabitConfig.Timed -> { + (instance?.timeSpent ?: 0L) >= config.durationMillis + } + } + } + + val completedCount = dayStatus.count { it } + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + onClick = {} + ) { + Column(Modifier + .fillMaxSize() + .padding(12.dp)) { + Text( + text = stringResource(R.string.This_Week), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = stringResource(R.string.completed_habits, completedCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 24.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + daysOfWeek.forEachIndexed { index, day -> + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + Card( + shape = RoundedCornerShape(50), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = if(dayStatus[index]) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f) + ) + ) { + Icon( + imageVector = if (dayStatus[index]) Icons.Default.Verified else Icons.Default.Pending, + contentDescription = null, + modifier = Modifier.padding(4.dp) + ) + } + Text( + text = day, + style = MaterialTheme.typography.labelLarge, + ) + } + } + } + } + } +} + +@Composable +fun MonthlyHabitAnalyticsCard( + radius: Int, + habit: HabitModel, + instances: List +) { + val today = LocalDate.now() + val currentYearMonth = YearMonth.of(today.year, today.month) + val daysInMonth = currentYearMonth.lengthOfMonth() + + val instanceMap = remember(instances) { + instances.associateBy { it.instanceDate } + } + + val weekRanges = remember(daysInMonth) { + val ranges = mutableListOf() + var start = 1 + while (start <= daysInMonth) { + val end = minOf(start + 6, daysInMonth) + ranges.add(start..end) + start = end + 1 + } + ranges + } + + val weekCounts = remember(instances, habit) { + val counts = MutableList(weekRanges.size) { 0 } + + for (day in 1..daysInMonth) { + val date = currentYearMonth.atDay(day) + val epoch = date.toEpochDay() + + val instance = instanceMap[epoch] + + val isCompleted = when (val config = habit.habitConfig) { + is HabitConfig.Simple -> + instance?.isCompleted(habit) == true + + is HabitConfig.Counted -> + (instance?.count ?: 0) >= config.goal + + is HabitConfig.Timed -> + (instance?.timeSpent ?: 0L) >= config.durationMillis + } + + if (isCompleted) { + weekRanges.forEachIndexed { index, range -> + if (day in range) { + counts[index]++ + return@forEachIndexed + } + } + } + } + + counts + } + + val completedDays = weekCounts.sum() + + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + onClick = {} + ) { + Column( + Modifier + .fillMaxSize() + .padding(12.dp) + ) { + Text( + text = stringResource(R.string.This_Month), + style = MaterialTheme.typography.titleMedium, + ) + + Text( + text = stringResource(R.string.completed_habits, completedDays), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 24.dp) + ) + + HabitBarChart( + weekCounts = weekCounts, + weekLabels = weekRanges.map { "${it.first}-${it.last}" } + ) + } + } +} + +@Composable +fun SingleHabitWeeklyProgressChart( + radius: Int, + habit: HabitModel, + instances: List, + modifier: Modifier = Modifier +) { + val today = LocalDate.now() + val startOfWeek = today.with(DayOfWeek.MONDAY) + val weekDays = (0..6).map { startOfWeek.plusDays(it.toLong()) } + val instanceMap = remember(instances) { instances.associateBy { it.instanceDate } } + + val percentages = weekDays.map { day -> + val dayEpoch = day.toEpochDay() + val dayOfWeekIndex = (day.dayOfWeek.value + 6) % 7 + + val weekly = habit.recurrence as? RecurrenceRule.Weekly + + val isScheduled = + weekly?.daysOfWeek?.contains(dayOfWeekIndex) == true && + habit.isLive() + + if (!isScheduled) return@map 0f + + val instance = instanceMap[dayEpoch] + + when (val config = habit.habitConfig) { + is HabitConfig.Simple -> { + if (instance?.isCompleted(habit) == true) 100f else 0f + } + + is HabitConfig.Counted -> { + val progress = (instance?.count ?: 0).toFloat() / config.goal + (progress.coerceIn(0f, 1f)) * 100f + } + + is HabitConfig.Timed -> { + val progress = (instance?.timeSpent ?: 0L).toFloat() / config.durationMillis + (progress.coerceIn(0f, 1f)) * 100f + } + } + } + + WeeklyProgressChart(radius, modifier, percentages) +} + +@Composable +fun HabitsWeeklyProgressAnalysis( + radius: Int, + habits: List, + habitInstances: List, + modifier: Modifier = Modifier +) { + val today = LocalDate.now() + val startOfWeek = today.with(DayOfWeek.MONDAY) + val weekDays = (0..6).map { startOfWeek.plusDays(it.toLong()) } + val instanceMap = habitInstances.associateBy { it.habitId to it.instanceDate } + + val percentages = weekDays.map { day -> + val dayEpoch = day.toEpochDay() + val dayOfWeekIndex = (day.dayOfWeek.value + 6) % 7 + + val scheduledHabits = habits.filter { habit -> + val weekly = habit.recurrence as? RecurrenceRule.Weekly + + weekly?.daysOfWeek?.contains(dayOfWeekIndex) == true && habit.isLive() + } + + val completed = scheduledHabits.count { habit -> + val instance = instanceMap[habit.id to dayEpoch] + instance?.isCompleted(habit) == true + } + + if (scheduledHabits.isNotEmpty()) { + (completed.toFloat() / scheduledHabits.size) * 100f + } else 0f + } + + WeeklyProgressChart(radius, modifier, percentages) +} + +@Composable +fun WeeklyProgressChart( + radius: Int, + modifier: Modifier = Modifier, + percentages: List +){ + val primaryColor = MaterialTheme.colorScheme.primary + val primaryAlpha = primaryColor.copy(alpha = 0.4f) + + val today = LocalDate.now() + val startOfWeek = today.with(DayOfWeek.MONDAY) + val weekDays = (0..6).map { startOfWeek.plusDays(it.toLong()) } + + val dayLabels = listOf( + stringResource(R.string.monday_short), + stringResource(R.string.tuesday_short), + stringResource(R.string.wednesday_short), + stringResource(R.string.thursday_short), + stringResource(R.string.friday_short), + stringResource(R.string.saturday_short), + stringResource(R.string.sunday_short) + ) + Card( + modifier = Modifier.fillMaxWidth(), + shape = shapeManager(radius = radius * 2), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ), + onClick = {} + ) { + Column(modifier = modifier.padding(12.dp)) { + Text( + stringResource(R.string.weekly_habit_completion), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp, top = 8.dp) + ) + + Canvas( + modifier = Modifier + .fillMaxWidth() + .padding(6.dp) + .height(200.dp) + ) { + val leftPadding = 32.dp.toPx() + val bottomPadding = 32.dp.toPx() + val widthPerPoint = (size.width - leftPadding) / (weekDays.size - 1) + val height = size.height - bottomPadding + + // Vertical percentage labels + for (i in 0..4) { + val y = height - i * height / 4 + drawContext.canvas.nativeCanvas.apply { + drawText( + "${i * 25}%", + leftPadding - 14.dp.toPx(), + y, + android.graphics.Paint().apply { + color = primaryColor.toArgb() + textSize = 12.sp.toPx() + textAlign = android.graphics.Paint.Align.RIGHT + } + ) + } + } + + // Line path + val linePath = Path().apply { + percentages.forEachIndexed { index, percentage -> + val x = leftPadding + index * widthPerPoint + val y = height - (percentage / 100f * height) + if (index == 0) moveTo(x, y) else lineTo(x, y) + } + } + + // Shadow under line + val shadowPath = Path().apply { + addPath(linePath) + lineTo(leftPadding + (weekDays.size - 1) * widthPerPoint, height) + lineTo(leftPadding, height) + close() + } + + drawPath( + path = shadowPath, + brush = Brush.verticalGradient( + colors = listOf(primaryAlpha, primaryColor.copy(alpha = 0f)), + startY = 0f, + endY = height + ) + ) + + // Draw line + drawPath( + path = linePath, + color = primaryColor, + style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round) + ) + + // Draw dots + percentages.forEachIndexed { index, percentage -> + val x = leftPadding + index * widthPerPoint + val y = height - (percentage / 100f * height) + drawCircle( + color = primaryColor, + radius = 6.dp.toPx(), + center = Offset(x, y) + ) + } + + // Draw day labels + dayLabels.forEachIndexed { index, day -> + val x = leftPadding + index * widthPerPoint + val y = height + 24.dp.toPx() + drawContext.canvas.nativeCanvas.apply { + drawText( + day, + x, + y, + android.graphics.Paint().apply { + color = primaryColor.toArgb() + textSize = 12.sp.toPx() + textAlign = android.graphics.Paint.Align.CENTER + } + ) + } + } + } + } + } +} + +@Composable +fun HabitBarChart( + weekCounts: List, + weekLabels: List, + modifier: Modifier = Modifier +) { + val maxDaysPerWeek = 7 + val yLabels = (maxDaysPerWeek downTo 1).toList() + val primaryColor = MaterialTheme.colorScheme.primary + + Row( + modifier = modifier + .fillMaxWidth() + .height(220.dp) + .padding(bottom = 16.dp) + ) { + // Y-axis labels + Box( + modifier = Modifier + .width(20.dp) + .padding(end = 8.dp) + .fillMaxHeight() + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val stepHeight = size.height / maxDaysPerWeek + val textPaint = Paint().asFrameworkPaint().apply { + isAntiAlias = true + textSize = 32f + color = primaryColor.toArgb() + textAlign = android.graphics.Paint.Align.RIGHT + } + + yLabels.forEach { label -> + val y = size.height - (stepHeight * label) + drawContext.canvas.nativeCanvas.drawText( + label.toString(), + size.width - 10f, + y + 5f, + textPaint + ) + } + } + } + + // Bar chart canvas + Canvas( + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) { + val barWidth = size.width / (weekCounts.size * 2 + 1) + val stepHeight = size.height / maxDaysPerWeek + + weekCounts.forEachIndexed { index, count -> + val barHeight = stepHeight * count + val x = barWidth + index * (barWidth + barWidth) + val y = size.height - barHeight + + drawRoundRect( + color = primaryColor, + topLeft = Offset(x, y), + size = Size(barWidth, barHeight), + cornerRadius = CornerRadius(x = 16.dp.toPx(), y = 16.dp.toPx()) + ) + + drawIntoCanvas { canvas -> + val paint = Paint().asFrameworkPaint().apply { + isAntiAlias = true + color = primaryColor.toArgb() + textSize = 30f + textAlign = android.graphics.Paint.Align.CENTER + } + + canvas.nativeCanvas.drawText( + weekLabels[index], + x + barWidth / 2, + size.height + 40f, + paint + ) + } + } + + for (i in 1..maxDaysPerWeek) { + val y = size.height - (stepHeight * i) + drawLine( + color = Color.Gray.copy(alpha = 0.5f), + start = Offset(0f, y), + end = Offset(size.width, y), + strokeWidth = 0.25.dp.toPx() + ) + } + } + } +} + +data class StreakData( + val currentStreak: Int, + val bestStreak: Int +) + +fun calculateStreaks( + recurrence: RecurrenceRule, + startDateTime: Long, + habitConfig: HabitConfig, + instances: List +): StreakData { + + if (instances.isEmpty()) { + return StreakData( + currentStreak = 0, + bestStreak = 0 + ) + } + + val expectedDates = + getExpectedDates( + recurrence = recurrence, + startDateTime = startDateTime + ) + + val completedDates = + getCompletedDates( + habitConfig = habitConfig, + instances = instances + ) + + val currentStreak = + calculateCurrentStreak( + expectedDates = expectedDates, + completedDates = completedDates + ) + + val bestStreak = + calculateBestStreak( + expectedDates = expectedDates, + completedDates = completedDates + ) + + return StreakData( + currentStreak = currentStreak, + bestStreak = bestStreak + ) +} + +private fun getCompletedDates( + habitConfig: HabitConfig, + instances: List +): Set { + + return when (habitConfig) { + + is HabitConfig.Simple -> { + + instances + .map { it.instanceDate } + .toSet() + } + + is HabitConfig.Counted -> { + + instances + .filter { instance -> + + val count = + instance.count ?: 0 + + count >= habitConfig.goal + } + .map { it.instanceDate } + .toSet() + } + + is HabitConfig.Timed -> { + + instances + .map { it.instanceDate } + .toSet() + } + } +} + +private fun getExpectedDates( + recurrence: RecurrenceRule, + startDateTime: Long +): List { + + val today = LocalDate.now() + + val startDate = + Instant.ofEpochMilli(startDateTime) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + val weekly = + recurrence as? RecurrenceRule.Weekly + ?: return emptyList() + + val expectedDates = mutableListOf() + + var current = startDate + + while (!current.isAfter(today)) { + + // Monday=0 ... Sunday=6 + val dayOfWeek = + current.dayOfWeek.value - 1 + + if (dayOfWeek in weekly.daysOfWeek) { + expectedDates.add(current.toEpochDay()) + } + + current = current.plusDays(1) + } + + return expectedDates +} + +private fun calculateCurrentStreak( + expectedDates: List, + completedDates: Set +): Int { + + if (expectedDates.isEmpty()) return 0 + + var streak = 0 + + for (expectedDate in expectedDates.asReversed()) { + + if (expectedDate in completedDates) { + streak++ + } else { + break + } + } + + // If latest expected date is incomplete, + // streak is dead. + val latestExpected = expectedDates.last() + + if (latestExpected !in completedDates) { + return 0 + } + + return streak +} + +private fun calculateBestStreak(expectedDates: List, completedDates: Set): Int { + + if (expectedDates.isEmpty()) return 0 + + var bestStreak = 0 + var currentStreak = 0 + + for (expectedDate in expectedDates) { + + if (expectedDate in completedDates) { + + currentStreak++ + + bestStreak = + maxOf(bestStreak, currentStreak) + + } else { + + currentStreak = 0 + } + } + + return bestStreak +} + +@Composable +fun HabitConfigRow( + modifier: Modifier, + config: HabitConfig, + onChangeConfig: (HabitConfig)->Unit +) { + Row( + modifier + .height(48.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + HabitConfigCard(isExpanded = config is HabitConfig.Simple, config = HabitConfig.Simple) { onChangeConfig(HabitConfig.Simple) } + HabitConfigCard(isExpanded = config is HabitConfig.Counted, config = HabitConfig.Counted()) { onChangeConfig(HabitConfig.Counted()) } + // HabitConfigCard(isExpanded = config is HabitConfig.Timed, config = HabitConfig.Timed()) { onChangeConfig(HabitConfig.Timed()) } + } +} + +@Composable +fun HabitConfigCard( + isExpanded: Boolean, + config: HabitConfig, + onClick: () -> Unit +){ + val title = when(config) { + is HabitConfig.Simple -> "Simple" + is HabitConfig.Counted -> "Counter" + is HabitConfig.Timed -> "Timed" + } + + val icon = when(config) { + is HabitConfig.Simple -> Icons.Default.Check + is HabitConfig.Counted -> Icons.Default.Numbers + is HabitConfig.Timed -> Icons.Default.Timer + } + + Card( + modifier = Modifier.clip(RoundedCornerShape(50)).padding(horizontal = 2.dp), + shape = RoundedCornerShape(50), + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = if(isExpanded) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Row(Modifier.padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { + if(isExpanded){ + IconButton( + onClick = onClick, + modifier = Modifier.size(32.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary + ) + ) { Icon(icon, null, Modifier.size(18.dp)) } + + Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier + .padding(end = 8.dp) + .widthIn(max = 150.dp), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)) + } + else{ + IconButton(onClick = onClick, modifier = Modifier.size(32.dp), + ) { Icon(icon, null, Modifier.size(18.dp)) } + } + } + } +} + +fun buildFilterCategories(recurrence: RecurrenceRule): List { + return listOf( + FilterCategory( + name = "Sort By", + options = listOf( + FilterOption("latest", "Latest"), + FilterOption("oldest", "Oldest") + ), + type = SelectionType.SINGLE + ), + FilterCategory( + name = "Recurrence", + options = listOf( + FilterOption("recurrence", "Occurs On", null, recurrence) + ), + type = SelectionType.RECURRENCE + ), + FilterCategory( + name = "Type", + options = listOf( + FilterOption("simple", "Simple"), + FilterOption("timed", "Timed") + ), + type = SelectionType.SINGLE + ), + FilterCategory( + name = "Active", + options = listOf( + FilterOption("live", "Live"), + FilterOption("ended", "Ended") + ), + type = SelectionType.SINGLE + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/habits/HabitDetails.kt b/app/src/main/java/com/flux/ui/screens/habits/HabitDetails.kt index fce1390..8939dfd 100644 --- a/app/src/main/java/com/flux/ui/screens/habits/HabitDetails.kt +++ b/app/src/main/java/com/flux/ui/screens/habits/HabitDetails.kt @@ -1,28 +1,27 @@ package com.flux.ui.screens.habits import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import com.flux.data.model.HabitConfig import com.flux.data.model.HabitInstanceModel import com.flux.data.model.HabitModel import com.flux.navigation.NavRoutes -import com.flux.ui.components.HabitCalendarCard -import com.flux.ui.components.HabitEndCard -import com.flux.ui.components.HabitScaffold -import com.flux.ui.components.HabitStartCard -import com.flux.ui.components.HabitStreakCard -import com.flux.ui.components.MonthlyHabitAnalyticsCard -import com.flux.ui.components.WeeklyHabitAnalyticsCard +import com.flux.ui.common.DeleteAlert +import com.flux.ui.common.HabitScaffold import com.flux.ui.events.HabitEvents +import java.time.LocalDate @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -35,15 +34,23 @@ fun HabitDetails( onHabitEvents: (HabitEvents) -> Unit ) { val context = LocalContext.current + var showDeleteDialog by remember { mutableStateOf(false) } + val todayEpoch = LocalDate.now().toEpochDay() + val todayInstance = habitInstances.firstOrNull { it.instanceDate == todayEpoch } - HabitScaffold( - title = habit.title, - description = habit.description, - onBackPressed = { navController.popBackStack() }, - onDeleteClicked = { + if(showDeleteDialog){ + DeleteAlert({ + showDeleteDialog=false + }, { navController.popBackStack() onHabitEvents(HabitEvents.DeleteHabit(habit, context)) - }, + showDeleteDialog=false + }) + } + + HabitScaffold( + onBackPressed = { navController.popBackStack() }, + onDeleteClicked = { showDeleteDialog=true }, onEditClicked = { navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, habit.id)) }, content = { innerPadding -> LazyColumn( @@ -53,24 +60,20 @@ fun HabitDetails( .fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - item { HabitStartCard(habit.startDateTime, radius) } - if(habit.endDateTime!=-1L){ item { HabitEndCard(habit.endDateTime, radius) } } - item { HabitStreakCard(habit, habitInstances, radius) } - item { - Spacer(Modifier.height(8.dp)) - HabitCalendarCard( - radius, - habit.id, - workspaceId, - habit.startDateTime, - habit.endDateTime, - habit.recurrence, - habitInstances, - onHabitEvents - ) + item { HabitDetailedInfo(radius, habit, habitInstances) } + if(habit.habitConfig is HabitConfig.Counted) { + item { CountedHabitStatus(radius, habit, todayInstance, onHabitEvents) } + } + if(habit.habitConfig is HabitConfig.Timed) { + item { TimedHabitStatus(radius, habit, todayInstance, onHabitEvents) } + } + item { HabitCalendarCard(radius, habit, habitInstances, onHabitEvents) } + item { WeeklyHabitAnalyticsCard(radius, habit, habitInstances) } + if(habit.habitConfig is HabitConfig.Timed || habit.habitConfig is HabitConfig.Counted){ + item { SingleHabitWeeklyProgressChart(radius, habit, habitInstances) } } - item { WeeklyHabitAnalyticsCard(radius, habitInstances) } - item { MonthlyHabitAnalyticsCard(radius, habitInstances) } + item { MonthlyHabitAnalyticsCard(radius, habit, habitInstances) } + item { SingleHabitHeatMap(radius, habit, habitInstances) } } } ) diff --git a/app/src/main/java/com/flux/ui/screens/habits/HabitScreen.kt b/app/src/main/java/com/flux/ui/screens/habits/HabitScreen.kt new file mode 100644 index 0000000..92c0a1f --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/habits/HabitScreen.kt @@ -0,0 +1,242 @@ +package com.flux.ui.screens.habits + +import android.os.Build +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.EventAvailable +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.R +import com.flux.data.model.HabitInstanceModel +import com.flux.data.model.RecurrenceRule +import com.flux.data.model.WorkspaceModel +import com.flux.data.model.isCounted +import com.flux.data.model.isLive +import com.flux.navigation.Loader +import com.flux.navigation.NavRoutes +import com.flux.other.canScheduleReminder +import com.flux.other.isNotificationPermissionGranted +import com.flux.other.openAppNotificationSettings +import com.flux.other.requestExactAlarmPermission +import com.flux.ui.common.EmptyHabits +import com.flux.ui.common.SpaceSearchBar +import com.flux.ui.common.SpaceTopBar +import com.flux.ui.common.SpacesMenu +import com.flux.ui.events.HabitEvents +import com.flux.ui.screens.workspaces.SpacesToolBar +import com.flux.ui.state.HabitState +import com.flux.ui.state.Settings +import java.time.LocalDate + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HabitScreen( + navController: NavController, + state: HabitState, + settings: Settings, + workspace: WorkspaceModel, + onShowSpaceBottomSheet: () -> Unit, + onSpaceChange: (Int) -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, + onEvent: (HabitEvents) -> Unit +){ + val context = LocalContext.current + val workspaceId = workspace.workspaceId + val isLoading = state.isLoading + val allInstances = state.allInstances + val radius = settings.data.cornerRadius + val is24HourFormat = settings.data.is24HourFormat + var query by remember { mutableStateOf("") } + val allHabits = state.allHabits + .filter { it.workspaceId == workspaceId } + .filter { it.title.contains(query, ignoreCase = true) || it.description.contains(query, ignoreCase = true) } + val currentHabits = allHabits.filter { it.isLive() } + val pastHabits = allHabits.filter { !it.isLive() } + var showSearchBar by remember { mutableStateOf(false) } + var showSpacesMenu by remember { mutableStateOf(false) } + val notificationPermissionLabel = stringResource(R.string.Notification_Permission) + val reminderPermissionLabel = stringResource(R.string.Reminder_Permission) + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + }, + floatingActionButton = { + FloatingActionButton({ + if (!canScheduleReminder(context)) { + Toast.makeText(context, reminderPermissionLabel, Toast.LENGTH_SHORT).show() + requestExactAlarmPermission(context) + } + if (!isNotificationPermissionGranted(context)) { + Toast.makeText(context, notificationPermissionLabel, Toast.LENGTH_SHORT).show() + openAppNotificationSettings(context) + } + if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) { + navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, "")) + } + }) { Icon(Icons.Default.Add, null) } + } + ) { innerPadding -> + when { + isLoading -> Loader() + else -> { + LazyColumn( + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if(showSearchBar){ SpaceSearchBar(query, { query=it }, { showSearchBar=false }) } + else { + SpacesToolBar( + stringResource(R.string.Habits), + Icons.Default.EventAvailable, + false, + onMainClick = { showSpacesMenu = true }, + onEditClick = onShowSpaceBottomSheet + ) + SpacesMenu(showSpacesMenu, workspace, onSpaceChange) { showSpacesMenu = false } + Row{ + IconButton({ showSearchBar = true }) { + Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary) + } + IconButton({ showSearchBar = true }) { + Icon(Icons.Default.FilterList, null, tint = MaterialTheme.colorScheme.primary) + } + } + } + } + } + if(allHabits.isEmpty()) item { EmptyHabits() } + items(currentHabits) { habit -> + val habitInstances = allInstances.filter { it.habitId == habit.id } + HabitPreviewCard( + radius = radius, + habit = habit, + is24HourFormat = is24HourFormat, + instances = habitInstances, + onClick = { date -> + if (isDateAllowedForHabit(habit.recurrence, date)) { + val oldInstance = habitInstances.find { it.instanceDate == date } + val count = if(habit.isCounted){ + if(oldInstance!=null) oldInstance.count+1 + else 1 + } else 0 + + val newInstance = HabitInstanceModel( + instanceDate = date, + habitId = habit.id, + workspaceId = workspaceId, + count = count + ) + onEvent(HabitEvents.UpdateInstance(newInstance, habit.habitConfig)) + } + }, + onAnalyticsClicked = { navController.navigate(NavRoutes.HabitDetails.withArgs(workspaceId, habit.id)) } + ) + } + if(pastHabits.isNotEmpty()) { + item { + Text( + stringResource(R.string.past_habits), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + items(pastHabits) { habit -> + val habitInstances = allInstances.filter { it.habitId == habit.id } + HabitPreviewCard ( + radius = radius, + is24HourFormat = is24HourFormat, + habit = habit, + instances = habitInstances, + onClick = {}, + onAnalyticsClicked = { + navController.navigate( + NavRoutes.HabitDetails.withArgs( + workspaceId, + habit.id + ) + ) + } + ) + } + } + } + } + } +} + +// Helper function to check if a date is allowed for the habit's recurrence +fun isDateAllowedForHabit(recurrence: RecurrenceRule, epochDay: Long): Boolean { + return when (recurrence) { + is RecurrenceRule.Weekly -> { + // Convert epoch day to LocalDate to get day of week + val localDate = LocalDate.ofEpochDay(epochDay) + // Convert to Monday=0, Tuesday=1, ..., Sunday=6 format + val dayOfWeek = (localDate.dayOfWeek.value + 6) % 7 + dayOfWeek in recurrence.daysOfWeek + } + is RecurrenceRule.Custom -> true + is RecurrenceRule.Once -> true + is RecurrenceRule.Monthly -> true + is RecurrenceRule.Yearly -> true + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/habits/Habits.kt b/app/src/main/java/com/flux/ui/screens/habits/Habits.kt deleted file mode 100644 index a8b9111..0000000 --- a/app/src/main/java/com/flux/ui/screens/habits/Habits.kt +++ /dev/null @@ -1,120 +0,0 @@ -package com.flux.ui.screens.habits - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.R -import com.flux.data.model.HabitInstanceModel -import com.flux.data.model.HabitModel -import com.flux.data.model.RecurrenceRule -import com.flux.data.model.isLive -import com.flux.navigation.Loader -import com.flux.navigation.NavRoutes -import com.flux.ui.components.EmptyHabits -import com.flux.ui.components.HabitPreviewCard -import com.flux.ui.events.HabitEvents -import com.flux.ui.state.Settings -import java.time.LocalDate - -fun LazyListScope.habitsHomeItems( - navController: NavController, - isLoading: Boolean, - radius: Int, - workspaceId: String, - allHabits: List, - allInstances: List, - settings: Settings, - onHabitEvents: (HabitEvents) -> Unit -) { - val currentHabits = allHabits.filter { it.isLive() } - val pastHabits = allHabits.filter { !it.isLive() } - - when { - isLoading -> item { Loader() } - allHabits.isEmpty() -> item { EmptyHabits() } - else -> { - items(currentHabits) { habit -> - val habitInstances = allInstances.filter { it.habitId == habit.id } - HabitPreviewCard( - radius = radius, - habit = habit, - instances = habitInstances, - settings = settings, - onToggleDone = { date -> - if (isDateAllowedForHabit(habit.recurrence, date)) { - val existing = habitInstances.find { it.instanceDate == date } - if (existing != null) { - onHabitEvents(HabitEvents.MarkUndone(existing)) - } else { - onHabitEvents( - HabitEvents.MarkDone( - HabitInstanceModel( - instanceDate = date, - habitId = habit.id, - workspaceId = workspaceId - ) - ) - ) - } - } - }, - onAnalyticsClicked = { navController.navigate(NavRoutes.HabitDetails.withArgs(workspaceId, habit.id)) } - ) - Spacer(Modifier.height(8.dp)) - } - if(pastHabits.isNotEmpty()) { - item { - Text( - stringResource(R.string.past_habits), - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(vertical = 8.dp) - ) - } - } - items(pastHabits) { habit -> - val habitInstances = allInstances.filter { it.habitId == habit.id } - HabitPreviewCard( - radius = radius, - habit = habit, - instances = habitInstances, - settings = settings, - onToggleDone = {}, - onAnalyticsClicked = { - navController.navigate( - NavRoutes.HabitDetails.withArgs( - workspaceId, - habit.id - ) - ) - } - ) - Spacer(Modifier.height(8.dp)) - } - } - } -} - -// Helper function to check if a date is allowed for the habit's recurrence -fun isDateAllowedForHabit(recurrence: RecurrenceRule, epochDay: Long): Boolean { - return when (recurrence) { - is RecurrenceRule.Weekly -> { - // Convert epoch day to LocalDate to get day of week - val localDate = LocalDate.ofEpochDay(epochDay) - // Convert to Monday=0, Tuesday=1, ..., Sunday=6 format - val dayOfWeek = (localDate.dayOfWeek.value + 6) % 7 - dayOfWeek in recurrence.daysOfWeek - } - is RecurrenceRule.Custom -> true - is RecurrenceRule.Once -> true - is RecurrenceRule.Monthly -> true - is RecurrenceRule.Yearly -> true - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/habits/NewHabit.kt b/app/src/main/java/com/flux/ui/screens/habits/NewHabit.kt index 134734a..cec41ed 100644 --- a/app/src/main/java/com/flux/ui/screens/habits/NewHabit.kt +++ b/app/src/main/java/com/flux/ui/screens/habits/NewHabit.kt @@ -1,22 +1,37 @@ package com.flux.ui.screens.habits +import android.widget.Toast import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AcUnit +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.AlarmAdd import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Create +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.NotificationsActive +import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.filled.Today +import androidx.compose.material.icons.filled.TrackChanges +import androidx.compose.material.icons.outlined.Circle import androidx.compose.material.icons.outlined.Flag +import androidx.compose.material.icons.outlined.StopCircle import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CenterAlignedTopAppBar @@ -25,6 +40,7 @@ import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch @@ -34,6 +50,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf @@ -54,10 +71,11 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.flux.R +import com.flux.data.model.HabitConfig import com.flux.data.model.HabitModel import com.flux.data.model.RecurrenceRule -import com.flux.ui.components.DatePickerModal -import com.flux.ui.components.TimePicker +import com.flux.ui.common.DatePickerModal +import com.flux.ui.common.TimePicker import com.flux.ui.events.HabitEvents import com.flux.ui.screens.events.getTextFieldColors import com.flux.ui.screens.events.toFormattedDate @@ -75,15 +93,15 @@ fun NewHabit( habit: HabitModel, settings: Settings, onHabitEvents: (HabitEvents) -> Unit -){ +) { val context = LocalContext.current var newHabitTitle by rememberSaveable { mutableStateOf(habit.title) } var newHabitDescription by rememberSaveable { mutableStateOf(habit.description) } var newHabitTime by rememberSaveable { mutableLongStateOf(habit.startDateTime) } var habitEndsOn by rememberSaveable { mutableLongStateOf(habit.endDateTime) } + var newHabitConfig by remember { mutableStateOf(habit.habitConfig) } var showDatePicker by remember { mutableStateOf(false) } - var timePickerDialog by remember { mutableStateOf(false) } - var neverEnds by rememberSaveable { mutableStateOf(habit.endDateTime==-1L) } + var neverEnds by rememberSaveable { mutableStateOf(habit.endDateTime == -1L) } val focusRequesterDesc = remember { FocusRequester() } val keyboardController = LocalSoftwareKeyboardController.current val weekdays = listOf( @@ -110,6 +128,11 @@ fun NewHabit( stringResource(R.string.Edit_Habit) } + // Cache per-type so toggling back restores original data + var cachedCountedConfig by remember { + mutableStateOf(habit.habitConfig as? HabitConfig.Counted ?: HabitConfig.Counted()) + } + Scaffold( containerColor = MaterialTheme.colorScheme.surfaceContainerLow, topBar = { @@ -126,16 +149,19 @@ fun NewHabit( enabled = newHabitTitle.isNotBlank() && selectedDays.isNotEmpty(), onClick = { navController.popBackStack() - onHabitEvents(HabitEvents.UpsertHabit( - context, - habit.copy( - title = newHabitTitle, - description = newHabitDescription, - startDateTime = newHabitTime, - endDateTime = habitEndsOn, - recurrence = RecurrenceRule.Weekly(selectedDays.toList()) + onHabitEvents( + HabitEvents.UpsertHabit( + context, + habit.copy( + title = newHabitTitle, + description = newHabitDescription, + startDateTime = newHabitTime, + endDateTime = habitEndsOn, + recurrence = RecurrenceRule.Weekly(selectedDays.toList()), + habitConfig = newHabitConfig + ) ) - )) + ) } ) { Icon(Icons.Default.Check, null) @@ -144,157 +170,606 @@ fun NewHabit( ) } ) { innerPadding -> - Column( + Box( modifier = Modifier + .fillMaxSize() .padding(innerPadding) - .padding(16.dp) - .fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(4.dp) ) { - TextField( - value = newHabitTitle, - onValueChange = { newHabitTitle = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { Text(stringResource(R.string.Title)) }, - singleLine = true, - textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), - colors = getTextFieldColors(), - keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next), - keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() }) - ) + LazyColumn( + Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues( + top = 16.dp, + bottom = 64.dp + ) + ) { + item { + TextField( + value = newHabitTitle, + onValueChange = { newHabitTitle = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.Title)) }, + singleLine = true, + textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), + colors = getTextFieldColors(), + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Words, + imeAction = ImeAction.Next + ), + keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() }) + ) + } - TextField( - value = newHabitDescription, - onValueChange = { newHabitDescription = it }, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 16.dp) - .focusRequester(focusRequesterDesc), - placeholder = { Text(stringResource(R.string.Description)) }, - singleLine = true, - shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp), - colors = getTextFieldColors(), - keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done), - keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) - ) + item { + TextField( + value = newHabitDescription, + onValueChange = { newHabitDescription = it }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp) + .focusRequester(focusRequesterDesc), + placeholder = { Text(stringResource(R.string.Description)) }, + singleLine = true, + shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp), + colors = getTextFieldColors(), + keyboardOptions = KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) + ) + } - HorizontalDivider() + item { HorizontalDivider() } - Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){ - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Default.AlarmAdd, - contentDescription = "Alarm Icon" - ) - Text(stringResource(R.string.time)) + item { + when (newHabitConfig) { + is HabitConfig.Simple -> { + SimpleConfigItems( + newHabitTime, + settings.data.is24HourFormat + ) { habitTime, newConfig -> + newHabitTime = habitTime + newHabitConfig = newConfig + } + } + + is HabitConfig.Counted -> { + CountedConfigItems( + settings.data.is24HourFormat, + newHabitConfig as HabitConfig.Counted + ) { newHabitConfig = it } + } + + else -> { +// +// TimedConfigItems( +// newHabitTime, +// newHabitConfig as HabitConfig.Timed, +// settings.data.is24HourFormat +// ) { habitTime, newConfig -> +// newHabitTime = habitTime +// newHabitConfig = newConfig +// } + } + } } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Text(newHabitTime.toFormattedTime(settings.data.is24HourFormat)) - FilledTonalIconButton({ timePickerDialog = true }) { - Icon( - imageVector = Icons.Default.Create, - contentDescription = "Pick Time" - ) + + + item { HorizontalDivider() } + + item { + Row( + Modifier.padding(top = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Repeat, null) + Text(stringResource(R.string.repeat)) } } - } - HorizontalDivider() - Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Repeat, null) - Text(stringResource(R.string.repeat)) - } + item { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + weekdays.forEachIndexed { index, day -> + val isSelected = selectedDays.contains(index) + Card( + onClick = { + if (isSelected) { + // Prevent removing all days (must have at least one) + if (selectedDays.size > 1) { + selectedDays.remove(index) + } + } else { + selectedDays.add(index) + } + }, + modifier = Modifier + .weight(1f) + .padding(horizontal = 2.dp), + colors = CardDefaults.cardColors( + containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation( + 6.dp + ), + contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + ) + ) { + Text( + text = day, + modifier = Modifier + .padding(6.dp) + .fillMaxWidth(), + textAlign = TextAlign.Center + ) + } + } + } + } - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) { - weekdays.forEachIndexed { index, day -> - val isSelected = selectedDays.contains(index) - Card( - onClick = { - if (isSelected) { - // Prevent removing all days (must have at least one) - if (selectedDays.size > 1) { selectedDays.remove(index) } - } else { selectedDays.add(index) } - }, - modifier = Modifier - .weight(1f) - .padding(horizontal = 2.dp), - colors = CardDefaults.cardColors( - containerColor = if(isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - contentColor = if(isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface - ) + item { HorizontalDivider() } + + item { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween ) { - Text( - text = day, - modifier = Modifier.padding(6.dp).fillMaxWidth(), - textAlign = TextAlign.Center - ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Outlined.Flag, null) + Text(stringResource(R.string.never_ends)) + } + + Switch(neverEnds, onCheckedChange = { + if (neverEnds) { + neverEnds = false + habitEndsOn = + max(newHabitTime, System.currentTimeMillis()) + } else { + neverEnds = true + habitEndsOn = -1L + } + }) } } - } - HorizontalDivider() - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically){ - Icon(Icons.Outlined.Flag, null) - Text(stringResource(R.string.never_ends)) + item { + if (!neverEnds) { + Row( + Modifier + .fillMaxWidth() + .padding(top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Today, + null, + modifier = Modifier.size(24.dp) + ) + Text(stringResource(R.string.ends_on)) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(habitEndsOn.toFormattedDate()) + FilledTonalIconButton({ showDatePicker = true }) { + Icon( + imageVector = Icons.Default.Create, + contentDescription = "Pick Time" + ) + } + } + } + } } + } - Switch(neverEnds, onCheckedChange = { - if(neverEnds){ - neverEnds=false - habitEndsOn=max(newHabitTime, System.currentTimeMillis()) + HabitConfigRow( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + config = newHabitConfig, + onChangeConfig = { incoming -> + // Persist the current config before switching away + when (val current = newHabitConfig) { + is HabitConfig.Counted -> cachedCountedConfig = current + else -> Unit } - else{ - neverEnds=true - habitEndsOn=-1L + // Restore from cache when switching back to a known type + newHabitConfig = when (incoming) { + is HabitConfig.Counted -> cachedCountedConfig + else -> incoming } - }) + } + ) + } + } + + if (showDatePicker) { + DatePickerModal(onDateSelected = { + if (it != null) + habitEndsOn = LocalDate + .ofEpochDay(it / 86_400_000) + .atTime(LocalTime.MAX) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }) { + showDatePicker = false + } + } +} + +@Composable +fun SimpleConfigItems(startDateTime: Long, is24HourFormat: Boolean, onClick: (Long, HabitConfig.Simple) -> Unit) { + var showTimePicker by remember { mutableStateOf(false) } + + if (showTimePicker) { + TimePicker( + initialTime = startDateTime, + is24Hour = is24HourFormat, + onConfirm = { onClick(it, HabitConfig.Simple) } + ) { showTimePicker = false } + } + + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ){ + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.AlarmAdd, null) + Text("Reminder Time") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(startDateTime.toFormattedTime(is24HourFormat)) + FilledTonalIconButton({ showTimePicker = true }) { + Icon(Icons.Default.Create, null) } + } + } +} + +@Composable +fun CountedConfigItems( + is24HourFormat: Boolean, + config: HabitConfig.Counted, + onChange: (HabitConfig.Counted) -> Unit +){ + var goal by remember { mutableIntStateOf(config.goal) } + var unit by remember { mutableStateOf(config.unit) } + var intervalMillis by remember { mutableLongStateOf(config.intervalMillis) } + var activeStartTime by remember { mutableLongStateOf(config.activeStartTime) } + var activeEndTime by remember { mutableLongStateOf(config.activeEndTime) } + var showIntervalTimer by remember { mutableStateOf(false) } + var showActiveTimePicker by remember { mutableStateOf(false) } + var isChangingActiveStartTime by remember { mutableStateOf(false) } + val context = LocalContext.current + + val count = remember(activeStartTime, activeEndTime, intervalMillis) { + if (intervalMillis > 0) { ((activeEndTime - activeStartTime) / intervalMillis) + 1 } else 1 + } - if(!neverEnds){ - Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){ - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Today, null, modifier = Modifier.size(24.dp)) - Text(stringResource(R.string.ends_on)) + if (showIntervalTimer) { + TimerDialog( + durationMillis = intervalMillis, + onDismiss = { showIntervalTimer = false } + ) { duration -> + intervalMillis = duration + onChange(config.copy(intervalMillis = duration)) + } + } + + if (showActiveTimePicker) { + TimePicker( + initialTime = if(isChangingActiveStartTime) activeStartTime else activeEndTime, + is24Hour = is24HourFormat, + onConfirm = { + if(isChangingActiveStartTime) { + onChange(config.copy(activeStartTime = it)) + activeStartTime = it + if(activeEndTime2, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon(Icons.Default.Remove, null) + } + Text(goal.toString()) + IconButton( + { + goal+=1 + onChange(config.copy(goal = goal)) + }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon(Icons.Default.Add, null) + } + } + } + + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.AcUnit, null) + Text("Unit (Optional)") + } + + TextField( + value = unit, + onValueChange = { + unit = it + onChange(config.copy(unit=it)) }, + singleLine = true, + modifier = Modifier.width(100.dp), + colors = getTextFieldColors(), + textStyle = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center) + ) + } + + HorizontalDivider() + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Outlined.Circle, null) + Text("From") + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(activeStartTime.toFormattedTime(is24HourFormat)) + FilledTonalIconButton({ + isChangingActiveStartTime=true + showActiveTimePicker = true + }) { + Icon(Icons.Default.Create, null) } - HorizontalDivider() } + } + + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Outlined.StopCircle, null) + Text("To") + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(activeEndTime.toFormattedTime(is24HourFormat)) + FilledTonalIconButton({ + isChangingActiveStartTime=false + showActiveTimePicker = true + }) { Icon(Icons.Default.Create, null) } + } + } + + HorizontalDivider() + + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ){ + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.NotificationsActive, null) + Text("Remind Every") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(formatDuration(intervalMillis)) + FilledTonalIconButton({ showIntervalTimer = true }) { + Icon(Icons.Default.Create, null) + } + } + } + + Card( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainerLow) + ){ + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text("Habit will remind for $count times in a day.") + } + } + } +} + +@Composable +fun TimedConfigItems( + startDateTime: Long, + config: HabitConfig.Timed, + is24HourFormat: Boolean, + onChange: (Long, HabitConfig.Timed)->Unit +){ + var newStartDateTime by remember { mutableLongStateOf(startDateTime) } + var durationMillis by remember { mutableLongStateOf(config.durationMillis) } + var showTimePicker by remember { mutableStateOf(false) } + var showDurationPicker by remember { mutableStateOf(false) } - if (timePickerDialog) { - TimePicker( - initialTime = newHabitTime, - is24Hour = settings.data.is24HourFormat, - onConfirm = { newHabitTime=it } - ) { timePickerDialog = false } + if (showDurationPicker) { + TimerDialog( + durationMillis = durationMillis, + onDismiss = { showDurationPicker = false } + ) { duration -> + durationMillis = duration + onChange(newStartDateTime, config.copy(durationMillis = duration)) + } + } + + if (showTimePicker) { + TimePicker( + initialTime = newStartDateTime, + is24Hour = is24HourFormat, + onConfirm = { + newStartDateTime = it + onChange(it, config.copy(durationMillis = durationMillis)) } + ) { showTimePicker = false } + } - if(showDatePicker){ - DatePickerModal(onDateSelected = { - if(it!=null) - habitEndsOn = LocalDate - .ofEpochDay(it / 86_400_000) - .atTime(LocalTime.MAX) - .atZone(ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - }){ - showDatePicker=false + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ){ + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.AlarmAdd, null) + Text("Reminder Time") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(startDateTime.toFormattedTime(is24HourFormat)) + FilledTonalIconButton({ showTimePicker = true }) { + Icon(Icons.Default.Create, null) + } + } + } + HorizontalDivider() + Row( + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ){ + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.Timer, null) + Text("Duration") + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(formatDuration(durationMillis)) + FilledTonalIconButton({ showDurationPicker = true }) { + Icon(Icons.Default.Create, null) } } } } +} + +fun formatDuration(durationMillis: Long): String { + val totalMinutes = durationMillis / 60000 + val hours = totalMinutes / 60 + val minutes = totalMinutes % 60 + + return when { + hours > 0 && minutes > 0 -> "${hours}h ${minutes}m" + hours > 0 -> "${hours}h" + else -> "${minutes}m" + } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt b/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt index f115cc3..6d8b50b 100644 --- a/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt +++ b/app/src/main/java/com/flux/ui/screens/journal/EditJournal.kt @@ -3,6 +3,7 @@ package com.flux.ui.screens.journal import android.app.Activity import android.net.Uri import android.webkit.WebView +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -12,29 +13,44 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.LabelImportant +import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -49,20 +65,10 @@ import com.flux.other.HeaderNode import com.flux.other.ensureStorageRoot import com.flux.other.printPdf import com.flux.other.shareJournal -import com.flux.ui.components.DeleteAlert -import com.flux.ui.components.JournalDetailsTopBar -import com.flux.ui.components.LinkDialog -import com.flux.ui.components.ListDialog -import com.flux.ui.components.MarkdownEditorRow -import com.flux.ui.components.NotesInfoBottomSheet -import com.flux.ui.components.OutlineBottomSheet -import com.flux.ui.components.RecordAudioDialog -import com.flux.ui.components.ShareDialog -import com.flux.ui.components.TableDialog -import com.flux.ui.components.TaskDialog -import com.flux.ui.components.TaskItem -import com.flux.ui.components.convertMillisToDate -import com.flux.ui.components.convertMillisToTime +import com.flux.ui.common.DeleteAlert +import com.flux.ui.common.JournalDetailsTopBar +import com.flux.ui.common.convertMillisToDate +import com.flux.ui.common.convertMillisToTime import com.flux.ui.events.JournalEvents import com.flux.ui.screens.notes.FindAndReplaceField import com.flux.ui.screens.notes.FindAndReplaceState @@ -78,6 +84,22 @@ import java.time.Instant import java.time.LocalDate import java.time.ZoneId import androidx.compose.runtime.saveable.Saver +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import com.flux.data.model.LabelModel +import com.flux.navigation.NavRoutes +import com.flux.ui.common.DatePickerModal +import com.flux.ui.screens.notes.LinkDialog +import com.flux.ui.screens.notes.ListDialog +import com.flux.ui.screens.notes.MarkdownEditorRow +import com.flux.ui.screens.notes.NotesInfoBottomSheet +import com.flux.ui.screens.notes.OutlineBottomSheet +import com.flux.ui.screens.notes.RecordAudioDialog +import com.flux.ui.screens.notes.SelectLabelDialog +import com.flux.ui.screens.notes.ShareDialog +import com.flux.ui.screens.notes.TableDialog +import com.flux.ui.screens.notes.TaskDialog +import com.flux.ui.screens.notes.TaskItem @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -91,6 +113,7 @@ fun EditJournal( isLineNumbersVisible: Boolean, startWithReadView: Boolean, rootUri: String?, + allLabels: List, journalViewModel: JournalViewModel, settingsViewModel: SettingsViewModel, onJournalEvents: (JournalEvents) -> Unit @@ -99,7 +122,8 @@ fun EditJournal( save = { it.text.toString() }, restore = { TextFieldState(it) } ) - val isToday = LocalDate.now() == Instant.ofEpochMilli(journal.dateTime).atZone(ZoneId.systemDefault()).toLocalDate() + var isToday by remember { mutableStateOf(LocalDate.now() == Instant.ofEpochMilli(journal.dateTime).atZone(ZoneId.systemDefault()).toLocalDate()) } + var journalDate by remember { mutableLongStateOf(journal.dateTime) } val contentState = rememberSaveable(saver = textFieldStateSaver) { TextFieldState(journal.text) } val context = LocalContext.current val scope = rememberCoroutineScope() @@ -117,6 +141,7 @@ fun EditJournal( var selectedHeader by remember { mutableStateOf(null) } var searchState by remember { mutableStateOf(FindAndReplaceState()) } var isSearching by remember { mutableStateOf(false) } + var showDateDialog by rememberSaveable { mutableStateOf(false) } var showAboutNotes by rememberSaveable { mutableStateOf(false) } var showLinkDialog by rememberSaveable { mutableStateOf(false) } var showTaskDialog by rememberSaveable { mutableStateOf(false) } @@ -125,6 +150,12 @@ fun EditJournal( val isReadView by remember { derivedStateOf { pagerState.currentPage == 1 } } var showAudioRecorder by rememberSaveable { mutableStateOf(false) } var readWebView by remember { mutableStateOf(null) } + var showLabelDialog by rememberSaveable { mutableStateOf(false) } + val currentLabelIds = rememberSaveable { + mutableStateListOf().apply { + addAll(journal.labels) + } + } val recorder = AudioRecorder(context) val rootPicker = @@ -164,19 +195,23 @@ fun EditJournal( isSearching = false } + LaunchedEffect(journalDate) { + isToday = LocalDate.now() == Instant.ofEpochMilli(journalDate).atZone(ZoneId.systemDefault()).toLocalDate() + } + fun onSaveJournal() { val newText = contentState.text.toString() val hasChanged = newText != journal.text - val hasMeaningfulContent = newText.isNotBlank() - if (!hasChanged || !hasMeaningfulContent && journal.text.isBlank()) return + if (!hasChanged && currentLabelIds.toList()==journal.labels) return onJournalEvents( JournalEvents.UpsertEntry( journal.copy( text = newText, - dateTime = if (isToday) System.currentTimeMillis() else journal.dateTime + dateTime = if (isToday) System.currentTimeMillis() else journalDate, + labels = currentLabelIds.toList() ) ) ) @@ -208,8 +243,9 @@ fun EditJournal( onAboutClicked = { onJournalEvents(JournalEvents.CalculateTextState(contentState.text.toString())) showAboutNotes=true }, - onShareNote = { showShareDialog=true }, - onSaveNote = { showSaveDialog=true }, + onShareNote = { showShareDialog = true }, + onSaveNote = { showSaveDialog = true }, + onAddLabel = { showLabelDialog = true }, onPrintNote = { printPdf(context as Activity, readWebView, convertMillisToDate(journal.dateTime)+"_"+ convertMillisToTime(journal.dateTime)) } ) }, @@ -262,7 +298,9 @@ fun EditJournal( .fillMaxSize() .padding(innerPadding)) { AnimatedContent( - modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp), targetState = isSearching, contentAlignment = Alignment.TopCenter ) { @@ -273,9 +311,68 @@ fun EditJournal( } } + LazyRow( + modifier = Modifier.padding(start = 12.dp, top = 2.dp, bottom = 2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + item { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable { showDateDialog=true } + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + Icons.Default.CalendarToday, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + convertMillisToDate(journalDate), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelLarge + ) + } + } + } + items(allLabels.filter { l -> currentLabelIds.contains(l.labelId) }) { label -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primaryContainer) + .clickable { showLabelDialog = true } + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + Icons.AutoMirrored.Default.LabelImportant, + contentDescription = "Label", + modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + label.value, + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelLarge + ) + } + } + } + } + /*-------------------------------------------------*/ val scrollState = rememberScrollState() HorizontalPager( + modifier = Modifier.padding(top=2.dp), state = pagerState, beyondViewportPageCount = 1, userScrollEnabled = false @@ -312,6 +409,58 @@ fun EditJournal( OutlineBottomSheet(showOutlineSheet, outline, sheetState, onHeaderClick = { selectedHeader = it } ) { showOutlineSheet=false } + if(showDateDialog){ + DatePickerModal( + onDateSelected = { selectedDate -> + + if (selectedDate == null) return@DatePickerModal + + val now = System.currentTimeMillis() + + val selectedLocalDate = Instant.ofEpochMilli(selectedDate) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + + val today = LocalDate.now() + + when { + + // future date not allowed + selectedLocalDate.isAfter(today) -> { + Toast.makeText( + context, + "Future journal entries are not allowed", + Toast.LENGTH_SHORT + ).show() + } + + // today -> use current exact time + selectedLocalDate.isEqual(today) -> { + journalDate = now + } + + // past date -> keep selected date but current time + else -> { + + val currentTime = Instant.ofEpochMilli(now) + .atZone(ZoneId.systemDefault()) + .toLocalTime() + + val finalMillis = selectedLocalDate + .atTime(currentTime) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + + journalDate = finalMillis + } + } + } + ) { + showDateDialog = false + } + } + if (showLinkDialog) { LinkDialog(onDismissRequest = { showLinkDialog = false }) { linkName, linkUri -> val insertText = "[${linkName}](${linkUri})" @@ -386,11 +535,22 @@ fun EditJournal( } } + if (showLabelDialog) { + SelectLabelDialog(currentLabelIds, allLabels, + onConfirmation = { + currentLabelIds.clear() + currentLabelIds.addAll(it) + }, + onDismissRequest = { showLabelDialog = false }, + onAddLabel = { navController.navigate(NavRoutes.EditLabels.withArgs(journal.workspaceId)) } + ) + } + NotesInfoBottomSheet( isVisible = showAboutNotes, words = aboutJournal.wordCountWithPunctuation, characters = aboutJournal.charCount, - lastEdited = convertMillisToDate(journal.dateTime), + lastEdited = "${convertMillisToDate(journal.dateTime)}, ${convertMillisToTime(journal.dateTime)}", sheetState = sheetState, paragraph = aboutJournal.paragraphCount, wordsWithoutPunctuations = aboutJournal.wordCountWithoutPunctuation, diff --git a/app/src/main/java/com/flux/ui/screens/journal/JournalComponents.kt b/app/src/main/java/com/flux/ui/screens/journal/JournalComponents.kt new file mode 100644 index 0000000..ed15098 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/journal/JournalComponents.kt @@ -0,0 +1,420 @@ +package com.flux.ui.screens.journal + +import android.annotation.SuppressLint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.LabelImportant +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.Typography +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flux.data.model.LabelModel +import com.flux.other.parseMarkdownContent +import com.flux.ui.common.CategoryRow +import com.flux.ui.common.DateOptionRow +import com.flux.ui.common.DatePickerModal +import com.flux.ui.common.FilterCategory +import com.flux.ui.common.FilterOption +import com.flux.ui.common.MultiOptionRow +import com.flux.ui.common.OptionRow +import com.flux.ui.common.SelectionType +import com.flux.ui.screens.settings.shapeManager +import kotlin.collections.set + +@Composable +fun JournalCardHeader( + formattedTimestamp: String, + colorScheme: ColorScheme = MaterialTheme.colorScheme, + typography: Typography = MaterialTheme.typography +) { + Box(modifier = Modifier + .wrapContentWidth() + .padding(bottom = 8.dp, top = 4.dp, start = 4.dp)) { + Canvas( + modifier = Modifier + .align(Alignment.CenterStart) + .size(8.dp) + ) { + drawCircle( + color = colorScheme.onSurface, + radius = size.minDimension / 2 + ) + } + + BasicText( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 16.dp), + text = formattedTimestamp, + style = typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = ColorProducer { colorScheme.onSurfaceVariant } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JournalPreview( + radius: Int, + content: String, + labels: List, + onClick: () -> Unit +) { + Card( + colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + modifier = Modifier.clip(shapeManager(isBoth = true, radius = radius / 2)).fillMaxWidth(), + shape = shapeManager(isBoth = true, radius = radius / 2), + onClick = onClick + ) { + Text( + text = parseMarkdownContent(content), + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .alpha(0.9f) + .padding(12.dp) + .heightIn(min = 50.dp) + ) + + FlowRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + labels.forEach { label -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + Icons.AutoMirrored.Default.LabelImportant, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + text = label.value, + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } +} + +@Composable +fun TimelineBody( + isLast: Boolean, + color: Color = MaterialTheme.colorScheme.onSurface.copy(0.3f), + thickness: Dp = 2.dp +) { + Canvas( + modifier = Modifier + .width(16.dp) + .fillMaxHeight() + ) { + if (!isLast) { + val x = size.width / 2f + drawLine( + color = color, + start = Offset(x, 0f), + end = Offset(x, size.height), + strokeWidth = thickness.toPx(), + cap = StrokeCap.Round + ) + } + } +} + +fun buildFilterCategories(labels: List, date: Long?): List { + return listOf( + FilterCategory( + name = "Sort By", + options = listOf( + FilterOption("latest", "Latest"), + FilterOption("oldest", "Oldest") + ), + type = SelectionType.SINGLE + ), + FilterCategory( + name = "Date", + options = listOf( + FilterOption("date", "Filter by Date", date) + ), + type = SelectionType.DATE + ), + FilterCategory( + name = "Labels", + options = labels.map { + FilterOption(it.labelId, it.value) + }, + type = SelectionType.MULTIPLE + ), + ) +} + +@SuppressLint("ConfigurationScreenWidthHeight") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JournalFilterSheet( + filterState: FilterState, + labels: List, + sheetState: SheetState, + onDismiss: () -> Unit = {}, + onApply: ( + Map, + Map>, + Long? + ) -> Unit +) { + + var showDatePicker by remember { mutableStateOf(false) } + + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.4f + val categories = remember(labels) { + buildFilterCategories(labels, filterState.selectedDate) + } + var selectedCategory by remember { mutableStateOf(categories.first()) } + + val singleSelections = remember(filterState) { + mutableStateMapOf().apply { + filterState.sort?.let { put("Sort By", it) } + } + } + + val selectedDate = remember(filterState) { + mutableStateOf(filterState.selectedDate) + } + + val multiSelections = remember(filterState, categories) { + mutableStateMapOf>().apply { + categories + .filter { it.type == SelectionType.MULTIPLE } + .forEach { category -> + val list = mutableStateListOf() + + if (category.name == "Labels") { + list.addAll(filterState.selectedLabelIds) + } + + put(category.name, list) + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight) + .padding(top = 8.dp) + ) { + + Row(modifier = Modifier.weight(1f)) { + + // LEFT PANEL + LazyColumn( + modifier = Modifier + .width(150.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + items(categories) { category -> + CategoryRow( + label = category.name, + isSelected = category.name == selectedCategory.name, + onClick = { selectedCategory = category } + ) + } + } + + Box( + modifier = Modifier + .width(0.5.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.onSurface) + ) + + // RIGHT PANEL + LazyColumn(modifier = Modifier.weight(1f)) { + + if (selectedCategory.options.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text("No options available") + } + } + } + + items(selectedCategory.options) { option -> + + when (selectedCategory.type) { + + SelectionType.SINGLE -> { + val selected = singleSelections[selectedCategory.name] + + OptionRow( + label = option.label, + isSelected = selected == option.id, + onClick = { + singleSelections[selectedCategory.name] = option.id + } + ) + } + + SelectionType.MULTIPLE -> { + val list = + multiSelections[selectedCategory.name] + ?: return@items + + val isSelected = option.id in list + + MultiOptionRow( + label = option.label, + isSelected = isSelected, + onClick = { + if (isSelected) list.remove(option.id) + else list.add(option.id) + } + ) + } + + SelectionType.DATE -> { + DateOptionRow( + date = selectedDate.value, + label = option.label, + onClick = { + showDatePicker = true + } + ) + } + + else -> {} + } + } + } + } + + HorizontalDivider() + + // FOOTER + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + singleSelections.clear() + singleSelections["View"] = "grid" + multiSelections.values.forEach { it.clear() } + selectedDate.value = null + } + ) { + Text("Reset") + } + + Button( + modifier = Modifier.weight(2f), + onClick = { + onApply( + singleSelections.toMap(), + multiSelections.mapValues { it.value.toSet() }, + selectedDate.value + ) + onDismiss() + } + ) { + Text("Apply") + } + } + + Spacer(Modifier.navigationBarsPadding()) + } + } + + if (showDatePicker) { + DatePickerModal( + onDateSelected = { selected -> + selectedDate.value = selected + showDatePicker = false + }, + onDismiss = { + showDatePicker = false + } + ) + } +} diff --git a/app/src/main/java/com/flux/ui/screens/journal/JournalHome.kt b/app/src/main/java/com/flux/ui/screens/journal/JournalHome.kt deleted file mode 100644 index 05a47ae..0000000 --- a/app/src/main/java/com/flux/ui/screens/journal/JournalHome.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.flux.ui.screens.journal - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.text.BasicText -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AutoStories -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.Typography -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.R -import com.flux.data.model.JournalModel -import com.flux.navigation.Loader -import com.flux.navigation.NavRoutes -import com.flux.ui.components.DailyViewCalendar -import com.flux.ui.components.MonthlyViewCalendar -import com.flux.ui.state.Settings -import java.time.YearMonth -import androidx.compose.ui.Alignment -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.ColorProducer -import androidx.compose.ui.text.style.TextOverflow -import com.flux.other.parseMarkdownContent -import com.flux.ui.components.TimelineBody -import com.flux.ui.components.convertMillisToTime -import com.flux.ui.components.shapeManager -import com.flux.ui.events.JournalEvents -import java.time.LocalDate - -@OptIn(ExperimentalMaterial3Api::class) -fun LazyListScope.journalHomeItems( - navController: NavController, - settings: Settings, - selectedMonth: YearMonth, - selectedDate: Long, - isLoading: Boolean, - workspaceId: String, - monthlyJournalCount: Map = emptyMap(), - allEntries: List, - onJournalEvents: (JournalEvents) -> Unit -) { - val isMonthlyView = settings.data.isCalendarMonthlyView - val radius = settings.data.cornerRadius - - if (isMonthlyView) { - item { - MonthlyViewCalendar( - selectedMonth, selectedDate, monthlyJournalCount, - onMonthChange = { - onJournalEvents(JournalEvents.ChangeMonth(it)) - }, - onDateChange = { - onJournalEvents(JournalEvents.ChangeDate(it)) - }) - } - } else { - item { - DailyViewCalendar( - selectedMonth, - selectedDate, - onDateChange = { - onJournalEvents(JournalEvents.ChangeDate(it)) - }) - } - } - - when { - isLoading -> item { Loader() } - allEntries.isEmpty() -> item { EmptyJournal() } - else -> { - itemsIndexed(allEntries) { _, entry -> - Column(Modifier.padding(top = 16.dp)) { - JournalCardHeader(convertMillisToTime(entry.dateTime)) - Row(modifier = Modifier.height(IntrinsicSize.Min)) { - TimelineBody(isLast = false) - JournalPreview(radius, entry.text) { - navController.navigate( - NavRoutes.EditJournal.withArgs( - workspaceId, - entry.journalId, - 0L - ) - ) - } - } - } - } - } - } -} - -@Composable -private fun JournalCardHeader( - formattedTimestamp: String, - colorScheme: ColorScheme = MaterialTheme.colorScheme, - typography: Typography = MaterialTheme.typography -) { - Box(modifier = Modifier - .wrapContentWidth() - .padding(bottom = 8.dp, top = 4.dp, start = 4.dp)) { - Canvas( - modifier = Modifier - .align(Alignment.CenterStart) - .size(8.dp) - ) { - drawCircle( - color = colorScheme.onSurface, - radius = size.minDimension / 2 - ) - } - - BasicText( - modifier = Modifier - .align(Alignment.CenterStart) - .padding(start = 16.dp), - text = formattedTimestamp, - style = typography.bodyMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = ColorProducer { colorScheme.onSurfaceVariant } - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun JournalPreview( - radius: Int, - content: String, - onClick: () -> Unit -) { - Card( - colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), - modifier = Modifier.clip(shapeManager(isBoth = true, radius = radius / 2)).fillMaxWidth(), - shape = shapeManager(isBoth = true, radius = radius / 2), - onClick = onClick - ) { - Text( - text = parseMarkdownContent(content), - style = MaterialTheme.typography.bodyMedium, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .alpha(0.9f) - .padding(12.dp) - .heightIn(max = 300.dp, min = 50.dp) - ) - } -} - -@Composable -fun EmptyJournal() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.AutoStories, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text(stringResource(R.string.Empty_Journal)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/journal/JournalScreen.kt b/app/src/main/java/com/flux/ui/screens/journal/JournalScreen.kt new file mode 100644 index 0000000..e4be1c4 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/journal/JournalScreen.kt @@ -0,0 +1,346 @@ +package com.flux.ui.screens.journal + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.AutoStories +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.R +import com.flux.data.model.JournalModel +import com.flux.data.model.LabelModel +import com.flux.data.model.WorkspaceModel +import com.flux.navigation.Loader +import com.flux.navigation.NavRoutes +import com.flux.ui.common.EmptyJournal +import com.flux.ui.common.SpaceSearchBar +import com.flux.ui.common.SpaceTopBar +import com.flux.ui.common.SpacesMenu +import com.flux.ui.common.convertMillisToDate +import com.flux.ui.common.convertMillisToTime +import com.flux.ui.events.JournalEvents +import com.flux.ui.screens.workspaces.SpacesToolBar +import com.flux.ui.state.JournalState +import com.flux.ui.state.Settings +import java.time.Instant +import java.time.ZoneId + +data class FilterState( + val sort: String? = null, + val selectedDate: Long? = null, + val selectedLabelIds: Set = emptySet() +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun JournalScreen( + navController: NavController, + state: JournalState, + settings: Settings, + workspace: WorkspaceModel, + allLabels: List, + onShowSpaceBottomSheet: () -> Unit, + onSpaceChange: (Int) -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, + onEvent: (JournalEvents) -> Unit +){ + val workspaceId = workspace.workspaceId + val isLoading = state.isLoading + val radius = settings.data.cornerRadius + val context = LocalContext.current + var query by remember { mutableStateOf("") } + var showSpacesMenu by remember { mutableStateOf(false) } + var showSearchBar by remember { mutableStateOf(false) } + var isToolsSheetVisible by remember { mutableStateOf(false) } + var isActionButtonExpanded by remember { mutableStateOf(false) } + val importSuccess = stringResource(R.string.import_success) + val importFailed = stringResource(R.string.import_failed) + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var filterState by remember { mutableStateOf(FilterState()) } + val labelMap = remember(allLabels) { allLabels.associateBy { it.labelId } } + + val allEntries = state.data + .filter { it.workspaceId==workspaceId } + // 🔍 Search + .filter { entry -> + val matchesText = entry.text.contains(query, ignoreCase = true) + val matchesTime = convertMillisToTime(entry.dateTime).contains(query, ignoreCase = true) || + convertMillisToDate(entry.dateTime).contains(query, ignoreCase = true) + + val matchesLabel = + entry.labels.any { labelId -> + labelMap[labelId]?.value + ?.contains(query, true) == true + } + + matchesText || matchesLabel || matchesTime + } + + // 🏷 Label filter + .let { entry -> + if (filterState.selectedLabelIds.isEmpty()) entry + else entry.filter { e -> + e.labels.any { it in filterState.selectedLabelIds } + } + } + + // 🔄 Sorting + .let { entries -> + when (filterState.sort) { + "latest" -> entries.sortedByDescending { it.dateTime } + "oldest" -> entries.sortedBy { it.dateTime } + else -> entries + } + } + + // 🔄 Dated + .let { list -> + filterState.selectedDate?.let { selected -> + val zone = ZoneId.systemDefault() + + val selectedDay = Instant.ofEpochMilli(selected) + .atZone(zone) + .toLocalDate() + .toEpochDay() + + list.filter { item -> + val itemDay = Instant.ofEpochMilli(item.dateTime) + .atZone(zone) + .toLocalDate() + .toEpochDay() + + itemDay == selectedDay + } + } ?: list + } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { + try { + val contentResolver = context.contentResolver + val inputStream = contentResolver.openInputStream(uri) + val content = inputStream?.bufferedReader()?.use { it.readText() } ?: "" + + // Create a new note + val newEntry = JournalModel( + text = content, + workspaceId = workspaceId, + dateTime = System.currentTimeMillis() + ) + + onEvent(JournalEvents.UpsertEntry(newEntry)) + Toast.makeText(context, importSuccess, Toast.LENGTH_SHORT).show() + + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(context, importFailed, Toast.LENGTH_SHORT).show() + } + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + }, + floatingActionButton = { + Box(contentAlignment = Alignment.BottomEnd) { + // Expanded actions + AnimatedVisibility( + visible = isActionButtonExpanded, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.End + ) { + val buttonModifier = Modifier.width(120.dp) + + ExtendedFloatingActionButton( + modifier = buttonModifier, + onClick = { importLauncher.launch(arrayOf("text/markdown", "text/plain")) }, + icon = { Icon(Icons.Outlined.Download, contentDescription = null) }, + text = { Text("Import") } + ) + + ExtendedFloatingActionButton( + modifier = buttonModifier, + onClick = { navController.navigate(NavRoutes.EditJournal.withArgs(workspaceId, "", System.currentTimeMillis())) }, + icon = { Icon(Icons.Outlined.Create, contentDescription = null) }, + text = { Text("Create") } + ) + + ExtendedFloatingActionButton( + modifier = buttonModifier, + onClick = { isActionButtonExpanded = false }, + icon = { Icon(Icons.Outlined.Clear, contentDescription = null) }, + text = { Text("Clear") } + ) + } + } + + AnimatedVisibility( + visible = !isActionButtonExpanded, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + FloatingActionButton( { isActionButtonExpanded = true }) { + Icon(Icons.Default.Add, null) + } + } + } + } + ) { innerPadding -> + when { + isLoading -> Loader() + else -> { + LazyColumn( + Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + item { + Row( + Modifier.fillMaxWidth().padding(bottom=8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if(showSearchBar){ SpaceSearchBar(query, { query=it }, { showSearchBar=false }) } + else { + SpacesToolBar( + stringResource(R.string.Journal), + Icons.Default.AutoStories, + false, + onMainClick = { showSpacesMenu = true }, + onEditClick = onShowSpaceBottomSheet + ) + + SpacesMenu( + showSpacesMenu, + workspace, + onSpaceChange + ) { showSpacesMenu = false } + + Row { + IconButton({ showSearchBar = true }) { + Icon( + Icons.Default.Search, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton({ isToolsSheetVisible = true }) { + Icon( + Icons.Default.FilterList, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + if(allEntries.isEmpty()) item { EmptyJournal() } + items(allEntries) { entry-> + JournalCardHeader(convertMillisToDate(entry.dateTime) + ", " + convertMillisToTime(entry.dateTime)) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + TimelineBody(isLast = false) + JournalPreview(radius, entry.text, allLabels.filter { entry.labels.contains(it.labelId) }) { + navController.navigate( + NavRoutes.EditJournal.withArgs( + workspaceId, + entry.journalId, + 0L + ) + ) + } + } + } + } + } + } + } + + if(isToolsSheetVisible) { + JournalFilterSheet( + filterState, + allLabels, + rememberModalBottomSheetState(), + onDismiss = { isToolsSheetVisible = false }, + ) { single, multi, date -> + filterState = FilterState( + sort = single["Sort By"], + selectedLabelIds = multi["Labels"] ?: emptySet(), + selectedDate = date + ) + } + } +} + diff --git a/app/src/main/java/com/flux/ui/screens/notes/Labels.kt b/app/src/main/java/com/flux/ui/screens/labels/Labels.kt similarity index 93% rename from app/src/main/java/com/flux/ui/screens/notes/Labels.kt rename to app/src/main/java/com/flux/ui/screens/labels/Labels.kt index cb3a223..6236c5d 100644 --- a/app/src/main/java/com/flux/ui/screens/notes/Labels.kt +++ b/app/src/main/java/com/flux/ui/screens/labels/Labels.kt @@ -1,4 +1,4 @@ -package com.flux.ui.screens.notes +package com.flux.ui.screens.labels import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row @@ -33,9 +33,9 @@ import androidx.navigation.NavController import com.flux.R import com.flux.data.model.LabelModel import com.flux.navigation.Loader -import com.flux.ui.components.AddLabelDialog -import com.flux.ui.components.EmptyLabels -import com.flux.ui.events.NotesEvents +import com.flux.ui.common.EmptyLabels +import com.flux.ui.events.LabelEvents +import com.flux.ui.screens.notes.AddLabelDialog @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -44,7 +44,7 @@ fun EditLabels( isLoading: Boolean, workspaceId: String, allLabels: List, - onNotesEvents: (NotesEvents) -> Unit, + onEvent: (LabelEvents) -> Unit, ) { var showAddLabel by remember { mutableStateOf(false) } var selectedLabel by remember { mutableStateOf(LabelModel()) } @@ -53,8 +53,8 @@ fun EditLabels( AddLabelDialog( initialValue = selectedLabel.value, onConfirmation = { - onNotesEvents( - NotesEvents.UpsertLabel( + onEvent( + LabelEvents.UpsertLabel( selectedLabel.copy( value = it, workspaceId = workspaceId @@ -107,7 +107,7 @@ fun EditLabels( ) { items(allLabels) { label -> EditLabelBox(label.value, onDelete = { - onNotesEvents(NotesEvents.DeleteLabel(label)) + onEvent(LabelEvents.DeleteLabel(label)) }) { showAddLabel = true selectedLabel = label diff --git a/app/src/main/java/com/flux/ui/components/Dialog.kt b/app/src/main/java/com/flux/ui/screens/notes/NotesComponents.kt similarity index 57% rename from app/src/main/java/com/flux/ui/components/Dialog.kt rename to app/src/main/java/com/flux/ui/screens/notes/NotesComponents.kt index b853505..7f8626a 100644 --- a/app/src/main/java/com/flux/ui/components/Dialog.kt +++ b/app/src/main/java/com/flux/ui/screens/notes/NotesComponents.kt @@ -1,19 +1,24 @@ -package com.flux.ui.components +package com.flux.ui.screens.notes import android.Manifest +import android.annotation.SuppressLint import android.content.Context import android.net.Uri import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow -import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight @@ -21,71 +26,101 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerBasedShape +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.ZeroCornerSize import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowRight +import androidx.compose.material.icons.automirrored.filled.FormatAlignLeft import androidx.compose.material.icons.automirrored.filled.Label +import androidx.compose.material.icons.automirrored.filled.LabelImportant +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.automirrored.outlined.FormatIndentDecrease +import androidx.compose.material.icons.automirrored.outlined.FormatIndentIncrease import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted +import androidx.compose.material.icons.automirrored.outlined.Label import androidx.compose.material.icons.automirrored.outlined.LabelImportant -import androidx.compose.material.icons.filled.AccessTime +import androidx.compose.material.icons.automirrored.outlined.List +import androidx.compose.material.icons.filled.Abc import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.AudioFile import androidx.compose.material.icons.filled.Code -import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.EditCalendar import androidx.compose.material.icons.filled.FilePresent -import androidx.compose.material.icons.filled.FontDownload +import androidx.compose.material.icons.filled.FormatQuote import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Mic import androidx.compose.material.icons.filled.NewLabel +import androidx.compose.material.icons.filled.Numbers import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Remove -import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.StopCircle import androidx.compose.material.icons.filled.SubdirectoryArrowRight import androidx.compose.material.icons.filled.TextFields import androidx.compose.material.icons.filled.Verified +import androidx.compose.material.icons.outlined.AddChart +import androidx.compose.material.icons.outlined.AudioFile +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.DataArray +import androidx.compose.material.icons.outlined.DataObject +import androidx.compose.material.icons.outlined.Feedback +import androidx.compose.material.icons.outlined.FormatBold +import androidx.compose.material.icons.outlined.FormatItalic import androidx.compose.material.icons.outlined.FormatListNumbered -import androidx.compose.material.icons.outlined.NotificationsActive +import androidx.compose.material.icons.outlined.FormatPaint +import androidx.compose.material.icons.outlined.FormatQuote +import androidx.compose.material.icons.outlined.FormatUnderlined +import androidx.compose.material.icons.outlined.HorizontalRule +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.Lightbulb +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Mic +import androidx.compose.material.icons.outlined.ReportGmailerrorred +import androidx.compose.material.icons.outlined.StrikethroughS +import androidx.compose.material.icons.outlined.TableChart +import androidx.compose.material.icons.outlined.Title +import androidx.compose.material.icons.outlined.UnfoldLess +import androidx.compose.material.icons.outlined.UnfoldMore +import androidx.compose.material.icons.outlined.VideoFile +import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox -import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.RadioButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SheetState import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TimeInput -import androidx.compose.material3.TimePicker -import androidx.compose.material3.rememberDatePickerState -import androidx.compose.material3.rememberTimePickerState import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -93,47 +128,66 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.nativeCanvas import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri import com.flux.R import com.flux.data.model.LabelModel -import com.flux.data.model.WorkspaceModel +import com.flux.data.model.NotesModel import com.flux.other.AudioRecorder +import com.flux.other.Constants import com.flux.other.ExportType -import com.flux.ui.screens.workspaces.CompactWorkspaceCard -import com.flux.ui.theme.FONTS +import com.flux.other.HeaderNode +import com.flux.other.parseMarkdownContent +import com.flux.ui.screens.settings.ActionType +import com.flux.ui.screens.settings.CircleWrapper +import com.flux.ui.screens.settings.SettingOption +import com.flux.ui.screens.settings.shapeManager import com.flux.ui.theme.completed import com.flux.ui.theme.failed import kotlinx.coroutines.delay import kotlinx.serialization.Serializable import java.io.File -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale - +import kotlin.math.cos +import kotlin.math.sin +import androidx.compose.ui.platform.LocalConfiguration +import com.flux.ui.common.CategoryRow +import com.flux.ui.common.FilterCategory +import com.flux.ui.common.FilterOption +import com.flux.ui.common.MultiOptionRow +import com.flux.ui.common.OptionRow +import com.flux.ui.common.SelectionType +import kotlin.collections.filter + +// ------------- Dialogs ------------- @Composable fun AddLabelDialog( initialValue: String, @@ -243,11 +297,6 @@ fun SelectLabelDialog( ) } -fun convertMillisToDate(millis: Long): String { - val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault()) - return formatter.format(Date(millis)) -} - @Composable fun LabelCheckBoxList( checkedLabel: List, @@ -348,380 +397,6 @@ fun LabelCheckBoxList( } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DatePickerModal( - onDateSelected: (Long?) -> Unit, - onDismiss: () -> Unit -) { - val datePickerState = rememberDatePickerState() - - DatePickerDialog( - onDismissRequest = onDismiss, - confirmButton = { - TextButton(onClick = { - onDateSelected(datePickerState.selectedDateMillis) - onDismiss() - }) { - Text(stringResource(R.string.Set)) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.Cancel)) - } - } - ) { - DatePicker(state = datePickerState) - } -} - -fun convertMillisToTime(millis: Long, is24Hour: Boolean = false): String { - val pattern = if (is24Hour) "HH:mm" else "hh:mm a" - val formatter = SimpleDateFormat(pattern, Locale.getDefault()) - return formatter.format(Date(millis)) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TimePicker( - initialTime: Long, - is24Hour: Boolean = false, - onConfirm: (Long) -> Unit, - onDismiss: () -> Unit, -) { - val currentTime = Calendar.getInstance().apply { timeInMillis = initialTime } - val timePickerState = rememberTimePickerState( - initialHour = currentTime.get(Calendar.HOUR_OF_DAY), - initialMinute = currentTime.get(Calendar.MINUTE), - is24Hour = is24Hour, - ) - var showDial by remember { mutableStateOf(true) } - val toggleIcon = if (showDial) { Icons.Filled.EditCalendar } else { Icons.Filled.AccessTime } - - TimePickerDialog( - onDismiss = onDismiss, - onConfirm = { - val calendar = Calendar.getInstance().apply { - timeInMillis = initialTime - set(Calendar.HOUR_OF_DAY, timePickerState.hour) - set(Calendar.MINUTE, timePickerState.minute) - set(Calendar.SECOND, 0) - set(Calendar.MILLISECOND, 0) - } - onConfirm(calendar.timeInMillis) - onDismiss() - }, - toggle = { - IconButton(onClick = { showDial = !showDial }) { - Icon( - imageVector = toggleIcon, - contentDescription = "Time picker type toggle", - ) - } - }, - ) { - if (showDial) { - TimePicker(timePickerState) - } else { - TimeInput(timePickerState) - } - } -} - -// Remove the onDismiss() call from TimePickerDialog's onConfirm button -@Composable -fun TimePickerDialog( - title: String = stringResource(R.string.Select_Time), - onDismiss: () -> Unit, - onConfirm: () -> Unit, - toggle: @Composable () -> Unit = {}, - content: @Composable () -> Unit, -) { - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface( - shape = MaterialTheme.shapes.extraLarge, - tonalElevation = 6.dp, - modifier = - Modifier - .width(IntrinsicSize.Min) - .height(IntrinsicSize.Min) - .background( - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surface - ), - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 20.dp), - text = title, - style = MaterialTheme.typography.labelMedium - ) - content() - Row( - modifier = Modifier - .height(40.dp) - .fillMaxWidth() - ) { - toggle() - Spacer(modifier = Modifier.weight(1f)) - TextButton(onClick = onDismiss) { Text(stringResource(R.string.Cancel)) } - TextButton(onClick = onConfirm) { Text(stringResource(R.string.Set)) } - } - } - } - } -} - -@Composable -fun EventNotificationDialog( - currentOffset: Long, - onChange: (Long) -> Unit, - onCustomClick: () -> Unit, - onDismissRequest: () -> Unit -) { - val options = listOf( - 0L to stringResource(R.string.On_Time), - 5L to stringResource(R.string.five_minutes_before), - 30L to stringResource(R.string.thirty_minutes_before) - ) - - // Convert to minutes for comparison - val currentMinutes = currentOffset / 1000 / 60 - - Dialog(onDismissRequest = onDismissRequest) { - Card(Modifier.fillMaxWidth()) { - Column( - Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircleWrapper(MaterialTheme.colorScheme.primary) { - Icon( - Icons.Outlined.NotificationsActive, - contentDescription = "Notification Icon", - tint = MaterialTheme.colorScheme.onPrimary - ) - } - - Text( - stringResource(R.string.Add_Notification), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - ) - Spacer(Modifier.height(16.dp)) - options.forEach { (minutesBefore, label) -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { - onChange(minutesBefore * 60 * 1000) - onDismissRequest() - } - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(label) - RadioButton( - selected = currentMinutes == minutesBefore, - onClick = { - onChange(minutesBefore * 60 * 1000) - onDismissRequest() - } - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .clickable { - onCustomClick() - onDismissRequest() - } - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(stringResource(R.string.Custom)) - RadioButton( - selected = options.none { it.first == currentMinutes }, - onClick = { - onCustomClick() - onDismissRequest() - } - ) - } - } - } - } -} - -@Composable -fun SetPasskeyDialog(onConfirmRequest: (String) -> Unit, onDismissRequest: () -> Unit) { - var passKey by remember { mutableStateOf("") } - - Dialog(onDismissRequest) { - Card(shape = RoundedCornerShape(16.dp)) { - Column( - Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text( - stringResource(R.string.enterPasskey), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.primary - ) - - OutlinedTextField( - value = passKey, - singleLine = true, - onValueChange = { passKey = it }, - modifier = Modifier.fillMaxWidth(), - keyboardOptions = KeyboardOptions.Default.copy( - imeAction = ImeAction.Done - ), - keyboardActions = KeyboardActions( - onDone = { - onConfirmRequest(passKey) - onDismissRequest() - } - ) - ) - - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onDismissRequest) { Text(stringResource(R.string.Dismiss)) } - TextButton( - onClick = { - onConfirmRequest(passKey) - onDismissRequest() - }, - colors = ButtonDefaults.buttonColors() - ) { Text(stringResource(R.string.Confirm)) } - } - } - } - } -} - -@Composable -fun CustomNotificationDialog( - onDismissRequest: () -> Unit, - onConfirm: (offsetMillis: Long) -> Unit -) { - val timeUnits = listOf( - stringResource(R.string.minutes), - stringResource(R.string.hours), - stringResource(R.string.days) - ) - var selectedUnit by remember { mutableStateOf(timeUnits[0]) } - var amountText by remember { mutableStateOf("1") } - val amount = amountText.toIntOrNull()?.coerceAtLeast(1) ?: 1 - - val offsetMillis = when (selectedUnit) { - stringResource(R.string.minutes) -> amount * 60_000L - stringResource(R.string.hours) -> amount * 60 * 60_000L - stringResource(R.string.days) -> amount * 24 * 60 * 60_000L - else -> 0L - } - - Dialog(onDismissRequest = onDismissRequest) { - Card(Modifier.fillMaxWidth()) { - Column(Modifier.padding(16.dp)) { - Text( - stringResource(R.string.Custom_Notification), - style = MaterialTheme.typography.titleLarge - ) - Spacer(Modifier.height(8.dp)) - - timeUnits.forEach { unit -> - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .selectable( - selected = selectedUnit == unit, - onClick = { selectedUnit = unit } - ) - .padding(2.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = selectedUnit == unit, - onClick = { selectedUnit = unit } - ) - Text(unit, modifier = Modifier.padding(start = 8.dp)) - } - } - Spacer(Modifier.height(16.dp)) - OutlinedTextField( - value = amountText, - onValueChange = { if (it.all { c -> c.isDigit() }) amountText = it }, - label = { Text(stringResource(R.string.Amount)) }, - singleLine = true, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Number - ), - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(16.dp)) - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.Cancel)) } - Spacer(Modifier.width(8.dp)) - Button(onClick = { - onConfirm(offsetMillis) - onDismissRequest() - }) { - Text(stringResource(R.string.Set)) - } - } - } - } - } -} - -@Composable -fun DeleteAlert( - onDismissRequest: () -> Unit, - onConfirmation: () -> Unit, - dialogTitle: String = stringResource(R.string.deleteDialogTitle), - dialogText: String = stringResource(R.string.deleteDialogText), - icon: ImageVector = Icons.Default.DeleteOutline, -) { - AlertDialog( - icon = { - Icon( - icon, - contentDescription = "Delete Icon", - tint = MaterialTheme.colorScheme.error - ) - }, - title = { Text(text = dialogTitle) }, - text = { Text(text = dialogText) }, - onDismissRequest = { onDismissRequest() }, - confirmButton = { TextButton(onClick = { onConfirmation() }) { Text(stringResource(R.string.Confirm)) } }, - dismissButton = { TextButton(onClick = { onDismissRequest() }) { Text(stringResource(R.string.Dismiss)) } } - ) -} - @Composable fun ShareDialog( isSharing: Boolean, @@ -786,51 +461,6 @@ fun ExportCard(icon: ImageVector, title: String, onClick: () -> Unit){ } } -@Composable -fun FontDialog( - selectedFont: Int, - onSelectFont: (Int)->Unit, - onDismissRequest: () -> Unit -){ - Dialog(onDismissRequest = onDismissRequest) { - Card( - Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainerLow) - ) { - Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Icon(Icons.Default.FontDownload, null, modifier = Modifier.size(36.dp)) - Text(stringResource(R.string.Fonts), style = MaterialTheme.typography.titleLarge) - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - FONTS.forEachIndexed { index, font -> - val containerColor = - if (selectedFont == index) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh - val contentColor = - if (selectedFont == index) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface - Card( - onClick = { - onSelectFont(index) - onDismissRequest() - }, - shape = - if (selectedFont == index) RoundedCornerShape(50) - else RoundedCornerShape(8.dp), - colors = CardDefaults.cardColors( - containerColor = containerColor, - contentColor = contentColor - ) - ) { - Text(font, modifier = Modifier.padding(8.dp)) - } - } - } - } - } - } -} - @Composable fun LinkDialog( onDismissRequest: () -> Unit, @@ -1231,7 +861,7 @@ fun RecordAudioDialog( contentColor = Color.White ), modifier = Modifier.size(120.dp), shape = CurlyCornerShape(14.0, 6) - ) { + ) { Icon(Icons.Default.Mic, null, modifier = Modifier.size(75.dp)) } Spacer(Modifier.height(32.dp)) @@ -1301,6 +931,85 @@ fun RecordAudioDialog( } } +class CurlyCornerShape( + private val curlAmplitude: Double = 16.0, + private val curlCount: Int = 12, +) : CornerBasedShape( + topStart = ZeroCornerSize, + topEnd = ZeroCornerSize, + bottomEnd = ZeroCornerSize, + bottomStart = ZeroCornerSize +) { + + /** + * Calculates the x and y coordinates of a point on the curly circle at a given angle. + * + * @param centerX The x-coordinate of the center of the circle. + * @param centerY The y-coordinate of the center of the circle. + * @param baseRadius The base radius of the circle. + * @param amplitude The amplitude of the sine wave. + * @param angle The angle in radians. + * @return A Pair containing the x and y coordinates of the point. + */ + private fun calculateCurlyCirclePoint( + centerX: Double, + centerY: Double, + baseRadius: Double, + amplitude: Double, + angle: Double, + ): Pair { + // Calculate the radius with the sine wave applied. + val radius = baseRadius + amplitude * sin(curlCount * angle) + // Calculate x and y coordinates using polar coordinates. + val x = centerX + radius * cos(angle) + val y = centerY + radius * sin(angle) + return Pair(x, y) + } + + override fun createOutline( + size: Size, + topStart: Float, + topEnd: Float, + bottomEnd: Float, + bottomStart: Float, + layoutDirection: LayoutDirection + ): Outline { + val centerX = size.width / 2.0 + val centerY = size.height / 2.0 + val baseRadius = centerX - curlAmplitude + val path = Path() + + // Start at the rightmost point + val startPoint = calculateCurlyCirclePoint(centerX, centerY, baseRadius, curlAmplitude, 0.0) + path.moveTo(startPoint.first.toFloat(), startPoint.second.toFloat()) + + // Iterate through 360 degrees to draw the curly circle + for (angleDegrees in 1..360) { + // Convert the angle to radians. + val angleRadians = Math.toRadians(angleDegrees.toDouble()) + + // calculate the current point + val currentPoint = + calculateCurlyCirclePoint(centerX, centerY, baseRadius, curlAmplitude, angleRadians) + + path.lineTo(currentPoint.first.toFloat(), currentPoint.second.toFloat()) + } + + path.close() + return Outline.Generic(path) + } + + override fun copy( + topStart: CornerSize, + topEnd: CornerSize, + bottomEnd: CornerSize, + bottomStart: CornerSize, + ): CurlyCornerShape = CurlyCornerShape( + curlAmplitude = this.curlAmplitude, + curlCount = this.curlCount + ) +} + @Composable fun StudioRecorderUI( recorder: AudioRecorder, @@ -1569,7 +1278,6 @@ fun formatTimelineLabel(seconds: Int): String { } fun formatDuration(ms: Long): String { - val totalSec = ms / 1000 val min = totalSec / 60 val sec = totalSec % 60 @@ -1577,45 +1285,861 @@ fun formatDuration(ms: Long): String { return "%02d:%02d".format(min, sec) } +// ------------- Bottom Sheet ------------- @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ChangeWorkspaceDialog( - currentWorkspace: WorkspaceModel, - allWorkspace: List, - onDismiss: () -> Unit, - onChangeWorkspace: (WorkspaceModel) -> Unit, -){ - var query by rememberSaveable { mutableStateOf("") } - - Dialog(onDismiss) { - Card{ - Column( - Modifier - .fillMaxWidth() - .width(240.dp) - .padding(horizontal = 12.dp, vertical = 16.dp) - .heightIn(max=500.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - GeneralSearchBar( - textFieldState = TextFieldState(query), - leadingIcon = Icons.Default.Search, - onSearch = { query = it }, - onCloseClicked = { query = "" } - ) - Text(stringResource(R.string.current), style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold)) - CompactWorkspaceCard(currentWorkspace.icon, currentWorkspace.passKey.isNotBlank(), currentWorkspace.title){} - Text(stringResource(R.string.Others), style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold)) - FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { - allWorkspace.filter { it.workspaceId!=currentWorkspace.workspaceId && (it.title.contains(query) || it.description.contains(query))}.forEach { workspace-> - CompactWorkspaceCard(workspace.icon, workspace.passKey.isNotBlank(), workspace.title) { - onDismiss() - onChangeWorkspace(workspace) +fun OutlineBottomSheet( + isVisible: Boolean, + outline: HeaderNode, + sheetState: SheetState, + onHeaderClick: (IntRange) -> Unit, + onDismiss: () -> Unit +) { + + var isAllExpanded by rememberSaveable { mutableStateOf(true) } + + if (isVisible) { + ModalBottomSheet( + onDismissRequest = { onDismiss() }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + LazyColumn(Modifier + .fillMaxWidth() + .heightIn(300.dp)) { + item { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.Outline), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold + ) + + IconButton( + onClick = { isAllExpanded = !isAllExpanded } + ) { + Icon( + imageVector = if (isAllExpanded) Icons.Outlined.UnfoldLess + else Icons.Outlined.UnfoldMore, + contentDescription = "fold", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) } } + + HorizontalDivider() + } + items(outline.children) { header -> + HeaderItem( + header = header, + depth = 0, + onHeaderClick = onHeaderClick, + parentExpanded = isAllExpanded + ) + } + } + } + } +} + +@Composable +fun HeaderItem( + header: HeaderNode, + depth: Int, + parentExpanded: Boolean, + onHeaderClick: (IntRange) -> Unit +) { + var expanded by rememberSaveable { mutableStateOf(true) } + + LaunchedEffect(parentExpanded) { expanded = parentExpanded } + Row( + modifier = Modifier + .padding(start = (depth * 8).dp) + .fillMaxWidth() + .heightIn(min = 32.dp) + .clickable { + onHeaderClick(header.range) + }, + verticalAlignment = Alignment.CenterVertically + ) { + if (header.children.isNotEmpty()) { + IconButton( + modifier = Modifier.size(32.dp), + onClick = { + if (header.children.isNotEmpty()) { + expanded = !expanded + } } + ) { + Icon( + imageVector = if (expanded) Icons.Default.ArrowDropDown + else Icons.AutoMirrored.Filled.ArrowRight, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + contentDescription = null + ) } + } else { + Spacer(modifier = Modifier.width(32.dp)) } + + Text( + text = header.title, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + } + + if (expanded) { + header.children.forEach { child -> + HeaderItem( + header = child, + depth = depth + 1, + onHeaderClick = onHeaderClick, + parentExpanded = parentExpanded + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotesInfoBottomSheet( + words: Int, + lines: Int, + wordsWithoutPunctuations: Int, + paragraph: Int, + characters: Int, + lastEdited: String, + isVisible: Boolean, + sheetState: SheetState, + onDismiss: () -> Unit +) { + if (isVisible) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + LazyColumn( + Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + item { + SettingOption( + radius = shapeManager(isFirst = true, radius = 32), + icon = Icons.Default.Edit, + title = stringResource(R.string.Last_Edited), + description = lastEdited, + actionType = ActionType.None + ) + } + + item { + SettingOption( + radius = shapeManager(radius = 32), + icon = Icons.Default.Numbers, + title = stringResource(R.string.Word_Count), + description = words.toString(), + actionType = ActionType.None + ) + } + + item { + SettingOption( + radius = shapeManager(radius = 32), + icon = Icons.Default.FormatQuote, + title = stringResource(R.string.words_excluding_punctuations), + description = wordsWithoutPunctuations.toString(), + actionType = ActionType.None + ) + } + + item { + SettingOption( + radius = shapeManager(radius = 32), + icon = Icons.Default.Abc, + title = stringResource(R.string.Character_Count), + description = characters.toString(), + actionType = ActionType.None + ) + } + + item { + SettingOption( + radius = shapeManager(radius = 32), + icon = Icons.AutoMirrored.Filled.List, + title = stringResource(R.string.lines), + description = lines.toString(), + actionType = ActionType.None + ) + } + + item { + SettingOption( + radius = shapeManager(radius = 32, isLast = true), + icon = Icons.AutoMirrored.Filled.FormatAlignLeft, + title = stringResource(R.string.paragraph), + description = paragraph.toString(), + actionType = ActionType.None + ) + } + } + } + } +} + +fun buildFilterCategories(labels: List): List { + return listOf( + FilterCategory( + name = "Sort By", + options = listOf( + FilterOption("latest", "Latest"), + FilterOption("oldest", "Oldest") + ), + type = SelectionType.SINGLE + ), + FilterCategory( + name = "Labels", + options = labels.map { + FilterOption(it.labelId, it.value) + }, + type = SelectionType.MULTIPLE + ), + FilterCategory( + name = "Pinned", + options = listOf( + FilterOption("pinned", "Pinned"), + FilterOption("unpinned", "Unpinned") + ), + type = SelectionType.SINGLE + ), + FilterCategory( + name = "View", + options = listOf( + FilterOption("list", "List View"), + FilterOption("grid", "Grid View") + ), + type = SelectionType.SINGLE + ) + ) +} + +@SuppressLint("ConfigurationScreenWidthHeight") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotesFilterSheet( + filterState: FilterState, + labels: List, + sheetState: SheetState, + onDismiss: () -> Unit = {}, + onApply: (Map, Map>) -> Unit +) { + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.4f + val categories = remember(labels) { buildFilterCategories(labels) } + var selectedCategory by remember { mutableStateOf(categories.first()) } + + val singleSelections = remember(filterState) { + mutableStateMapOf().apply { + filterState.sort?.let { put("Sort By", it) } + filterState.view?.let { put("View", it) } + filterState.pinned?.let { put("Pinned", it) } + } + } + + val multiSelections = remember(filterState, categories) { + mutableStateMapOf>().apply { + categories + .filter { it.type == SelectionType.MULTIPLE } + .forEach { category -> + val list = mutableStateListOf() + + if (category.name == "Labels") { + list.addAll(filterState.selectedLabelIds) + } + + put(category.name, list) + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight) + .padding(top = 8.dp) + ) { + + Row(modifier = Modifier.weight(1f)) { + + // LEFT PANEL + LazyColumn( + modifier = Modifier + .width(150.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + items(categories) { category -> + CategoryRow( + label = category.name, + isSelected = category.name == selectedCategory.name, + onClick = { selectedCategory = category } + ) + } + } + + Box( + modifier = Modifier + .width(0.5.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.onSurface) + ) + + // RIGHT PANEL + LazyColumn(modifier = Modifier.weight(1f)) { + + if (selectedCategory.options.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text("No options available") + } + } + } + + items(selectedCategory.options) { option -> + + when (selectedCategory.type) { + + SelectionType.SINGLE -> { + val selected = + singleSelections[selectedCategory.name] + + OptionRow( + label = option.label, + isSelected = selected == option.id, + onClick = { + singleSelections[selectedCategory.name] = option.id + } + ) + } + + SelectionType.MULTIPLE -> { + val list = + multiSelections[selectedCategory.name] + ?: return@items + + val isSelected = option.id in list + + MultiOptionRow( + label = option.label, + isSelected = isSelected, + onClick = { + if (isSelected) list.remove(option.id) + else list.add(option.id) + } + ) + } + else -> {} + } + } + } + } + + HorizontalDivider() + + // FOOTER + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { + singleSelections.clear() + singleSelections["View"] = "grid" + multiSelections.values.forEach { it.clear() } + } + ) { + Text("Reset") + } + + Button( + modifier = Modifier.weight(2f), + onClick = { + onApply( + singleSelections.toMap(), + multiSelections.mapValues { it.value.toSet() } + ) + onDismiss() + } + ) { + Text("Apply") + } + } + + Spacer(Modifier.navigationBarsPadding()) + } + } +} + +// ------------- Card ------------- +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotesPreviewCard( + modifier: Modifier = Modifier, + radius: Int, + isSelected: Boolean, + note: NotesModel, + labels: List, + onClick: (String) -> Unit, + onLongPressed: () -> Unit +) { + Card( + colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + modifier = modifier + .clip(shapeManager(isBoth = true, radius = radius / 2)) + .combinedClickable( + onClick = { onClick(note.notesId) }, + onLongClick = onLongPressed + ), + shape = shapeManager(isBoth = true, radius = radius / 2), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + horizontalAlignment = Alignment.Start, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) { + Text( + text = note.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .alpha(0.75f) + .padding(horizontal = 12.dp) + .padding(top = 12.dp) + ) + + Text( + text = parseMarkdownContent(note.description), + style = MaterialTheme.typography.bodyMedium, + overflow = TextOverflow.Ellipsis, + maxLines = 12, + modifier = Modifier + .alpha(0.9f) + .padding(horizontal = 12.dp) + ) + + val maxVisibleLabels = 2 + val visibleLabels = labels.take(maxVisibleLabels) + val extraCount = labels.size - maxVisibleLabels + + if (visibleLabels.isNotEmpty()) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + visibleLabels.forEach { label -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + Icons.AutoMirrored.Default.LabelImportant, + null, + modifier = Modifier.size(15.dp), + tint = MaterialTheme.colorScheme.onPrimaryContainer + ) + Text( + label, + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + + if (extraCount > 0) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.primaryContainer) + ) { + Text( + text = "+$extraCount", + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.labelMedium + ) + } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CustomIconButton( + enabled: Boolean = true, + imageVector: ImageVector? = null, + painter: Int? = null, + contentDescription: String?=null, + onClick: () -> Unit +) { + IconButton( + onClick = onClick, + enabled = enabled, + ) { + if (imageVector != null) { + Icon( + imageVector = imageVector, + contentDescription = contentDescription + ) + } else { + Icon( + painter = painterResource(id = painter!!), + contentDescription = contentDescription + ) + } + } +} + +@Composable +fun MarkdownEditorRow( + canUndo: Boolean, + canRedo: Boolean, + onEdit: (String) -> Unit, + onTableButtonClick: () -> Unit, + onListButtonClick: () -> Unit, + onTaskButtonClick: () -> Unit, + onLinkButtonClick: () -> Unit, + onImageButtonClick: () -> Unit, + onAudioButtonClick: () -> Unit, + onRecordAudioClick: () -> Unit, + onVideoButtonClick: () -> Unit +) { + + var isExpanded by rememberSaveable { mutableStateOf(false) } + var isAlertExpanded by rememberSaveable { mutableStateOf(false) } + + Row( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .navigationBarsPadding() + .height(48.dp) + .border( + BorderStroke(0.5.dp, MaterialTheme.colorScheme.primary), + shape = RoundedCornerShape(50) + ) + .clip(RoundedCornerShape(50)) + .horizontalScroll(rememberScrollState()) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + verticalAlignment = Alignment.CenterVertically + ) { + CustomIconButton( + enabled = canUndo, + painter = R.drawable.undo, + contentDescription = "Undo" + ) { + onEdit(Constants.Editor.UNDO) + } + + CustomIconButton( + enabled = canRedo, + painter = R.drawable.redo, + contentDescription = "Redo" + ) { + onEdit(Constants.Editor.REDO) + } + + CustomIconButton( + imageVector = Icons.Outlined.Title, + contentDescription = "Heading Level" + ) { + isExpanded = !isExpanded + } + + AnimatedVisibility(visible = isExpanded) { + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + + CustomIconButton( + painter = R.drawable.format_h1, + contentDescription = "H1" + ) { + onEdit(Constants.Editor.H1) + } + + CustomIconButton( + painter = R.drawable.format_h2, + contentDescription = "H2" + ) { + onEdit(Constants.Editor.H2) + } + + CustomIconButton( + painter = R.drawable.format_h3, + contentDescription = "H3" + ) { + onEdit(Constants.Editor.H3) + } + + CustomIconButton( + painter = R.drawable.format_h4, + contentDescription = "H4" + ) { + onEdit(Constants.Editor.H4) + } + + CustomIconButton( + painter = R.drawable.format_h5, + contentDescription = "H5" + ) { + onEdit(Constants.Editor.H5) + } + + CustomIconButton( + painter = R.drawable.format_h6, + contentDescription = "H6" + ) { + onEdit(Constants.Editor.H6) + } + } + } + + CustomIconButton( + imageVector = Icons.Outlined.FormatBold, + contentDescription = "Bold" + ) { + onEdit(Constants.Editor.BOLD) + } + + CustomIconButton( + imageVector = Icons.Outlined.FormatItalic, + contentDescription = "Italic" + ) { + onEdit(Constants.Editor.ITALIC) + } + + CustomIconButton( + imageVector = Icons.Outlined.FormatUnderlined, + contentDescription = "Underline" + ) { + onEdit(Constants.Editor.UNDERLINE) + } + + CustomIconButton( + imageVector = Icons.Outlined.StrikethroughS, + contentDescription = "Strike Through" + ) { + onEdit(Constants.Editor.STRIKETHROUGH) + } + + CustomIconButton( + imageVector = Icons.Outlined.FormatPaint, + contentDescription = "Mark" + ) { + onEdit(Constants.Editor.MARK) + } + + CustomIconButton( + imageVector = Icons.Outlined.Code, + contentDescription = "Code" + ) { + onEdit(Constants.Editor.INLINE_CODE) + } + + CustomIconButton( + imageVector = Icons.Outlined.DataArray, + contentDescription = "Brackets" + ) { + onEdit(Constants.Editor.INLINE_BRACKETS) + } + + CustomIconButton( + imageVector = Icons.Outlined.DataObject, + contentDescription = "Braces" + ) { + onEdit(Constants.Editor.INLINE_BRACES) + } + + CustomIconButton( + imageVector = Icons.AutoMirrored.Outlined.FormatIndentIncrease, + contentDescription = "Tab" + ) { + onEdit(Constants.Editor.TAB) + } + + CustomIconButton( + imageVector = Icons.AutoMirrored.Outlined.FormatIndentDecrease, + contentDescription = "unTab" + ) { + onEdit(Constants.Editor.UN_TAB) + } + + CustomIconButton( + painter = R.drawable.function, + contentDescription = "Math" + ) { + onEdit(Constants.Editor.INLINE_MATH) + } + + CustomIconButton( + imageVector = Icons.Outlined.FormatQuote, + contentDescription = "Quote" + ) { + onEdit(Constants.Editor.QUOTE) + } + + CustomIconButton( + imageVector = Icons.AutoMirrored.Outlined.Label, + contentDescription = "Alert", + ) { + isAlertExpanded = !isAlertExpanded + } + + AnimatedVisibility(visible = isAlertExpanded) { + Row( + modifier = Modifier.fillMaxHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start + ) { + CustomIconButton( + imageVector = Icons.Outlined.Info, + contentDescription = "Note Alert", + ) { + onEdit(Constants.Editor.NOTE) + } + + CustomIconButton( + imageVector = Icons.Outlined.Lightbulb, + contentDescription = "Tip Alert", + ) { + onEdit(Constants.Editor.TIP) + } + + CustomIconButton( + imageVector = Icons.Outlined.Feedback, + contentDescription = "Important Alert", + ) { + onEdit(Constants.Editor.IMPORTANT) + + } + CustomIconButton( + imageVector = Icons.Outlined.Warning, + contentDescription = "Warning Alert", + ) { + onEdit(Constants.Editor.WARNING) + } + + CustomIconButton( + imageVector = Icons.Outlined.ReportGmailerrorred, + contentDescription = "Caution Alert", + ) { + onEdit(Constants.Editor.CAUTION) + } + } + } + + CustomIconButton( + imageVector = Icons.Outlined.HorizontalRule, + contentDescription = "Horizontal Rule", + ) { + onEdit(Constants.Editor.RULE) + } + + CustomIconButton( + imageVector = Icons.Outlined.TableChart, + contentDescription = "Table", + onClick = onTableButtonClick + ) + + CustomIconButton( + imageVector = Icons.Outlined.AddChart, + contentDescription = "Mermaid Diagram", + ) { + onEdit(Constants.Editor.DIAGRAM) + } + + CustomIconButton( + imageVector = Icons.AutoMirrored.Outlined.List, + contentDescription = "List", + onClick = onListButtonClick + ) + + CustomIconButton( + imageVector = Icons.Outlined.CheckBox, + contentDescription = "Task List", + onClick = onTaskButtonClick + ) + + CustomIconButton( + imageVector = Icons.Outlined.Link, + contentDescription = "Link", + onClick = onLinkButtonClick + ) + + CustomIconButton( + imageVector = Icons.Outlined.Mic, + contentDescription = "Audio", + onClick = onRecordAudioClick + ) + + CustomIconButton( + imageVector = Icons.Outlined.AudioFile, + contentDescription = "Audio", + onClick = onAudioButtonClick + ) + + CustomIconButton( + imageVector = Icons.Outlined.VideoFile, + contentDescription = "Video", + onClick = onVideoButtonClick + ) + + CustomIconButton( + imageVector = Icons.Outlined.Image, + contentDescription = "Image", + onClick = onImageButtonClick + ) } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/notes/NotesDetails.kt b/app/src/main/java/com/flux/ui/screens/notes/NotesDetails.kt index 490d22c..d131a9d 100644 --- a/app/src/main/java/com/flux/ui/screens/notes/NotesDetails.kt +++ b/app/src/main/java/com/flux/ui/screens/notes/NotesDetails.kt @@ -79,19 +79,9 @@ import com.flux.other.HeaderNode import com.flux.other.ensureStorageRoot import com.flux.other.printPdf import com.flux.other.shareNote -import com.flux.ui.components.DeleteAlert -import com.flux.ui.components.LinkDialog -import com.flux.ui.components.ListDialog -import com.flux.ui.components.MarkdownEditorRow -import com.flux.ui.components.NoteDetailsTopBar -import com.flux.ui.components.NotesInfoBottomSheet -import com.flux.ui.components.OutlineBottomSheet -import com.flux.ui.components.SelectLabelDialog -import com.flux.ui.components.ShareDialog -import com.flux.ui.components.TableDialog -import com.flux.ui.components.TaskDialog -import com.flux.ui.components.TaskItem -import com.flux.ui.components.convertMillisToDate +import com.flux.ui.common.DeleteAlert +import com.flux.ui.common.NoteDetailsTopBar +import com.flux.ui.common.convertMillisToDate import com.flux.ui.events.NotesEvents import com.flux.ui.state.TextState import com.flux.ui.viewModel.NotesViewModel @@ -101,7 +91,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json import com.flux.other.AudioRecorder -import com.flux.ui.components.RecordAudioDialog +import com.flux.ui.common.convertMillisToTime @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable @@ -211,7 +201,6 @@ fun NoteDetails( } fun onSaveNote() { - val newTitle = titleState.text.toString() val newDescription = contentState.text.toString() @@ -320,9 +309,7 @@ fun NoteDetails( .fillMaxSize() .padding(innerPadding)) { AnimatedContent( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 4.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp), targetState = isSearching, contentAlignment = Alignment.TopCenter ) { @@ -510,7 +497,7 @@ fun NoteDetails( isVisible = showAboutNotes, words = aboutNotes.wordCountWithPunctuation, characters = aboutNotes.charCount, - lastEdited = convertMillisToDate(note.lastEdited), + lastEdited = "${convertMillisToDate(note.lastEdited)}, ${convertMillisToTime(note.lastEdited)}", sheetState = sheetState, paragraph = aboutNotes.paragraphCount, wordsWithoutPunctuations = aboutNotes.wordCountWithoutPunctuation, diff --git a/app/src/main/java/com/flux/ui/screens/notes/NotesHome.kt b/app/src/main/java/com/flux/ui/screens/notes/NotesHome.kt deleted file mode 100644 index a394b67..0000000 --- a/app/src/main/java/com/flux/ui/screens/notes/NotesHome.kt +++ /dev/null @@ -1,166 +0,0 @@ -package com.flux.ui.screens.notes - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.times -import androidx.navigation.NavController -import com.flux.R -import com.flux.data.model.LabelModel -import com.flux.data.model.NotesModel -import com.flux.navigation.Loader -import com.flux.navigation.NavRoutes -import com.flux.ui.components.EmptyNotes -import com.flux.ui.components.NotesPreviewCard -import com.flux.ui.events.NotesEvents -import kotlin.math.ceil - -@OptIn(ExperimentalMaterial3Api::class) -fun LazyListScope.notesHomeItems( - navController: NavController, - workspaceId: String, - selectedNotes: List, - query: String, - radius: Int, - isGridView: Boolean, - allLabels: List, - isLoading: Boolean, - allNotes: List, - onNotesEvents: (NotesEvents) -> Unit -) { - val pinnedNotes = allNotes.filter { it.isPinned && (it.title.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true)) } - val unPinnedNotes = allNotes.filter { !it.isPinned && (it.title.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true)) } - - when { - isLoading -> item { Loader() } - else -> { - if (pinnedNotes.isEmpty() && unPinnedNotes.isEmpty()) { item { EmptyNotes() } } - else { - if (pinnedNotes.isNotEmpty()) { - item { - Text( - stringResource(R.string.Pinned), - modifier = Modifier.padding(vertical = 8.dp), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary - ) - } - } - - item { - val columns = if (isGridView) 2 else 1 - val rowCount = ceil(pinnedNotes.size / columns.toFloat()).toInt() - val gridHeight = rowCount * 300.dp + if (pinnedNotes.isNotEmpty() && unPinnedNotes.isEmpty()) 200.dp else 25.dp - - Box( - Modifier - .fillMaxWidth() - .heightIn(max=gridHeight) - ) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(columns), - userScrollEnabled = false, - verticalItemSpacing = 8.dp, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(pinnedNotes, key = { it.notesId }) { note -> - NotesPreviewCard( - radius = radius, - isSelected = selectedNotes.contains(note.notesId), - note = note, - labels = allLabels.filter { note.labels.contains(it.labelId) } - .map { it.value }, - onClick = { - navController.navigate( - NavRoutes.NoteDetails.withArgs( - workspaceId, - note.notesId - ) - ) - }, - onLongPressed = { - if (selectedNotes.contains(note.notesId)) { - onNotesEvents(NotesEvents.UnSelectNotes(note.notesId)) - } else { - onNotesEvents(NotesEvents.SelectNotes(note.notesId)) - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - - if (pinnedNotes.isNotEmpty()) { - item { - Text( - stringResource(R.string.Others), - modifier = Modifier.padding(vertical = 8.dp), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary - ) - } - } - - item { - val columns = if (isGridView) 2 else 1 - val rowCount = ceil(unPinnedNotes.size / columns.toFloat()).toInt() - val gridHeight = rowCount * 300.dp + 200.dp - - Box(Modifier.fillMaxWidth().height(gridHeight)) { - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(columns), - userScrollEnabled = false, - verticalItemSpacing = 8.dp, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.fillMaxWidth() - ) { - items(unPinnedNotes, key = { it.notesId }) { note -> - NotesPreviewCard( - radius = radius, - isSelected = selectedNotes.contains(note.notesId), - note = note, - labels = allLabels.filter { note.labels.contains(it.labelId) }.map { it.value }, - onClick = { - navController.navigate( - NavRoutes.NoteDetails.withArgs( - workspaceId, - note.notesId - ) - ) - }, - onLongPressed = { - if (selectedNotes.contains(note.notesId)) { - onNotesEvents(NotesEvents.UnSelectNotes(note.notesId)) - } else { - onNotesEvents(NotesEvents.SelectNotes(note.notesId)) - } - }, - modifier = Modifier.fillMaxWidth() - ) - } - } - } - } - } - } - } -} diff --git a/app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt b/app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt new file mode 100644 index 0000000..24cf4f9 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/notes/NotesScreen.kt @@ -0,0 +1,425 @@ +package com.flux.ui.screens.notes + +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkVertically +import com.flux.R +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Notes +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.outlined.Clear +import androidx.compose.material.icons.outlined.Create +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.FilterList +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.data.model.LabelModel +import com.flux.data.model.NotesModel +import com.flux.data.model.WorkspaceModel +import com.flux.navigation.Loader +import com.flux.navigation.NavRoutes +import com.flux.ui.common.DeleteAlert +import com.flux.ui.common.EmptyNotes +import com.flux.ui.common.SelectedToolBarRow +import com.flux.ui.common.SpaceSearchBar +import com.flux.ui.common.SpaceTopBar +import com.flux.ui.common.SpacesMenu +import com.flux.ui.events.NotesEvents +import com.flux.ui.events.SettingEvents +import com.flux.ui.screens.workspaces.SpacesToolBar +import com.flux.ui.state.NotesState +import com.flux.ui.state.Settings + +data class FilterState( + val sort: String? = null, + val view: String? = null, + val pinned: String? = null, + val selectedLabelIds: Set = emptySet() +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NotesScreen( + navController: NavController, + state: NotesState, + allLabels: List, + settings: Settings, + workspace: WorkspaceModel, + onShowSpaceBottomSheet: () -> Unit, + onSpaceChange: (Int) -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, + onEvent: (NotesEvents) -> Unit, + onSettingChange: (SettingEvents) -> Unit +){ + var query by remember { mutableStateOf("") } + val context = LocalContext.current + val workspaceId = workspace.workspaceId + val isGridView = settings.data.isGridView + val radius = settings.data.cornerRadius + val isLoading = state.isLoading + val columns = if(isGridView) 2 else 1 + val selectedNotes = remember { mutableStateListOf() } + val importSuccess = stringResource(R.string.import_success) + val importFailed = stringResource(R.string.import_failed) + var isToolsSheetVisible by remember { mutableStateOf(false) } + var showSearchBar by remember { mutableStateOf(false) } + var isDialogVisible by remember { mutableStateOf(false) } + var showSpacesMenu by remember { mutableStateOf(false) } + var isActionButtonExpanded by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + var filterState by remember { mutableStateOf(FilterState()) } + val labelMap = remember(allLabels) { + allLabels.associateBy { it.labelId } + } + + val allNotes = state.allNotes + .filter { note-> + note.workspaceId==workspaceId + } + + // 🔍 Search + .filter { note -> + val matchesText = + note.title.contains(query, true) || + note.description.contains(query, true) + + val matchesLabel = + note.labels.any { labelId -> + labelMap[labelId]?.value + ?.contains(query, true) == true + } + + matchesText || matchesLabel + } + + // 📌 Pinned filter + .let { notes -> + when (filterState.pinned) { + "pinned" -> notes.filter { it.isPinned } + "unpinned" -> notes.filter { !it.isPinned } + else -> notes + } + } + + // 🏷 Label filter + .let { notes -> + if (filterState.selectedLabelIds.isEmpty()) notes + else notes.filter { note -> + note.labels.any { it in filterState.selectedLabelIds } + } + } + + // 🔄 Sorting + .let { notes -> + when (filterState.sort) { + "latest" -> notes.sortedByDescending { it.lastEdited } + "oldest" -> notes.sortedBy { it.lastEdited } + else -> notes + } + } + + val pinnedNotes = allNotes.filter { it.isPinned } + val unPinnedNotes = allNotes.filter { !it.isPinned } + val showLabels = pinnedNotes.isNotEmpty() + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { + try { + val contentResolver = context.contentResolver + val inputStream = contentResolver.openInputStream(uri) + val content = inputStream?.bufferedReader()?.use { it.readText() } ?: "" + + // Get filename (remove extension) + val fileName = uri.lastPathSegment + ?.substringAfterLast("/") + ?.substringBeforeLast(".") + ?: "Imported Note" + + // Create a new note + val newNote = NotesModel( + title = fileName, + description = content, + workspaceId = workspaceId, + lastEdited = System.currentTimeMillis() + ) + + onEvent(NotesEvents.UpsertNote(newNote)) + Toast.makeText(context, importSuccess, Toast.LENGTH_SHORT).show() + + } catch (e: Exception) { + e.printStackTrace() + Toast.makeText(context, importFailed, Toast.LENGTH_SHORT).show() + } + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + }, + floatingActionButton = { + Box(contentAlignment = Alignment.BottomEnd) { + // Expanded actions + AnimatedVisibility( + visible = isActionButtonExpanded, + enter = fadeIn() + expandVertically(expandFrom = Alignment.Bottom), + exit = fadeOut() + shrinkVertically(shrinkTowards = Alignment.Bottom) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.End + ) { + val buttonModifier = Modifier.width(120.dp) + + ExtendedFloatingActionButton( + modifier = buttonModifier, + onClick = { importLauncher.launch(arrayOf("text/markdown", "text/plain")) }, + icon = { Icon(Icons.Outlined.Download, contentDescription = null) }, + text = { Text("Import") } + ) + + ExtendedFloatingActionButton( + modifier = buttonModifier, + onClick = { navController.navigate(NavRoutes.NoteDetails.withArgs(workspaceId, "")) }, + icon = { Icon(Icons.Outlined.Create, contentDescription = null) }, + text = { Text("Create") } + ) + + ExtendedFloatingActionButton( + modifier = buttonModifier, + onClick = { isActionButtonExpanded = false }, + icon = { Icon(Icons.Outlined.Clear, contentDescription = null) }, + text = { Text("Clear") } + ) + } + } + + AnimatedVisibility( + visible = !isActionButtonExpanded, + enter = scaleIn() + fadeIn(), + exit = scaleOut() + fadeOut() + ) { + FloatingActionButton( { isActionButtonExpanded = true }) { + Icon(Icons.Default.Add, null) + } + } + } + } + ) { innerPadding -> + when { + isLoading -> Loader() + else -> { + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(columns), + verticalItemSpacing = 8.dp, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + item(span = StaggeredGridItemSpan.FullLine) { + if (selectedNotes.isEmpty()){ + Row( + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if(showSearchBar){ SpaceSearchBar(query, { query=it }, { showSearchBar=false }) } + else { + SpacesToolBar( + stringResource(R.string.Notes), + Icons.AutoMirrored.Filled.Notes, + false, + onMainClick = { showSpacesMenu = true }, + onEditClick = onShowSpaceBottomSheet + ) + SpacesMenu( + showSpacesMenu, + workspace, + onSpaceChange + ) { showSpacesMenu = false } + + Row{ + IconButton({ showSearchBar = true }) { + Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary) + } + IconButton({ isToolsSheetVisible = true }) { + Icon(Icons.Outlined.FilterList, null, tint = MaterialTheme.colorScheme.primary) + } + } + } + } + } + else{ + SelectedToolBarRow( + true, + selectedNotes.size, + selectedNotes.size == allNotes.size, + pinnedNotes.size == allNotes.size, + { selectedNotes.clear() }, + { isDialogVisible = true }, + { + onEvent(NotesEvents.TogglePinMultiple(selectedNotes.toList())) + selectedNotes.clear() + }, + { + if (allNotes.size == selectedNotes.size) { + selectedNotes.clear() + } else { + selectedNotes.clear() + selectedNotes.addAll(allNotes) + } + } + ) + } + } + if (allNotes.isEmpty()) item(span = StaggeredGridItemSpan.FullLine) { EmptyNotes() } + if(showLabels) { + item(span = StaggeredGridItemSpan.FullLine) { + Text( + text = stringResource(R.string.Pinned), + modifier = Modifier.fillMaxWidth() + ) + } + } + + items(pinnedNotes, key = { it.notesId }) { note -> + NotesPreviewCard( + radius = radius, + isSelected = selectedNotes.contains(note), + note = note, + labels = allLabels.filter { note.labels.contains(it.labelId) }.map { it.value }, + onClick = { navController.navigate(NavRoutes.NoteDetails.withArgs(workspaceId, note.notesId)) }, + onLongPressed = { + if (selectedNotes.contains(note)) { + selectedNotes.remove(note) + } else { + selectedNotes.add(note) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + + if(showLabels) { + item(span = StaggeredGridItemSpan.FullLine) { + Text( + text = stringResource(R.string.Others), + modifier = Modifier.fillMaxWidth() + ) + } + } + items(unPinnedNotes, key = { it.notesId }) { note -> + NotesPreviewCard( + radius = radius, + isSelected = selectedNotes.contains(note), + note = note, + labels = allLabels.filter { note.labels.contains(it.labelId) }.map { it.value }, + onClick = { navController.navigate(NavRoutes.NoteDetails.withArgs(workspaceId, note.notesId)) }, + onLongPressed = { + if (selectedNotes.contains(note)) { + selectedNotes.remove(note) + } else { + selectedNotes.add(note) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } + } + + if (isDialogVisible) { + DeleteAlert(onConfirmation = { + onEvent(NotesEvents.DeleteNotes(selectedNotes.toList())) + selectedNotes.clear() + isDialogVisible = false + }, onDismissRequest = { isDialogVisible = false }) + } + + if(isToolsSheetVisible) { + NotesFilterSheet( + filterState.copy(view = if(isGridView) "grid" else "list"), + allLabels, + rememberModalBottomSheetState(), + onDismiss = { isToolsSheetVisible = false }, + ) { single, multi -> + filterState = FilterState( + sort = single["Sort By"], + view = single["View"], + pinned = single["Pinned"], + selectedLabelIds = multi["Labels"] ?: emptySet() + ) + + if(isGridView && single["View"]=="list" || !isGridView && single["View"]=="grid"){ + onSettingChange(SettingEvents.UpdateSettings(settings.data.copy(isGridView=(single["View"]=="grid")))) + } + } + } +} diff --git a/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoard.kt b/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoard.kt index bdc1652..ad4859a 100644 --- a/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoard.kt +++ b/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoard.kt @@ -1,71 +1,172 @@ package com.flux.ui.screens.progressBoard import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Flag -import androidx.compose.material.icons.filled.HourglassEmpty -import androidx.compose.material.icons.filled.Timelapse -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.TrackChanges +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.navigation.NavController import com.flux.R import com.flux.data.model.ProgressBoardModel +import com.flux.data.model.WorkspaceModel import com.flux.navigation.Loader -import com.flux.other.icons -import com.flux.ui.components.BoardStatusItem -import com.flux.ui.components.convertMillisToDate -import com.flux.ui.components.shapeManager +import com.flux.navigation.NavRoutes +import com.flux.ui.common.EmptyProgressItems +import com.flux.ui.common.SpaceSearchBar +import com.flux.ui.common.SpaceTopBar +import com.flux.ui.common.SpacesMenu +import com.flux.ui.common.convertMillisToDate +import com.flux.ui.events.ProgressBoardEvents +import com.flux.ui.screens.workspaces.SpacesToolBar +import com.flux.ui.state.ProgressBoardState +import com.flux.ui.state.Settings import com.flux.ui.theme.completed import com.flux.ui.theme.failed import com.flux.ui.theme.pending -fun LazyListScope.progressBoardItems( - isLoading: Boolean, - radius: Int, - boardItems: List, - onClick: (ProgressBoardModel)->Unit +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ProgressTrackerScreen( + navController: NavController, + state: ProgressBoardState, + settings: Settings, + workspace: WorkspaceModel, + onShowSpaceBottomSheet: () -> Unit, + onSpaceChange: (Int) -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, + onEvent: (ProgressBoardEvents) -> Unit ){ - when { - isLoading -> item { Loader() } - boardItems.isEmpty() -> item { EmptyProgressItems() } - else -> - item { - val notStartedItems = boardItems.filter { it.status == 0 } - val inProgressItems = boardItems.filter { it.status == 1 } - val completedItems = boardItems.filter { it.status == 2 } + val workspaceId = workspace.workspaceId + val isLoading = state.isLoading + val radius = settings.data.cornerRadius + var query by remember { mutableStateOf("") } + val boardItems = state.allItems.filter { + it.workspaceId == workspaceId && ( + it.title.contains(query, ignoreCase = true) || + convertMillisToDate(it.startDate).contains(query, ignoreCase = true) || + convertMillisToDate(it.endDate).contains(query, ignoreCase = true) + ) + } + val notStartedItems = boardItems.filter { it.status == 0 } + val inProgressItems = boardItems.filter { it.status == 1 } + val completedItems = boardItems.filter { it.status == 2 } + var showSpacesMenu by remember { mutableStateOf(false) } + var showSearchBar by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var selectedItem by remember { mutableStateOf(null) } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() - LazyRow( - Modifier.fillMaxWidth().padding(vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + }, + floatingActionButton = { + FloatingActionButton({ selectedItem = ProgressBoardModel(workspaceId=workspaceId) }) { + Icon(Icons.Default.Add, null) + } + } + ) { innerPadding -> + when { + isLoading -> Loader() + else -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { + item { + if(showSearchBar){ SpaceSearchBar(query, { query = it }, { showSearchBar = false }) } + else { + Row( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + SpacesToolBar( + stringResource(R.string.progress_tracker), + Icons.Default.TrackChanges, + false, + onMainClick = { showSpacesMenu = true }, + onEditClick = onShowSpaceBottomSheet + ) + SpacesMenu( + showSpacesMenu, + workspace, + onSpaceChange + ) { showSpacesMenu = false } + + Row { + IconButton({ showSearchBar = true }) { + Icon( + Icons.Default.Search, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + IconButton({ showSearchBar = true }) { + Icon( + Icons.Default.FilterList, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + } + if(boardItems.isEmpty()) item { EmptyProgressItems() } if (notStartedItems.isNotEmpty()) { item { BoardContainer( failed, stringResource(R.string.not_started), radius, - notStartedItems, - onClick - ) + notStartedItems + ) { selectedItem = it } } } if (inProgressItems.isNotEmpty()) { @@ -74,9 +175,8 @@ fun LazyListScope.progressBoardItems( pending, stringResource(R.string.in_progress), radius, - inProgressItems, - onClick - ) + inProgressItems + ) { selectedItem = it } } } @@ -86,115 +186,25 @@ fun LazyListScope.progressBoardItems( completed, stringResource(R.string.Completed), radius, - completedItems, - onClick - ) + completedItems + ) { selectedItem = it } } } } - } - } -} - -@Composable -fun BoardContainer( - containerColor: Color, - status: String, - radius: Int, - items: List, - onClick: (ProgressBoardModel) -> Unit -){ - Card( - modifier = Modifier.width(300.dp), - shape = shapeManager(radius = radius * 2), - onClick = { }, - colors = CardDefaults.cardColors(containerColor = containerColor.copy(0.1f)) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - BoardStatusItem(true, status, containerColor){} - Spacer(Modifier.height(4.dp)) - items.forEach { item-> - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - shape = shapeManager(radius = radius * 2), - onClick = { onClick(item) }, - colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surface) - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icons[item.icon], - null, - modifier = Modifier.alpha(0.8f) - ) - Column(modifier = Modifier.fillMaxWidth()) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text(item.title, style = MaterialTheme.typography.bodyMedium) - } - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (item.startDate != -1L) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Icon(Icons.Default.Timelapse, null, modifier = Modifier.size(16.dp).alpha(0.75f)) - Text( - convertMillisToDate(item.startDate), - modifier = Modifier.alpha(0.75f), - style = MaterialTheme.typography.labelMedium - ) - } - } - if (item.endDate != -1L) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - Icon(Icons.Default.Flag, null, modifier = Modifier.size(16.dp).alpha(0.75f)) - Text( - convertMillisToDate(item.endDate), - modifier = Modifier.alpha(0.75f), - style = MaterialTheme.typography.labelMedium - ) - } - } - } - } - } - } - } } } -} -@Composable -fun EmptyProgressItems() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 24.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.HourglassEmpty, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text("Empty! No Data Found") + selectedItem?.let { item -> + NewBoardItemSheet( + true, + sheetState, + item, + { selectedItem = null }, + { + onEvent(ProgressBoardEvents.UpsertProgressItem(it)) + }) { + onEvent(ProgressBoardEvents.DeleteProgressItem(it) + ) + } } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoardComponents.kt b/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoardComponents.kt new file mode 100644 index 0000000..f4d3eda --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/progressBoard/ProgressBoardComponents.kt @@ -0,0 +1,406 @@ +package com.flux.ui.screens.progressBoard + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Timelapse +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import com.flux.R +import com.flux.data.model.ProgressBoardModel +import com.flux.other.icons +import com.flux.ui.common.ChangeIconSheet +import com.flux.ui.common.DatePickerModal +import com.flux.ui.common.convertMillisToDate +import com.flux.ui.screens.events.getTextFieldColors +import com.flux.ui.screens.settings.shapeManager +import com.flux.ui.theme.completed +import com.flux.ui.theme.failed +import com.flux.ui.theme.pending + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewBoardItemSheet( + isVisible: Boolean, + sheetState: SheetState, + progressBoardItem: ProgressBoardModel, + onDismiss: () -> Unit, + onConfirm: (ProgressBoardModel) -> Unit, + onDelete: (ProgressBoardModel) -> Unit +) { + val focusRequesterDesc = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + var selectedStatus by remember(progressBoardItem) { mutableIntStateOf(progressBoardItem.status) } + var startDate by remember(progressBoardItem) { mutableLongStateOf(progressBoardItem.startDate) } + var endDate by remember(progressBoardItem) { mutableLongStateOf(progressBoardItem.endDate) } + var showDateSelector by remember { mutableStateOf(false) } + var isSelectingStartDate by remember { mutableStateOf(true) } + var title by remember(progressBoardItem) { mutableStateOf(progressBoardItem.title) } + var description by remember(progressBoardItem) { mutableStateOf(progressBoardItem.description ) } + var isChangeIcon by remember { mutableStateOf(false) } + val iconSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var newIcon by remember(progressBoardItem) { mutableIntStateOf(progressBoardItem.icon) } + + if(showDateSelector){ + DatePickerModal({ + if (isSelectingStartDate) startDate=it?:-1L + else endDate=it?:-1L + }) { + showDateSelector=false + } + } + + ChangeIconSheet (isChangeIcon, iconSheetState, { isChangeIcon=false }) { + newIcon=it + isChangeIcon=false + } + + if (isVisible) { + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp)) { + IconButton( + { isChangeIcon=true }, + modifier = Modifier.align(Alignment.CenterHorizontally).size(56.dp), + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { + Icon(icons[newIcon], null, modifier = Modifier.size(40.dp)) + } + Spacer(Modifier.height(8.dp)) + TextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(stringResource(R.string.Title)) }, + singleLine = true, + textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), + colors = getTextFieldColors(), + keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() }) + ) + + TextField( + value = description, + onValueChange = { description=it }, + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp, bottom = 16.dp) + .focusRequester(focusRequesterDesc), + placeholder = { Text(stringResource(R.string.Description)) }, + singleLine = true, + shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp), + colors = getTextFieldColors(), + keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() }) + ) + + LazyRow(Modifier.fillMaxWidth().padding(start = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + item { + BoardStatusItem(selectedStatus==0, stringResource(R.string.not_started), failed) { + selectedStatus=0 + } + } + item { + BoardStatusItem(selectedStatus==1, stringResource(R.string.in_progress), pending){ + selectedStatus=1 + } + } + item { + BoardStatusItem(selectedStatus==2, stringResource(R.string.Completed), completed){ + selectedStatus=2 + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BoardTimeCard(Icons.Default.Timelapse, stringResource(R.string.start)) { + showDateSelector=true + isSelectingStartDate=true + } + Spacer(Modifier.width(2.dp)) + Text( + if(startDate==-1L) stringResource(R.string.empty) else convertMillisToDate(startDate), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.clip(RoundedCornerShape(50)) + .clickable{ + showDateSelector=true + isSelectingStartDate=true + } + .padding(vertical = 4.dp, horizontal = 8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth().padding(start = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + BoardTimeCard(Icons.Default.Flag, stringResource(R.string.target)) { + showDateSelector=true + isSelectingStartDate=false + } + Text( + if(endDate==-1L) stringResource(R.string.empty) else convertMillisToDate(endDate), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.clip(RoundedCornerShape(50)) + .clickable{ + showDateSelector=true + isSelectingStartDate=false + } + .padding(vertical = 4.dp, horizontal = 8.dp)) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + if(progressBoardItem.title.isNotBlank()){ + FilledTonalButton( + onClick = { + keyboardController?.hide() + onDismiss() + onDelete(progressBoardItem) + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer + ) + ) { + Text(stringResource(R.string.delete)) + } + } + Spacer(Modifier.width(8.dp)) + FilledTonalButton( + enabled = title.isNotBlank(), + onClick = { + keyboardController?.hide() + onConfirm( + progressBoardItem.copy( + title= title, + description = description, + icon = newIcon, + startDate = startDate, + endDate = endDate, + status = selectedStatus + ) + ) + onDismiss() + } + ) { + Text(stringResource(R.string.Confirm)) + } + } + } + } + } +} + +@Composable +fun BoardStatusItem(isSelected: Boolean, status: String, color: Color, onClick: () -> Unit){ + Card( + onClick = onClick, + shape = RoundedCornerShape(50), + colors = CardDefaults.cardColors( + containerColor = if(isSelected) color.copy(0.5f) else color.copy(0.1f) + ) + ) { + Row( + Modifier.padding(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .padding(start = 2.dp) + .size(12.dp) + .clip(RoundedCornerShape(50)) + .then( + if (isSelected) { + Modifier.background(color) + } else { + Modifier.border(2.dp, color, RoundedCornerShape(50)) + } + ) + ) + Text( + status, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(4.dp) + ) + } + } +} + +@Composable +fun BoardTimeCard(icon: ImageVector, title: String, onClick: () -> Unit){ + Card( + onClick = onClick, + shape = RoundedCornerShape(50), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) + ) { + Row( + Modifier.padding(vertical = 2.dp, horizontal = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, null, modifier = Modifier.size(18.dp).alpha(0.75f)) + Text( + title, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(4.dp).alpha(0.8f) + ) + } + } +} + +@Composable +fun BoardContainer( + containerColor: Color, + status: String, + radius: Int, + items: List, + onClick: (ProgressBoardModel) -> Unit +){ + Card( + modifier = Modifier.width(300.dp), + shape = shapeManager(radius = radius * 2), + onClick = { }, + colors = CardDefaults.cardColors(containerColor = containerColor.copy(0.1f)) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + BoardStatusItem(true, status, containerColor){} + Spacer(Modifier.height(4.dp)) + items.forEach { item-> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + shape = shapeManager(radius = radius * 2), + onClick = { onClick(item) }, + colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surface) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icons[item.icon], + null, + modifier = Modifier.alpha(0.8f) + ) + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text(item.title, style = MaterialTheme.typography.bodyMedium) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (item.startDate != -1L) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon(Icons.Default.Timelapse, null, modifier = Modifier + .size(16.dp) + .alpha(0.75f)) + Text( + convertMillisToDate(item.startDate), + modifier = Modifier.alpha(0.75f), + style = MaterialTheme.typography.labelMedium + ) + } + } + if (item.endDate != -1L) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon(Icons.Default.Flag, null, modifier = Modifier + .size(16.dp) + .alpha(0.75f)) + Text( + convertMillisToDate(item.endDate), + modifier = Modifier.alpha(0.75f), + style = MaterialTheme.typography.labelMedium + ) + } + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/flux/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/flux/ui/screens/search/SearchScreen.kt new file mode 100644 index 0000000..f10234c --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/search/SearchScreen.kt @@ -0,0 +1,731 @@ +package com.flux.ui.screens.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Done +import androidx.compose.material.icons.filled.FilterList +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.R +import com.flux.data.model.EventModel +import com.flux.data.model.HabitInstanceModel +import com.flux.data.model.HabitModel +import com.flux.data.model.JournalModel +import com.flux.data.model.LabelModel +import com.flux.data.model.NotesModel +import com.flux.data.model.ProgressBoardModel +import com.flux.data.model.Space +import com.flux.data.model.TodoModel +import com.flux.data.model.WorkspaceModel +import com.flux.data.model.getSpacesList +import com.flux.data.model.isCounted +import com.flux.data.model.isLive +import com.flux.navigation.Loader +import com.flux.navigation.NavRoutes +import com.flux.other.computeMonthlyEventDates +import com.flux.ui.common.BottomBar +import com.flux.ui.common.CategoryRow +import com.flux.ui.common.EmptyEvents +import com.flux.ui.common.EmptyHabits +import com.flux.ui.common.EmptyJournal +import com.flux.ui.common.EmptyNotes +import com.flux.ui.common.EmptyProgressItems +import com.flux.ui.common.EmptyTodoList +import com.flux.ui.common.GeneralSearchBar +import com.flux.ui.common.MultiOptionRow +import com.flux.ui.common.SearchFilterCategory +import com.flux.ui.common.SearchFilterOption +import com.flux.ui.common.SelectionType +import com.flux.ui.common.convertMillisToDate +import com.flux.ui.common.convertMillisToTime +import com.flux.ui.events.HabitEvents +import com.flux.ui.events.ProgressBoardEvents +import com.flux.ui.events.TaskEvents +import com.flux.ui.events.TodoEvents +import com.flux.ui.screens.events.DailyViewCalendar +import com.flux.ui.screens.events.EventCard +import com.flux.ui.screens.events.MonthlyViewCalendar +import com.flux.ui.screens.habits.HabitPreviewCard +import com.flux.ui.screens.habits.isDateAllowedForHabit +import com.flux.ui.screens.journal.JournalCardHeader +import com.flux.ui.screens.journal.JournalPreview +import com.flux.ui.screens.journal.TimelineBody +import com.flux.ui.screens.notes.NotesPreviewCard +import com.flux.ui.screens.progressBoard.BoardContainer +import com.flux.ui.screens.progressBoard.NewBoardItemSheet +import com.flux.ui.screens.todo.TodoExpandableCard +import com.flux.ui.state.States +import com.flux.ui.theme.completed +import com.flux.ui.theme.failed +import com.flux.ui.theme.pending +import com.flux.ui.viewModel.ViewModels +import java.time.LocalDate +import java.time.YearMonth +import kotlin.collections.minus +import kotlin.collections.plus +import kotlin.math.absoluteValue + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen(navController: NavController, states: States, viewModels: ViewModels){ + var query by rememberSaveable { mutableStateOf("") } + val allSpaces = getSpacesList().filter { it.id!=6 } + val lockedWorkspace = states.workspaceState.allWorkspaces.filter { it.passKey?.isNotBlank()==true }.map { it.workspaceId } + var filterState by remember { + mutableStateOf( + FilterState( + selectedWorkspaceIds = states.workspaceState.allWorkspaces + .filterNot { lockedWorkspace.contains(it.workspaceId) } + .map { it.workspaceId } + .toSet(), + + selectedSpacesIds = allSpaces + .map { it.id.toString() } + .toSet() + ) + ) + } + val radius = states.settings.data.cornerRadius + val is24HourFormat = states.settings.data.is24HourFormat + val isMonthlyView = states.settings.data.isCalendarMonthlyView + val isLoading = + states.notesState.isLoading || states.labelState.isLoading || + states.journalState.isLoading || states.habitState.isLoading || + states.eventState.isAllEventsLoading || states.progressBoardState.isLoading || + states.todoState.isLoading + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selectedWorkspaceIds = states.workspaceState.allWorkspaces + .filterNot { lockedWorkspace.contains(it.workspaceId) } + .filter { it.workspaceId in filterState.selectedWorkspaceIds }.map { it.workspaceId } + val selectedSpaces = allSpaces.filter { it.id.toString() in filterState.selectedSpacesIds } + + val labels = states.labelState.allLabels + .filterNot { lockedWorkspace.contains(it.workspaceId) } + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + + val labelMap = remember(labels) { labels.associateBy { it.labelId } } + var isToolsSheetVisible by remember { mutableStateOf(false) } + + val notes = states.notesState.allNotes + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + .filter { note -> + val matchesText = note.title.contains(query, true) || note.description.contains(query, true) + val matchesLabel = note.labels.any { labelId -> labelMap[labelId]?.value?.contains(query, true) == true } + + matchesText || matchesLabel + } + .filterNot { lockedWorkspace.contains(it.workspaceId) } + + val todoLists = states.todoState.allLists + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + .filter { it.title.contains(query, ignoreCase = true) || it.items.any { item -> item.value.contains(query, ignoreCase = true) } } + .filterNot { lockedWorkspace.contains(it.workspaceId) } + + val expandedTODOIds = rememberSaveable { mutableStateOf>(emptySet()) } + + val journals = states.journalState.data + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + .filter { entry -> + val matchesText = entry.text.contains(query, ignoreCase = true) + val matchesTime = convertMillisToTime(entry.dateTime).contains(query, ignoreCase = true) || + convertMillisToDate(entry.dateTime).contains(query, ignoreCase = true) + + val matchesLabel = entry.labels.any { labelId -> labelMap[labelId]?.value?.contains(query, true) == true } + + matchesText || matchesLabel || matchesTime + }.filterNot { lockedWorkspace.contains(it.workspaceId) } + + val habits = states.habitState.allHabits + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + .filter { + it.title.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true) + }.filterNot { lockedWorkspace.contains(it.workspaceId) } + + val currentHabits = habits.filter { it.isLive() } + val pastHabits = habits.filter { !it.isLive() } + val selectedDate = states.eventState.selectedDate + val selectedMonth = states.eventState.selectedYearMonth + val datedEvents = states.eventState.datedEvents + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + .filter { it.title.contains(query, ignoreCase = true) } + .filterNot { lockedWorkspace.contains(it.workspaceId) } + + val pendingTasks = datedEvents.filter { task -> + val instance = states.eventState.allEventInstances + .find { it.eventId == task.id && it.instanceDate == selectedDate } + instance == null + } + val completedTasks = datedEvents.filter { task -> + val instance = states.eventState.allEventInstances + .find { it.eventId == task.id && it.instanceDate == selectedDate } + instance != null + } + val monthlyEventCount = + computeMonthlyEventDates( + states.eventState.allEvent + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + .filterNot { lockedWorkspace.contains(it.workspaceId) }, + selectedMonth + ) + val boardItems = states.progressBoardState.allItems + .filter { selectedWorkspaceIds.contains(it.workspaceId) } + .filter { + it.title.contains(query, ignoreCase = true) || + convertMillisToDate(it.startDate).contains(query, ignoreCase = true) || + convertMillisToDate(it.endDate).contains(query, ignoreCase = true) } + .filterNot { lockedWorkspace.contains(it.workspaceId) } + + val notStartedItems = boardItems.filter { it.status == 0 } + val inProgressItems = boardItems.filter { it.status == 1 } + val completedItems = boardItems.filter { it.status == 2 } + var selectedProgressBoardItem by remember { mutableStateOf(null) } + var selectedSpace by remember { mutableIntStateOf(1) } + + LaunchedEffect(query) { + if(query.isNotBlank() && expandedTODOIds.value.isEmpty()){ + expandedTODOIds.value = todoLists.map { it.id }.toSet() + } + else if(query.isBlank()) { expandedTODOIds.value = emptySet() } + } + + LaunchedEffect(selectedSpaces) { + val availableIds = selectedSpaces.filter { it.id != 6 }.map { it.id } + if (availableIds.isNotEmpty() && selectedSpace !in availableIds) { + selectedSpace = availableIds.first() + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + GeneralSearchBar( + leadingIcon = Icons.Default.Search, + trailingIcon = Icons.Default.FilterList, + textFieldState = TextFieldState(query), + onSearch = { query = it }, + onTrailingIconClicked = { isToolsSheetVisible=true }, + onCloseClicked = { query = "" } + ) + } + ) { innerPadding -> + Box(Modifier.fillMaxSize().padding(innerPadding)) { + when { + isLoading -> Loader() + else -> { + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 12.dp), + contentPadding = PaddingValues( + top = 16.dp, + bottom = 64.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { + LazyRow( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + items(selectedSpaces){ space-> + FilterChip( + onClick = { selectedSpace = space.id }, + label = { Text(space.title) }, + selected = selectedSpace == space.id, + leadingIcon = { + Icon( + imageVector = if (selectedSpace == space.id) Icons.Filled.Done else space.icon, + contentDescription = "Done icon", + modifier = Modifier.size(FilterChipDefaults.IconSize) + ) + }, + ) + } + } + } + + when(selectedSpace.absoluteValue) { + 1 -> searchedNotes(navController, radius, notes, labels) + 2 -> searchedTodo(navController, radius, expandedTODOIds.value, todoLists, { id-> + expandedTODOIds.value = + if (id in expandedTODOIds.value) expandedTODOIds.value - id + else expandedTODOIds.value + id + }, viewModels.todoViewModel::onEvent) + 3 -> searchedEvent(navController, radius, is24HourFormat,pendingTasks, completedTasks, selectedDate, selectedMonth, isMonthlyView, monthlyEventCount, viewModels.eventViewModel::onEvent) + 4 -> searchedJournal(navController, radius, journals, labels) + 5 -> searchedHabits(navController, radius, is24HourFormat, currentHabits, pastHabits, states.habitState.allInstances, viewModels.habitViewModel::onEvent) + 7 -> searchedProgressBoard(radius, notStartedItems, inProgressItems, completedItems) { selectedProgressBoardItem = it } + else -> searchedNotes(navController, radius, notes, labels) + } + } + BottomBar( + navController = navController, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 16.dp) + ) + } + } + } + } + + selectedProgressBoardItem?.let { item -> + NewBoardItemSheet( + true, + sheetState, + item, + { selectedProgressBoardItem = null }, + { viewModels.progressBoardViewModel.onEvent(ProgressBoardEvents.UpsertProgressItem(it)) } + ) { viewModels.progressBoardViewModel.onEvent(ProgressBoardEvents.DeleteProgressItem(it)) } + } + + if(isToolsSheetVisible){ + FilterSheet( + filterState, + states.workspaceState.allWorkspaces.filterNot { lockedWorkspace.contains(it.workspaceId) }, + allSpaces, + sheetState, + { isToolsSheetVisible=false } + ) { map -> + + filterState = FilterState( + selectedWorkspaceIds = map["WORKSPACES"] ?: emptySet(), + selectedSpacesIds = map["SPACES"] ?: emptySet() + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.searchedNotes(navController: NavController, radius: Int, notes: List, labels: List){ + if (notes.isEmpty()) item { EmptyNotes() } + items(notes, key = { it.notesId }) { note -> + NotesPreviewCard( + radius = radius, + isSelected = false, + note = note, + labels = labels.filter { note.labels.contains(it.labelId) }.map { it.value }, + onClick = { navController.navigate(NavRoutes.NoteDetails.withArgs(note.workspaceId, note.notesId)) }, + onLongPressed = { navController.navigate(NavRoutes.NoteDetails.withArgs(note.workspaceId, note.notesId)) }, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.searchedJournal(navController: NavController, radius: Int, allEntries: List, labels: List){ + if(allEntries.isEmpty()) item { EmptyJournal() } + items(allEntries, key = { it.journalId }) { journal -> + JournalCardHeader(convertMillisToDate(journal.dateTime) + ", " + convertMillisToTime(journal.dateTime)) + Row(modifier = Modifier.height(IntrinsicSize.Min)) { + TimelineBody(isLast = false) + JournalPreview( + radius, + journal.text, + labels.filter { journal.labels.contains(it.labelId) }) { + navController.navigate( + NavRoutes.EditJournal.withArgs( + journal.workspaceId, + journal.journalId, + 0L + ) + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.searchedHabits( + navController: NavController, + radius: Int, + is24HourFormat: Boolean, + currentHabits: List, + pastHabits: List, + instances: List, + onEvent: (HabitEvents) -> Unit +) { + if((currentHabits+pastHabits).isEmpty()) item { EmptyHabits() } + items(currentHabits) { habit -> + val habitInstances = instances.filter { it.habitId == habit.id } + HabitPreviewCard( + radius = radius, + habit = habit, + is24HourFormat = is24HourFormat, + instances = habitInstances, + onClick = { date -> + if (isDateAllowedForHabit(habit.recurrence, date)) { + val oldInstance = habitInstances.find { it.instanceDate == date } + val count = if(habit.isCounted){ + if(oldInstance!=null) oldInstance.count+1 + else 1 + } else 0 + + val newInstance = HabitInstanceModel( + instanceDate = date, + habitId = habit.id, + workspaceId = habit.workspaceId, + count = count + ) + onEvent(HabitEvents.UpdateInstance(newInstance, habit.habitConfig)) + } + }, + onAnalyticsClicked = { navController.navigate(NavRoutes.HabitDetails.withArgs(habit.workspaceId, habit.id)) } + ) + } + if(pastHabits.isNotEmpty()) { + item { + Text( + stringResource(R.string.past_habits), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + items(pastHabits) { habit -> + val habitInstances = instances.filter { it.habitId == habit.id } + HabitPreviewCard ( + radius = radius, + is24HourFormat = is24HourFormat, + habit = habit, + instances = habitInstances, + onClick = {}, + onAnalyticsClicked = { + navController.navigate( + NavRoutes.HabitDetails.withArgs( + habit.workspaceId, + habit.id + ) + ) + } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.searchedEvent( + navController: NavController, + radius: Int, + is24HourFormat: Boolean, + pendingTasks: List, + completedTasks: List, + selectedDate: Long, + selectedMonth: YearMonth, + isMonthlyView: Boolean, + monthlyEventCount: Map, + onEvent: (TaskEvents) -> Unit +) { + if (isMonthlyView) { + item { + MonthlyViewCalendar( + selectedMonth, selectedDate, monthlyEventCount, + onMonthChange = { onEvent(TaskEvents.ChangeMonth(it)) }, + onDateChange = { onEvent(TaskEvents.ChangeDate(it)) } + ) + } + } else { + item { + DailyViewCalendar(selectedMonth, selectedDate){ + onEvent(TaskEvents.ChangeDate(it)) + } + } + } + + if((pendingTasks + completedTasks).isEmpty()) item { EmptyEvents() } + if (pendingTasks.isNotEmpty()) { + items(pendingTasks) { task -> + EventCard( + radius = radius, + is24HourFormat = is24HourFormat, + isPending = true, + title = task.title, + repeat = task.recurrence, + startDateTime = task.startDateTime, + onChangeStatus = { onEvent(TaskEvents.ToggleStatus(true, task.id, task.workspaceId, selectedDate)) }, + onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(task.workspaceId, task.id, selectedDate)) } + ) + Spacer(Modifier.height(8.dp)) + } + } + if (completedTasks.isNotEmpty()) { + items(completedTasks) { task -> + EventCard( + radius = radius, + is24HourFormat = is24HourFormat, + isPending = false, + title = task.title, + repeat = task.recurrence, + startDateTime = task.startDateTime, + onChangeStatus = { onEvent(TaskEvents.ToggleStatus(false, task.id, task.workspaceId, selectedDate)) }, + onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(task.workspaceId, task.id, selectedDate)) } + ) + Spacer(Modifier.height(8.dp)) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.searchedProgressBoard( + radius: Int, + notStartedItems: List, + inProgressItems: List, + completedItems: List, + onSelectProgressBoardItem: (ProgressBoardModel) -> Unit +) { + if((notStartedItems + inProgressItems + completedItems).isEmpty()) item { EmptyProgressItems() } + if (notStartedItems.isNotEmpty()) { + item { + BoardContainer( + failed, + stringResource(R.string.not_started), + radius, + notStartedItems, + onSelectProgressBoardItem + ) + } + } + + if (inProgressItems.isNotEmpty()) { + item { + BoardContainer( + pending, + stringResource(R.string.in_progress), + radius, + inProgressItems, + onSelectProgressBoardItem + ) + } + } + + if (completedItems.isNotEmpty()) { + item { + BoardContainer( + completed, + stringResource(R.string.Completed), + radius, + completedItems, + onSelectProgressBoardItem + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +fun LazyListScope.searchedTodo( + navController: NavController, + radius: Int, + expandedTODOIds: Set, + todoList: List, + onExpandToggle: (String) -> Unit, + onEvent: (TodoEvents) -> Unit +) { + if(todoList.isEmpty()) item { EmptyTodoList() } + items(todoList, key = { it.id }) { todoItem -> + TodoExpandableCard( + navController = navController, + radius = radius, + item = todoItem, + workspaceId = todoItem.workspaceId, + isExpanded = todoItem.id in expandedTODOIds, + onExpandToggle = onExpandToggle, + onTodoEvents = onEvent + ) + } +} + +fun buildSearchFilterCategories(workspaces: List, spaces: List): List { + return listOf( + SearchFilterCategory( + name = "WORKSPACES", + options = workspaces.map { SearchFilterOption(it.workspaceId, it.title) }, + type = SelectionType.MULTIPLE + ), + SearchFilterCategory( + name = "SPACES", + options = spaces.map { SearchFilterOption(it.id.toString(), it.title) }, + type = SelectionType.MULTIPLE + ) + ) +} + +data class FilterState( + val selectedWorkspaceIds: Set, + val selectedSpacesIds: Set +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FilterSheet( + filterState: FilterState, + workspaces: List, + spaces: List, + sheetState: SheetState, + onDismiss: () -> Unit = {}, + onApply: (Map>) -> Unit +) { + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.4f + val categories = remember(workspaces, spaces) { + buildSearchFilterCategories(workspaces, spaces) + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { + // key resets all remembered state whenever filterState changes + key(filterState) { + var selectedCategory by remember { mutableStateOf(categories.first()) } + + val multiSelections = remember { + mutableStateMapOf>().apply { + categories.forEach { category -> + val selected = when (category.name) { + "WORKSPACES" -> filterState.selectedWorkspaceIds + "SPACES" -> filterState.selectedSpacesIds + else -> emptySet() + } + put(category.name, mutableStateListOf().apply { addAll(selected) }) + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = maxHeight) + .padding(top = 8.dp) + ) { + Row(modifier = Modifier.weight(1f)) { + // LEFT PANEL + LazyColumn( + modifier = Modifier + .width(150.dp) + .background(MaterialTheme.colorScheme.surfaceContainer) + ) { + items(categories) { category -> + CategoryRow( + label = category.name, + isSelected = category.name == selectedCategory.name, + onClick = { selectedCategory = category } + ) + } + } + + Box( + modifier = Modifier + .width(0.5.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.onSurface) + ) + + // RIGHT PANEL + LazyColumn(modifier = Modifier.weight(1f)) { + if (selectedCategory.options.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { Text("No options available") } + } + } + + items(selectedCategory.options) { option -> + when (selectedCategory.type) { + SelectionType.MULTIPLE -> { + val list = multiSelections[selectedCategory.name] ?: return@items + val isSelected = option.id in list + MultiOptionRow( + label = option.label, + isSelected = isSelected, + onClick = { + if (isSelected) list.remove(option.id) + else list.add(option.id) + } + ) + } + else -> {} + } + } + } + } + + HorizontalDivider() + + // FOOTER + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { multiSelections.values.forEach { it.clear() } } + ) { Text("Reset") } + + Button( + modifier = Modifier.weight(2f), + onClick = { + onApply(multiSelections.mapValues { it.value.toSet() }) + onDismiss() + } + ) { Text("Apply") } + } + + Spacer(Modifier.navigationBarsPadding()) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/settings/About.kt b/app/src/main/java/com/flux/ui/screens/settings/About.kt index 519af09..6f56c7f 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/About.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/About.kt @@ -26,12 +26,7 @@ import androidx.core.net.toUri import androidx.navigation.NavController import com.flux.BuildConfig import com.flux.R -import com.flux.ui.components.ActionType -import com.flux.ui.components.BasicScaffold -import com.flux.ui.components.SettingIcon -import com.flux.ui.components.SettingOption -import com.flux.ui.components.SingleSettingOption -import com.flux.ui.components.shapeManager +import com.flux.ui.common.BasicScaffold @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/flux/ui/screens/settings/Contact.kt b/app/src/main/java/com/flux/ui/screens/settings/Contact.kt index dd9577d..868dfdb 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Contact.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Contact.kt @@ -21,10 +21,7 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.navigation.NavController import com.flux.R -import com.flux.ui.components.ActionType -import com.flux.ui.components.BasicScaffold -import com.flux.ui.components.SettingOption -import com.flux.ui.components.shapeManager +import com.flux.ui.common.BasicScaffold @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/app/src/main/java/com/flux/ui/screens/settings/Customize.kt b/app/src/main/java/com/flux/ui/screens/settings/Customize.kt index 3dc9a01..0b1bda5 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Customize.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Customize.kt @@ -18,7 +18,6 @@ import androidx.compose.material.icons.filled.Colorize import androidx.compose.material.icons.filled.LightMode import androidx.compose.material.icons.rounded.FontDownload import androidx.compose.material.icons.rounded.RoundedCorner -import androidx.compose.material.icons.rounded.ViewCompact import androidx.compose.material.icons.rounded.ViewCompactAlt import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -40,13 +39,8 @@ import androidx.compose.ui.window.Dialog import androidx.navigation.NavController import com.flux.R import com.flux.navigation.NavRoutes -import com.flux.ui.components.ActionType -import com.flux.ui.components.BasicScaffold -import com.flux.ui.components.FontDialog -import com.flux.ui.components.SettingIcon -import com.flux.ui.components.SettingOption -import com.flux.ui.components.SingleSettingOption -import com.flux.ui.components.shapeManager +import com.flux.ui.common.BasicScaffold +import com.flux.ui.common.FontDialog import com.flux.ui.events.SettingEvents import com.flux.ui.state.Settings @@ -100,9 +94,7 @@ fun Customize( text = stringResource(R.string.Themes), description = stringResource(R.string.change_app_theme), leadingIcon = SettingIcon.Vector(Icons.Default.LightMode) - ) { - navController.navigate(NavRoutes.Theme.route) - } + ) { navController.navigate(NavRoutes.Theme.route) } } item { @@ -147,60 +139,15 @@ fun Customize( } item { SettingOption( - title = stringResource(R.string.Compact_Mode), - description = stringResource(R.string.Compact_Mode_Desc), + title = "Mode", + description = "Normal • Compact", icon = Icons.Rounded.ViewCompactAlt, - radius = shapeManager(radius = settings.data.cornerRadius, isLast = settings.data.workspaceGridColumns == 1), - variable = settings.data.workspaceGridColumns > 1, - actionType = ActionType.SWITCH, - switchEnabled = { - if (it) { - onSettingsEvents( - SettingEvents.UpdateSettings( - settings.data.copy( - workspaceGridColumns = 2 - ) - ) - ) - } else { - onSettingsEvents( - SettingEvents.UpdateSettings( - settings.data.copy( - workspaceGridColumns = 1 - ) - ) - ) - } - } - ) - } - item { - val isEnabled = settings.data.workspaceGridColumns > 1 - SettingOption( - title = stringResource(R.string.Extreme_Compact_Mode), - description = stringResource(R.string.Extreme_Compact_Mode_Desc), - icon = Icons.Rounded.ViewCompact, - isEnabled = isEnabled, radius = shapeManager(radius = settings.data.cornerRadius, isLast = true), - variable = settings.data.workspaceGridColumns == 3, - actionType = ActionType.SWITCH, - switchEnabled = { - if (it) { - onSettingsEvents( - SettingEvents.UpdateSettings( - settings.data.copy( - workspaceGridColumns = 3 - ) - ) - ) - } else { - onSettingsEvents( - SettingEvents.UpdateSettings( - settings.data.copy( - workspaceGridColumns = 2 - ) - ) - ) + actionType = ActionType.CUSTOM, + onCustomClick = { + navController.navigate(NavRoutes.Mode.route) { + launchSingleTop = true + restoreState = true } } ) diff --git a/app/src/main/java/com/flux/ui/screens/settings/Data.kt b/app/src/main/java/com/flux/ui/screens/settings/Data.kt index d12a02c..05f6755 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Data.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Data.kt @@ -52,10 +52,7 @@ import com.flux.other.canScheduleReminder import com.flux.other.isNotificationPermissionGranted import com.flux.other.openAppNotificationSettings import com.flux.other.requestExactAlarmPermission -import com.flux.ui.components.ActionType -import com.flux.ui.components.DeleteAlert -import com.flux.ui.components.SettingOption -import com.flux.ui.components.shapeManager +import com.flux.ui.common.DeleteAlert import com.flux.ui.events.SettingEvents import com.flux.ui.state.Settings import com.flux.ui.viewModel.BackupViewModel diff --git a/app/src/main/java/com/flux/ui/screens/settings/Editor.kt b/app/src/main/java/com/flux/ui/screens/settings/Editor.kt index 08ede0e..ead11bf 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Editor.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Editor.kt @@ -29,10 +29,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.flux.R -import com.flux.ui.components.ActionType -import com.flux.ui.components.BasicScaffold -import com.flux.ui.components.SettingOption -import com.flux.ui.components.shapeManager +import com.flux.ui.common.BasicScaffold import com.flux.ui.events.SettingEvents import com.flux.ui.state.Settings diff --git a/app/src/main/java/com/flux/ui/screens/settings/Languages.kt b/app/src/main/java/com/flux/ui/screens/settings/Languages.kt index 8d37a3e..12ba460 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Languages.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Languages.kt @@ -40,11 +40,8 @@ import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import androidx.navigation.NavController import com.flux.R -import com.flux.ui.components.CircleWrapper -import com.flux.ui.components.GeneralSearchBar -import com.flux.ui.components.MaterialText -import com.flux.ui.components.RenderRadio -import com.flux.ui.components.shapeManager +import com.flux.ui.common.EmptyLanguage +import com.flux.ui.common.GeneralSearchBar import com.flux.ui.state.Settings import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException @@ -78,21 +75,27 @@ fun Languages(navController: NavController, settings: Settings) { .padding(innerPadding) .padding(16.dp) ) { - item { - LanguageItem( - title = stringResource(R.string.System_language), - isSelected = currentLocale.isEmpty, - shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), - icon = R.drawable.translate, - description = stringResource(R.string.System_language_desc), - onRadioClicked = { AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) } - ) + if(query.isBlank()){ + item { + LanguageItem( + title = stringResource(R.string.System_language), + isSelected = currentLocale.isEmpty, + shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), + icon = R.drawable.translate, + description = stringResource(R.string.System_language_desc), + onRadioClicked = { AppCompatDelegate.setApplicationLocales(LocaleListCompat.getEmptyLocaleList()) } + ) + } } val filteredLanguages = supportedLanguages .toList() .filter { it.first.contains(query, ignoreCase = true) } + if(filteredLanguages.isEmpty()){ + item { EmptyLanguage() } + } + itemsIndexed(filteredLanguages) { index, (displayName, languageCode) -> val languageInfo = getLanguageInfo(languageCode) @@ -101,6 +104,8 @@ fun Languages(navController: NavController, settings: Settings) { isSelected = !currentLocale.isEmpty && currentLocale[0]?.language == languageCode, shape = shapeManager( radius = settings.data.cornerRadius, + isBoth = filteredLanguages.size==1, + isFirst = index == 0 && query.isNotBlank(), isLast = index == filteredLanguages.lastIndex ), icon = languageInfo.iconRes, diff --git a/app/src/main/java/com/flux/ui/screens/settings/Mode.kt b/app/src/main/java/com/flux/ui/screens/settings/Mode.kt new file mode 100644 index 0000000..6cb7b5b --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/settings/Mode.kt @@ -0,0 +1,208 @@ +package com.flux.ui.screens.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.List +import androidx.compose.material.icons.filled.ViewCompactAlt +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavController +import com.flux.ui.common.BasicScaffold +import com.flux.ui.events.SettingEvents +import com.flux.ui.state.Settings + +@Composable +fun Mode( + navController: NavController, + settings: Settings, + onSettingsEvents: (SettingEvents) -> Unit +){ + BasicScaffold( + title = "Mode", + onBackClicked = { navController.popBackStack() } + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(16.dp, 8.dp, 16.dp, 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.SpaceBetween + ) { + item { + OutlinedCard(modifier = Modifier + .height(360.dp) + .width(200.dp)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + if(settings.data.workspaceGridColumns==1){ + Card( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary) + ) {} + Card( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary) + ) {} + Card( + modifier = Modifier + .fillMaxWidth() + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary) + ) {} + } + else{ + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Card( + modifier = Modifier + .weight(1f) + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondary) + ) {} + Card( + modifier = Modifier + .weight(1f) + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiary) + ) {} + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Card( + modifier = Modifier + .weight(1f) + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondary) + ) {} + Card( + modifier = Modifier + .weight(1f) + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiary) + ) {} + } + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Card( + modifier = Modifier + .weight(1f) + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondary) + ) {} + Card( + modifier = Modifier + .weight(1f) + .height(100.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiary) + ) {} + } + } + } + } + } + + item { + Card(modifier = Modifier.fillMaxWidth()) { + Column(Modifier + .fillMaxWidth() + .padding(16.dp)) { + Text( + "Select Mode", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + fontSize = 20.sp + ) + Spacer(Modifier.height(12.dp)) + AnimatedVisibility(visible = !settings.data.dynamicTheme) { + SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = 0, + count = 2 + ), + onClick = { + onSettingsEvents( + SettingEvents.UpdateSettings( + settings.data.copy( + workspaceGridColumns = 1 + ) + ) + ) + }, + selected = settings.data.workspaceGridColumns==1, + label = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(Icons.AutoMirrored.Filled.List, null) + Text("Default Mode") + } + } + ) + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape( + index = 1, + count = 2 + ), + onClick = { + onSettingsEvents( + SettingEvents.UpdateSettings( + settings.data.copy( + workspaceGridColumns = 2 + ) + ) + ) + }, + selected = settings.data.workspaceGridColumns==2, + label = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(Icons.Default.ViewCompactAlt, null) + Text("Compact Mode") + } + } + ) + } + } + } + } + + Spacer(Modifier.height(16.dp)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/settings/Privacy.kt b/app/src/main/java/com/flux/ui/screens/settings/Privacy.kt index d97b02b..8a690ce 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Privacy.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Privacy.kt @@ -5,7 +5,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.EnhancedEncryption import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.RemoveRedEye import androidx.compose.material3.ExperimentalMaterial3Api @@ -15,10 +14,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.flux.R -import com.flux.ui.components.ActionType -import com.flux.ui.components.BasicScaffold -import com.flux.ui.components.SettingOption -import com.flux.ui.components.shapeManager +import com.flux.ui.common.BasicScaffold import com.flux.ui.events.SettingEvents import com.flux.ui.state.Settings diff --git a/app/src/main/java/com/flux/ui/components/SettingOption.kt b/app/src/main/java/com/flux/ui/screens/settings/SettingComponents.kt similarity index 62% rename from app/src/main/java/com/flux/ui/components/SettingOption.kt rename to app/src/main/java/com/flux/ui/screens/settings/SettingComponents.kt index 6e914e5..6b725e9 100644 --- a/app/src/main/java/com/flux/ui/components/SettingOption.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/SettingComponents.kt @@ -1,49 +1,120 @@ -package com.flux.ui.components +package com.flux.ui.screens.settings -import android.content.ClipboardManager -import android.content.Context import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowForwardIos import androidx.compose.material.icons.automirrored.rounded.OpenInNew import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +@Composable +fun SelectableColorPlatte( + modifier: Modifier = Modifier, + selected: Boolean, + colorScheme: ColorScheme, + onClick: () -> Unit +) = Box(modifier = modifier.clip(MaterialTheme.shapes.large)) { + Surface( + modifier = Modifier + .clickable { onClick() } + .padding(6.dp) + .size(48.dp), + shape = CircleShape, + color = colorScheme.primary, + ) { + Box { + Surface( + modifier = Modifier + .size(48.dp) + .offset((-24).dp, 24.dp), + color = colorScheme.tertiary, + ) {} + Surface( + modifier = Modifier + .size(48.dp) + .offset(24.dp, 24.dp), + color = colorScheme.secondaryContainer, + ) {} + AnimatedVisibility( + visible = selected, + modifier = Modifier + .align(Alignment.Center) + .clip(CircleShape) + .background(colorScheme.tertiaryContainer), + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Icon( + imageVector = Icons.Outlined.Check, + contentDescription = "Checked", + modifier = Modifier + .padding(8.dp) + .size(16.dp), + tint = MaterialTheme.colorScheme.onTertiary + ) + } + } + } +} + enum class ActionType { RADIOBUTTON, SWITCH, @@ -345,3 +416,170 @@ fun RenderCustomIcon() { ) } +@Composable +fun SettingCategory( + title: String, + subTitle: String = "", + icon: ImageVector, + shape: RoundedCornerShape, + isLast: Boolean = false, + action: () -> Unit = {}, + composableAction: @Composable (() -> Unit) -> Unit = {}, +) { + var showCustomAction by remember { mutableStateOf(false) } + if (showCustomAction) composableAction { showCustomAction = !showCustomAction } + + ElevatedCard( + shape = shape, + modifier = Modifier + .clip(shape) + .clickable { + showCustomAction = showCustomAction.not() + action() + }, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp) + ) + ) { + Row( + modifier = Modifier + .clip(shape) + .fillMaxSize() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RenderCategoryIcon(icon = icon) + Spacer(modifier = Modifier.width(8.dp)) + Column { + RenderCategoryTitle(title = title) + RenderCategoryDescription(subTitle = subTitle) + } + } + } + Spacer(modifier = Modifier.height(if (isLast) 12.dp else 2.dp)) +} + +fun shapeManager( + isBoth: Boolean = false, + isLast: Boolean = false, + isFirst: Boolean = false, + radius: Int +): RoundedCornerShape { + val smallerRadius: Dp = (radius / 5).dp + val defaultRadius: Dp = radius.dp + + return when { + isBoth -> RoundedCornerShape(defaultRadius) + isLast -> RoundedCornerShape(smallerRadius, smallerRadius, defaultRadius, defaultRadius) + isFirst -> RoundedCornerShape(defaultRadius, defaultRadius, smallerRadius, smallerRadius) + else -> RoundedCornerShape(smallerRadius) + } +} + +@Composable +fun CircleWrapper( + color: Color = MaterialTheme.colorScheme.background, + size: Dp = 8.dp, + content: @Composable () -> Unit +) { + Box( + modifier = Modifier + .background( + color = color, + shape = RoundedCornerShape(50) + ) + .padding(size), + ) { + content() + } +} + +@Composable +fun MaterialText( + modifier: Modifier = Modifier, + title: String, + description: String? = null, + titleSize: TextUnit = 14.sp, + descriptionSize: TextUnit = 11.sp, + center: Boolean = false, + titleColor: Color = MaterialTheme.colorScheme.onSurface, + descriptionColor: Color = MaterialTheme.colorScheme.primary +) { + Column( + modifier = modifier, + horizontalAlignment = if (center) Alignment.CenterHorizontally else Alignment.Start, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium.copy(fontSize = titleSize), + color = titleColor, + textAlign = if (center) TextAlign.Center else TextAlign.Start + ) + if (description != null) { + Text( + text = description, + style = MaterialTheme.typography.bodySmall.copy(fontSize = descriptionSize), + color = descriptionColor, + maxLines = 6, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun RenderRadio( + enabled: Boolean, + onRadioEnabled: () -> Unit +) { + RadioButton( + selected = enabled, + onClick = { + onRadioEnabled() + }, + modifier = Modifier + .scale(0.9f) + .padding(0.dp) + ) +} + +@Composable +fun RenderCategoryTitle(title: String) { + Text( + text = title, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) +} + +@Composable +fun RenderCategoryDescription(subTitle: String) { + if (subTitle.isNotBlank()) { + Text( + color = MaterialTheme.colorScheme.primary, + text = subTitle, + fontSize = 10.sp + ) + } +} + +@Composable +fun RenderCategoryIcon(icon: ImageVector) { + Box( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(50) + ), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.surfaceContainerHigh, + modifier = Modifier + .scale(1f) + .padding(9.dp) + ) + } +} diff --git a/app/src/main/java/com/flux/ui/screens/settings/Settings.kt b/app/src/main/java/com/flux/ui/screens/settings/Settings.kt index a6a86b5..27d3c74 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Settings.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Settings.kt @@ -1,7 +1,10 @@ package com.flux.ui.screens.settings import android.content.Intent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn @@ -14,8 +17,14 @@ import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.PrivacyTip +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -24,11 +33,7 @@ import androidx.core.net.toUri import androidx.navigation.NavController import com.flux.R import com.flux.navigation.NavRoutes -import com.flux.ui.components.BasicScaffold -import com.flux.ui.components.SettingCategory -import com.flux.ui.components.SettingIcon -import com.flux.ui.components.SingleSettingOption -import com.flux.ui.components.shapeManager +import com.flux.ui.common.BottomBar import com.flux.ui.state.Settings @OptIn(ExperimentalMaterial3Api::class) @@ -38,127 +43,145 @@ fun Settings( settings: Settings, ) { val context = LocalContext.current - BasicScaffold( - title = stringResource(R.string.Settings), - onBackClicked = { navController.popBackStack() } - ) { innerPadding -> - LazyColumn( - modifier = Modifier - .padding(innerPadding) - .padding(16.dp, 8.dp, 16.dp) - ) { - item { - SettingCategory( - title = stringResource(R.string.Customize), - subTitle = stringResource(R.string.Customize_desc), - icon = Icons.Rounded.Palette, - shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), - action = { - navController.navigate(NavRoutes.Customize.route) { - launchSingleTop = true - restoreState = true - } - }) - } - item { - SettingCategory( - title = stringResource(R.string.editor_title), - subTitle = stringResource(R.string.editor_subtitle), - icon = Icons.Rounded.EditNote, - shape = shapeManager(radius = settings.data.cornerRadius), - action = { - navController.navigate(NavRoutes.Editor.route) { - launchSingleTop = true - restoreState = true - } - }) - } + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + CenterAlignedTopAppBar( + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + title = { Text(stringResource(R.string.Settings)) }, + ) + }, + ){ innerPadding -> + Box(modifier = Modifier.fillMaxSize().padding(innerPadding)) { + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 12.dp), + contentPadding = PaddingValues( + top = 16.dp, + bottom = 64.dp + ), + ) { + item { + SettingCategory( + title = stringResource(R.string.Customize), + subTitle = stringResource(R.string.Customize_desc), + icon = Icons.Rounded.Palette, + shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), + action = { + navController.navigate(NavRoutes.Customize.route) { + launchSingleTop = true + restoreState = true + } + }) + } - item { - SettingCategory( - title = stringResource(R.string.Languages), - subTitle = stringResource(R.string.Languages_desc), - icon = Icons.Rounded.Language, - isLast = true, - shape = shapeManager(radius = settings.data.cornerRadius, isLast = true), - action = { - navController.navigate(NavRoutes.Languages.route) { - launchSingleTop = true - restoreState = true - } - } - ) - } + item { + SettingCategory( + title = stringResource(R.string.editor_title), + subTitle = stringResource(R.string.editor_subtitle), + icon = Icons.Rounded.EditNote, + shape = shapeManager(radius = settings.data.cornerRadius), + action = { + navController.navigate(NavRoutes.Editor.route) { + launchSingleTop = true + restoreState = true + } + }) + } - item { - SettingCategory( - title = stringResource(R.string.Privacy), - subTitle = stringResource(R.string.Privacy_desc), - icon = Icons.Rounded.PrivacyTip, - shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), - action = { - navController.navigate(NavRoutes.Privacy.route) { - launchSingleTop = true - restoreState = true + item { + SettingCategory( + title = stringResource(R.string.Languages), + subTitle = stringResource(R.string.Languages_desc), + icon = Icons.Rounded.Language, + isLast = true, + shape = shapeManager(radius = settings.data.cornerRadius, isLast = true), + action = { + navController.navigate(NavRoutes.Languages.route) { + launchSingleTop = true + restoreState = true + } } - }) - } + ) + } - item { - SettingCategory( - title = stringResource(R.string.data_title), - subTitle = stringResource(R.string.data_subtitle), - icon = Icons.Rounded.Backup, - shape = shapeManager(radius = settings.data.cornerRadius, isLast = true), - action = { - navController.navigate(NavRoutes.Backup.route) - } - ) - } + item { + SettingCategory( + title = stringResource(R.string.Privacy), + subTitle = stringResource(R.string.Privacy_desc), + icon = Icons.Rounded.PrivacyTip, + shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), + action = { + navController.navigate(NavRoutes.Privacy.route) { + launchSingleTop = true + restoreState = true + } + }) + } - item { - Spacer(Modifier.height(12.dp)) - SettingCategory( - title = stringResource(R.string.About), - subTitle = stringResource(R.string.About_desc), - icon = Icons.Rounded.Info, - shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), - action = { - navController.navigate(NavRoutes.About.route) { - launchSingleTop = true - restoreState = true + item { + SettingCategory( + title = stringResource(R.string.data_title), + subTitle = stringResource(R.string.data_subtitle), + icon = Icons.Rounded.Backup, + shape = shapeManager(radius = settings.data.cornerRadius, isLast = true), + action = { + navController.navigate(NavRoutes.Backup.route) } - }) - } + ) + } - item { - SettingCategory( - title = stringResource(R.string.Contact), - subTitle = stringResource(R.string.Contact_desc), - icon = Icons.AutoMirrored.Rounded.ContactSupport, - shape = shapeManager(radius = settings.data.cornerRadius, isLast = true), - action = { - navController.navigate(NavRoutes.Contact.route) { - launchSingleTop = true - restoreState = true - } - }) - } + item { + Spacer(Modifier.height(12.dp)) + SettingCategory( + title = stringResource(R.string.About), + subTitle = stringResource(R.string.About_desc), + icon = Icons.Rounded.Info, + shape = shapeManager(radius = settings.data.cornerRadius, isFirst = true), + action = { + navController.navigate(NavRoutes.About.route) { + launchSingleTop = true + restoreState = true + } + }) + } - item { - Spacer(Modifier.height(12.dp)) - SingleSettingOption( - radius = settings.data.cornerRadius, - text = stringResource(R.string.Support), - description = stringResource(R.string.Support_desc), - trailingIcon = SettingIcon.Vector(Icons.Default.Coffee), - last = true - ) { - val intent = Intent(Intent.ACTION_VIEW, "https://coff.ee/chindaronit".toUri()) - context.startActivity(intent) + item { + SettingCategory( + title = stringResource(R.string.Contact), + subTitle = stringResource(R.string.Contact_desc), + icon = Icons.AutoMirrored.Rounded.ContactSupport, + shape = shapeManager(radius = settings.data.cornerRadius, isLast = true), + action = { + navController.navigate(NavRoutes.Contact.route) { + launchSingleTop = true + restoreState = true + } + }) + } + + item { + Spacer(Modifier.height(12.dp)) + SingleSettingOption( + radius = settings.data.cornerRadius, + text = stringResource(R.string.Support), + description = stringResource(R.string.Support_desc), + trailingIcon = SettingIcon.Vector(Icons.Default.Coffee), + last = true + ) { + val intent = + Intent(Intent.ACTION_VIEW, "https://coff.ee/chindaronit".toUri()) + context.startActivity(intent) + } } } + + BottomBar( + navController = navController, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt b/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt index 0f6d674..037b459 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/StorageSelection.kt @@ -39,7 +39,6 @@ import androidx.navigation.NavController import com.flux.R import com.flux.navigation.NavRoutes import com.flux.other.ensureStorageRoot -import com.flux.ui.components.CircleWrapper import com.flux.ui.viewModel.SettingsViewModel @Composable diff --git a/app/src/main/java/com/flux/ui/screens/settings/Themes.kt b/app/src/main/java/com/flux/ui/screens/settings/Themes.kt index 49beed5..6b3c3d0 100644 --- a/app/src/main/java/com/flux/ui/screens/settings/Themes.kt +++ b/app/src/main/java/com/flux/ui/screens/settings/Themes.kt @@ -43,8 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.flux.R -import com.flux.ui.components.BasicScaffold -import com.flux.ui.components.SelectableColorPlatte +import com.flux.ui.common.BasicScaffold import com.flux.ui.events.SettingEvents import com.flux.ui.state.Settings import com.flux.ui.theme.lightSchemes @@ -203,15 +202,15 @@ fun Themes( item { IconButton({ - onSettingsEvents( - SettingEvents.UpdateSettings( - settings.data.copy( - isAutomaticTheme = false, - isDarkMode = true, - amoledTheme = false + onSettingsEvents( + SettingEvents.UpdateSettings( + settings.data.copy( + isAutomaticTheme = false, + isDarkMode = true, + amoledTheme = false + ) ) ) - ) }, colors = IconButtonDefaults.iconButtonColors( containerColor = Color.Black.copy(0.8f), diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoHome.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt similarity index 78% rename from app/src/main/java/com/flux/ui/screens/todo/TodoHome.kt rename to app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt index 7e31041..4718f4c 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoHome.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoComponents.kt @@ -7,13 +7,10 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Checklist import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.outlined.Circle @@ -33,53 +30,20 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import com.flux.R import com.flux.data.model.TodoItem import com.flux.data.model.TodoModel -import com.flux.navigation.Loader import com.flux.navigation.NavRoutes -import com.flux.ui.components.DeleteAlert -import com.flux.ui.components.shapeManager +import com.flux.ui.common.DeleteAlert import com.flux.ui.events.TodoEvents - -@OptIn(ExperimentalMaterial3Api::class) -fun LazyListScope.todoHomeItems( - navController: NavController, - radius: Int, - allList: List, - workspaceId: String, - isLoading: Boolean, - expandedTODOIds: Set, - onTodoEvents: (TodoEvents) -> Unit, - onExpandToggle: (String) -> Unit -) { - when { - isLoading -> item { Loader() } - allList.isEmpty() -> item { EmptyTodoList() } - else -> { - items(allList, key = {it.id}) { todoItem-> - TodoExpandableCard( - navController = navController, - radius = radius, - item = todoItem, - workspaceId = workspaceId, - isExpanded = todoItem.id in expandedTODOIds, - onExpandToggle = onExpandToggle, - onTodoEvents = onTodoEvents - ) - } - } - } -} +import com.flux.ui.screens.settings.shapeManager @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TodoExpandableCard( +fun TodoExpandableCard( navController: NavController, radius: Int, item: TodoModel, @@ -185,6 +149,7 @@ private fun TodoItems( val checkedItems = todoList.items.filter { it.isChecked } val unCheckedItems = todoList.items.filter { !it.isChecked } val allSortedItems = unCheckedItems + checkedItems + LazyColumn( Modifier.padding(horizontal = 6.dp).padding(top = 4.dp, bottom = 12.dp).heightIn(max = 400.dp), verticalArrangement = Arrangement.spacedBy(2.dp) @@ -214,22 +179,4 @@ private fun TodoItems( } } } -} - -@Composable -fun EmptyTodoList() { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.Checklist, - contentDescription = null, - modifier = Modifier.size(48.dp) - ) - Text(stringResource(R.string.Empty_Lists)) - } -} +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt index e8764c6..ef32bd1 100644 --- a/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoDetail.kt @@ -44,7 +44,7 @@ import androidx.navigation.NavController import com.flux.R import com.flux.data.model.TodoItem import com.flux.data.model.TodoModel -import com.flux.ui.components.DeleteAlert +import com.flux.ui.common.DeleteAlert import com.flux.ui.events.TodoEvents @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt b/app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt new file mode 100644 index 0000000..6dc6513 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/todo/TodoScreen.kt @@ -0,0 +1,174 @@ +package com.flux.ui.screens.todo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddTask +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.TaskAlt +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.R +import com.flux.data.model.WorkspaceModel +import com.flux.navigation.Loader +import com.flux.navigation.NavRoutes +import com.flux.ui.common.EmptyTodoList +import com.flux.ui.common.SpaceSearchBar +import com.flux.ui.common.SpaceTopBar +import com.flux.ui.common.SpacesMenu +import com.flux.ui.events.TodoEvents +import com.flux.ui.screens.workspaces.SpacesToolBar +import com.flux.ui.state.Settings +import com.flux.ui.state.TodoState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TodoScreen( + navController: NavController, + state: TodoState, + settings: Settings, + workspace: WorkspaceModel, + onShowSpaceBottomSheet: () -> Unit, + onSpaceChange: (Int) -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, + onEvent: (TodoEvents) -> Unit +){ + val workspaceId = workspace.workspaceId + val isLoading = state.isLoading + val radius = settings.data.cornerRadius + var showSpacesMenu by remember { mutableStateOf(false) } + var query by remember { mutableStateOf("") } + val allList = state.allLists.filter { + it.workspaceId == workspaceId && ( + it.title.contains(query, ignoreCase = true) || + it.items.any { item -> item.value.contains(query, ignoreCase = true) } + ) + } + var showSearchBar by remember { mutableStateOf(false) } + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + val expandedTODOIds = rememberSaveable(workspaceId) { mutableStateOf>(emptySet()) } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + }, + floatingActionButton = { + FloatingActionButton({ navController.navigate(NavRoutes.TodoDetail.withArgs(workspaceId, "")) }) { + Icon(Icons.Default.AddTask, null) + } + } + ) { innerPadding -> + when { + isLoading -> Loader() + else -> { + LazyColumn( + Modifier.fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + item { + Row( + Modifier.fillMaxWidth().padding(bottom=8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if(showSearchBar){ + SpaceSearchBar( + query, + { query=it }, + { + showSearchBar=false + expandedTODOIds.value=emptySet() + } + ) + } + else { + SpacesToolBar( + stringResource(R.string.To_Do), + Icons.Default.TaskAlt, + false, + onMainClick = { showSpacesMenu = true }, + onEditClick = onShowSpaceBottomSheet + ) + SpacesMenu( + showSpacesMenu, + workspace, + onSpaceChange + ) { showSpacesMenu = false } + + IconButton({ + expandedTODOIds.value = emptySet() + expandedTODOIds.value = allList.map { it.id }.toSet() + showSearchBar = true + }) { + Icon( + Icons.Default.Search, + null, + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } + + if(allList.isEmpty()) item { EmptyTodoList() } + items(allList, key = { it.id }) { todoItem -> + TodoExpandableCard( + navController = navController, + radius = radius, + item = todoItem, + workspaceId = workspaceId, + isExpanded = todoItem.id in expandedTODOIds.value, + onExpandToggle = { id-> + expandedTODOIds.value = + if (id in expandedTODOIds.value) expandedTODOIds.value - id + else expandedTODOIds.value + id + }, + onTodoEvents = onEvent + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/EmptyWorkspace.kt b/app/src/main/java/com/flux/ui/screens/workspaces/EmptyWorkspace.kt new file mode 100644 index 0000000..2faaeb2 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/workspaces/EmptyWorkspace.kt @@ -0,0 +1,88 @@ +package com.flux.ui.screens.workspaces + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.R +import com.flux.data.model.WorkspaceModel +import com.flux.navigation.NavRoutes +import com.flux.ui.common.SpaceTopBar + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmptyWorkspace( + navController: NavController, + workspace: WorkspaceModel, + onShowSpaceBottomSheet: () -> Unit, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, +){ + val workspaceId = workspace.workspaceId + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + SpaceTopBar( + scrollBehavior = scrollBehavior, + title = workspace.title, + description = workspace.description, + cover = workspace.cover, + icon = workspace.icon, + isLocked = workspace.passKey!=null, + onBackPressed = { navController.popBackStack() }, + onAddCover = onAddCover, + onRemoveCover = onRemoveCover, + onToggleLock = onToggleLock, + onDeleteWorkspace = onDeleteWorkspace, + onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) } + ) + } + ) { innerPadding -> + LazyColumn( + Modifier.fillMaxSize() + .padding(innerPadding) + .padding(12.dp) + .nestedScroll(scrollBehavior.nestedScrollConnection) + ) { + item { + Row( + Modifier.fillMaxWidth().padding(bottom=8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Button(onShowSpaceBottomSheet){ + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Icon(Icons.Default.Add, null) + Text(stringResource(R.string.Add_Space)) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/ExtremeCompactMode.kt b/app/src/main/java/com/flux/ui/screens/workspaces/ExtremeCompactMode.kt deleted file mode 100644 index c449ef6..0000000 --- a/app/src/main/java/com/flux/ui/screens/workspaces/ExtremeCompactMode.kt +++ /dev/null @@ -1,497 +0,0 @@ -package com.flux.ui.screens.workspaces - -import android.os.Build -import android.widget.Toast -import androidx.annotation.RequiresApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Lock -import androidx.compose.material.icons.filled.MoreVert -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.key -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.R -import com.flux.data.model.WorkspaceModel -import com.flux.data.model.getSpacesList -import com.flux.navigation.NavRoutes -import com.flux.ui.components.AddNewSpacesBottomSheet -import com.flux.ui.components.ChangeIconBottomSheet -import com.flux.ui.components.DeleteAlert -import com.flux.ui.components.NewWorkspaceBottomSheet -import com.flux.ui.components.SetPasskeyDialog -import com.flux.ui.events.HabitEvents -import com.flux.ui.events.JournalEvents -import com.flux.ui.events.NotesEvents -import com.flux.ui.events.TaskEvents -import com.flux.ui.events.TodoEvents -import com.flux.ui.events.WorkspaceEvents -import kotlinx.coroutines.launch -import com.flux.data.model.ProgressBoardModel -import com.flux.other.icons -import com.flux.ui.components.ChangeWorkspaceDialog -import com.flux.ui.components.EmptySpaces -import com.flux.ui.components.ExtremeCompactModeDropDown -import com.flux.ui.components.NewBoardItemSheet -import com.flux.ui.events.ProgressBoardEvents -import com.flux.ui.state.States -import com.flux.ui.viewModel.ViewModels - -@Composable -fun CompactWorkspaceCard(icon: Int, isLocked: Boolean, title: String, onClick: ()->Unit){ - Card( - modifier = Modifier.clip(RoundedCornerShape(50)), - shape = RoundedCornerShape(50), - onClick = onClick, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp), - contentColor = MaterialTheme.colorScheme.primary - ) - ) { - Row(Modifier.padding(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) { - if(!isLocked){ - IconButton( - onClick = onClick, - modifier = Modifier.size(32.dp), - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { Icon(icons[icon], null, Modifier.size(18.dp)) } - } - else{ - Row(Modifier.padding(end = 8.dp)) { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) - .clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) - .padding(7.dp) - ) { Icon(icons[icon], null, Modifier.size(17.dp), tint = MaterialTheme.colorScheme.onPrimary) } - Spacer(Modifier.width(1.dp)) - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.primary, RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) - .clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) - .padding(7.dp) - ) { Icon(Icons.Default.Lock, null, Modifier.size(17.dp), tint = MaterialTheme.colorScheme.onPrimary) } - } - } - - Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier - .padding(end = 8.dp) - .widthIn(max = 150.dp), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)) - } - } -} - -@Composable -fun CompactAddNewWorkspaceCard(onClick: ()->Unit){ - Card( - modifier = Modifier.clip(RoundedCornerShape(50)), - shape = RoundedCornerShape(50), - onClick = onClick, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) { - Row(Modifier.padding(4.dp), horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { - IconButton( - onClick = onClick, - modifier = Modifier.size(28.dp), - colors = IconButtonDefaults.iconButtonColors( - contentColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - ) { Icon(Icons.Default.Add, null, Modifier.size(18.dp)) } - Text(stringResource(R.string.add), modifier = Modifier.padding(end = 12.dp), style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)) - } - } -} - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ExtremeCompactMode( - navController: NavController, - states: States, - viewModels: ViewModels -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - var isAddNewWorkspace by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - - when { - states.workspaceState.currentWorkspace == null -> { - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - title = { CompactAddNewWorkspaceCard { isAddNewWorkspace = true } }, - navigationIcon = {}, - actions = { - IconButton( - onClick = {navController.navigate(NavRoutes.Settings.route)}, - colors = IconButtonDefaults.iconButtonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - ) { Icon(Icons.Default.Settings, null) } - } - ) - } - ) { innerPadding -> - Box(Modifier.padding(innerPadding)){ - EmptySpaces() - } - } - } - else -> { - val wrongPassKeyLabel = stringResource(R.string.Wrong_Passkey) - val allWorkspaces = states.workspaceState.allWorkspaces - val currentWorkspace = states.workspaceState.currentWorkspace - val workspaceId = currentWorkspace.workspaceId - var lockedWorkspace by remember { mutableStateOf(null) } - var showChangeWorkspaceBottomSheet by remember { mutableStateOf(false) } - val isCompactMode = true - val allSpaces = getSpacesList() - val selectedNotes = states.notesState.selectedNotes - val allNotes = states.notesState.allNotes - var notesQuery by remember { mutableStateOf("")} - val selectedSpaceId = rememberSaveable { mutableIntStateOf(if (currentWorkspace.selectedSpaces.isEmpty()) -1 else currentWorkspace.selectedSpaces.first()) } - val currentSpace = allSpaces.find { it.id == selectedSpaceId.intValue } - var isMoreExpanded by remember { mutableStateOf(false) } - var editWorkspaceDialog by remember { mutableStateOf(false) } - var editIconSheet by remember { mutableStateOf(false) } - var showDeleteWorkspaceDialog by remember { mutableStateOf(false) } - var showLockDialog by remember { mutableStateOf(false) } - var addSpaceBottomSheet by remember { mutableStateOf(false) } - var addProgressItem by remember { mutableStateOf(false) } - var selectedProgressBoardItem by remember { mutableStateOf(ProgressBoardModel(workspaceId = workspaceId)) } - var showDeleteDialog by remember { mutableStateOf(false) } - val expandedTODOIds = rememberSaveable(workspaceId) { mutableStateOf>(emptySet()) } - - lockedWorkspace?.let { - SetPasskeyDialog(onConfirmRequest = { passkey -> - if (it.passKey == passkey) { - viewModels.workspaceViewModel.onEvent(WorkspaceEvents.ChangeWorkspace(it)) - } else { - Toast.makeText(context, wrongPassKeyLabel, Toast.LENGTH_SHORT).show() - } - }) { lockedWorkspace = null } - } - - LaunchedEffect(workspaceId) { - viewModels.notesViewModel.onEvent(NotesEvents.EnterWorkspace(workspaceId)) - viewModels.journalViewModel.onEvent(JournalEvents.EnterWorkspace(workspaceId)) - viewModels.todoViewModel.onEvent(TodoEvents.EnterWorkspace(workspaceId)) - viewModels.eventViewModel.onEvent(TaskEvents.EnterWorkspace(workspaceId)) - viewModels.habitViewModel.onEvent(HabitEvents.EnterWorkspace(workspaceId)) - viewModels.progressBoardViewModel.onEvent(ProgressBoardEvents.EnterWorkspace(workspaceId)) - } - - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - topBar = { - TopAppBar( - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), - title = { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - CompactWorkspaceCard(currentWorkspace.icon, currentWorkspace.passKey.isNotBlank(), currentWorkspace.title) { showChangeWorkspaceBottomSheet = true } - CompactAddNewWorkspaceCard { isAddNewWorkspace = true } - } }, - navigationIcon = {}, - actions = { - Row(Modifier.padding(end = 8.dp)) { - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) - .clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)) - .clickable { isMoreExpanded = true } - .padding(8.dp) - ) { Icon(Icons.Default.MoreVert, null, Modifier.size(22.dp), tint = MaterialTheme.colorScheme.primary) } - ExtremeCompactModeDropDown ( - isMoreExpanded, - isLocked = currentWorkspace.passKey.isNotBlank(), - showEditLabel = currentWorkspace.selectedSpaces.contains(1), - onDelete = { showDeleteWorkspaceDialog = true }, - onEditDetails = { editWorkspaceDialog = true }, - onEditLabel = { navController.navigate(NavRoutes.EditLabels.withArgs(workspaceId)) }, - onToggleLock = { - if (currentWorkspace.passKey.isNotBlank()) { - viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(currentWorkspace.copy(passKey = ""))) - } else showLockDialog = true - } - ){ isMoreExpanded = false} - Spacer(Modifier.width(1.dp)) - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) - .clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)) - .clickable { navController.navigate(NavRoutes.Settings.route) } - .padding(8.dp) - ) { Icon(Icons.Default.Settings, null, Modifier.size(22.dp), tint = MaterialTheme.colorScheme.primary) } - } - } - ) - } - ) { innerPadding -> - LazyColumn(Modifier - .padding(innerPadding) - .padding(horizontal = 12.dp) - .padding(bottom = 8.dp)) { - spacesToolbarList( - navController, - isCompactMode, - selectedSpaceId.intValue, - workspaceId, - currentWorkspace, - states, - notesQuery, - { notesQuery = it }, - viewModels.settingsViewModel::onEvent, - viewModels.notesViewModel::onEvent, - { selectedSpaceId.intValue = it }, - { addSpaceBottomSheet = true }, - { - addProgressItem = true - selectedProgressBoardItem = - ProgressBoardModel(workspaceId = workspaceId) - } - ) - spaceDetailItems( - navController, - workspaceId, - currentWorkspace.selectedSpaces, - states, - notesQuery, - states.settings.data.cornerRadius, - currentSpace, - viewModels.habitViewModel::onEvent, - viewModels.notesViewModel::onEvent, - viewModels.journalViewModel::onEvent, - viewModels.eventViewModel::onEvent, - viewModels.todoViewModel::onEvent, - expandedTODOIds.value, - { id -> - expandedTODOIds.value = - if (id in expandedTODOIds.value) expandedTODOIds.value - id else expandedTODOIds.value + id - }, - { - addProgressItem = true - selectedProgressBoardItem = it - } - ) - } - } - - if (showLockDialog) { - SetPasskeyDialog( - { - viewModels.workspaceViewModel.onEvent( - WorkspaceEvents.UpsertSpace( - currentWorkspace.copy( - passKey = it - ) - ) - ) - }) - { showLockDialog = false } - } - - if (showDeleteWorkspaceDialog) { - DeleteAlert(onConfirmation = { - showDeleteWorkspaceDialog = false - viewModels.workspaceViewModel.onEvent(WorkspaceEvents.DeleteSpace(currentWorkspace)) - viewModels.notesViewModel.onEvent(NotesEvents.DeleteAllWorkspaceNotes(workspaceId)) - viewModels.todoViewModel.onEvent(TodoEvents.DeleteAllWorkspaceLists(workspaceId)) - viewModels.eventViewModel.onEvent(TaskEvents.DeleteAllWorkspaceEvents(workspaceId, context)) - viewModels.habitViewModel.onEvent(HabitEvents.DeleteAllWorkspaceHabits(workspaceId, context)) - viewModels.journalViewModel.onEvent(JournalEvents.DeleteWorkspaceEntries(workspaceId)) - viewModels.progressBoardViewModel.onEvent(ProgressBoardEvents.DeleteBoardItemsByWorkspace(workspaceId)) - }, onDismissRequest = { - showDeleteWorkspaceDialog = false - }) - } - - AddNewSpacesBottomSheet( - isVisible = addSpaceBottomSheet, - sheetState = sheetState, - selectedSpaces = allSpaces.filter { currentWorkspace.selectedSpaces.contains(it.id) }, - onDismiss = { addSpaceBottomSheet = false }, - onRemove = { spaceId -> - val newSelected = currentWorkspace.selectedSpaces.firstOrNull { it != spaceId } ?: -1 - selectedSpaceId.intValue = newSelected - - viewModels.workspaceViewModel.onEvent( - WorkspaceEvents.UpsertSpace( - currentWorkspace.copy( - selectedSpaces = currentWorkspace.selectedSpaces.minus( - spaceId - ) - ) - ) - ) - - removeSpaceData( - workspaceId, - spaceId, - context, - viewModels.eventViewModel::onEvent, - viewModels.todoViewModel::onEvent, - viewModels.habitViewModel::onEvent, - viewModels.notesViewModel::onEvent, - viewModels.journalViewModel::onEvent, - viewModels.progressBoardViewModel::onEvent - ) - }, - onSelect = { - if (selectedSpaceId.intValue == -1) selectedSpaceId.intValue = it - viewModels.workspaceViewModel.onEvent( - WorkspaceEvents.UpsertSpace( - currentWorkspace.copy( - selectedSpaces = currentWorkspace.selectedSpaces.plus( - it - ) - ) - ) - ) - } - ) - - if(showChangeWorkspaceBottomSheet){ - ChangeWorkspaceDialog( - currentWorkspace, - allWorkspaces, - { showChangeWorkspaceBottomSheet=false }, - { - if(it.passKey.isNotBlank()){ - lockedWorkspace = it - return@ChangeWorkspaceDialog - } - else { viewModels.workspaceViewModel.onEvent(WorkspaceEvents.ChangeWorkspace(it)) } - }, - ) - } - - // Edit Workspace BottomSheet - key(workspaceId) { - NewWorkspaceBottomSheet( - isEditing = true, - workspace = currentWorkspace, - isVisible = editWorkspaceDialog, - sheetState = sheetState, - onDismiss = { - scope.launch { sheetState.hide() } - .invokeOnCompletion { editWorkspaceDialog = false } - }, - onConfirm = { - viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(it)) - scope.launch { sheetState.hide() }.invokeOnCompletion { - editWorkspaceDialog = false - } - } - ) - } - - // Edit Workspace Sheet - ChangeIconBottomSheet( - isVisible = editIconSheet, - sheetState = sheetState, - onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { editIconSheet = false } - }, - onConfirm = { index -> - scope.launch { sheetState.hide() }.invokeOnCompletion { - viewModels.workspaceViewModel.onEvent( - WorkspaceEvents.UpsertSpace( - currentWorkspace.copy( - icon = index - ) - ) - ) - editIconSheet = false - } - } - ) - - NewBoardItemSheet( - addProgressItem, sheetState, selectedProgressBoardItem, - { addProgressItem = false }, { - addProgressItem = false - viewModels.progressBoardViewModel.onEvent( - ProgressBoardEvents.UpsertProgressItem( - it - ) - ) - selectedProgressBoardItem = ProgressBoardModel(workspaceId = workspaceId) - }) { - viewModels.progressBoardViewModel.onEvent(ProgressBoardEvents.DeleteProgressItem(it)) - } - - if (showDeleteDialog) { - DeleteAlert(onConfirmation = { - viewModels.notesViewModel.onEvent(NotesEvents.DeleteNotes(allNotes.filter { - selectedNotes.contains( - it.notesId - ) - })) - viewModels.notesViewModel.onEvent(NotesEvents.ClearSelection) - showDeleteDialog = false - }, onDismissRequest = { - showDeleteDialog = false - }) - } - } - } - - NewWorkspaceBottomSheet(isVisible = isAddNewWorkspace, sheetState = sheetState, onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) { - isAddNewWorkspace = false - } - } - }, onConfirm = { viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(it)) }) -} diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/NewWorkspaceScreen.kt b/app/src/main/java/com/flux/ui/screens/workspaces/NewWorkspaceScreen.kt new file mode 100644 index 0000000..6df87f8 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/workspaces/NewWorkspaceScreen.kt @@ -0,0 +1,200 @@ +package com.flux.ui.screens.workspaces + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.R +import com.flux.data.model.WorkspaceModel +import com.flux.data.model.getSpacesList +import com.flux.other.icons +import com.flux.ui.common.ChangeIconSheet +import com.flux.ui.common.CompactCard +import com.flux.ui.common.EditorScaffold +import com.flux.ui.events.WorkspaceEvents +import com.flux.ui.screens.events.getTextFieldColors +import kotlinx.coroutines.launch +import kotlin.collections.forEach + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewWorkspaceScreen( + navController: NavController, + workspace: WorkspaceModel = WorkspaceModel(), + onEvent: (WorkspaceEvents) -> Unit, +){ + val allSpaces = getSpacesList() + val isNew = workspace.title.isBlank() + var title by remember { mutableStateOf(workspace.title) } + var description by remember { mutableStateOf(workspace.description) } + var icon by remember { mutableIntStateOf(workspace.icon) } + var passkey by remember { mutableStateOf(workspace.passKey) } + val focusRequesterDesc = remember { FocusRequester() } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + val scope = rememberCoroutineScope() + var isSheetVisible by remember { mutableStateOf(false) } + var isDialogVisible by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val selectedSpacesId = remember { + mutableStateListOf().apply{ addAll(workspace.selectedSpaces) } + } + var selectedSpaces = allSpaces.filter { selectedSpacesId.contains(it.id) } + var otherSpaces = allSpaces.filter { !selectedSpacesId.contains(it.id) } + + LaunchedEffect(selectedSpacesId) { + selectedSpaces = allSpaces.filter { selectedSpacesId.contains(it.id) } + otherSpaces = allSpaces.filter { !selectedSpacesId.contains(it.id) } + } + + EditorScaffold( + title = if(isNew) stringResource(R.string.Add_Workspace) else stringResource(R.string.Edit_Workspace), + canSave = title.isNotBlank(), + onBackPressed = { navController.popBackStack() }, + onDone = { + onEvent( + WorkspaceEvents.UpsertSpace( + workspace.copy( + title = title, + description = description, + icon = icon, + passKey = passkey, + selectedSpaces = selectedSpacesId.toList() + ) + ) + ) + navController.popBackStack() + } + ) { innerPadding-> + Column(Modifier.fillMaxSize().padding(innerPadding).padding(12.dp)) { + TextField( + value = title, + onValueChange = { title = it }, + modifier = Modifier.fillMaxWidth().padding(bottom = 3.dp), + placeholder = { Text(stringResource(R.string.Title)) }, + singleLine = true, + shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp), + colors = getTextFieldColors(), + keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() }) + ) + + TextField( + value = description, + onValueChange = { description = it }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequesterDesc), + placeholder = { Text(stringResource(R.string.Description)) }, + singleLine = true, + shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp), + colors = getTextFieldColors(), + keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + keyboardController?.hide() + focusManager.clearFocus(force = true) + } + ) + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){ + Text("Icon", style = MaterialTheme.typography.bodyLarge) + IconButton( + { isSheetVisible = true }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { Icon(icons[icon], null) } + } + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){ + Text(stringResource(R.string.Lock_Workspace), style = MaterialTheme.typography.bodyLarge) + Switch(passkey!=null, onCheckedChange = { passkey = if(it) "" else null }) + } + + passkey?.let { + Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){ + Text("Passkey", style = MaterialTheme.typography.bodyLarge) + + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if(it.isNotBlank()) Text("****") + IconButton( + { isDialogVisible = true }, + colors = IconButtonDefaults.iconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), + contentColor = MaterialTheme.colorScheme.primary + ) + ) { Icon(Icons.Default.Edit, null) } + } + } + } + + if(isNew){ + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + if(selectedSpaces.isNotEmpty()) Text("Selected spaces") + FlowRow(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + selectedSpaces.forEach { space -> + CompactCard(space.icon, space.title) { selectedSpacesId.remove(space.id) } + } + } + if(otherSpaces.isNotEmpty()) Text(if(selectedSpaces.isEmpty()) "Available Spaces" else "Other available") + FlowRow(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + otherSpaces.forEach { space -> + CompactCard(space.icon, space.title) { selectedSpacesId.add(space.id) } + } + } + } + } + } + + ChangeIconSheet ( + isVisible = isSheetVisible, + sheetState = sheetState, + onDismiss = { scope.launch { sheetState.hide() }.invokeOnCompletion { isSheetVisible = false } }, + onConfirm = { idx-> scope.launch { sheetState.hide() }.invokeOnCompletion { icon = idx } } + ) + + if (isDialogVisible) { SetPasskeyDialog(passkey,{ passkey = it }) { isDialogVisible = false } } +} + diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/NormalMode.kt b/app/src/main/java/com/flux/ui/screens/workspaces/NormalMode.kt deleted file mode 100644 index 618d386..0000000 --- a/app/src/main/java/com/flux/ui/screens/workspaces/NormalMode.kt +++ /dev/null @@ -1,229 +0,0 @@ -package com.flux.ui.screens.workspaces - -import android.widget.Toast -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells -import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan -import androidx.compose.foundation.lazy.staggeredgrid.items -import androidx.compose.foundation.text.input.TextFieldState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.data.model.WorkspaceModel -import com.flux.navigation.NavRoutes -import com.flux.ui.components.EmptySpaces -import com.flux.ui.components.NewWorkspaceBottomSheet -import com.flux.ui.components.SelectedBar -import com.flux.ui.components.SetPasskeyDialog -import com.flux.ui.components.WorkspaceCard -import com.flux.ui.events.WorkspaceEvents -import kotlinx.coroutines.launch -import com.flux.R -import com.flux.ui.components.GeneralSearchBar - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NormalMode( - snackbarHostState: SnackbarHostState, - navController: NavController, - gridColumns: Int, - radius: Int, - allSpaces: List, - onWorkSpaceEvents: (WorkspaceEvents) -> Unit, -) { - var query by rememberSaveable { mutableStateOf("") } - var isAddNewWorkspace by remember { mutableStateOf(false) } - val selectedWorkspace = remember { mutableStateListOf() } - var lockedWorkspace by remember { mutableStateOf(null) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - val context = LocalContext.current - val wrongPassKeyLabel = stringResource(R.string.Wrong_Passkey) - - lockedWorkspace?.let { - SetPasskeyDialog(onConfirmRequest = { passkey -> - if (it.passKey == passkey) { - navController.navigate(NavRoutes.WorkspaceHome.withArgs(it.workspaceId)) - } else { - Toast.makeText(context, wrongPassKeyLabel, Toast.LENGTH_SHORT).show() - } - }) { lockedWorkspace = null } - } - - fun handleWorkspaceClick(space: WorkspaceModel) { - if (space.passKey.isNotBlank()) { lockedWorkspace = space - } else { navController.navigate(NavRoutes.WorkspaceHome.withArgs(space.workspaceId)) } - } - - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - topBar = { - if (selectedWorkspace.isEmpty()) { - GeneralSearchBar( - leadingIcon = Icons.Default.Search, - trailingIcon = Icons.Default.Settings, - textFieldState = TextFieldState(query), - onSearch = { query = it }, - onLeadingIconClicked = { }, - onTrailingIconClicked = { navController.navigate(NavRoutes.Settings.route) }, - onCloseClicked = { query = "" } - ) - } else{ - Box(Modifier.padding(top = 42.dp)){ - SelectedBar( - false, - selectedWorkspace.containsAll(allSpaces), - selectedWorkspace.all { it.isPinned }, - selectedWorkspace.size, - onPinClick = { onWorkSpaceEvents(WorkspaceEvents.UpsertSpaces(selectedWorkspace.toList())) }, - onDeleteClick = {}, - onSelectAllClick = { - if (selectedWorkspace.containsAll(allSpaces)){ selectedWorkspace.clear() } - else { - selectedWorkspace.clear() - selectedWorkspace.addAll(allSpaces) - } - }, - onCloseClick = { selectedWorkspace.clear() } - ) - } - } - }, - floatingActionButton = { - FloatingActionButton({ isAddNewWorkspace = true }){ - Icon(Icons.Default.Add, null) - } - }, - snackbarHost = { SnackbarHost(snackbarHostState) } - ) { innerPadding -> - if (allSpaces.isEmpty()) { EmptySpaces() } else { - val vSpacing = when (gridColumns) { - 1 -> 12.dp - 2 -> 16.dp - else -> 10.dp - } - LazyVerticalStaggeredGrid( - columns = StaggeredGridCells.Fixed(gridColumns), - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .padding(vertical = 16.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.spacedBy(1.dp), - verticalItemSpacing = vSpacing - ) { - if (allSpaces.none { - it.title.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true) - }) { - item(span = StaggeredGridItemSpan.FullLine) { EmptySpaces() } - } - - if (allSpaces.any { - it.isPinned && (it.title.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true)) - }) { - item(span = StaggeredGridItemSpan.FullLine) { - Text( - stringResource(R.string.Pinned), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.padding(vertical = 8.dp) - ) - } - } - - items(allSpaces.filter { - it.isPinned && (it.title.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true)) - }) { space -> - WorkspaceCard( - gridColumns = gridColumns, - iconIndex = space.icon, - radius = radius, - isLocked = space.passKey.isNotBlank(), - cover = space.cover, - title = space.title, - description = space.description, - isSelected = selectedWorkspace.contains(space), - onClick = { handleWorkspaceClick(space) }, - onLongPressed = { - if (selectedWorkspace.contains(space)) selectedWorkspace.remove(space) - else selectedWorkspace.add(space) - } - ) - } - - if (allSpaces.any { - it.isPinned && (it.title.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true)) - }) { - item(span = StaggeredGridItemSpan.FullLine) { - Text( - stringResource(R.string.Others), - modifier = Modifier.padding(vertical = 8.dp), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) - ) - } - } - - items(allSpaces.filter { - !it.isPinned && (it.title.contains(query, ignoreCase = true) || - it.description.contains(query, ignoreCase = true)) - }) { space -> - WorkspaceCard( - gridColumns = gridColumns, - iconIndex = space.icon, - radius = radius, - isLocked = space.passKey.isNotBlank(), - cover = space.cover, - title = space.title, - description = space.description, - isSelected = selectedWorkspace.contains(space), - onClick = { handleWorkspaceClick(space) }, - onLongPressed = { - if (selectedWorkspace.contains(space)) selectedWorkspace.remove(space) - else selectedWorkspace.add(space) - } - ) - } - } - } - } - - NewWorkspaceBottomSheet(isVisible = isAddNewWorkspace, sheetState = sheetState, onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { - if (!sheetState.isVisible) { - isAddNewWorkspace = false - } - } - }, onConfirm = { onWorkSpaceEvents(WorkspaceEvents.UpsertSpace(it)) }) -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/NormalModeDetails.kt b/app/src/main/java/com/flux/ui/screens/workspaces/NormalModeDetails.kt deleted file mode 100644 index 87136a8..0000000 --- a/app/src/main/java/com/flux/ui/screens/workspaces/NormalModeDetails.kt +++ /dev/null @@ -1,343 +0,0 @@ -package com.flux.ui.screens.workspaces - -import android.content.Context -import android.net.Uri -import android.os.Build -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.data.model.WorkspaceModel -import com.flux.data.model.getSpacesList -import com.flux.navigation.NavRoutes -import com.flux.other.icons -import com.flux.ui.components.AddNewSpacesBottomSheet -import com.flux.ui.components.ChangeIconBottomSheet -import com.flux.ui.components.DeleteAlert -import com.flux.ui.components.NewWorkspaceBottomSheet -import com.flux.ui.components.SetPasskeyDialog -import com.flux.ui.components.WorkspaceTopBar -import com.flux.ui.events.HabitEvents -import com.flux.ui.events.JournalEvents -import com.flux.ui.events.NotesEvents -import com.flux.ui.events.TaskEvents -import com.flux.ui.events.TodoEvents -import com.flux.ui.events.WorkspaceEvents -import kotlinx.coroutines.launch -import com.flux.data.model.ProgressBoardModel -import com.flux.other.ensureStorageRoot -import com.flux.ui.components.NewBoardItemSheet -import com.flux.ui.events.ProgressBoardEvents -import com.flux.ui.events.SettingEvents -import com.flux.ui.state.States -import com.flux.ui.viewModel.SettingsViewModel - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun NormalModeDetails( - navController: NavController, - states: States, - workspace: WorkspaceModel, - settingsViewModel: SettingsViewModel, - onWorkspaceEvents: (WorkspaceEvents) -> Unit, - onNotesEvents: (NotesEvents) -> Unit, - onTaskEvents: (TaskEvents) -> Unit, - onHabitEvents: (HabitEvents) -> Unit, - onTodoEvents: (TodoEvents) -> Unit, - onJournalEvents: (JournalEvents) -> Unit, - onSettingEvents: (SettingEvents) -> Unit, - onProgressBoardEvents: (ProgressBoardEvents) -> Unit -) { - val context = LocalContext.current - val workspaceId = workspace.workspaceId - val isCompactMode = states.settings.data.workspaceGridColumns>1 - val allSpaces = getSpacesList() - val selectedSpaceId = rememberSaveable { mutableIntStateOf(if (workspace.selectedSpaces.isEmpty()) -1 else workspace.selectedSpaces.first()) } - val currentSpace = allSpaces.find { it.id == selectedSpaceId.intValue } - var editWorkspaceDialog by remember { mutableStateOf(false) } - var editIconSheet by remember { mutableStateOf(false) } - var showDeleteWorkspaceDialog by remember { mutableStateOf(false) } - var showLockDialog by remember { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val scope = rememberCoroutineScope() - var addSpaceBottomSheet by remember { mutableStateOf(false) } - var addProgressItem by remember { mutableStateOf(false) } - var selectedProgressBoardItem by remember { mutableStateOf(ProgressBoardModel(workspaceId=workspaceId)) } - val spacesList = getSpacesList() - var showDeleteDialog by remember { mutableStateOf(false) } - var query by remember { mutableStateOf("") } - val selectedNotes = states.notesState.selectedNotes - val allNotes = states.notesState.allNotes - val expandedTODOIds = rememberSaveable(workspaceId) { - mutableStateOf>(emptySet()) - } - val imagePickerLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.GetContent(), - onResult = { uri: Uri? -> uri?.let { onWorkspaceEvents(WorkspaceEvents.ChangeCover(context, uri, workspace)) } } - ) - val rootPicker = - rememberLauncherForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - val uri = result.data?.data ?: return@rememberLauncherForActivityResult - settingsViewModel.saveRootUri(uri) - } - - LaunchedEffect(workspaceId) { - onNotesEvents(NotesEvents.EnterWorkspace(workspaceId)) - onJournalEvents(JournalEvents.EnterWorkspace(workspaceId)) - onTodoEvents(TodoEvents.EnterWorkspace(workspaceId)) - onTaskEvents(TaskEvents.EnterWorkspace(workspaceId)) - onHabitEvents(HabitEvents.EnterWorkspace(workspaceId)) - onProgressBoardEvents(ProgressBoardEvents.EnterWorkspace(workspaceId)) - } - - Scaffold( - containerColor = MaterialTheme.colorScheme.surfaceContainerLow, - topBar = { - WorkspaceTopBar( - workspace, - states.settings.data.workspaceGridColumns==1, - onBackPressed = { navController.popBackStack() }, - onDelete = { showDeleteWorkspaceDialog = true }, - onTogglePinned = { - onWorkspaceEvents( - WorkspaceEvents.UpsertSpace( - workspace.copy( - isPinned = !workspace.isPinned - ) - ) - ) - }, - onToggleLock = { - if (workspace.passKey.isNotBlank()) { - onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(passKey = ""))) - } else showLockDialog = true - }, - onAddCover = { ensureStorageRoot( - scope = scope, - settingsViewModel = settingsViewModel, - rootPicker = rootPicker - ) { imagePickerLauncher.launch("image/*") } }, - onEditDetails = { editWorkspaceDialog = true }, - onEditLabel = { navController.navigate(NavRoutes.EditLabels.withArgs(workspaceId)) }, - onRemoveCover = { onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(cover = ""))) } - ) - } - ) { innerPadding -> - LazyColumn(Modifier.padding(innerPadding).padding(horizontal = 12.dp).padding(bottom = 8.dp)) { - if(!isCompactMode){ - item { - IconButton(onClick = { editIconSheet = true }) { - Icon( - icons[workspace.icon], - null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - item { - Text( - workspace.title, - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.primary - ) - } - if (workspace.description.isNotBlank()) { - item { Text(workspace.description, style = MaterialTheme.typography.bodyLarge) } - } - } - spacesToolbarList( - navController, - isCompactMode, - selectedSpaceId.intValue, - workspaceId, - workspace, - states, - query, - {query=it}, - onSettingEvents, - onNotesEvents, - { selectedSpaceId.intValue = it }, - { addSpaceBottomSheet=true }, - { - addProgressItem = true - selectedProgressBoardItem = ProgressBoardModel(workspaceId=workspaceId) - } - ) - spaceDetailItems( - navController, - workspaceId, - workspace.selectedSpaces, - states, - query, - states.settings.data.cornerRadius, - currentSpace, - onHabitEvents, - onNotesEvents, - onJournalEvents, - onTaskEvents, - onTodoEvents, - expandedTODOIds.value, - {id -> expandedTODOIds.value = if (id in expandedTODOIds.value) expandedTODOIds.value - id else expandedTODOIds.value + id }, - { - addProgressItem=true - selectedProgressBoardItem=it - } - ) - } - } - - if (showLockDialog) { - SetPasskeyDialog( - { onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(passKey = it))) }) - { showLockDialog = false } - } - - if (showDeleteWorkspaceDialog) { - DeleteAlert(onConfirmation = { - showDeleteWorkspaceDialog = false - navController.popBackStack() - onWorkspaceEvents(WorkspaceEvents.DeleteSpace(workspace)) - onNotesEvents(NotesEvents.DeleteAllWorkspaceNotes(workspaceId)) - onTodoEvents(TodoEvents.DeleteAllWorkspaceLists(workspaceId)) - onTaskEvents(TaskEvents.DeleteAllWorkspaceEvents(workspaceId, context)) - onJournalEvents(JournalEvents.DeleteWorkspaceEntries(workspaceId)) - onHabitEvents(HabitEvents.DeleteAllWorkspaceHabits(workspaceId, context)) - onProgressBoardEvents(ProgressBoardEvents.DeleteBoardItemsByWorkspace(workspaceId)) - }, onDismissRequest = { - showDeleteWorkspaceDialog = false - }) - } - - AddNewSpacesBottomSheet( - isVisible = addSpaceBottomSheet, - sheetState = sheetState, - selectedSpaces = spacesList.filter { workspace.selectedSpaces.contains(it.id) }, - onDismiss = { addSpaceBottomSheet = false }, - onRemove = { spaceId -> - val newSelected = workspace.selectedSpaces.firstOrNull { it != spaceId } ?: -1 - selectedSpaceId.intValue = newSelected - - onWorkspaceEvents( - WorkspaceEvents.UpsertSpace( - workspace.copy(selectedSpaces = workspace.selectedSpaces.minus(spaceId)) - ) - ) - - removeSpaceData( - workspaceId, spaceId, context, onTaskEvents, onTodoEvents, - onHabitEvents, onNotesEvents, onJournalEvents, onProgressBoardEvents - ) - }, - onSelect = { - if (selectedSpaceId.intValue == -1) selectedSpaceId.intValue = it - onWorkspaceEvents( - WorkspaceEvents.UpsertSpace( - workspace.copy( - selectedSpaces = workspace.selectedSpaces.plus( - it - ) - ) - ) - ) - } - ) - - // Edit Workspace Sheet - NewWorkspaceBottomSheet( - isEditing = true, - workspace = workspace, - isVisible = editWorkspaceDialog, - sheetState = sheetState, - onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { editWorkspaceDialog = false } - }, - onConfirm = { - onWorkspaceEvents(WorkspaceEvents.UpsertSpace(it)) - scope.launch { sheetState.hide() }.invokeOnCompletion { - editWorkspaceDialog = false - } - } - ) - - // Edit Workspace Sheet - ChangeIconBottomSheet( - isVisible = editIconSheet, - sheetState = sheetState, - onDismiss = { - scope.launch { sheetState.hide() }.invokeOnCompletion { editIconSheet = false } - }, - onConfirm = { index -> - scope.launch { sheetState.hide() }.invokeOnCompletion { - onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(icon = index))) - editIconSheet = false - } - } - ) - - NewBoardItemSheet(addProgressItem, sheetState, selectedProgressBoardItem, - { addProgressItem=false }, { - addProgressItem=false - onProgressBoardEvents(ProgressBoardEvents.UpsertProgressItem(it)) - selectedProgressBoardItem=ProgressBoardModel(workspaceId=workspaceId) - }) { - onProgressBoardEvents(ProgressBoardEvents.DeleteProgressItem(it)) - } - - if(showDeleteDialog){ - DeleteAlert(onConfirmation = { - onNotesEvents(NotesEvents.DeleteNotes(allNotes.filter { selectedNotes.contains(it.notesId) })) - onNotesEvents(NotesEvents.ClearSelection) - showDeleteDialog=false - }, onDismissRequest = { - showDeleteDialog=false - }) - } -} - -fun removeSpaceData( - workspaceId: String, - spaceId: Int, - context: Context, - onTaskEvents: (TaskEvents) -> Unit, - onTodoEvents: (TodoEvents) -> Unit, - onHabitEvents: (HabitEvents) -> Unit, - onNotesEvents: (NotesEvents) -> Unit, - onJournalEvents: (JournalEvents) -> Unit, - onProgressBoardEvents: (ProgressBoardEvents) -> Unit -) { - when (spaceId) { - 1 -> onNotesEvents(NotesEvents.DeleteAllWorkspaceNotes(workspaceId)) - 2 -> onTodoEvents(TodoEvents.DeleteAllWorkspaceLists(workspaceId)) - 3 -> onTaskEvents(TaskEvents.DeleteAllWorkspaceEvents(workspaceId, context)) - 5 -> onJournalEvents(JournalEvents.DeleteWorkspaceEntries(workspaceId)) - 6 -> onHabitEvents(HabitEvents.DeleteAllWorkspaceHabits(workspaceId, context)) - 7 -> onProgressBoardEvents(ProgressBoardEvents.DeleteBoardItemsByWorkspace(workspaceId)) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/SpaceToolbarList.kt b/app/src/main/java/com/flux/ui/screens/workspaces/SpaceToolbarList.kt deleted file mode 100644 index 0098095..0000000 --- a/app/src/main/java/com/flux/ui/screens/workspaces/SpaceToolbarList.kt +++ /dev/null @@ -1,213 +0,0 @@ -package com.flux.ui.screens.workspaces - -import android.os.Build -import android.widget.Toast -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresApi -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Notes -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import com.flux.R -import com.flux.data.model.NotesModel -import com.flux.data.model.WorkspaceModel -import com.flux.data.model.getSpacesList -import com.flux.navigation.NavRoutes -import com.flux.ui.components.DeleteAlert -import com.flux.ui.components.EventToolBar -import com.flux.ui.components.HabitToolBar -import com.flux.ui.components.JournalToolBar -import com.flux.ui.components.NotesToolBar -import com.flux.ui.components.ProgressTrackerToolBar -import com.flux.ui.components.SelectedBar -import com.flux.ui.components.SpacesMenu -import com.flux.ui.components.SpacesToolBar -import com.flux.ui.components.TodoToolBar -import com.flux.ui.events.NotesEvents -import com.flux.ui.events.SettingEvents -import com.flux.ui.state.States - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -fun LazyListScope.spacesToolbarList( - navController: NavController, - isCompactMode: Boolean, - selectedSpaceId: Int, - workspaceId: String, - workspace: WorkspaceModel, - states: States, - query: String, - onSearchNotes: (String) -> Unit, - onSettingEvents: (SettingEvents) -> Unit, - onNotesEvents: (NotesEvents) -> Unit, - onChangeSpace: (Int) -> Unit, - onEditSpaceList: () -> Unit, - onAddProgressBarItem: () -> Unit -){ - if(!isCompactMode) item { Spacer(Modifier.height(8.dp)) } - item { - val context = LocalContext.current - val spacesList = getSpacesList() - val selectedNotes = states.notesState.selectedNotes - val allNotes = states.notesState.allNotes - val totalNotes = allNotes.size - var showDeleteDialog by remember { mutableStateOf(false) } - var showSpacesMenu by remember { mutableStateOf(false) } - val importSuccess = stringResource(R.string.import_success) - val importFailed = stringResource(R.string.import_failed) - val journalSelectedDate = states.journalState.selectedDate - val settings = states.settings.data - - val importNoteLauncher = rememberLauncherForActivityResult( - contract = ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { - try { - val contentResolver = context.contentResolver - val inputStream = contentResolver.openInputStream(uri) - val content = inputStream?.bufferedReader()?.use { it.readText() } ?: "" - - // Get filename (remove extension) - val fileName = uri.lastPathSegment - ?.substringAfterLast("/") - ?.substringBeforeLast(".") - ?: "Imported Note" - - // Create a new note - val newNote = NotesModel( - title = fileName, - description = content, - workspaceId = workspaceId, - lastEdited = System.currentTimeMillis() - ) - - onNotesEvents(NotesEvents.UpsertNote(newNote)) - Toast.makeText(context, importSuccess, Toast.LENGTH_SHORT).show() - - } catch (e: Exception) { - e.printStackTrace() - Toast.makeText(context, importFailed, Toast.LENGTH_SHORT).show() - } - } - } - - if(showDeleteDialog){ - DeleteAlert(onConfirmation = { - onNotesEvents(NotesEvents.DeleteNotes(allNotes.filter { selectedNotes.contains(it.notesId) })) - onNotesEvents(NotesEvents.ClearSelection) - showDeleteDialog=false - }, onDismissRequest = { - showDeleteDialog=false - }) - } - - if (spacesList.find { it.id == selectedSpaceId }?.title == stringResource(R.string.Notes) && selectedNotes.isNotEmpty()) { - SelectedBar( - true, - totalNotes == selectedNotes.size, - allNotes.filter { selectedNotes.contains(it.notesId) }.all { it.isPinned }, - selectedNotes.size, - onPinClick = { - onNotesEvents(NotesEvents.TogglePinMultiple(allNotes.filter { - selectedNotes.contains( - it.notesId - ) - })) - }, - onDeleteClick = { showDeleteDialog = true }, - onSelectAllClick = { - if (allNotes.size == selectedNotes.size) { - onNotesEvents(NotesEvents.ClearSelection) - } else { - onNotesEvents(NotesEvents.SelectAllNotes) - } - }, - onCloseClick = { onNotesEvents(NotesEvents.ClearSelection) } - ) - } else { - if(!isCompactMode) Spacer(Modifier.height(8.dp)) - - Row( - Modifier - .fillMaxWidth() - .padding(bottom=8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - SpacesToolBar(spacesList.find { it.id == selectedSpaceId }?.title ?: "", spacesList.find { it.id == selectedSpaceId }?.icon - ?: Icons.AutoMirrored.Default.Notes, - selectedSpaceId == -1, - onMainClick = { showSpacesMenu = true }, - onEditClick = onEditSpaceList - ) - SpacesMenu( - expanded = showSpacesMenu, - workspace = workspace, - onConfirm = onChangeSpace - ) { showSpacesMenu = false } - - if (spacesList.find { it.id == selectedSpaceId }?.title == stringResource(R.string.Habits)) { - HabitToolBar(context) { navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, "")) } - } - if (spacesList.find { it.id == selectedSpaceId }?.title == stringResource(R.string.Notes)) { - NotesToolBar( - navController, - workspaceId, - query, - settings.isGridView, - onSearch = onSearchNotes, - onImportNote = { importNoteLauncher.launch(arrayOf("text/markdown", "text/plain")) }, - onChangeView = { - onSettingEvents( - SettingEvents.UpdateSettings( - settings.copy(isGridView = !settings.isGridView) - ) - ) - }) - } - if (spacesList.find { it.id == selectedSpaceId }?.title == stringResource(R.string.Journal)) { - JournalToolBar(navController, workspaceId, journalSelectedDate,settings.isCalendarMonthlyView) { - onSettingEvents(SettingEvents.UpdateSettings(settings.copy(isCalendarMonthlyView = it))) - } - } - if (spacesList.find { it.id == selectedSpaceId }?.title == stringResource(R.string.To_Do)) { - TodoToolBar(navController, workspaceId) - } - if (spacesList.find { it.id == selectedSpaceId }?.title == stringResource(R.string.progress_tracker)) { - ProgressTrackerToolBar(onAddProgressBarItem) - } - if (spacesList.find { it.id == selectedSpaceId }?.title == stringResource(R.string.Events)) { - EventToolBar( - navController, - workspaceId, - context, - states.eventState.selectedDate, - settings.isCalendarMonthlyView, - onClick = { - onSettingEvents( - SettingEvents.UpdateSettings( - settings.copy(isCalendarMonthlyView = it) - ) - ) - } - ) - } - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/Spaces.kt b/app/src/main/java/com/flux/ui/screens/workspaces/Spaces.kt deleted file mode 100644 index 112572f..0000000 --- a/app/src/main/java/com/flux/ui/screens/workspaces/Spaces.kt +++ /dev/null @@ -1,107 +0,0 @@ -package com.flux.ui.screens.workspaces - -import androidx.compose.foundation.lazy.LazyListScope -import androidx.navigation.NavController -import com.flux.data.model.ProgressBoardModel -import com.flux.data.model.Space -import com.flux.ui.events.HabitEvents -import com.flux.ui.events.JournalEvents -import com.flux.ui.events.NotesEvents -import com.flux.ui.events.TaskEvents -import com.flux.ui.events.TodoEvents -import com.flux.ui.screens.analytics.analyticsItems -import com.flux.ui.screens.events.eventHomeItems -import com.flux.ui.screens.habits.habitsHomeItems -import com.flux.ui.screens.journal.journalHomeItems -import com.flux.ui.screens.notes.notesHomeItems -import com.flux.ui.screens.progressBoard.progressBoardItems -import com.flux.ui.screens.todo.todoHomeItems -import com.flux.ui.state.States - -fun LazyListScope.spaceDetailItems( - navController: NavController, - workspaceId: String, - availableSpaces: List, - states: States, - query: String, - radius: Int, - space: Space?, - onHabitEvents: (HabitEvents) -> Unit, - onNotesEvents: (NotesEvents) -> Unit, - onJournalEvents: (JournalEvents) -> Unit, - onTaskEvents: (TaskEvents) -> Unit, - onTodoEvents: (TodoEvents) -> Unit, - expandedTODOIds: Set, - onToggleTODO: (String) -> Unit, - onProgressItemClick: (ProgressBoardModel) -> Unit, -) { - when (space?.id) { - 5 -> habitsHomeItems( - navController, - states.habitState.isLoading, - radius, - workspaceId, - states.habitState.allHabits, - states.habitState.allInstances, - states.settings, - onHabitEvents - ) - 1 -> notesHomeItems( - navController, - workspaceId, - states.notesState.selectedNotes, - query, - radius, - states.settings.data.isGridView, - states.notesState.allLabels, - states.notesState.isNotesLoading, - states.notesState.allNotes, - onNotesEvents - ) - 4 -> journalHomeItems( - navController, - states.settings, - states.journalState.selectedYearMonth, - states.journalState.selectedDate, - states.journalState.isLoading, - workspaceId, - states.journalState.monthlyJournalCount, - states.journalState.datedEntries, - onJournalEvents - ) - 6 -> analyticsItems( - availableSpaces, - radius, - states.habitState.allInstances, - states.habitState.allHabits.size, - states.notesState.allNotes.size, - states.journalState.allEntries, - states.habitState.allHabits, - states.eventState.allEvent, - states.eventState.allEventInstances - ) - 7 -> progressBoardItems( - states.progressBoardState.isLoading, - radius, - states.progressBoardState.allItems, - onProgressItemClick - ) - 2 -> todoHomeItems( - navController, radius, states.todoState.allLists, workspaceId, - states.todoState.isLoading, expandedTODOIds, onTodoEvents, onToggleTODO - ) - 3 -> eventHomeItems( - navController, radius, - states.settings.data.isCalendarMonthlyView, - states.settings.data.is24HourFormat, - states.eventState.isDatedEventLoading, - workspaceId, - states.eventState.selectedYearMonth, - states.eventState.selectedDate, - states.eventState.monthlyEventDates, - states.eventState.datedEvents, - states.eventState.allEventInstances, - onTaskEvents - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/WorkSpaces.kt b/app/src/main/java/com/flux/ui/screens/workspaces/WorkSpaces.kt deleted file mode 100644 index 5d24cc1..0000000 --- a/app/src/main/java/com/flux/ui/screens/workspaces/WorkSpaces.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.flux.ui.screens.workspaces - -import android.os.Build -import androidx.annotation.RequiresApi -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SnackbarHostState -import androidx.compose.runtime.Composable -import androidx.navigation.NavController -import com.flux.ui.state.States -import com.flux.ui.viewModel.ViewModels - -@RequiresApi(Build.VERSION_CODES.TIRAMISU) -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WorkSpaces( - snackbarHostState: SnackbarHostState, - navController: NavController, - states: States, - viewModels: ViewModels, -) { - val radius = states.settings.data.cornerRadius - val gridColumns = states.settings.data.workspaceGridColumns - val isExtremeCompactMode = gridColumns==3 - when { - isExtremeCompactMode -> - ExtremeCompactMode( - navController, - states, - viewModels - ) - else -> - NormalMode( - snackbarHostState, - navController, - gridColumns, - radius, - states.workspaceState.allWorkspaces, - viewModels.workspaceViewModel::onEvent - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceComponents.kt b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceComponents.kt new file mode 100644 index 0000000..a0aed79 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceComponents.kt @@ -0,0 +1,441 @@ +package com.flux.ui.screens.workspaces + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Remove +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.surfaceColorAtElevation +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri +import coil.compose.AsyncImage +import com.flux.R +import com.flux.data.model.Space +import com.flux.data.model.getSpacesList +import com.flux.other.icons +import com.flux.ui.common.DeleteAlert +import com.flux.ui.screens.settings.shapeManager + +// ------------- Dialog ------------- +@Composable +fun SetPasskeyDialog(key: String?=null, onConfirmRequest: (String) -> Unit, onDismissRequest: () -> Unit) { + var passKey by remember { mutableStateOf(key?: "") } + + Dialog(onDismissRequest) { + Card(shape = RoundedCornerShape(16.dp)) { + Column( + Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + Text( + stringResource(R.string.enterPasskey), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary + ) + + OutlinedTextField( + value = passKey, + singleLine = true, + onValueChange = { passKey = it }, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Done + ), + keyboardActions = KeyboardActions( + onDone = { + onConfirmRequest(passKey) + onDismissRequest() + } + ) + ) + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onDismissRequest) { Text(stringResource(R.string.Dismiss)) } + TextButton( + onClick = { + onConfirmRequest(passKey) + onDismissRequest() + }, + colors = ButtonDefaults.buttonColors() + ) { Text(stringResource(R.string.Confirm)) } + } + } + } + } +} + +// ------------- Bottom Sheet ------------- +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddNewSpacesBottomSheet( + isVisible: Boolean, + sheetState: SheetState, + selectedSpaces: List, + onDismiss: () -> Unit, + onRemove: (Int) -> Unit, + onSelect: (Int) -> Unit +) { + var showDeleteDialog by remember { mutableStateOf(false) } + var removeSpace by remember { mutableIntStateOf(-1) } + val spacesList = getSpacesList() + if (showDeleteDialog) { + DeleteAlert(onConfirmation = { + onRemove(removeSpace) + removeSpace = -1 + showDeleteDialog = false + }, onDismissRequest = { + showDeleteDialog = false + }) + } + + if (isVisible) { + ModalBottomSheet( + onDismissRequest = { onDismiss() }, + sheetState = sheetState, + containerColor = MaterialTheme.colorScheme.surfaceContainer + ) { + LazyColumn(Modifier + .fillMaxWidth() + .padding(vertical = 16.dp)) { + if (selectedSpaces.isNotEmpty()) { + item { + Text( + stringResource(R.string.Current), + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + item { + FlowRow( + modifier = Modifier.padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + selectedSpaces.forEach { space -> + SpaceCard(space, true) { + removeSpace = space.id + showDeleteDialog = true + } + } + } + } + if (selectedSpaces.size != spacesList.size) { + item { + Text( + stringResource(R.string.Available_Spaces), + modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp), + fontWeight = FontWeight.Bold + ) + } + } + item { + FlowRow( + modifier = Modifier.padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + spacesList.filterNot { id -> selectedSpaces.contains(id) } + .forEach { space -> + SpaceCard(space, false) { onSelect(space.id) } + } + } + } + } + } + } +} + +// ------------- Card ------------- +@Composable +fun SpaceCard(space: Space, isSelected: Boolean, onClick: () -> Unit) { + val cardContainerColor = + if (isSelected) MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) else MaterialTheme.colorScheme.surfaceContainerHigh + val cardContentColor = + if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface + val iconContainerColor = + if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface + val iconContentColor = + if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface + + Card( + modifier = Modifier.clip(RoundedCornerShape(50)), + shape = RoundedCornerShape(50), + onClick = onClick, + colors = CardDefaults.cardColors( + containerColor = cardContainerColor, + contentColor = cardContentColor + ) + ) { + Row(modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) { + IconButton( + modifier = Modifier.size(32.dp), + onClick = onClick, + colors = IconButtonDefaults.iconButtonColors( + containerColor = iconContainerColor, + contentColor = iconContentColor + ) + ) { Icon(space.icon, null, modifier = Modifier.size(18.dp)) } + Spacer(Modifier.width(2.dp)) + Text(space.title, style = MaterialTheme.typography.bodyMedium) + } + } +} + +@Composable +fun WorkspaceCard( + gridColumns: Int, + radius: Int, + isLocked: Boolean = false, + cover: String, + title: String, + description: String, + iconIndex: Int, + isSelected: Boolean, + onClick: () -> Unit, + onLongPressed: ()->Unit +){ + val coverHeight = when (gridColumns) { + 1 -> 120.dp + 2 -> 100.dp + else -> 80.dp + } + + val maxTitleLines = when (gridColumns) { + 1 -> 2 + else -> 1 + } + + val maxDescriptionLines = when (gridColumns) { + 1 -> 3 + else -> 2 + } + + val paddingValues = when (gridColumns) { + 1 -> 8.dp + 2 -> 6.dp + else -> 4.dp + } + + val iconSize = when (gridColumns) { + 1 -> 28.dp + 2 -> 24.dp + else -> 18.dp + } + + val titleStyle = when (gridColumns) { + 1 -> MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + 2 -> MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold) + else -> MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold) + } + + val descriptionStyle = when (gridColumns) { + 1 -> MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Normal) + 2 -> MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal) + else -> MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraLight) + } + + Card( + shape = shapeManager(radius = radius*2), + modifier = Modifier.fillMaxWidth().padding(horizontal = paddingValues) + .clip(shapeManager(radius = radius*2)) + .combinedClickable( + onClick = onClick, + onLongClick = onLongPressed + ), + colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)), + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null + ) { + Column( + Modifier + .fillMaxSize() + .padding(bottom = 4.dp) + ) { + if (cover.isBlank()) { + Box( + Modifier + .fillMaxWidth() + .height(coverHeight) + .alpha(0.125f) + .background(MaterialTheme.colorScheme.onSurface) + ) + } else { + AsyncImage( + model = cover.toUri(), + modifier = Modifier + .height(coverHeight) + .alpha(0.8f), + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + Row( + modifier = Modifier.padding(vertical = 8.dp, horizontal = paddingValues), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + icons[iconIndex], + null, + Modifier.size(iconSize), + MaterialTheme.colorScheme.primary + ) + if (isLocked) Icon( + Icons.Default.Lock, + null, + Modifier.size(iconSize), + MaterialTheme.colorScheme.primary + ) + Text( + title, + modifier = Modifier.padding(start = 4.dp), + maxLines = maxTitleLines, + style = titleStyle, + overflow = TextOverflow.Clip, + color = MaterialTheme.colorScheme.primary + ) + } + + Text( + description, + style = descriptionStyle, + modifier = Modifier + .padding(bottom = 6.dp) + .padding(horizontal = paddingValues), + maxLines = maxDescriptionLines, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +fun SpacesToolBar( + title: String, + icon: ImageVector, + isEmptyWorkspace: Boolean, + onMainClick: () -> Unit = {}, + onEditClick: () -> Unit = {} +) { + Box(Modifier.clip(RoundedCornerShape(50)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)) + ) { + if (isEmptyWorkspace) { + Row( + modifier = Modifier + .clickable { onEditClick() } + .padding(vertical = 6.dp, horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Add, + contentDescription = stringResource(R.string.Add_Spaces_Content_Desc), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = stringResource(R.string.Add_Space), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary + ) + } + } else { + Row( + modifier = Modifier.height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier + .clickable { onMainClick() } + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp) + ) { + Icon( + icon, + contentDescription = stringResource(R.string.Space_Content_Desc), + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.primary + ) + Icon( + Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.Space_Content_Desc), + tint = MaterialTheme.colorScheme.primary + ) + } + + VerticalDivider(Modifier.fillMaxHeight()) + + // Right section (Edit icon) + Row(Modifier.clickable { onEditClick() }, + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Edit, + modifier = Modifier.padding(6.dp), + contentDescription = stringResource(R.string.Edit_Space_Content_Desc), + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceDetails.kt b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceDetails.kt new file mode 100644 index 0000000..94e92e0 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceDetails.kt @@ -0,0 +1,301 @@ +package com.flux.ui.screens.workspaces + +import android.content.Context +import android.net.Uri +import android.os.Build +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.navigation.NavController +import com.flux.data.model.WorkspaceModel +import com.flux.data.model.getSpacesList +import com.flux.other.ensureStorageRoot +import com.flux.ui.common.DeleteAlert +import com.flux.ui.events.HabitEvents +import com.flux.ui.events.JournalEvents +import com.flux.ui.events.NotesEvents +import com.flux.ui.events.TaskEvents +import com.flux.ui.events.TodoEvents +import com.flux.ui.events.WorkspaceEvents +import com.flux.ui.events.ProgressBoardEvents +import com.flux.ui.screens.analytics.AnalyticScreen +import com.flux.ui.screens.events.EventScreen +import com.flux.ui.screens.habits.HabitScreen +import com.flux.ui.screens.journal.JournalScreen +import com.flux.ui.screens.notes.NotesScreen +import com.flux.ui.screens.progressBoard.ProgressTrackerScreen +import com.flux.ui.screens.todo.TodoScreen +import com.flux.ui.state.States +import com.flux.ui.viewModel.ViewModels + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkspaceDetails( + navController: NavController, + states: States, + workspace: WorkspaceModel, + viewModels: ViewModels +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val workspaceId = workspace.workspaceId + val selectedSpaceId = rememberSaveable { mutableIntStateOf(if (workspace.selectedSpaces.isEmpty()) -1 else workspace.selectedSpaces.first()) } + var isDeleteDialogVisible by remember { mutableStateOf(false) } + var isPasskeyDialogVisible by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val spacesList = getSpacesList() + var showBottomSheet by remember { mutableStateOf(false) } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { uri: Uri? -> uri?.let { viewModels.workspaceViewModel.onEvent(WorkspaceEvents.ChangeCover(context, uri, workspace)) } } + ) + + val rootPicker = + rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + val uri = result.data?.data ?: return@rememberLauncherForActivityResult + viewModels.settingsViewModel.saveRootUri(uri) + } + + SpaceMapper( + navController = navController, + workspace = workspace, + spaceId = selectedSpaceId.intValue, + states = states, + viewModels = viewModels, + onAddCover = { + ensureStorageRoot( + scope = scope, + settingsViewModel = viewModels.settingsViewModel, + rootPicker = rootPicker + ) { imagePickerLauncher.launch("image/*") } + }, + onRemoveCover = { viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.copy(cover = ""))) }, + onDeleteWorkspace = { isDeleteDialogVisible = true }, + onToggleLock = { + if(workspace.passKey!=null) { + viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.copy(passKey = null))) + } + else { isPasskeyDialogVisible = true } + }, + onSpaceChange = { selectedSpaceId.intValue = it }, + onShowSpaceBottomSheet = { showBottomSheet = true }, + ) + + if (isPasskeyDialogVisible) { + SetPasskeyDialog( + onConfirmRequest = { + viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.copy(passKey = it))) + }, + onDismissRequest = { isPasskeyDialogVisible = false } + ) + } + + if (isDeleteDialogVisible) { + DeleteAlert(onConfirmation = { + isDeleteDialogVisible = false + navController.popBackStack() + clearWorkspace(context, workspace, viewModels) + }, onDismissRequest = { + isDeleteDialogVisible = false + }) + } + + AddNewSpacesBottomSheet( + isVisible = showBottomSheet, + sheetState = sheetState, + selectedSpaces = spacesList.filter { workspace.selectedSpaces.contains(it.id) }, + onDismiss = { showBottomSheet = false }, + onRemove = { spaceId -> + val newSelected = workspace.selectedSpaces.firstOrNull { it != spaceId } ?: -1 + selectedSpaceId.intValue = newSelected + + viewModels.workspaceViewModel.onEvent( + WorkspaceEvents.UpsertSpace( + workspace.copy(selectedSpaces = workspace.selectedSpaces.minus(spaceId)) + ) + ) + + removeSpaceData(workspaceId, spaceId, context, viewModels) + }, + onSelect = { + if (selectedSpaceId.intValue == -1) selectedSpaceId.intValue = it + viewModels.workspaceViewModel.onEvent( + WorkspaceEvents.UpsertSpace( + workspace.copy( + selectedSpaces = workspace.selectedSpaces.plus( + it + ) + ) + ) + ) + } + ) +} + +fun clearWorkspace(context: Context, workspace: WorkspaceModel, viewModels: ViewModels){ + viewModels.workspaceViewModel.onEvent(WorkspaceEvents.DeleteSpace(workspace)) + workspace.selectedSpaces.forEach { spaceId -> + removeSpaceData(workspace.workspaceId, spaceId, context, viewModels) + } +} + +fun removeSpaceData( + workspaceId: String, + spaceId: Int, + context: Context, + viewModels: ViewModels +) { + when (spaceId) { + 1 -> viewModels.notesViewModel.onEvent(NotesEvents.DeleteAllWorkspaceNotes(workspaceId)) + 2 -> viewModels.todoViewModel.onEvent(TodoEvents.DeleteAllWorkspaceLists(workspaceId)) + 3 -> viewModels.eventViewModel.onEvent(TaskEvents.DeleteAllWorkspaceEvents(workspaceId, context)) + 4 -> viewModels.journalViewModel.onEvent(JournalEvents.DeleteWorkspaceEntries(workspaceId)) + 5 -> viewModels.habitViewModel.onEvent(HabitEvents.DeleteAllWorkspaceHabits(workspaceId, context)) + 7 -> viewModels.progressBoardViewModel.onEvent(ProgressBoardEvents.DeleteBoardItemsByWorkspace(workspaceId)) + } +} + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +@Composable +fun SpaceMapper( + navController: NavController, + workspace: WorkspaceModel, + spaceId: Int, + states: States, + viewModels: ViewModels, + onAddCover: () -> Unit, + onRemoveCover: () -> Unit, + onDeleteWorkspace: () -> Unit, + onToggleLock: () -> Unit, + onSpaceChange: (Int) -> Unit, + onShowSpaceBottomSheet: () -> Unit, +) { + when (spaceId) { + 1 -> NotesScreen( + navController, + states.notesState, + states.labelState.allLabels, + states.settings, + workspace, + onShowSpaceBottomSheet, + onSpaceChange, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock, + viewModels.notesViewModel::onEvent, + viewModels.settingsViewModel::onEvent + ) + + 2 -> TodoScreen( + navController, + states.todoState, + states.settings, + workspace, + onShowSpaceBottomSheet, + onSpaceChange, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock, + viewModels.todoViewModel::onEvent + ) + + 3 -> EventScreen( + navController, + states.eventState, + states.settings, + workspace, + onShowSpaceBottomSheet, + onSpaceChange, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock, + viewModels.eventViewModel::onEvent + ) + + 4 -> JournalScreen( + navController, + states.journalState, + states.settings, + workspace, + states.labelState.allLabels, + onShowSpaceBottomSheet, + onSpaceChange, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock, + viewModels.journalViewModel::onEvent + ) + + 5 -> HabitScreen( + navController, + states.habitState, + states.settings, + workspace, + onShowSpaceBottomSheet, + onSpaceChange, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock, + viewModels.habitViewModel::onEvent + ) + + 6 -> AnalyticScreen( + navController, + states, + workspace, + workspace.selectedSpaces, + onShowSpaceBottomSheet, + onSpaceChange, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock + ) + + 7 -> ProgressTrackerScreen( + navController, + states.progressBoardState, + states.settings, + workspace, + onShowSpaceBottomSheet, + onSpaceChange, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock, + viewModels.progressBoardViewModel::onEvent + ) + + else -> { + EmptyWorkspace( + navController, + workspace, + onShowSpaceBottomSheet, + onAddCover, + onRemoveCover, + onDeleteWorkspace, + onToggleLock + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceHomeScreen.kt b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceHomeScreen.kt new file mode 100644 index 0000000..5ffae64 --- /dev/null +++ b/app/src/main/java/com/flux/ui/screens/workspaces/WorkspaceHomeScreen.kt @@ -0,0 +1,214 @@ +package com.flux.ui.screens.workspaces + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import com.flux.data.model.WorkspaceModel +import com.flux.navigation.NavRoutes +import com.flux.ui.common.EmptySpaces +import com.flux.ui.events.WorkspaceEvents +import com.flux.R +import com.flux.ui.common.BottomBar +import com.flux.ui.common.SelectedToolBarRow +import com.flux.ui.events.LabelEvents +import com.flux.ui.state.States +import com.flux.ui.viewModel.ViewModels + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WorkspaceHomeScreen( + snackbarHostState: SnackbarHostState, + navController: NavController, + states: States, + viewModels: ViewModels, +) { + val context = LocalContext.current + val radius = states.settings.data.cornerRadius + val gridColumns = states.settings.data.workspaceGridColumns + val allSpaces = states.workspaceState.allWorkspaces + var query by rememberSaveable { mutableStateOf("") } + val wrongPassKeyLabel = stringResource(R.string.Wrong_Passkey) + val selectedWorkspace = remember { mutableStateListOf() } + var lockedWorkspace by remember { mutableStateOf(null) } + + lockedWorkspace?.let { + SetPasskeyDialog(onConfirmRequest = { passkey -> + if (it.passKey == passkey) { + navController.navigate(NavRoutes.WorkspaceHome.withArgs(it.workspaceId)) + } else { + Toast.makeText(context, wrongPassKeyLabel, Toast.LENGTH_SHORT).show() + } + }) { lockedWorkspace = null } + } + + fun handleWorkspaceClick(space: WorkspaceModel) { + if (space.passKey!=null) { lockedWorkspace = space } + else { + navController.navigate(NavRoutes.WorkspaceHome.withArgs(space.workspaceId)) + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainerLow, + topBar = { + if (selectedWorkspace.isNotEmpty()) { + Box(Modifier.padding(top = 42.dp)){ + SelectedToolBarRow ( + false, + selectedWorkspace.size, + selectedWorkspace.containsAll(allSpaces), + selectedWorkspace.all { it.isPinned }, + onTogglePin = { viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpaces(selectedWorkspace.toList())) }, + onToggleSelection = { + if (selectedWorkspace.containsAll(allSpaces)){ selectedWorkspace.clear() } + else { + selectedWorkspace.clear() + selectedWorkspace.addAll(allSpaces) + } + }, + onClear = { selectedWorkspace.clear() } + ) + } + } + }, + snackbarHost = { SnackbarHost(snackbarHostState) } + ) { innerPadding -> + if (allSpaces.isEmpty()) { EmptySpaces() } else { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + val vSpacing = when (gridColumns) { + 1 -> 12.dp + 2 -> 16.dp + else -> 10.dp + } + LazyVerticalStaggeredGrid( + columns = StaggeredGridCells.Fixed(gridColumns), + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(1.dp), + verticalItemSpacing = vSpacing, + contentPadding = PaddingValues( + top = 16.dp, + bottom = 64.dp + ) + ) { + if (allSpaces.none { + it.title.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true) + }) { + item(span = StaggeredGridItemSpan.FullLine) { EmptySpaces() } + } + + if (allSpaces.any { + it.isPinned && (it.title.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true)) + }) { + item(span = StaggeredGridItemSpan.FullLine) { + Text( + stringResource(R.string.Pinned), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier.padding(vertical = 8.dp) + ) + } + } + + items(allSpaces.filter { + it.isPinned && (it.title.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true)) + }) { space -> + WorkspaceCard( + gridColumns = gridColumns, + iconIndex = space.icon, + radius = radius, + isLocked = space.passKey != null, + cover = space.cover, + title = space.title, + description = space.description, + isSelected = selectedWorkspace.contains(space), + onClick = { handleWorkspaceClick(space) }, + onLongPressed = { + if (selectedWorkspace.contains(space)) selectedWorkspace.remove( + space + ) + else selectedWorkspace.add(space) + } + ) + } + + if (allSpaces.any { + it.isPinned && (it.title.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true)) + }) { + item(span = StaggeredGridItemSpan.FullLine) { + Text( + stringResource(R.string.Others), + modifier = Modifier.padding(vertical = 8.dp), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold) + ) + } + } + + items(allSpaces.filter { + !it.isPinned && (it.title.contains(query, ignoreCase = true) || + it.description.contains(query, ignoreCase = true)) + }) { space -> + WorkspaceCard( + gridColumns = gridColumns, + iconIndex = space.icon, + radius = radius, + isLocked = space.passKey != null, + cover = space.cover, + title = space.title, + description = space.description, + isSelected = selectedWorkspace.contains(space), + onClick = { handleWorkspaceClick(space) }, + onLongPressed = { + if (selectedWorkspace.contains(space)) selectedWorkspace.remove( + space + ) + else selectedWorkspace.add(space) + } + ) + } + } + BottomBar( + navController = navController, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/state/EventState.kt b/app/src/main/java/com/flux/ui/state/EventState.kt index fe98a5f..102ed9f 100644 --- a/app/src/main/java/com/flux/ui/state/EventState.kt +++ b/app/src/main/java/com/flux/ui/state/EventState.kt @@ -8,7 +8,6 @@ import java.time.YearMonth data class EventState( val isDatedEventLoading: Boolean = true, val isAllEventsLoading: Boolean = true, - val workspaceId: String? = null, val monthlyEventDates: Map = emptyMap(), val selectedYearMonth: YearMonth = YearMonth.now(), val selectedDate: Long = LocalDate.now().toEpochDay(), diff --git a/app/src/main/java/com/flux/ui/state/HabitState.kt b/app/src/main/java/com/flux/ui/state/HabitState.kt index a72c76e..478a378 100644 --- a/app/src/main/java/com/flux/ui/state/HabitState.kt +++ b/app/src/main/java/com/flux/ui/state/HabitState.kt @@ -5,7 +5,6 @@ import com.flux.data.model.HabitModel data class HabitState( val isLoading: Boolean = true, - val workspaceId: String? = null, val allHabits: List = emptyList(), val allInstances: List = emptyList() ) \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/state/JournalState.kt b/app/src/main/java/com/flux/ui/state/JournalState.kt index 7aa4182..46eb7ab 100644 --- a/app/src/main/java/com/flux/ui/state/JournalState.kt +++ b/app/src/main/java/com/flux/ui/state/JournalState.kt @@ -3,16 +3,11 @@ package com.flux.ui.state import com.flux.data.model.JournalModel import com.flux.other.HeaderNode import java.time.LocalDate -import java.time.YearMonth data class JournalState( val isLoading: Boolean = false, - val workspaceId: String? = null, val monthlyJournalCount: Map = emptyMap(), val outline: HeaderNode = HeaderNode("", 0, IntRange.EMPTY), val textState: TextState = TextState(), - val selectedYearMonth: YearMonth = YearMonth.now(), - val selectedDate: Long = LocalDate.now().toEpochDay(), - val allEntries: List = emptyList(), - val datedEntries: List = emptyList() + val data: List = emptyList(), ) \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/state/LabelState.kt b/app/src/main/java/com/flux/ui/state/LabelState.kt new file mode 100644 index 0000000..b0b57e0 --- /dev/null +++ b/app/src/main/java/com/flux/ui/state/LabelState.kt @@ -0,0 +1,8 @@ +package com.flux.ui.state + +import com.flux.data.model.LabelModel + +data class LabelState( + val isLoading: Boolean = true, + val allLabels: List = emptyList() +) diff --git a/app/src/main/java/com/flux/ui/state/NotesState.kt b/app/src/main/java/com/flux/ui/state/NotesState.kt index 2539980..5f578fc 100644 --- a/app/src/main/java/com/flux/ui/state/NotesState.kt +++ b/app/src/main/java/com/flux/ui/state/NotesState.kt @@ -1,17 +1,13 @@ package com.flux.ui.state -import com.flux.data.model.LabelModel import com.flux.data.model.NotesModel import com.flux.other.HeaderNode data class NotesState( - val isNotesLoading: Boolean = true, - val isLabelsLoading: Boolean = true, - val workspaceId: String? = null, + val isLoading: Boolean = true, val aboutNote: TextState = TextState(), val outline: HeaderNode = HeaderNode("", 0, IntRange.EMPTY), val textState: TextState = TextState(), val selectedNotes: List = emptyList(), val allNotes: List = emptyList(), - val allLabels: List = emptyList() ) diff --git a/app/src/main/java/com/flux/ui/state/ProgressBoardState.kt b/app/src/main/java/com/flux/ui/state/ProgressBoardState.kt index d37575d..58bffad 100644 --- a/app/src/main/java/com/flux/ui/state/ProgressBoardState.kt +++ b/app/src/main/java/com/flux/ui/state/ProgressBoardState.kt @@ -4,6 +4,5 @@ import com.flux.data.model.ProgressBoardModel data class ProgressBoardState( val isLoading: Boolean = true, - val workspaceId: String? = null, val allItems: List = emptyList() ) diff --git a/app/src/main/java/com/flux/ui/state/States.kt b/app/src/main/java/com/flux/ui/state/States.kt index 923a028..cfd9b1c 100644 --- a/app/src/main/java/com/flux/ui/state/States.kt +++ b/app/src/main/java/com/flux/ui/state/States.kt @@ -8,5 +8,6 @@ data class States( val workspaceState: WorkspaceState, val journalState: JournalState, val progressBoardState: ProgressBoardState, + val labelState: LabelState, val settings: Settings ) \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt index 3985b5c..b237162 100644 --- a/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/BackupViewModel.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.viewModelScope import com.flux.data.database.FluxBackup import com.flux.data.database.FluxDatabase import com.flux.data.model.SettingsModel +import com.flux.data.model.toScheduleRequest import com.flux.data.repository.SettingsRepository import com.flux.other.BackupFrequency import com.flux.other.BackupManager @@ -36,6 +37,7 @@ class BackupViewModel @Inject constructor( ignoreUnknownKeys = true // old backups won't have new fields → skip them coerceInputValues = true // null where non-null expected → use default encodeDefaults = true // ensure new fields are always written on export + classDiscriminator = "type" } suspend fun exportBackup(context: Context) { @@ -131,7 +133,7 @@ class BackupViewModel @Inject constructor( // --- Habits --- backup.habits.forEach { habit -> if (!db.habitDao.exists(habit.id)) { - scheduleNextReminder(context, habit) + scheduleNextReminder(context, habit.toScheduleRequest()) db.habitDao.upsertHabit(habit) } } @@ -154,7 +156,7 @@ class BackupViewModel @Inject constructor( // --- Events --- backup.events.forEach { event -> if (!db.eventDao.exists(event.id)){ - scheduleNextReminder(context, event) + scheduleNextReminder(context, event.toScheduleRequest()) db.eventDao.upsertEvent(event) } } diff --git a/app/src/main/java/com/flux/ui/viewModel/EventViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/EventViewModel.kt index 5af8415..ca3f125 100644 --- a/app/src/main/java/com/flux/ui/viewModel/EventViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/EventViewModel.kt @@ -6,8 +6,10 @@ import androidx.lifecycle.viewModelScope import com.flux.data.model.EventInstanceModel import com.flux.data.model.EventModel import com.flux.data.model.occursOn +import com.flux.data.model.toScheduleRequest import com.flux.data.repository.EventRepository import com.flux.other.cancelReminder +import com.flux.other.computeMonthlyEventDates import com.flux.other.scheduleNextReminder import com.flux.ui.events.TaskEvents import com.flux.ui.state.EventState @@ -19,14 +21,11 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.time.LocalDate import java.time.YearMonth -import java.time.temporal.ChronoUnit import javax.inject.Inject data class Quadruple(val first: A, val second: B, val third: C, val fourth: D) @@ -56,59 +55,34 @@ class EventViewModel @Inject constructor( init { viewModelScope.launch { - state - .map { it.workspaceId } - .distinctUntilChanged() - .filterNotNull() - .flatMapLatest { workspaceId: String -> - combine( - repository.loadAllWorkspaceEvents(workspaceId), - repository.loadAllEventInstances(workspaceId) - ) { events: List, instances: List -> - Pair(events, instances) - } - } - .combine( - state.map { it.selectedDate }.distinctUntilChanged() - ) { eventsAndInstances, selectedDate -> - val (allEvents, allInstances) = eventsAndInstances - val datedEvents = filterByDate(allEvents, selectedDate) - Triple(allEvents, datedEvents, allInstances) - } - .combine( - state.map { it.selectedYearMonth }.distinctUntilChanged() - ) { (allEvents, datedEvents, allInstances), selectedYearMonth -> - val monthlyDates = computeMonthlyEventDates(allEvents, selectedYearMonth) - Quadruple(allEvents, datedEvents, allInstances, monthlyDates) - } - .collect { (allEvents, datedEvents, allInstances, monthlyDates) -> - updateState { - it.copy( - isAllEventsLoading = false, - isDatedEventLoading = false, - allEvent = allEvents, - datedEvents = datedEvents, - allEventInstances = allInstances, - monthlyEventDates = monthlyDates - ) - } + combine( + repository.loadEventData(), + repository.loadEventInstanceData(), + state.map { it.selectedDate }.distinctUntilChanged(), + state.map { it.selectedYearMonth }.distinctUntilChanged() + ) { allEvents, allInstances, selectedDate, selectedYearMonth -> + val datedEvents = filterByDate(allEvents, selectedDate) + val monthlyDates = computeMonthlyEventDates(allEvents, selectedYearMonth) + + Quadruple(allEvents, datedEvents, allInstances, monthlyDates) + }.collect { (allEvents, datedEvents, allInstances, monthlyDates) -> + + updateState { + it.copy( + isAllEventsLoading = false, + isDatedEventLoading = false, + allEvent = allEvents, + datedEvents = datedEvents, + allEventInstances = allInstances, + monthlyEventDates = monthlyDates + ) } + } } } private fun reduce(event: TaskEvents) { when (event) { - is TaskEvents.EnterWorkspace -> { - updateState { - if (it.workspaceId == event.workspaceId) { it } else { - it.copy( - workspaceId = event.workspaceId, - isAllEventsLoading = true, - isDatedEventLoading = true - ) - } - } - } is TaskEvents.ChangeMonth -> updateState { it.copy(selectedYearMonth = event.newYearMonth) } is TaskEvents.ChangeDate -> { val newDate = event.newLocalDate @@ -153,81 +127,26 @@ class EventViewModel @Inject constructor( private fun deleteEvent(data: EventModel, context: Context) { viewModelScope.launch(Dispatchers.IO) { - cancelReminder( - context, - data.id, - data.type.toString(), - data.title, - data.description, - data.workspaceId, - data.endDateTime, - data.startDateTime, - data.notificationOffset, - data.recurrence, - ) + cancelReminder(context, data.toScheduleRequest()) repository.deleteEvent(data) } } private fun upsertEvent(context: Context, data: EventModel) { viewModelScope.launch(Dispatchers.IO) { - cancelReminder( - context, - data.id, - data.type.toString(), - data.title, - data.description, - data.workspaceId, - data.endDateTime, - data.startDateTime, - data.notificationOffset, - data.recurrence, - ) - + cancelReminder(context, data.toScheduleRequest()) repository.upsertEvent(data) - - scheduleNextReminder( - context = context, - item = data - ) + scheduleNextReminder(context = context, data.toScheduleRequest()) } } private fun deleteWorkspaceEvents(workspaceId: String, context: Context) { viewModelScope.launch(Dispatchers.IO) { - state.value.allEvent.forEach { event -> - cancelReminder( - context, - event.id, - event.type.toString(), - event.title, - event.description, - event.workspaceId, - event.endDateTime, - event.startDateTime, - event.notificationOffset, - event.recurrence - ) - } + state.value.allEvent.forEach { event -> cancelReminder(context, event.toScheduleRequest()) } repository.deleteAllWorkspaceEvent(workspaceId) } } - private fun computeMonthlyEventDates( - events: List, - yearMonth: YearMonth - ): Map { - val monthStart = yearMonth.atDay(1) - val monthEnd = yearMonth.atEndOfMonth() - - if (monthEnd < monthStart) return emptyMap() - - return (0..ChronoUnit.DAYS.between(monthStart, monthEnd)) - .map { monthStart.plusDays(it) } - .associateWith { date -> events.count { event -> event.occursOn(date) } } - .filter { (_, count) -> count > 0 } - } - private fun filterByDate( entries: List, epochDay: Long diff --git a/app/src/main/java/com/flux/ui/viewModel/HabitViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/HabitViewModel.kt index 63109f0..58e273c 100644 --- a/app/src/main/java/com/flux/ui/viewModel/HabitViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/HabitViewModel.kt @@ -3,8 +3,10 @@ package com.flux.ui.viewModel import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flux.data.model.HabitConfig import com.flux.data.model.HabitInstanceModel import com.flux.data.model.HabitModel +import com.flux.data.model.toScheduleRequest import com.flux.data.repository.HabitRepository import com.flux.other.cancelReminder import com.flux.other.scheduleNextReminder @@ -44,29 +46,20 @@ class HabitViewModel @Inject constructor(private val repository: HabitRepository private suspend fun reduce(event: HabitEvents) { when (event) { is HabitEvents.DeleteHabit -> deleteHabit(event.habit, event.context) - is HabitEvents.EnterWorkspace -> { updateState { if (it.workspaceId == event.workspaceId) { it } else { it.copy(workspaceId = event.workspaceId, isLoading = true) }} } is HabitEvents.UpsertHabit -> upsertHabit(event.context, event.habit) - is HabitEvents.MarkDone -> upsertInstance(event.habitInstance) - is HabitEvents.MarkUndone -> deleteInstance(event.habitInstance) + is HabitEvents.UpdateInstance -> updateInstance(event.habitInstance, event.config) is HabitEvents.DeleteAllWorkspaceHabits -> deleteWorkspaceHabits(event.workspaceId, event.context) } } init { viewModelScope.launch { - state - .map { it.workspaceId } - .distinctUntilChanged() - .filterNotNull() - .flatMapLatest { workspaceId -> - combine( - repository.loadAllHabitsOfWorkspace(workspaceId), - repository.loadAllHabitInstance(workspaceId) - ) { habits, instances -> - habits.sortedBy { it.startDateTime } to instances - } - } - .collect { (habits, instances) -> + combine( + repository.loadHabitData(), + repository.loadHabitInstanceData() + ) { habits, instances -> + habits.sortedBy { it.startDateTime } to instances + }.collect { (habits, instances) -> updateState { it.copy( isLoading = false, @@ -85,52 +78,32 @@ class HabitViewModel @Inject constructor(private val repository: HabitRepository private fun deleteWorkspaceHabits(workspaceId: String, context: Context) { viewModelScope.launch(Dispatchers.IO) { _state.value.allHabits.forEach { habit -> - cancelReminder( - context, - habit.id, - habit.type.toString(), - habit.title, - habit.description, - habit.workspaceId, - habit.endDateTime, - habit.startDateTime, - habit.notificationOffset, - habit.recurrence - ) + cancelReminder(context, habit.toScheduleRequest()) } repository.deleteAllWorkspaceHabit(workspaceId) } } - private fun upsertInstance(instance: HabitInstanceModel) { + private fun updateInstance(instance: HabitInstanceModel, config: HabitConfig) { + if(_state.value.allInstances.filter { it.habitId == instance.habitId }.any { it.instanceDate == instance.instanceDate } && config is HabitConfig.Simple){ + deleteInstance(instance) + return + } viewModelScope.launch(Dispatchers.IO) { repository.upsertHabitInstance(instance) } } private fun deleteHabit(data: HabitModel, context: Context) { viewModelScope.launch(Dispatchers.IO) { - cancelReminder(context, data.id, data.type.toString(), data.title, data.description, data.workspaceId, data.endDateTime, data.startDateTime, data.notificationOffset, data.recurrence) + cancelReminder(context,data.toScheduleRequest()) repository.deleteHabit(data) } } private fun upsertHabit(context: Context, data: HabitModel) { viewModelScope.launch(Dispatchers.IO) { - cancelReminder( - context, - data.id, - data.type.toString(), - data.title, - data.description, - data.workspaceId, - data.endDateTime, - data.startDateTime, - data.notificationOffset, - data.recurrence - ) - + cancelReminder(context,data.toScheduleRequest()) repository.upsertHabit(data) - - scheduleNextReminder(context = context, item = data) + scheduleNextReminder(context = context, data.toScheduleRequest()) } } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt index 92804b3..69af624 100644 --- a/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/JournalViewModel.kt @@ -8,7 +8,6 @@ import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flux.data.model.JournalModel -import com.flux.data.model.writtenOnDate import com.flux.data.repository.JournalRepository import com.flux.data.repository.SettingsRepository import com.flux.other.Constants @@ -31,11 +30,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.commonmark.Extension @@ -53,10 +47,6 @@ import org.commonmark.node.Heading import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import java.io.OutputStreamWriter -import java.time.Instant -import java.time.LocalDate -import java.time.YearMonth -import java.time.ZoneId import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @@ -78,35 +68,9 @@ class JournalViewModel @Inject constructor( init { viewModelScope.launch { - state - .map { it.workspaceId } - .distinctUntilChanged() - .filterNotNull() - .flatMapLatest { workspaceId -> - repository.loadAllEntries(workspaceId) - } - .combine( - state.map { it.selectedDate }.distinctUntilChanged() - ) { allJournals, selectedDate -> - val datedJournals = filterByDate(allJournals, selectedDate) - Pair(allJournals, datedJournals) - } - .combine( - state.map { it.selectedYearMonth }.distinctUntilChanged() - ) { (allJournals, datedJournals), selectedYearMonth -> - val monthlyCount = computeMonthlyCount(allJournals, selectedYearMonth) - Triple(allJournals, datedJournals, monthlyCount) - } - .collect { (allJournals, datedJournals, monthlyCount) -> - updateState { - it.copy( - isLoading = false, - allEntries = allJournals, - datedEntries = datedJournals, - monthlyJournalCount = monthlyCount - ) - } - } + repository.loadJournalData().collect { data -> + updateState { it.copy(isLoading = false, data = data) } + } } } @@ -136,14 +100,6 @@ class JournalViewModel @Inject constructor( is JournalEvents.DeleteEntry -> deleteEntry(event.entry) is JournalEvents.DeleteWorkspaceEntries -> deleteAllWorkspaceEntries(event.workspaceId) is JournalEvents.UpsertEntry -> upsertEntry(event.entry) - is JournalEvents.ChangeMonth -> updateState { it.copy(selectedYearMonth = event.newYearMonth) } - is JournalEvents.ChangeDate -> { - val newDate = event.newLocalDate - updateState { it.copy( - selectedDate = newDate, - selectedYearMonth = YearMonth.from(LocalDate.ofEpochDay(newDate)) - ) } - } is JournalEvents.CalculateOutline -> { val hash = event.content.hashCode() @@ -178,11 +134,8 @@ class JournalViewModel @Inject constructor( val rootUri = settingsRepository.getStorageRoot() val contentState = event.contentState - val baseDir = - getOrCreateDirectory(context, rootUri, Constants.File.FLUX) - val audioDir = baseDir?.let { dir -> - getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_AUDIO) - } + val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX) + val audioDir = baseDir?.let { dir -> getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_AUDIO) } audioDir?.let { dir -> val sourceFile = DocumentFile.fromSingleUri(context, sourceUri) @@ -278,30 +231,9 @@ class JournalViewModel @Inject constructor( } } } - - is JournalEvents.EnterWorkspace -> { updateState { if (it.workspaceId == event.workspaceId) { it } else { it.copy(workspaceId = event.workspaceId, isLoading = true) }} } } } - private fun computeMonthlyCount( - journals: List, - yearMonth: YearMonth - ): Map { - return journals - .filter { journal -> - val journalDate = Instant.ofEpochMilli(journal.dateTime) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - YearMonth.from(journalDate) == yearMonth - } - .groupBy { journal -> - Instant.ofEpochMilli(journal.dateTime) - .atZone(ZoneId.systemDefault()) - .toLocalDate() - } - .mapValues { (_, entries) -> entries.size } - } - fun buildOutline(markdown: CharSequence): HeaderNode { val document = PARSER.parse(markdown.toString()) val root = HeaderNode("", 0, IntRange.EMPTY) @@ -409,12 +341,4 @@ class JournalViewModel @Inject constructor( private fun upsertEntry(data: JournalModel) { viewModelScope.launch(Dispatchers.IO) { repository.upsertEntry(data) } } - - private fun filterByDate( - entries: List, - epochDay: Long - ): List { - val date = LocalDate.ofEpochDay(epochDay) - return entries.filter { it.writtenOnDate(date) } - } } \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/viewModel/LabelViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/LabelViewModel.kt new file mode 100644 index 0000000..c3c0bfd --- /dev/null +++ b/app/src/main/java/com/flux/ui/viewModel/LabelViewModel.kt @@ -0,0 +1,59 @@ +package com.flux.ui.viewModel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flux.data.model.LabelModel +import com.flux.data.repository.LabelRepository +import com.flux.ui.events.LabelEvents +import com.flux.ui.state.LabelState +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class LabelViewModel @Inject constructor( + private val repository: LabelRepository +) : ViewModel() { + private val _state: MutableStateFlow = MutableStateFlow(LabelState()) + val state: StateFlow = _state.asStateFlow() + + fun onEvent(event: LabelEvents) { + viewModelScope.launch { reduce(event = event) } + } + + private fun updateState(reducer: (LabelState) -> LabelState) { + _state.value = reducer(_state.value) + } + + init { + viewModelScope.launch { + repository.loadAllLabels().collect { labels -> updateState { it.copy(isLoading = false, allLabels = labels) } } + } + } + + private fun reduce(event: LabelEvents) { + when (event) { + is LabelEvents.DeleteLabel -> deleteLabel(event.data) + is LabelEvents.UpsertLabel -> upsertLabel(event.data) + is LabelEvents.DeleteAllWorkspaceLabels -> deleteAllWorkspaceLabels(event.workspaceId) + } + } + + private fun deleteLabel(data: LabelModel) { + viewModelScope.launch(Dispatchers.IO) { repository.deleteLabel(data) } + } + + private fun upsertLabel(data: LabelModel) { + viewModelScope.launch(Dispatchers.IO) { repository.upsertLabel(data) } + } + + private fun deleteAllWorkspaceLabels(workspaceId: String) { + viewModelScope.launch(Dispatchers.IO) { repository.deleteAllWorkspaceLabels(workspaceId) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt index cc01fa6..97a1154 100644 --- a/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/NotesViewModel.kt @@ -7,7 +7,6 @@ import androidx.compose.ui.util.fastJoinToString import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.flux.data.model.LabelModel import com.flux.data.model.NotesModel import com.flux.data.repository.NoteRepository import com.flux.data.repository.SettingsRepository @@ -32,11 +31,6 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -102,27 +96,9 @@ class NotesViewModel @Inject constructor( init { viewModelScope.launch { - state - .map { it.workspaceId } - .distinctUntilChanged() - .filterNotNull() - .flatMapLatest { workspaceId -> - combine( - repository.loadAllNotes(workspaceId), - repository.loadAllLabels(workspaceId) - ) { notes, labels -> - notes.sortedByDescending { it.lastEdited } to labels - } - } - .collect { (notes, labels) -> - updateState { - it.copy( - isNotesLoading = false, - isLabelsLoading = false, - allNotes = notes, - allLabels = labels - ) - } + repository.loadNotesData() + .collect { notes -> + updateState { it.copy(isLoading = false, allNotes = notes) } } } } @@ -133,8 +109,6 @@ class NotesViewModel @Inject constructor( is NotesEvents.DeleteNotes -> deleteNotes(event.data) is NotesEvents.TogglePinMultiple -> togglePinMultiple(event.data) is NotesEvents.DeleteNote -> deleteNote(event.data) - is NotesEvents.DeleteLabel -> deleteLabel(event.data) - is NotesEvents.UpsertLabel -> upsertLabel(event.data) is NotesEvents.DeleteAllWorkspaceNotes -> deleteWorkspaceNotes(event.workspaceId) is NotesEvents.ClearSelection -> { updateState { it.copy(selectedNotes = emptyList()) } } is NotesEvents.SelectNotes -> { updateState { it.copy(selectedNotes = it.selectedNotes.plus(event.noteId)) } } @@ -276,8 +250,6 @@ class NotesViewModel @Inject constructor( updateState { it.copy(textState = TextState.fromText(event.content)) } } } - - is NotesEvents.EnterWorkspace -> { updateState { if (it.workspaceId == event.workspaceId) { it } else { it.copy(workspaceId = event.workspaceId, isNotesLoading = true, isLabelsLoading = true) }} } } } @@ -385,14 +357,6 @@ class NotesViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { repository.deleteNote(data) } } - private fun deleteLabel(data: LabelModel) { - viewModelScope.launch(Dispatchers.IO) { repository.deleteLabel(data) } - } - - private fun upsertLabel(data: LabelModel) { - viewModelScope.launch(Dispatchers.IO) { repository.upsertLabel(data) } - } - private fun deleteWorkspaceNotes(workspaceId: String) { viewModelScope.launch(Dispatchers.IO) { repository.deleteAllWorkspaceNotes(workspaceId) } } diff --git a/app/src/main/java/com/flux/ui/viewModel/ProgressBoardViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/ProgressBoardViewModel.kt index fad4c24..4d72f56 100644 --- a/app/src/main/java/com/flux/ui/viewModel/ProgressBoardViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/ProgressBoardViewModel.kt @@ -12,10 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,28 +33,15 @@ class ProgressBoardViewModel @Inject constructor( init { viewModelScope.launch { - state - .map { it.workspaceId } - .distinctUntilChanged() - .filterNotNull() - .flatMapLatest { workspaceId: String -> - repository.getBoardItemsByWorkspace(workspaceId) - } - .collect { items -> - updateState { - it.copy( - isLoading = false, - allItems = items - ) - } - } + repository.getProgressBoardData().collect { items -> + updateState { it.copy(isLoading = false, allItems = items) } + } } } private fun reduce(event: ProgressBoardEvents) { when (event) { is ProgressBoardEvents.DeleteBoardItemsByWorkspace -> deleteBoardItemsByWorkspace(event.workspaceId) - is ProgressBoardEvents.EnterWorkspace -> { updateState { if (it.workspaceId == event.workspaceId) { it } else {it.copy(workspaceId = event.workspaceId, isLoading = true) }} } is ProgressBoardEvents.DeleteProgressItem -> deleteProgressItem(event.data) is ProgressBoardEvents.UpsertProgressItem -> upsertProgressItem(event.data) } diff --git a/app/src/main/java/com/flux/ui/viewModel/TodoViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/TodoViewModel.kt index ae14436..0fdbbce 100644 --- a/app/src/main/java/com/flux/ui/viewModel/TodoViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/TodoViewModel.kt @@ -12,10 +12,6 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject @@ -37,21 +33,8 @@ class TodoViewModel @Inject constructor( init { viewModelScope.launch { - state - .map { it.workspaceId } - .distinctUntilChanged() - .filterNotNull() - .flatMapLatest { workspaceId: String -> - repository.loadAllLists(workspaceId) - } - .collect { lists -> - updateState { - it.copy( - isLoading = false, - allLists = lists - ) - } - } + repository.loadTodoData() + .collect { lists -> updateState { it.copy(isLoading = false, allLists = lists) } } } } @@ -60,7 +43,6 @@ class TodoViewModel @Inject constructor( is TodoEvents.DeleteList -> { deleteList(event.data) } is TodoEvents.UpsertList -> { upsertList(event.data) } is TodoEvents.DeleteAllWorkspaceLists -> deleteWorkspaceLists(event.workspaceId) - is TodoEvents.EnterWorkspace -> { updateState { if (it.workspaceId == event.workspaceId) { it } else {it.copy(workspaceId = event.workspaceId, isLoading = true) }} } } } diff --git a/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt b/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt index f33acbb..2ffb819 100644 --- a/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt +++ b/app/src/main/java/com/flux/ui/viewModel/ViewModels.kt @@ -9,5 +9,6 @@ data class ViewModels( val journalViewModel: JournalViewModel, val settingsViewModel: SettingsViewModel, val backupViewModel: BackupViewModel, + val labelViewModel: LabelViewModel, val progressBoardViewModel: ProgressBoardViewModel ) diff --git a/app/src/main/java/com/flux/ui/viewModel/WorkspaceViewModel.kt b/app/src/main/java/com/flux/ui/viewModel/WorkspaceViewModel.kt index 29faef4..9bf0b71 100644 --- a/app/src/main/java/com/flux/ui/viewModel/WorkspaceViewModel.kt +++ b/app/src/main/java/com/flux/ui/viewModel/WorkspaceViewModel.kt @@ -100,7 +100,6 @@ class WorkspaceViewModel @Inject constructor( } } - private fun togglePinWorkspaces(spaces: List) { viewModelScope.launch(Dispatchers.IO) { val isAllPinned=spaces.all { it.isPinned } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 718125e..a9668a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,9 @@ browser = "1.9.0" documentfile = "1.1.0" workRuntimeKtx = "2.11.1" foundationLayout = "1.10.5" +adaptive = "1.2.0" +foundation = "1.11.0" +navigationCommonKtx = "2.9.8" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -70,6 +73,9 @@ androidx-browser = { group = "androidx.browser", name = "browser", version.ref = androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" } androidx-compose-foundation-layout = { group = "androidx.compose.foundation", name = "foundation-layout", version.ref = "foundationLayout" } +androidx-compose-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive", version.ref = "adaptive" } +androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version.ref = "foundation" } +androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCommonKtx" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }