9 Commits
Author SHA1 Message Date
chindaronit b6f5e24224 src: removing gradle-daemon-jvm.properties 2026-06-25 16:41:23 +05:30
chindaronit 4ce8d319a6 chore: removing foojay plugin from setting.gradle for fdroid build 2026-06-24 17:37:48 +05:30
chindaronit b9dc0d0f71 feat:
1. Content copy/move to another workspaces
2. Various export options in todo
3. Clone a data point
4. Responsive UI for various display size.
5. Day addition in journal timeline with 24-hour format support

fix:
1. text overflow at various places
2026-06-24 22:04:50 +05:30
chindaronit 6154e09516 feat:
1. Notes Preview Mode to adjust notes height
src:
    1. Improved Markdown Render in preview mode for media, links and codeblocks
fix:
    1. Progress Tracker date bug.
    2. Automatically detect line break in editor
2026-06-07 22:07:32 +05:30
chindaronit 5493c78ec6 fix: migration update. 2026-06-07 00:34:54 +05:30
chindaronit 293a8f34ba fix: Build crash due to room migration,
chore: CONTRIBUTING.md
2026-06-07 00:02:05 +05:30
chindaronit 14a462afba fix: TodoExpandable card bug in SearchScreen.kt 2026-06-06 14:36:36 +05:30
chindaronit a2b0540403 feat:
1. Added Undo in Todo Item removal
2. Draggable Items to reorder in todo.
3. Reminder in Todo Items to remind and analyse the list.
fix:
1. Dated Journal entry bug
2. Create Button Text overflow in note/journal.
2026-06-06 14:08:52 +05:30
chindaronit 575d44485b feat: drag handle added to redorder todo items. 2026-05-25 22:55:48 +05:30
80 changed files with 4981 additions and 708 deletions
+222
View File
@@ -0,0 +1,222 @@
# Contributing to Flux
Thank you for your interest in contributing to Flux! Your support means a lot.
---
## Table of Contents
- [Getting Started](#getting-started)
- [How to Contribute](#how-to-contribute)
- [Branch Naming](#branch-naming)
- [Commit Messages](#commit-messages)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Architecture Overview](#architecture-overview)
- [Module Structure](#module-structure)
- [Code Style & Conventions](#code-style--conventions)
- [Tech Stack](#tech-stack)
---
## Getting Started
1. Install the latest stable version of [Android Studio](https://developer.android.com/studio).
2. Clone the repository:
```bash
git clone git@github.com:chindaronit/Flux.git
```
3. Open the project in Android Studio and let Gradle sync complete.
4. Select `Run > Run 'app'` to build and launch the app on a device or emulator.
**Requirements:**
- Android SDK target: **36**, minimum: **29** (Android 10+)
- Kotlin: **2.2.10**
- JVM Target: **11**
- Build tool: **Gradle with Kotlin DSL**
---
## How to Contribute
1. **Fork the Repository** — Fork to your own GitHub account.
2. **Create a Branch** — Branch off from `main` using the naming conventions below.
3. **Make Your Changes** — Follow the architecture and code style described in this guide.
4. **Open an Issue First** — For non-trivial changes, create an issue before opening a PR so the approach can be discussed.
5. **Submit a Pull Request** — Target the `dev` branch. Reference the related issue in the PR description.
---
## Branch Naming
Use descriptive, hyphenated branch names prefixed by type:
| Type | Pattern | Example |
|------|---------|---------|
| Feature | `feat/<short-description>` | `feat/add-calendar-widget` |
| Bug fix | `fix/<issue-or-description>` | `fix/resolve-issue-123` |
| Refactor | `refactor/<description>` | `refactor/viewmodel-cleanup` |
| Translation | `i18n/<language>` | `i18n/add-japanese` |
| Documentation | `docs/<description>` | `docs/update-contributing` |
---
## Commit Messages
Write concise, present-tense commit messages that describe *what* the change does:
```
feat: add biometric lock toggle in settings
fix: resolve crash when deleting last workspace
refactor: extract reminder logic into ReminderReceiver
i18n: add Spanish translation strings
```
Avoid vague messages like `fix stuff`, `WIP`, or `update`.
---
## Pull Request Guidelines
- Target the **`dev`** branch, not `master`/`main`.
- Reference the issue your PR addresses (e.g., `Closes #42`).
- Keep PRs focused — one feature or fix per PR.
- Ensure the app builds and runs without errors before submitting.
- Add or update string resources in `res/values/strings.xml` for any user-facing text.
- If adding a new screen, register it in the navigation graph (`navigation/`).
- If adding a new data entity, update the relevant DAO, model, repository, and DI module.
- For new languages, add a corresponding `res/values-<lang>/strings.xml` file.
I'll review your pull request as soon as possible. Thank you for your contribution!
---
## Architecture Overview
Flux follows **MVI (ModelViewIntent)** architecture with **Jetpack Compose** for the UI layer. The data flow is strictly unidirectional:
```
User Interaction (Intent / Event)
ViewModel
State Update
Composable UI (re-renders from State)
```
- **Model** — Data layer: Room entities, DAOs, repositories.
- **View** — Composable screens that observe `State` objects and emit `Events`.
- **Intent/ViewModel** — Processes events, calls repository methods, and emits new `State`.
Side effects (navigation, toasts, etc.) are handled via `Effects` (one-shot flows) separate from persistent UI state.
**Dependency injection** is provided by **Hilt**, keeping ViewModels and repositories decoupled and testable.
---
## Module Structure
All source code lives under `app/src/main/java/com/flux/`.
```
app/src/main/java/com/flux/
├── data/ # Data layer
│ ├── dao/ # Room DAO interfaces (one per entity)
│ ├── models/ # Room @Entity data classes and related data models
│ ├── repository/ # Repository implementations (single source of truth)
│ └── database/ # Room database definition and migrations
├── di/ # Hilt dependency injection modules
│ ├── DataModules.kt # Provides database and DAO instances
│ ├── RepositoryModules.kt # Binds repository interfaces to implementations
│ └── Flux.kt # @HiltAndroidApp application class
├── navigation/ # Compose Navigation graph and route definitions
├── other/ # Utility and system-integration classes
│ ├── utils/ # General-purpose helper functions and extensions
│ ├── BackupManager.kt # Handles data backup and restore logic
│ ├── ReminderReceiver.kt # BroadcastReceiver for scheduled reminders
│ ├── BootReceiver.kt # Reschedules reminders after device reboot
│ ├── workspaceIcons/ # Icon assets and mapping utilities for workspaces
│ ├── BiometricAuthentication.kt # Biometric lock integration
│ └── [other app-wide utilities]
├── ui/ # UI layer (Jetpack Compose)
│ ├── common/ # Shared, reusable composables (buttons, dialogs, cards, etc.)
│ ├── events/ # Sealed classes / data classes representing user intents per screen
│ ├── states/ # Data classes holding observable UI state per screen
│ ├── effects/ # One-shot side-effect flows (navigation, snackbars, etc.)
│ ├── screens/ # Full-screen composables, one file per screen
│ ├── viewModels/ # Hilt-injected ViewModels; process events, expose state
│ └── theme/ # Material 3 color scheme, typography, and shape definitions
└── MainActivity.kt # Single-activity entry point; hosts NavHost
```
```
app/src/main/res/
├── drawable/ # Vector drawables and icons
└── values/ # Resource files
├── strings.xml # Default (English) strings
└── values-<lang>/ # Translations (hi, fr, pt-BR, ru, de, es, nl, zh-rCN, …)
```
### Adding a New Feature — Checklist
When building a new screen or feature, create/update files in this order:
1. **`data/models/`** — Define the Room entity or data class.
2. **`data/dao/`** — Write the DAO interface with required queries.
3. **`data/database/`** — Add the entity to the database and increment the version with a migration.
4. **`data/repository/`** — Implement the repository exposing `Flow`s and suspend functions.
5. **`di/`** — Bind the new DAO/repository in the appropriate Hilt module.
6. **`ui/events/`** — Define a sealed class for all user actions on the new screen.
7. **`ui/states/`** — Define the data class representing the full UI state for the screen.
8. **`ui/viewModels/`** — Implement the ViewModel; inject the repository via Hilt.
9. **`ui/screens/`** — Build the Composable screen; collect state, dispatch events.
10. **`navigation/`** — Register the new route and composable in the nav graph.
11. **`res/values/strings.xml`** — Add all user-facing strings; keep zero hardcoded text in composables.
---
## Code Style & Conventions
- **Language**: Kotlin only. No Java files.
- **Formatting**: Follow standard Kotlin style (4-space indent, no wildcard imports).
- **Naming**:
- ViewModels: `<Feature>ViewModel` (e.g., `NoteViewModel`)
- States: `<Feature>State` (e.g., `NoteState`)
- Events: `<Feature>Event` (e.g., `NoteEvent`)
- Effects: `<Feature>Effect` (e.g., `NoteEffect`)
- Screens: `<Feature>Screen` (e.g., `NoteScreen`)
- DAOs: `<Entity>Dao` (e.g., `NoteDao`)
- **State hoisting**: Keep composables stateless where possible; hoist state to the ViewModel.
- **No business logic in composables**: Composables should only render state and forward events.
- **Strings**: All user-visible text must live in `strings.xml`. Never hardcode strings in composables.
- **Markdown content**: Flux uses CommonMark + GitHub Flavored Markdown (GFM) with LaTeX math support. Refer to `Guide.md` for supported syntax.
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Language | Kotlin 2.2.10 |
| UI | Jetpack Compose + Material 3 (Material You) |
| Architecture | MVI + ViewModel |
| DI | Hilt |
| Database | Room (SQLite abstraction) |
| Navigation | Compose Navigation |
| Markdown | CommonMark + Flexmark (HTML→MD) |
| Math | LaTeX via inline renderer |
| Async | Kotlin Coroutines + Flow |
| Build | Gradle Kotlin DSL |
---
## Questions?
Open a [Discussion](https://github.com/chindaronit/Flux/discussions) or file an [Issue](https://github.com/chindaronit/Flux/issues) — happy to help you get oriented.
+31 -23
View File
@@ -40,23 +40,25 @@
## 🎉 Features
- 📝 **Sleek, Minimalistic Design** with Material You (Material 3)
- 🌟 **Workspace Templates**: Organize tasks with custom workspaces
- 🔐 **Biometric App Lock** for privacy
- 🚀 **Frequent Updates & Improvements**
---
## 💬 Contact Me
- 📧 **Email:** ronitchinda100@gmail.com
- 📸 **Instagram:** [@chinda.ronit](https://www.instagram.com/chinda_ronit/)
***Modern Material You Design** built with Material 3 for a clean and intuitive experience.
* 🗂️ **Flexible Workspaces** with customizable templates to organize notes, tasks, journals, habits, and events according to your workflow.
* 📝 **Rich Markdown Editor** with support for formatting, code blocks, tables, LaTeX, Mermaid diagrams, and more.
***Advanced Task Management** with recurring tasks, reminders, progress tracking, and completion analytics.
* 📔 **Daily Journals & Notes** designed for capturing ideas, reflections, and long-form content.
* 📅 **Integrated Calendar & Events** to manage schedules and important dates in one place.
* 🔥 **Habit Tracking & Analytics** with insights, statistics, and visual progress monitoring.
* 🖼️ **Media Support** for attaching images and organizing content alongside text.
* 🔒 **Privacy-Focused Security** with optional biometric app lock and screen protection.
* 📴 **Offline-First Architecture** powered by local storage for fast and reliable access.
* 🎨 **Highly Customizable Experience** with themes, workspace organization, and personalized layouts.
* 🌍 **Open Source & Community Driven** under GPL-3.0 with frequent updates and continuous improvements.
* 🚀 **Built with Jetpack Compose** for a fast, smooth, and native Android experience.
---
## 🌎 Translations
English, Hindi, French, Portugal (Brazil), Russian, German, Spanish, Dutch
English, Hindi, French, Portugal (Brazil), Russian, German, Spanish, Dutch, Chinese (Simple)
## 🔎 Technical Details
@@ -64,7 +66,7 @@ English, Hindi, French, Portugal (Brazil), Russian, German, Spanish, Dutch
- **Build Tool**: Gradle with Kotlin DSL
- **Android Version**: The application targets Android SDK version 36 and is compatible with devices
running Android SDK version 29 and above.
- **Kotlin Version**: 2.2.10.
- **Kotlin Version**: 2.4.0
- **Java Version**: JVM Target 11.
## 🛠️ Architecture
@@ -73,15 +75,19 @@ English, Hindi, French, Portugal (Brazil), Russian, German, Spanish, Dutch
## 📚 Libraries and Frameworks
- **Compose**: A modern toolkit for building native Android UI.
- **Hilt**: A dependency injection library for Android.
- **KSP (Kotlin Symbol Processing API)**: Enhances Kotlin compilation with additional metadata
processing.
- **Room**: A persistence library providing an abstraction layer over SQLite.
- **Compose Navigation**: Simplifies the implementation of navigation between screens.
- **Material Icons**: Provides Material Design icons for consistent visual elements.
- **CommonMark**: For markdown parsing and rendering.
- **Flexmark HTML to Markdown Converter**: Converts HTML to Markdown.
- 🎨 Modern Material You Interface — Material 3 design language, clean and intuitive UX.
- 🗂️ Customizable Workspaces — templates for notes, tasks, journals, habits, and events.
- 📝 Powerful Markdown Editor — tables, code blocks, LaTeX equations, Mermaid diagrams, and advanced formatting.
- 📋 Comprehensive Task Management — recurring tasks, reminders, progress tracking, and completion analytics.
- 📔 Notes & Journaling — capture ideas, daily reflections, and long-form content.
- 📅 Calendar & Event Planning — manage schedules, deadlines, and important dates.
- 📊 Habit Tracking & Insights — statistics and visual progress analytics.
- 🖼️ Media Support — attach and organize images alongside your content.
- 🔐 Privacy-First Design — biometric app lock and screen protection.
- ✈️ Offline-First Architecture — fast, reliable access without requiring an internet connection.
- ⚙️ Personalized Experience — theme customization and flexible workspace organization.
- 🔓 Open Source — licensed under GPL-3.0, with active development and regular updates.
- 🤖 Built with Jetpack Compose — smooth, modern, native Android experience.
## 📦 Installation
@@ -101,7 +107,9 @@ an issue. If you want to contribute code directly to this project, you can creat
<div align="center">
## Thanks to all contributors
## Credits.
[![Stargazers over time](https://starchart.cc/chindaronit/Flux.svg?variant=adaptive)](https://starchart.cc/chindaronit/Flux)
<a href="https://github.com/chindaronit/Flux/graphs/contributors">
<img src="https://contrib.rocks/image?repo=chindaronit/Flux" width="200"/>
+9 -5
View File
@@ -8,14 +8,14 @@ plugins {
android {
namespace = "com.flux"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.flux"
minSdk = 29
targetSdk = 36
versionCode = 11
versionName = "3.1.5"
targetSdk = 37
versionCode = 14
versionName = "3.1.8"
}
dependenciesInfo {
@@ -106,6 +106,7 @@ dependencies {
// Hilt
ksp(libs.hilt.android.compiler)
ksp(libs.kotlinMetadataWorkaround)
implementation(libs.hilt.android)
implementation(libs.hilt.navigation.compose)
@@ -121,7 +122,7 @@ dependencies {
implementation(libs.gson)
implementation(libs.kotlinx.serialization.json)
// CommonMark, for markdown rendering and parsing
// CommonMark, for Markdown rendering and parsing
implementation(libs.commonmark.ext.autolink)
implementation(libs.commonmark.ext.footnotes)
implementation(libs.commonmark.ext.ins)
@@ -133,4 +134,7 @@ dependencies {
implementation(libs.commonmark.ext.yaml.front.matter)
implementation(libs.commonmark)
implementation(libs.flexmarkHtml2mdConverter)
// draggable list
implementation(libs.reorderable)
}
+30 -3
View File
@@ -146,12 +146,39 @@
}
</style>
<!-- Async CSS loading -->
<link rel="stylesheet" href="file:///android_asset/katex/katex.min.css">
<link rel="stylesheet" href="file:///android_asset/prism/prism-theme-light.css"
<link rel="stylesheet"
href="file:///android_asset/prism/prism-theme-light.css"
id="prism-light-theme">
<link rel="stylesheet" href="file:///android_asset/prism/prism-theme-dark.css"
<link rel="stylesheet"
href="file:///android_asset/prism/prism-theme-dark.css"
id="prism-dark-theme">
<style>
pre {
background-color: {{PRE_BACKGROUND}} !important;
}
pre[class*="language-"] {
background-color: {{PRE_BACKGROUND}} !important;
color: {{TEXT_COLOR}} !important;
border-radius: 8px;
}
pre code {
background: transparent !important;
}
:not(pre) > code,
:not(pre) > code[class*="language-"] {
background-color: {{CODE_BACKGROUND}} !important;
color: {{TEXT_COLOR}} !important;
padding: 4px 4px 2px 4px;
border-radius: 4px;
}
</style>
</head>
<body>
<main>{{CONTENT}}</main>
@@ -0,0 +1,29 @@
package com.flux.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.flux.data.model.TodoInstance
import kotlinx.coroutines.flow.Flow
@Dao
interface TodoInstanceDao {
@Query("SELECT EXISTS(SELECT 1 FROM TodoInstance WHERE todoId = :todoId and instanceDate = :instanceDate)")
suspend fun exists(todoId: String, instanceDate: Long): Boolean
@Query("SELECT * FROM TodoInstance")
fun loadAll(): Flow<List<TodoInstance>>
@Query("SELECT * FROM TodoInstance")
fun loadAllInstances(): List<TodoInstance>
@Query("DELETE FROM TodoInstance WHERE workspaceId = :workspaceId")
suspend fun deleteAllWorkspaceInstance(workspaceId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertTodoInstance(instance: TodoInstance)
@Query("DELETE FROM TodoInstance WHERE todoId = :listId")
suspend fun deleteListInstances(listId: String)
}
@@ -9,6 +9,7 @@ import com.flux.data.model.LabelModel
import com.flux.data.model.NotesModel
import com.flux.data.model.ProgressBoardModel
import com.flux.data.model.SettingsModel
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
import kotlinx.serialization.Serializable
@@ -18,6 +19,7 @@ data class FluxBackup(
val workspaces: List<WorkspaceModel> = emptyList(),
val notes: List<NotesModel> = emptyList(),
val todos: List<TodoModel> = emptyList(),
val todoInstances: List<TodoInstance> = emptyList(),
val habits: List<HabitModel> = emptyList(),
val habitInstances: List<HabitInstanceModel> = emptyList(),
val journals: List<JournalModel> = emptyList(),
@@ -15,6 +15,7 @@ import com.flux.data.dao.NotesDao
import com.flux.data.dao.ProgressBoardDao
import com.flux.data.dao.SettingsDao
import com.flux.data.dao.TodoDao
import com.flux.data.dao.TodoInstanceDao
import com.flux.data.dao.WorkspaceDao
import com.flux.data.model.Converter
import com.flux.data.model.EventInstanceModel
@@ -26,6 +27,7 @@ import com.flux.data.model.LabelModel
import com.flux.data.model.NotesModel
import com.flux.data.model.ProgressBoardModel
import com.flux.data.model.SettingsModel
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
import com.google.gson.Gson
@@ -35,8 +37,8 @@ import kotlinx.serialization.json.Json
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 = 8,
entities = [EventModel::class, LabelModel::class, EventInstanceModel::class, SettingsModel::class, NotesModel::class, HabitModel::class, HabitInstanceModel::class, WorkspaceModel::class, TodoModel::class, JournalModel::class, ProgressBoardModel::class, TodoInstance::class],
version = 10,
exportSchema = false
)
@TypeConverters(Converter::class)
@@ -52,6 +54,7 @@ abstract class FluxDatabase : RoomDatabase() {
abstract val todoDao: TodoDao
abstract val labelDao: LabelDao
abstract val progressBoardDao: ProgressBoardDao
abstract val todoInstanceDao: TodoInstanceDao
}
private fun SupportSQLiteDatabase.safeExec(sql: String) {
@@ -347,4 +350,66 @@ val MIGRATION_7_8 = object : Migration(7, 8) {
if (!db.columnExists("JournalModel", "labels"))
db.safeExec("ALTER TABLE JournalModel ADD COLUMN labels TEXT NOT NULL DEFAULT '[]'")
}
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
// 1. Create corrected TodoModel table with proper constraints
db.safeExec("""
CREATE TABLE TodoModel_new (
id TEXT NOT NULL PRIMARY KEY,
workspaceId TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL DEFAULT '',
items TEXT NOT NULL DEFAULT '[]',
startDateTime INTEGER NOT NULL DEFAULT ${System.currentTimeMillis()},
recurrence TEXT NOT NULL DEFAULT '{"type":"NONE"}'
)
""".trimIndent())
// 2. Copy existing data, coercing any nulls
db.safeExec("""
INSERT INTO TodoModel_new (id, workspaceId, title, items, startDateTime, recurrence)
SELECT
id,
COALESCE(workspaceId, ''),
COALESCE(title, ''),
COALESCE(items, '[]'),
${System.currentTimeMillis()},
'{"type":"NONE"}'
FROM TodoModel
""".trimIndent())
// 3. Swap tables
db.safeExec("DROP TABLE TodoModel")
db.safeExec("ALTER TABLE TodoModel_new RENAME TO TodoModel")
// 4. Create TodoInstance table
if (!db.tableExists("TodoInstance")) {
db.safeExec("""
CREATE TABLE TodoInstance (
id TEXT NOT NULL PRIMARY KEY,
todoId TEXT NOT NULL,
instanceDate INTEGER NOT NULL,
workspaceId TEXT NOT NULL,
items TEXT NOT NULL DEFAULT '[]',
FOREIGN KEY (todoId) REFERENCES TodoModel(id) ON DELETE CASCADE
)
""".trimIndent())
}
// 5. Create unique index on TodoInstance
db.safeExec("""
CREATE UNIQUE INDEX IF NOT EXISTS
index_TodoInstance_todoId_instanceDate
ON TodoInstance(todoId, instanceDate)
""".trimIndent())
}
}
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
if (!db.columnExists("SettingsModel", "notesPreviewMode"))
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN notesPreviewMode INTEGER NOT NULL DEFAULT 1")
}
}
@@ -73,5 +73,7 @@ fun EventModel.occursOn(date: LocalDate): Boolean {
date.dayOfMonth == eventStart.dayOfMonth &&
date.month == eventStart.month
}
else -> return false
}
}
@@ -4,6 +4,9 @@ import kotlinx.serialization.Serializable
@Serializable
sealed class RecurrenceRule {
@Serializable
object NONE: RecurrenceRule()
@Serializable
object Once : RecurrenceRule()
@@ -5,7 +5,7 @@ import android.content.Intent
import com.flux.other.ReminderReceiver
import kotlinx.serialization.json.Json
enum class ReminderType { EVENT, HABIT }
enum class ReminderType { EVENT, HABIT, TODO }
data class ScheduleRequest(
val itemId: String,
@@ -44,13 +44,6 @@ data class ScheduleRequest(
}
}
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,
@@ -76,6 +69,18 @@ fun EventModel.toScheduleRequest() = ScheduleRequest(
workspaceId = workspaceId
)
fun TodoModel.toScheduleRequest() = ScheduleRequest(
itemId = id,
itemType = ReminderType.TODO,
title = title,
description = "",
recurrence = recurrence,
startDateTime = startDateTime,
endDateTime = -1L,
notificationOffset = 0L,
workspaceId = workspaceId
)
fun ScheduleRequest.toIntent(context: Context): Intent {
return Intent(context, ReminderReceiver::class.java).apply {
putExtra("itemId", itemId)
@@ -28,5 +28,6 @@ data class SettingsModel(
val isLineNumbersVisible: Boolean = false,
val startWithReadView: Boolean = false,
val storageRootUri: String? = null,
val backupFrequency: Int = 0
val backupFrequency: Int = 0,
val notesPreviewMode: Int = 1
)
@@ -1,18 +1,23 @@
package com.flux.data.model
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import java.util.UUID
import kotlinx.serialization.Serializable
import java.time.LocalDate
@Serializable
@Entity
data class TodoModel(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val workspaceId: String ="",
val workspaceId: String = "",
val title: String = "",
val items: List<TodoItem> = emptyList()
val items: List<TodoItem> = emptyList(),
val startDateTime: Long = System.currentTimeMillis(),
val recurrence: RecurrenceRule = RecurrenceRule.NONE,
)
@Serializable
@@ -20,4 +25,187 @@ data class TodoItem(
val id: String = UUID.randomUUID().toString(),
val value: String = "",
val isChecked: Boolean = false
)
)
@Serializable
@Entity(
foreignKeys = [ForeignKey(
entity = TodoModel::class,
parentColumns = ["id"],
childColumns = ["todoId"],
onDelete = ForeignKey.CASCADE
)],
indices = [
Index(
value = ["todoId", "instanceDate"],
unique = true
)
]
)
data class TodoInstance(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
val todoId: String = "",
val workspaceId: String="",
val instanceDate: Long = LocalDate.now().toEpochDay(),
val items: List<TodoItem> = emptyList()
)
fun TodoInstance.isCompleted(): Boolean {
return items.all { it.isChecked }
}
fun TodoModel.toHtml(): String {
val itemsHtml = items.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "" else ""
"""
<div class="todo-item">
<span class="checkbox">$checkbox</span>
<span>${item.value}</span>
</div>
""".trimIndent()
}
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: sans-serif;
padding: 32px;
color: black;
background: white;
}
h1 {
margin-bottom: 24px;
}
.todo-item {
display: flex;
align-items: center;
margin-bottom: 16px;
font-size: 18px;
}
.checkbox {
margin-right: 12px;
}
</style>
</head>
<body>
<h1>$title</h1>
$itemsHtml
</body>
</html>
""".trimIndent()
}
fun TodoInstance.toHtml(title: String): String {
val itemsHtml = items.joinToString("\n") { item ->
val checkbox = if (item.isChecked) "" else ""
"""
<div class="todo-item">
<span class="checkbox">$checkbox</span>
<span class="text">${item.value}</span>
</div>
""".trimIndent()
}
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<style>
html {
width: max-content;
height: max-content;
}
body {
display: inline-block;
width: max-content;
margin: 0;
padding: 32px;
background: white;
color: black;
font-family: sans-serif;
}
h1 {
margin: 0 0 24px 0;
font-size: 28px;
font-weight: bold;
white-space: nowrap;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
font-size: 18px;
line-height: 1.5;
white-space: nowrap;
}
.checkbox {
flex-shrink: 0;
font-size: 20px;
}
.text {
flex: 1;
}
</style>
</head>
<body>
<h1>$title</h1>
$itemsHtml
</body>
</html>
""".trimIndent()
}
// Title is excluded
fun TodoModel.toMarkdownContent(): String {
return buildString {
items.forEach { item ->
val check = if (item.isChecked) "x" else " "
append("- [$check] ${item.value}\n")
}
}
}
fun TodoModel.toMarkdown(): String {
return buildString {
append("# $title\n\n")
items.forEach { item ->
val check = if (item.isChecked) "x" else " "
append("- [$check] ${item.value}\n")
}
}
}
fun TodoModel.toText(): String {
return buildString {
append("# $title\n\n")
items.forEach { item ->
val check = if (item.isChecked) "" else ""
append("- $check ${item.value}\n")
}
}
}
@@ -1,5 +1,6 @@
package com.flux.data.repository
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import kotlinx.coroutines.flow.Flow
@@ -7,5 +8,10 @@ interface TodoRepository {
suspend fun upsertList(list: TodoModel)
suspend fun deleteList(list: TodoModel)
suspend fun deleteAllWorkspaceLists(workspaceId: String)
suspend fun upsertInstance(todoInstance: TodoInstance)
suspend fun deleteAllWorkspaceInstance(workspaceId: String)
suspend fun existInstance(listId: String, instanceDate: Long): Boolean
suspend fun loadAllLists(): List<TodoModel>
fun loadAllTodoInstance(): Flow<List<TodoInstance>>
fun loadTodoData(): Flow<List<TodoModel>>
}
@@ -1,6 +1,8 @@
package com.flux.data.repository
import com.flux.data.dao.TodoDao
import com.flux.data.dao.TodoInstanceDao
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
@@ -8,7 +10,8 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
class TodoRepositoryImpl @Inject constructor(
val dao: TodoDao
val dao: TodoDao,
val instanceDao: TodoInstanceDao
) : TodoRepository {
override fun loadTodoData(): Flow<List<TodoModel>> {
return dao.loadTodoData()
@@ -19,10 +22,33 @@ class TodoRepositoryImpl @Inject constructor(
}
override suspend fun deleteList(list: TodoModel) {
return withContext(Dispatchers.IO) { dao.deleteList(list) }
return withContext(Dispatchers.IO) {
instanceDao.deleteListInstances(list.id)
dao.deleteList(list)
}
}
override suspend fun deleteAllWorkspaceLists(workspaceId: String) {
return withContext(Dispatchers.IO) { dao.deleteAllWorkspaceLists(workspaceId) }
}
override suspend fun upsertInstance(todoInstance: TodoInstance) {
return withContext(Dispatchers.IO) { instanceDao.upsertTodoInstance(todoInstance) }
}
override suspend fun deleteAllWorkspaceInstance(workspaceId: String) {
return withContext(Dispatchers.IO) { instanceDao.deleteAllWorkspaceInstance(workspaceId) }
}
override suspend fun existInstance(listId: String, instanceDate: Long): Boolean {
return withContext(Dispatchers.IO) { instanceDao.exists(listId, instanceDate) }
}
override suspend fun loadAllLists(): List<TodoModel> {
return withContext(Dispatchers.IO) { dao.loadAllLists() }
}
override fun loadAllTodoInstance(): Flow<List<TodoInstance>> {
return instanceDao.loadAll()
}
}
+10 -1
View File
@@ -13,6 +13,7 @@ import com.flux.data.dao.NotesDao
import com.flux.data.dao.ProgressBoardDao
import com.flux.data.dao.SettingsDao
import com.flux.data.dao.TodoDao
import com.flux.data.dao.TodoInstanceDao
import com.flux.data.dao.WorkspaceDao
import com.flux.data.database.FluxDatabase
import com.flux.data.database.MIGRATION_1_2
@@ -22,6 +23,8 @@ 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_8_9
import com.flux.data.database.MIGRATION_9_10
import com.flux.other.BackupManager
import dagger.Module
import dagger.Provides
@@ -48,7 +51,9 @@ object DataModule {
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10
)
.build()
@@ -88,6 +93,10 @@ object DataModule {
@Provides
fun provideTodoDao(db: FluxDatabase): TodoDao = db.todoDao
@Singleton
@Provides
fun provideTodoInstanceDao(db: FluxDatabase): TodoInstanceDao = db.todoInstanceDao
@Singleton
@Provides
fun provideLabelDao(db: FluxDatabase): LabelDao = db.labelDao
@@ -27,10 +27,12 @@ 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.NotesPreviewSetting
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.NewTodoList
import com.flux.ui.screens.todo.TodoDetail
import com.flux.ui.screens.workspaces.NewWorkspaceScreen
import com.flux.ui.screens.workspaces.WorkspaceDetails
@@ -50,6 +52,7 @@ sealed class NavRoutes(val route: String) {
data object NewHabit : NavRoutes("workspace/habit/new") // new habit
data object EventDetails : NavRoutes("workspace/event/details") // event detail
data object TodoDetail : NavRoutes("workspace/todo/details") // TodoList
data object NewTodoList : NavRoutes("workspace/todo/newTodo") // TodoList
data object EditJournal : NavRoutes("workspace/journal/edit") // Journal
data object NewEvent : NavRoutes("workspace/event/edit") // new event
data object Analytics : NavRoutes("Analytics")
@@ -66,6 +69,7 @@ sealed class NavRoutes(val route: String) {
data object Backup : NavRoutes("setting/backup")
data object Editor : NavRoutes("setting/editor")
data object Mode : NavRoutes("setting/mode")
data object NotesPreview : NavRoutes("setting/editor/notesPreview")
fun withArgs(vararg args: Any): String {
return buildString {
@@ -101,6 +105,7 @@ val NotesScreens =
NavRoutes.NoteDetails.route + "/{workspaceId}" + "/{notesId}" to { navController, notesId, workspaceId, states, viewModel ->
NoteDetails(
navController,
states.workspaceState.allWorkspaces,
states.notesState.outline,
states.notesState.textState,
workspaceId,
@@ -108,13 +113,15 @@ val NotesScreens =
states.settings.data.isLintValid,
states.settings.data.isLineNumbersVisible,
states.settings.data.startWithReadView,
states.notesState.allNotes.find { it.notesId == notesId }
?: NotesModel(workspaceId = workspaceId),
states.notesState.allNotes.find { it.notesId == notesId } ?: NotesModel(workspaceId = workspaceId),
states.settings.data.storageRootUri,
states.labelState.allLabels.filter { it.workspaceId==workspaceId },
viewModel.settingsViewModel,
viewModel.notesViewModel,
viewModel.notesViewModel::onEvent
viewModel.notesViewModel::onEvent,
viewModel.journalViewModel::onEvent,
viewModel.todoViewModel::onEvent,
viewModel.workspaceViewModel::onEvent
)
}
)
@@ -127,8 +134,10 @@ val HabitScreens =
states.settings.data.cornerRadius,
workspaceId,
states.habitState.allHabits.first { it.id == habitId },
states.workspaceState.allWorkspaces,
states.habitState.allInstances.filter { it.habitId == habitId },
viewModel.habitViewModel::onEvent
viewModel.habitViewModel::onEvent,
viewModel.workspaceViewModel::onEvent
)
},
NavRoutes.NewHabit.route + "/{workspaceId}" + "/{habitId}" to { navController, habitId, workspaceId, states, viewModel ->
@@ -146,8 +155,22 @@ val TodoScreens =
NavRoutes.TodoDetail.route + "/{workspaceId}" + "/{listId}" to { navController, listId, workspaceId, states, viewModel ->
TodoDetail(
navController,
states.todoState.allLists.find { it.id == listId }
?: TodoModel(workspaceId = workspaceId),
states.settings.data.cornerRadius,
states.todoState.allLists.first { it.id==listId },
states.workspaceState.allWorkspaces,
states.todoState.allInstances.filter { it.workspaceId==workspaceId && it.todoId==listId },
workspaceId,
viewModel.todoViewModel::onEvent,
viewModel.notesViewModel::onEvent,
viewModel.journalViewModel::onEvent,
viewModel.workspaceViewModel::onEvent
)
},
NavRoutes.NewTodoList.route + "/{workspaceId}" + "/{listId}" to { navController, listId, workspaceId, states, viewModel ->
NewTodoList(
navController,
states.settings.data.is24HourFormat,
states.todoState.allLists.find { it.id == listId } ?: TodoModel(workspaceId = workspaceId),
workspaceId,
viewModel.todoViewModel::onEvent
)
@@ -159,6 +182,8 @@ val JournalScreens =
NavRoutes.EditJournal.route + "/{workspaceId}" + "/{journalId}" + "/{journalDateTime}" to { navController, journalId, journalDateTime, workspaceId, states, viewModel ->
EditJournal(
navController,
states.workspaceState.allWorkspaces,
workspaceId,
states.journalState.data.find { it.journalId == journalId } ?: JournalModel(workspaceId = workspaceId, dateTime = journalDateTime),
states.journalState.outline,
states.journalState.textState,
@@ -170,7 +195,10 @@ val JournalScreens =
states.labelState.allLabels.filter { it.workspaceId==workspaceId },
viewModel.journalViewModel,
viewModel.settingsViewModel,
viewModel.journalViewModel::onEvent
viewModel.journalViewModel::onEvent,
viewModel.notesViewModel::onEvent,
viewModel.todoViewModel::onEvent,
viewModel.workspaceViewModel::onEvent
)
}
)
@@ -207,6 +235,9 @@ val SettingsScreens =
},
NavRoutes.Mode.route to { navController, _, states, viewModels ->
Mode(navController, states.settings, viewModels.settingsViewModel::onEvent)
},
NavRoutes.NotesPreview.route to { navController, _, states, viewModels ->
NotesPreviewSetting(navController, states.settings, viewModels.settingsViewModel::onEvent)
}
)
@@ -215,12 +246,14 @@ val EventScreens =
NavRoutes.EventDetails.route + "/{workspaceId}" + "/{eventId}" + "/{instanceDate}" to { navController, states, viewModels, eventId, workspaceId, instanceDate, _ ->
EventDetails(
navController,
states.workspaceState.allWorkspaces,
workspaceId,
states.eventState.allEvent.find { it.id == eventId } ?: EventModel(workspaceId = workspaceId),
states.eventState.allEventInstances.find { it.eventId == eventId && it.instanceDate == instanceDate } == null,
instanceDate,
states.settings,
viewModels.eventViewModel::onEvent
viewModels.eventViewModel::onEvent,
viewModels.workspaceViewModel::onEvent
)
},
NavRoutes.NewEvent.route + "/{workspaceId}" + "/{eventId}" + "/{eventDate}" to { navController, states, viewModels, eventId, workspaceId, _, eventDate ->
@@ -33,6 +33,7 @@ class BackupWorker(
val notesDao = DataModule.provideNotesDao(fluxDatabase)
val workspaceDao = DataModule.provideWorkspaceDao(fluxDatabase)
val todoDao = DataModule.provideTodoDao(fluxDatabase)
val todoInstanceDao = DataModule.provideTodoInstanceDao(fluxDatabase)
val habitDao = DataModule.provideHabitDao(fluxDatabase)
val habitInstanceDao = DataModule.provideHabitInstanceDao(fluxDatabase)
val journalDao = DataModule.provideJournalDao(fluxDatabase)
@@ -52,6 +53,7 @@ class BackupWorker(
workspaces = workspaceDao.getAll(),
notes = notesDao.loadAllNotes(),
todos = todoDao.loadAllLists(),
todoInstances = todoInstanceDao.loadAllInstances(),
habits = habitDao.loadAllHabits(),
habitInstances = habitInstanceDao.loadAllInstances(),
journals = journalDao.loadAllEntries(),
@@ -3,11 +3,13 @@ package com.flux.other
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.flux.data.model.RecurrenceRule
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 com.flux.data.repository.TodoRepository
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
@@ -53,11 +55,13 @@ class BootReceiver : BroadcastReceiver() {
val habitRepo = entryPoint.habitRepository()
val eventRepo = entryPoint.eventRepository()
val todoRepo = entryPoint.todoRepository()
val habits = habitRepo.loadAllHabits().filter { it.isLive() }.map { it.toScheduleRequest() }
val events = eventRepo.loadAllEvents().filter { it.isLive() }.map { it.toScheduleRequest() }
val todos = todoRepo.loadAllLists().filter { it.recurrence is RecurrenceRule.Weekly }.map { it.toScheduleRequest() }
return habits + events
return habits + events + todos
}
}
@@ -66,4 +70,5 @@ class BootReceiver : BroadcastReceiver() {
interface ReceiverEntryPoint {
fun habitRepository(): HabitRepository
fun eventRepository(): EventRepository
fun todoRepository(): TodoRepository
}
+17 -3
View File
@@ -67,7 +67,7 @@ object Constants {
}
}
enum class ExportType {
enum class ExportType {
TXT,
MARKDOWN,
HTML,
@@ -75,6 +75,18 @@ enum class ExportType {
PDF
}
enum class ConvertType {
NOTE,
TODO,
JOURNAL,
EVENT
}
enum class DataCopyType {
COPY,
MOVE
}
object Properties {
// Regex pattern to extract the YAML block between "---" markers at the beginning of a note
private val YAML_BLOCK_PATTERN =
@@ -239,14 +251,16 @@ data class StyleRanges(
val markerRanges: List<IntRange>,
val linkRanges: List<IntRange>,
val fencedCodeBlockInfoRanges: List<IntRange>,
val codeBlockContentRanges: List<IntRange>
val codeBlockContentRanges: List<IntRange>,
val fenceMarkerRanges: List<IntRange>
) {
companion object {
val EMPTY = StyleRanges(
emptyList(), emptyList(), emptyList(),
emptyList(), emptyList(), emptyList(),
emptyList(), emptyList(), emptyList(),
emptyList(), emptyList(), emptyList()
emptyList(), emptyList(), emptyList(),
emptyList()
)
}
}
@@ -34,6 +34,8 @@ fun getNextOccurrence(rule: RecurrenceRule, startDateTime: Long): Long? {
is RecurrenceRule.Yearly -> {
findNextYearlyOccurrence(startDateTime, now)
}
else -> null
}
}
@@ -0,0 +1,440 @@
package com.flux.other
import android.content.Intent
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Audiotrack
import androidx.compose.material.icons.filled.Image
import androidx.compose.material.icons.filled.VideoFile
import androidx.compose.material3.Icon
import androidx.compose.runtime.*
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import com.flux.R
// MarkdownSegment.kt
sealed class MarkdownSegment {
/** Regular markdown text (bold, italic, inline code, links, etc.) */
data class Text(val content: String) : MarkdownSegment()
/** A fenced code block */
data class FencedCode(val info: String, val content: String) : MarkdownSegment()
/**
* All media references found in the note — rendered as chips at the bottom of the card.
* Never emitted as an inline segment; extracted globally by [extractMedia].
*/
data class MediaGroup(
val images: List<String>,
val videos: List<String>,
val audio: List<String>
) {
val isEmpty get() = images.isEmpty() && videos.isEmpty() && audio.isEmpty()
}
}
// ---------------------------------------------------------------------------
// Extension sets
// ---------------------------------------------------------------------------
private val IMAGE_EXTENSIONS = setOf("jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "heic")
private val VIDEO_EXTENSIONS = setOf("mp4", "mkv", "mov", "avi", "webm", "3gp", "m4v")
private val AUDIO_EXTENSIONS = setOf("mp3", "aac", "ogg", "wav", "flac", "m4a", "opus", "wma")
// Markdown image: ![alt](url)
private val MD_IMAGE_REGEX = Regex("""!\[[^\]]*]\(([^)]+)\)""")
// Markdown link: [text](url) — classified by URL extension
private val MD_LINK_REGEX = Regex("""\[[^\]]*]\(([^)]+)\)""")
// HTML video tag: <video src="..." or <video src='...'
private val HTML_VIDEO_REGEX = Regex("""<video[^>]+src=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
// HTML audio tag: <audio src="..." or <audio src='...'
private val HTML_AUDIO_REGEX = Regex("""<audio[^>]+src=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
// HTML img tag: <img src="..." or <img src='...'
private val HTML_IMG_REGEX = Regex("""<img[^>]+src=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
// Closing tags to strip alongside the opening tags
private val HTML_VIDEO_CLOSE = Regex("""</video\s*>""", RegexOption.IGNORE_CASE)
private val HTML_AUDIO_CLOSE = Regex("""</audio\s*>""", RegexOption.IGNORE_CASE)
// Bare https?:// URL not inside () — classified by extension
private val BARE_URL_REGEX = Regex("""(?<!\()https?://\S+""")
private val FENCE_REGEX = Regex(
"""^(`{3,}|~{3,})([\w\-+#.]*)[ \t]*\r?\n(.*?)\r?\n?\1[ \t]*$""",
setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private fun String.urlExtension() =
substringAfterLast('.', "").lowercase().substringBefore('?').substringBefore('#').trim()
private enum class MediaType { IMAGE, VIDEO, AUDIO }
private fun classifyUrl(url: String): MediaType? {
val ext = url.trim().urlExtension()
return when (ext) {
in IMAGE_EXTENSIONS -> MediaType.IMAGE
in VIDEO_EXTENSIONS -> MediaType.VIDEO
in AUDIO_EXTENSIONS -> MediaType.AUDIO
else -> null
}
}
// ---------------------------------------------------------------------------
// Global media extraction (call this separately from segmentMarkdown)
// ---------------------------------------------------------------------------
/**
* Scans the entire raw [text] for every media reference — markdown, HTML tags,
* and bare URLs — and returns a [MarkdownSegment.MediaGroup] plus a cleaned
* copy of the text with all media syntax removed.
*
* This is intentionally separate from [segmentMarkdown] so the caller
* (e.g. NotesPreviewCard) can render chips wherever it wants (e.g. card bottom)
* rather than inline inside the text column.
*/
data class MediaExtractionResult(
val cleanedText: String,
val media: MarkdownSegment.MediaGroup
)
fun extractMedia(text: String): MediaExtractionResult {
val images = mutableListOf<String>()
val videos = mutableListOf<String>()
val audio = mutableListOf<String>()
val stripRanges = mutableListOf<IntRange>()
fun addImage(url: String, range: IntRange) { images += url; stripRanges += range }
fun addVideo(url: String, range: IntRange) { videos += url; stripRanges += range }
fun addAudio(url: String, range: IntRange) { audio += url; stripRanges += range }
// 1. HTML <video src="..."></video>
HTML_VIDEO_REGEX.findAll(text).forEach { m ->
addVideo(m.groupValues[1].trim(), m.range)
}
// strip </video> closing tags
HTML_VIDEO_CLOSE.findAll(text).forEach { m -> stripRanges += m.range }
// 2. HTML <audio src="..."></audio>
HTML_AUDIO_REGEX.findAll(text).forEach { m ->
addAudio(m.groupValues[1].trim(), m.range)
}
HTML_AUDIO_CLOSE.findAll(text).forEach { m -> stripRanges += m.range }
// 3. HTML <img src="...">
HTML_IMG_REGEX.findAll(text).forEach { m ->
addImage(m.groupValues[1].trim(), m.range)
}
// 4. Markdown images ![alt](url) — always image
MD_IMAGE_REGEX.findAll(text).forEach { m ->
if (stripRanges.any { it.contains(m.range.first) }) return@forEach
addImage(m.groupValues[1].trim(), m.range)
}
// 5. Markdown links [text](url) — classify by extension
MD_LINK_REGEX.findAll(text).forEach { m ->
if (stripRanges.any { it.contains(m.range.first) }) return@forEach
val url = m.groupValues[1].trim()
when (classifyUrl(url)) {
MediaType.IMAGE -> addImage(url, m.range)
MediaType.VIDEO -> addVideo(url, m.range)
MediaType.AUDIO -> addAudio(url, m.range)
null -> { /* plain link — leave it */ }
}
}
// 6. Bare URLs — classify by extension
BARE_URL_REGEX.findAll(text).forEach { m ->
if (stripRanges.any { it.contains(m.range.first) }) return@forEach
val url = m.value.trim()
when (classifyUrl(url)) {
MediaType.IMAGE -> addImage(url, m.range)
MediaType.VIDEO -> addVideo(url, m.range)
MediaType.AUDIO -> addAudio(url, m.range)
null -> { /* plain URL — leave it */ }
}
}
// Build cleaned text by merging + removing strip ranges
val merged = stripRanges.sortedBy { it.first }.fold(mutableListOf<IntRange>()) { acc, r ->
if (acc.isEmpty() || acc.last().last < r.first - 1) acc.add(r)
else acc[acc.lastIndex] = acc.last().first..maxOf(acc.last().last, r.last)
acc
}
var cleaned = text
var offset = 0
for (r in merged) {
val start = (r.first - offset).coerceAtLeast(0)
val end = (r.last - offset + 1).coerceAtMost(cleaned.length)
if (start < end) {
cleaned = cleaned.removeRange(start, end)
offset += end - start
}
}
return MediaExtractionResult(
cleanedText = cleaned.trim(),
media = MarkdownSegment.MediaGroup(images, videos, audio)
)
}
// ---------------------------------------------------------------------------
// Segment splitter (fenced code blocks only — media handled separately above)
// ---------------------------------------------------------------------------
fun segmentMarkdown(text: String): List<MarkdownSegment> {
val segments = mutableListOf<MarkdownSegment>()
var lastEnd = 0
for (match in FENCE_REGEX.findAll(text)) {
if (match.range.first > lastEnd) {
val before = text.substring(lastEnd, match.range.first)
if (before.isNotBlank()) segments.add(MarkdownSegment.Text(before))
}
segments.add(MarkdownSegment.FencedCode(match.groupValues[2].trim(), match.groupValues[3]))
lastEnd = match.range.last + 1
}
if (lastEnd < text.length) {
val remaining = text.substring(lastEnd)
if (remaining.isNotBlank()) segments.add(MarkdownSegment.Text(remaining))
}
if (segments.isEmpty()) segments.add(MarkdownSegment.Text(text))
return segments
}
fun openUrl(context: android.content.Context, url: String) {
runCatching {
val uri = url.toUri().let { parsed ->
// Ensure the URL has a scheme so the browser can handle it
if (parsed.scheme.isNullOrBlank()) "https://$url".toUri() else parsed
}
context.startActivity(Intent(Intent.ACTION_VIEW, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
}
@Composable
fun MarkdownBlock(
text: String,
modifier: Modifier = Modifier,
linkColor: Color = Color(0xFF4A90D9),
codeBlockBackground: Color = MaterialTheme.colorScheme.surfaceVariant,
codeBlockTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onLinkClick: ((String) -> Unit)? = null,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
val context = LocalContext.current
val handleLink: (String) -> Unit = onLinkClick ?: { url -> openUrl(context, url) }
// Strip media from text before segmenting
val extracted = remember(text) { extractMedia(text) }
val segments = remember(extracted.cleanedText) { segmentMarkdown(extracted.cleanedText) }
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
segments.forEach { segment ->
when (segment) {
is MarkdownSegment.Text -> {
val annotated = parseMarkdownContent(segment.content, linkColor)
LinkAwareText(
annotated = annotated,
onLinkClick = handleLink,
onClick = onClick,
onLongClick = onLongClick
)
}
is MarkdownSegment.FencedCode -> {
FencedCodeBlock(
info = segment.info,
content = segment.content,
background = codeBlockBackground,
textColor = codeBlockTextColor,
onClick = onClick,
onLongClick = onLongClick
)
}
}
}
}
}
@Composable
fun MediaChipsRow(
media: MarkdownSegment.MediaGroup,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
if (media.isEmpty) return
FlowRow (
modifier = modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (media.images.isNotEmpty()) {
MediaChip(icon = Icons.Default.Image, label = stringResource(R.string.image), count = media.images.size)
}
if (media.videos.isNotEmpty()) {
MediaChip(icon = Icons.Default.VideoFile, label = stringResource(R.string.video), count = media.videos.size)
}
if (media.audio.isNotEmpty()) {
MediaChip(icon = Icons.Default.Audiotrack, label = stringResource(R.string.audio), count = media.audio.size)
}
}
}
@Composable
private fun MediaChip(icon: ImageVector, label: String, count: Int) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer,
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = if (count > 1) "$label × $count" else label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@Composable
private fun LinkAwareText(
annotated: AnnotatedString,
onLinkClick: (String) -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotated,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onBackground
),
onTextLayout = { textLayoutResult = it },
modifier = Modifier.pointerInput(annotated) {
detectTapGestures(
onTap = { offset ->
val layout = textLayoutResult ?: run { onClick(); return@detectTapGestures }
val charOffset = layout.getOffsetForPosition(offset)
val link = annotated
.getStringAnnotations(URL_ANNOTATION_TAG, charOffset, charOffset)
.firstOrNull()
if (link != null) onLinkClick(link.item) else onClick()
},
onLongPress = { onLongClick() }
)
}
)
}
@Composable
private fun FencedCodeBlock(
info: String,
content: String,
background: Color,
textColor: Color,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
shape = RoundedCornerShape(8.dp),
color = background,
tonalElevation = 0.dp
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
if (info.isNotBlank()) {
Text(
text = info,
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = textColor.copy(alpha = 0.55f)
),
modifier = Modifier
.padding(bottom = 8.dp)
.align(Alignment.End)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
) {
Text(
text = content,
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = textColor,
lineHeight = 20.sp
)
)
}
}
}
}
@@ -16,7 +16,6 @@ import android.os.Build
import android.provider.Settings
import android.widget.Toast
import androidx.annotation.RequiresApi
import androidx.compose.ui.res.stringResource
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
@@ -120,6 +119,7 @@ object NotificationDispatcher {
ensureChannel(context)
val isHabit = request.itemType == ReminderType.HABIT
val isEvent = request.itemType == ReminderType.EVENT
val doneIntent = Intent(context, ReminderReceiver::class.java).apply {
action = ACTION_MARK_DONE
@@ -142,16 +142,23 @@ object NotificationDispatcher {
.setContentTitle(request.title)
.setContentText(request.description)
.setSmallIcon(
if (isHabit) R.drawable.calendar_check
else R.drawable.check_list
if (isHabit) R.drawable.routine
else if(isEvent) R.drawable.calendar_check
else R.drawable.to_do_list
)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// persistent notification
builder.setOngoing(true).setAutoCancel(false)
if (request.itemType != ReminderType.TODO) {
builder.addAction(
R.drawable.check_list,
"Done",
donePendingIntent
)
}
val notification = builder.addAction(R.drawable.check_list, "Done", donePendingIntent).build()
val notification = builder.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify((request.itemId + request.itemType.name).hashCode(), notification)
}
+509 -60
View File
@@ -2,23 +2,32 @@ package com.flux.other
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher
import android.graphics.Canvas
import android.os.Handler
import android.os.Looper
import android.print.PrintAttributes
import android.print.PrintManager
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import com.flux.ui.viewModel.NotesViewModel
@@ -38,7 +47,6 @@ import org.commonmark.ext.front.matter.YamlFrontMatterExtension
import org.commonmark.ext.gfm.strikethrough.Strikethrough
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TableRow
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.ext.ins.Ins
import org.commonmark.ext.ins.InsExtension
import org.commonmark.node.AbstractVisitor
@@ -60,7 +68,12 @@ 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.TodoModel
import com.flux.data.model.occursOn
import com.flux.data.model.toHtml
import com.flux.data.model.toMarkdown
import com.flux.data.model.toText
import org.commonmark.ext.gfm.tables.TablesExtension
import java.time.LocalDate
import java.time.YearMonth
import java.time.temporal.ChronoUnit
@@ -261,6 +274,203 @@ fun shareNote(
}
}
fun shareTodo(
context: Context,
exportType: ExportType,
list: TodoModel,
readWebView: WebView?
) {
when (exportType) {
ExportType.TXT -> {
shareTodoText(
context,
list.toText(),
"text/plain"
)
}
ExportType.MARKDOWN -> {
shareTodoText(
context,
list.toMarkdown(),
"text/markdown"
)
}
ExportType.HTML -> {
shareTodoText(
context,
list.toHtml(),
"text/html"
)
}
ExportType.IMAGE -> {
val activity = context.findActivity()
exportHtmlAsImage(activity, list.toHtml()) { uri ->
shareImageUri(context, uri)
}
}
ExportType.PDF -> {
readWebView?.let {
createWebPrintJob(
it,
context as? Activity,
list.title
)
}
}
}
}
fun exportHtmlAsImage(
activity: Activity,
html: String,
widthDp: Int = 480,
onResult: (Uri) -> Unit
) {
val tag = "ExportHtmlAsImage"
val density = activity.resources.displayMetrics.density
val widthPx = (widthDp * density).toInt()
val rootView = activity.window.decorView as ViewGroup
val mainHandler = Handler(Looper.getMainLooper())
val webView = WebView(activity).apply {
settings.javaScriptEnabled = true
layoutParams = FrameLayout.LayoutParams(widthPx, FrameLayout.LayoutParams.WRAP_CONTENT)
translationX = 10000f
}
rootView.addView(webView)
webView.webViewClient = object : WebViewClient() {
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
}
override fun onPageFinished(view: WebView, url: String?) {
mainHandler.postDelayed({
view.evaluateJavascript(
"""
(function() {
var children = document.body.children;
if (children.length === 0) return '0';
var last = children[children.length - 1];
var rect = last.getBoundingClientRect();
var bottomWithPadding = rect.bottom + 32;
return (bottomWithPadding * $density).toString();
})()
""".trimIndent()
) { result ->
val contentHeightPx = result
?.trim()
?.removeSurrounding("\"")
?.toFloatOrNull()
?.toInt()
?.coerceAtLeast(100)
?: 0
if (contentHeightPx <= 0) {
rootView.removeView(view)
return@evaluateJavascript
}
mainHandler.post {
// Force WebView to exactly contentHeightPx
view.layoutParams = FrameLayout.LayoutParams(widthPx, contentHeightPx)
view.measure(
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(contentHeightPx, View.MeasureSpec.EXACTLY)
)
view.layout(0, 0, widthPx, contentHeightPx)
view.post {
try {
val bitmap = createBitmap(widthPx, contentHeightPx)
val canvas = Canvas(bitmap)
canvas.clipRect(0, 0, widthPx, contentHeightPx)
view.draw(canvas)
rootView.removeView(view)
Thread {
try {
val uri = saveBitmapAndGetUri(activity, bitmap)
mainHandler.post { onResult(uri) }
} catch (e: Exception) {
Log.e(tag, "❌ Failed to save bitmap: ${e.message}", e)
}
}.start()
} catch (e: Exception) {
Log.e(tag, "❌ Exception during bitmap drawing: ${e.message}", e)
rootView.removeView(view)
}
}
Log.d(tag, "contentHeightPx = $contentHeightPx, density = $density")
}
}
}, 300)
}
override fun onReceivedError(
view: WebView,
request: android.webkit.WebResourceRequest?,
error: android.webkit.WebResourceError?
) {
Log.e(tag, "❌ onReceivedError | url=${request?.url} | error=${error?.description}")
}
}
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
fun saveBitmapAndGetUri(context: Context, bitmap: Bitmap): Uri {
val cacheDir = File(context.cacheDir, "shared_images").apply { mkdirs() }
val file = File(cacheDir, "html_${System.currentTimeMillis()}.png")
FileOutputStream(file).use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
return FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
}
fun shareImageUri(context: Context, uri: Uri) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(Intent.createChooser(intent, "Share image via"))
}
fun Context.findActivity(): Activity {
var ctx = this
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
throw IllegalStateException("Activity not found")
}
private fun shareTodoText(
context: Context,
text: String,
mimeType: String
) {
val intent = Intent(Intent.ACTION_SEND).apply {
type = mimeType
putExtra(Intent.EXTRA_TEXT, text)
}
context.startActivity(
Intent.createChooser(intent, null)
)
}
private fun shareAsText(
context: Context,
type: ExportType,
@@ -434,7 +644,8 @@ val PARSER: Parser =
fun findTagRanges(text: String): StyleRanges {
if (text.isEmpty()) return StyleRanges.EMPTY
val document = PARSER.parse(text)
val normalizedText = if (text.endsWith('\n')) text else "$text\n"
val document = PARSER.parse(normalizedText)
val codeRanges = mutableListOf<IntRange>()
val boldRanges = mutableListOf<IntRange>()
val italicRanges = mutableListOf<IntRange>()
@@ -447,6 +658,7 @@ fun findTagRanges(text: String): StyleRanges {
val linkRanges = mutableListOf<IntRange>()
val fencedCodeBlockInfoRanges = mutableListOf<IntRange>()
val codeBlockContentRanges = mutableListOf<IntRange>()
val fenceMarkerRanges = mutableListOf<IntRange>() // NEW: tracks ``` open/close lines separately
document.accept(object : AbstractVisitor() {
@@ -551,8 +763,6 @@ fun findTagRanges(text: String): StyleRanges {
override fun visit(link: Link) {
val span = link.sourceSpans.firstOrNull()
if (span != null) {
// The entire link including text and URL needs to be styled
// Format is [text](url)
linkRanges.add(span.inputIndex until (span.inputIndex + span.length))
}
visitChildren(link)
@@ -621,24 +831,47 @@ fun findTagRanges(text: String): StyleRanges {
}
override fun visit(fencedCodeBlock: FencedCodeBlock) {
val span = fencedCodeBlock.sourceSpans.firstOrNull() ?: return
val spans = fencedCodeBlock.sourceSpans
if (spans.isEmpty()) return
// Get the opening fence marker (```language)
val openingFenceStartIndex = span.inputIndex
val openingMarkerLength = fencedCodeBlock.openingFenceLength ?: return
val infoStringStartIndex = openingFenceStartIndex + openingMarkerLength
markerRanges.add(openingFenceStartIndex until infoStringStartIndex) // ```
val infoStringLength = fencedCodeBlock.info?.length ?: 0
fencedCodeBlockInfoRanges.add(infoStringStartIndex until (infoStringStartIndex + infoStringLength)) // language
spans.forEachIndexed { index, span ->
when (index) {
0 -> {
// opening ``` or ```kotlin — goes into fenceMarkerRanges, not markerRanges
fenceMarkerRanges.add(
span.inputIndex until
(span.inputIndex + span.length)
)
val closingMarkerLength = fencedCodeBlock.closingFenceLength ?: return
val blockContentLength =
if (fencedCodeBlock.literal.isEmpty()) 0 else fencedCodeBlock.literal.length + 1
val fence =
openingFenceStartIndex + openingMarkerLength + infoStringLength + blockContentLength
codeBlockContentRanges.add((openingFenceStartIndex + openingMarkerLength + infoStringLength) until fence) // content
if (fence + closingMarkerLength <= text.length) {
markerRanges.add(fence until (fence + closingMarkerLength))
val openingText = text.substring(
span.inputIndex,
(span.inputIndex + span.length).coerceAtMost(text.length)
)
val firstSpace = openingText.indexOf(' ')
if (firstSpace != -1 && firstSpace + 1 < openingText.length) {
fencedCodeBlockInfoRanges.add(
(span.inputIndex + firstSpace + 1) until
(span.inputIndex + openingText.length)
)
}
}
spans.lastIndex -> {
// closing ``` — goes into fenceMarkerRanges, not markerRanges
fenceMarkerRanges.add(
span.inputIndex until
(span.inputIndex + span.length)
)
}
else -> {
codeBlockContentRanges.add(
span.inputIndex until
(span.inputIndex + span.length)
)
}
}
}
}
@@ -663,99 +896,315 @@ fun findTagRanges(text: String): StyleRanges {
markerRanges = markerRanges,
linkRanges = linkRanges,
fencedCodeBlockInfoRanges = fencedCodeBlockInfoRanges,
codeBlockContentRanges = codeBlockContentRanges
codeBlockContentRanges = codeBlockContentRanges,
fenceMarkerRanges = fenceMarkerRanges
)
}
fun parseMarkdownContent(text: String): AnnotatedString {
private data class LinkInfo(
val origStart: Int, // start in the pre-transform text
val origEnd: Int, // end in the pre-transform text
val display: String, // visible label ([display](url))
val url: String // destination URL
)
private fun collectLinks(text: String): List<LinkInfo> {
val document = PARSER.parse(text)
val links = mutableListOf<LinkInfo>()
document.accept(object : AbstractVisitor() {
override fun visit(link: Link) {
val span = link.sourceSpans.firstOrNull() ?: return
val display = buildString {
var child = link.firstChild
while (child != null) {
if (child is Text) append(child.literal)
child = child.next
}
}.ifEmpty { link.destination }
links.add(LinkInfo(span.inputIndex, span.inputIndex + span.length, display, link.destination))
}
override fun visit(image: Image) {
val span = image.sourceSpans.firstOrNull() ?: return
val altText = buildString {
var child = image.firstChild
while (child != null) {
if (child is Text) append(child.literal)
child = child.next
}
}.ifEmpty { image.destination }
links.add(LinkInfo(span.inputIndex, span.inputIndex + span.length, altText, image.destination))
}
})
return links.sortedBy { it.origStart }
}
private fun transformLinks(rawText: String): Pair<String, List<LinkInfo>> {
val links = collectLinks(rawText)
if (links.isEmpty()) return rawText to emptyList()
val sb = StringBuilder()
val adjustedLinks = mutableListOf<LinkInfo>()
var cursor = 0 // current position in rawText
var shift = 0 // cumulative length change
for (link in links) {
// Append everything before this link unchanged
sb.append(rawText, cursor, link.origStart)
// Append only the display text
val newStart = link.origStart + shift
sb.append(link.display)
val newEnd = newStart + link.display.length
adjustedLinks.add(link.copy(origStart = newStart, origEnd = newEnd))
shift += link.display.length - (link.origEnd - link.origStart)
cursor = link.origEnd
}
// Append any remaining text after the last link
sb.append(rawText, cursor, rawText.length)
return sb.toString() to adjustedLinks
}
// URL annotation tag used for click handling
const val URL_ANNOTATION_TAG = "URL"
fun parseMarkdownContent(text: String, linkColor: Color = Color.Blue): AnnotatedString {
if (text.isBlank()) return AnnotatedString(text)
val textWithoutProperties =
text.splitPropertiesAndContent().second
.replace("- [ ]", "")
.replace("- [x]", "")
val styleRanges = findTagRanges(textWithoutProperties)
val rawText = text.splitPropertiesAndContent().second
.replace("- [ ]", "")
.replace("- [x]", "")
.replace("- [X]", "")
.replace("-", "")
// Transform [display](url) → display, and get adjusted link positions
val (transformedText, adjustedLinks) = transformLinks(rawText)
val styleRanges = findTagRanges(transformedText)
return buildAnnotatedString {
fun safeAddStyle(style: SpanStyle, start: Int, end: Int) {
val safeStart = start.coerceAtLeast(0).coerceAtMost(text.length)
val safeEnd = end.coerceAtLeast(0).coerceAtMost(text.length)
addStyle(style, safeStart, safeEnd)
val safeStart = start.coerceAtLeast(0).coerceAtMost(transformedText.length)
val safeEnd = end.coerceAtLeast(0).coerceAtMost(transformedText.length)
if (safeStart < safeEnd) addStyle(style, safeStart, safeEnd)
}
fun safeAddStyle(style: ParagraphStyle, start: Int, end: Int) {
val safeStart = start.coerceAtLeast(0).coerceAtMost(text.length)
val safeEnd = end.coerceAtLeast(0).coerceAtMost(text.length)
addStyle(style, safeStart, safeEnd)
val safeStart = start.coerceAtLeast(0).coerceAtMost(transformedText.length)
val safeEnd = end.coerceAtLeast(0).coerceAtMost(transformedText.length)
if (safeStart < safeEnd) addStyle(style, safeStart, safeEnd)
}
styleRanges.apply {
// Inline code: `code`
codeRanges.forEach { range ->
safeAddStyle(CODE_STYLE, range.first, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.first, range.first + 1)
safeAddStyle(SYMBOL_STYLE, range.last - 1 + 1, range.last + 1)
safeAddStyle(
CODE_STYLE,
range.first,
range.last + 1
)
var delimiterLength = 0
var i = range.first
while (
i <= range.last &&
transformedText.getOrNull(i) == '`'
) {
delimiterLength++
i++
}
if (delimiterLength > 0) {
safeAddStyle(
SYMBOL_STYLE,
range.first,
range.first + delimiterLength
)
safeAddStyle(
SYMBOL_STYLE,
range.last - delimiterLength + 1,
range.last + 1
)
}
}
// Fenced code block content lines
codeBlockContentRanges.forEach { range ->
safeAddStyle(
CODE_BLOCK_STYLE,
range.first,
range.last + 1
)
}
// Fenced code block opening/closing ``` lines:
// Apply CODE_BLOCK_STYLE so the background is contiguous with the content,
// then apply SYMBOL_STYLE on top to dim/style the fence markers themselves.
fenceMarkerRanges.forEach { range ->
safeAddStyle(
CODE_BLOCK_STYLE,
range.first,
range.last + 1
)
safeAddStyle(
SYMBOL_STYLE,
range.first,
range.last + 1
)
}
boldItalicRanges.forEach { range ->
safeAddStyle(BOLD_ITALIC_STYLE, range.first, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.first, range.first + 3)
safeAddStyle(SYMBOL_STYLE, range.last - 3 + 1, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.last - 2, range.last + 1)
}
boldRanges.forEach { range ->
safeAddStyle(BOLD_STYLE, range.first, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.first, range.first + 2)
safeAddStyle(SYMBOL_STYLE, range.last - 2 + 1, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.last - 1, range.last + 1)
}
italicRanges.forEach { range ->
safeAddStyle(ITALIC_STYLE, range.first, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.first, range.first + 1)
safeAddStyle(SYMBOL_STYLE, range.last - 1 + 1, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.last, range.last + 1)
}
highlightRanges.forEach { range ->
safeAddStyle(HIGHLIGHT_STYLE, range.first, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.first, range.first + 2)
safeAddStyle(SYMBOL_STYLE, range.last - 2 + 1, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.last - 1, range.last + 1)
}
val combinedRanges = (strikethroughRanges + underlineRanges).distinct()
val combinedRanges =
(strikethroughRanges + underlineRanges).distinct()
combinedRanges.forEach { range ->
val hasStrikethrough = strikethroughRanges.any { it.overlaps(range) }
val hasUnderline = underlineRanges.any { it.overlaps(range) }
val hasStrikethrough =
strikethroughRanges.any { it.overlaps(range) }
val hasUnderline =
underlineRanges.any { it.overlaps(range) }
val style = when {
hasStrikethrough && hasUnderline -> STRIKETHROUGH_AND_UNDERLINE_STYLE
hasStrikethrough -> STRIKETHROUGH_STYLE
hasUnderline -> UNDERLINE_STYLE
hasStrikethrough && hasUnderline ->
STRIKETHROUGH_AND_UNDERLINE_STYLE
hasStrikethrough ->
STRIKETHROUGH_STYLE
hasUnderline ->
UNDERLINE_STYLE
else -> return@forEach
}
safeAddStyle(style, range.first, range.last + 1)
safeAddStyle(
style,
range.first,
range.last + 1
)
}
strikethroughRanges.forEach { range ->
safeAddStyle(SYMBOL_STYLE, range.first, range.first + 2)
safeAddStyle(SYMBOL_STYLE, range.last - 2 + 1, range.last + 1)
safeAddStyle(
SYMBOL_STYLE,
range.first,
range.first + 2
)
safeAddStyle(
SYMBOL_STYLE,
range.last - 1,
range.last + 1
)
}
underlineRanges.forEach { range ->
safeAddStyle(SYMBOL_STYLE, range.first, range.first + 2)
safeAddStyle(SYMBOL_STYLE, range.last - 2 + 1, range.last + 1)
safeAddStyle(
SYMBOL_STYLE,
range.first,
range.first + 2
)
safeAddStyle(
SYMBOL_STYLE,
range.last - 1,
range.last + 1
)
}
headerRanges.forEach { (range, level) ->
safeAddStyle(HEADER_STYLES[level - 1], range.first, range.last + 1)
safeAddStyle(HEADER_LINE_STYLES[level - 1], range.first, range.last + 1)
safeAddStyle(SYMBOL_STYLE, range.first, range.first + level + 1)
safeAddStyle(
HEADER_STYLES[level - 1],
range.first,
range.last + 1
)
safeAddStyle(
HEADER_LINE_STYLES[level - 1],
range.first,
range.last + 1
)
safeAddStyle(
SYMBOL_STYLE,
range.first,
range.first + level + 1
)
}
// Add styling for list markers
markerRanges.forEach { range ->
safeAddStyle(MARKER_STYLE, range.first, range.last + 1)
safeAddStyle(
MARKER_STYLE,
range.first,
range.last + 1
)
}
fencedCodeBlockInfoRanges.forEach { range ->
safeAddStyle(KEYWORD_STYLE, range.first, range.last + 1)
safeAddStyle(
KEYWORD_STYLE,
range.first,
range.last + 1
)
}
codeBlockContentRanges.forEach { range ->
safeAddStyle(CODE_BLOCK_STYLE, range.first, range.last + 1)
}
// Apply link styles and URL annotations using the adjusted (post-transform) positions
for (link in adjustedLinks) {
val start = link.origStart.coerceAtLeast(0).coerceAtMost(transformedText.length)
val end = link.origEnd.coerceAtLeast(0).coerceAtMost(transformedText.length)
if (start < end) {
addStyle(
SpanStyle(
color = linkColor,
textDecoration = TextDecoration.Underline
),
start,
end
)
// Attach the URL so ClickableText callers can open it
addStringAnnotation(
tag = URL_ANNOTATION_TAG,
annotation = link.url,
start = start,
end = end
)
}
}
append(textWithoutProperties)
append(transformedText)
}
}
@@ -94,6 +94,9 @@ fun NoteDetailsTopBar(
onShareNote: () -> Unit,
onSaveNote: () -> Unit,
onPrintNote: () -> Unit,
onConvertNote: () ->Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit
) {
CenterAlignedTopAppBar(
title = {
@@ -132,7 +135,7 @@ fun NoteDetailsTopBar(
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) }
DropdownMenuWithDetails(isPinned, onTogglePinned, onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onConvertNote, onCopyNote, onCloneNote, onDelete) }
)
}
@@ -152,6 +155,9 @@ fun JournalDetailsTopBar(
onShareNote: () -> Unit,
onSaveNote: () -> Unit,
onPrintNote: () -> Unit,
onConvertNote: () ->Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit
) {
CenterAlignedTopAppBar(
title = {
@@ -190,7 +196,7 @@ fun JournalDetailsTopBar(
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)
JournalDropdownMenu(onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete, onConvertNote, onCopyNote, onCloneNote)
}
)
}
@@ -357,9 +363,9 @@ fun SpaceTopBar(
val titleSectionHeightDp = 12.dp + titleRowDp + if (hasDescription) 2.dp + bodyLineHeightDp else 0.dp
val expandedHeightDp = if (hasCover)
coverHeightDp + titleSectionHeightDp + statusBarHeight - 32.dp
coverHeightDp + titleSectionHeightDp + statusBarHeight - 24.dp
else
toolbarHeight + titleSectionHeightDp + 24.dp
toolbarHeight + titleSectionHeightDp + 28.dp
val expandedPx = with(density) { expandedHeightDp.toPx() }
val collapsedPx = with(density) { toolbarHeight.toPx() }
@@ -41,6 +41,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.flux.R
import com.flux.data.model.RecurrenceRule
@@ -106,6 +107,7 @@ fun RecurrenceRule.label(): String = when (this) {
is RecurrenceRule.Weekly -> stringResource(R.string.Weekly)
is RecurrenceRule.Monthly -> stringResource(R.string.Monthly)
is RecurrenceRule.Yearly -> stringResource(R.string.Yearly)
else -> ""
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -219,33 +221,44 @@ fun RecurrenceBottomSheet(
stringResource(R.string.saturday_short),
stringResource(R.string.sunday_short)
)
Row (
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 7
) {
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),
onClick = {},
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
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
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
)
)
}
}
@@ -277,6 +290,8 @@ fun RecurrenceBottomSheet(
}
}
}
else -> {}
}
}
@@ -334,5 +349,7 @@ fun RecurrenceBottomSheet(
}
}
}
else -> {}
} as Long
}
+146 -1
View File
@@ -1,6 +1,7 @@
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.Column
import androidx.compose.foundation.layout.FlowRow
@@ -9,9 +10,12 @@ 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.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
@@ -21,12 +25,16 @@ 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.Checkbox
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.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.TextButton
@@ -36,25 +44,34 @@ import androidx.compose.material3.rememberDatePickerState
import androidx.compose.material3.rememberTimePickerState
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.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
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.data.model.WorkspaceModel
import com.flux.other.DataCopyType
import com.flux.other.icons
import com.flux.ui.screens.settings.CircleWrapper
import com.flux.ui.theme.FONTS
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
fun convertMillisToDate(millis: Long): String {
val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
formatter.timeZone = TimeZone.getDefault()
return formatter.format(Date(millis))
}
@@ -70,7 +87,16 @@ fun DatePickerModal(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
onDateSelected(datePickerState.selectedDateMillis)
val normalized = datePickerState.selectedDateMillis?.let { millis ->
Calendar.getInstance().apply {
timeInMillis = millis
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.timeInMillis
}
onDateSelected(normalized)
onDismiss()
}) {
Text(stringResource(R.string.Set))
@@ -258,4 +284,123 @@ fun FontDialog(
}
}
}
}
@Composable
fun DataCopyDialog(
workspaces: List<WorkspaceModel>,
onConfirm: (DataCopyType, List<WorkspaceModel>) -> Unit,
onDismiss: () -> Unit
){
val selectedWorkspaces = remember { mutableStateListOf<WorkspaceModel>() }
var selectedType by remember { mutableStateOf(DataCopyType.COPY) }
AlertDialog(
icon = {
SingleChoiceSegmentedButtonRow {
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = 0,
count = 2
),
onClick = {
selectedType = DataCopyType.COPY
selectedWorkspaces.clear()
},
selected = selectedType == DataCopyType.COPY,
label = { Text("Copy") }
)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = 1,
count = 2
),
onClick = {
selectedType = DataCopyType.MOVE
selectedWorkspaces.clear()
},
selected = selectedType == DataCopyType.MOVE,
label = { Text("Move") }
)
}
},
title = {
Text("Select Workspaces")
},
text = {
LazyColumn (Modifier
.fillMaxWidth()
.heightIn(max = 300.dp)) {
items(workspaces) { workspace->
val isChecked = selectedWorkspaces.contains(workspace)
Card(
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(50))
.padding(vertical = 2.dp)
.clickable {
if (isChecked) {
selectedWorkspaces.remove(workspace)
} else {
if(selectedType== DataCopyType.MOVE) if(selectedWorkspaces.isNotEmpty()) selectedWorkspaces.clear()
selectedWorkspaces.add(workspace)
}
},
shape = RoundedCornerShape(50)
) {
Row(
Modifier
.padding(vertical = 6.dp, horizontal = 8.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CircleWrapper(color = MaterialTheme.colorScheme.primary) {
Icon(
icons[workspace.icon],
null,
tint = MaterialTheme.colorScheme.onPrimary
)
}
Text(
text = workspace.title,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.width(150.dp)
)
}
Checkbox(checked = isChecked, onCheckedChange = {
if (isChecked) {
selectedWorkspaces.remove(workspace)
} else {
if(selectedType== DataCopyType.MOVE) if(selectedWorkspaces.isNotEmpty()) selectedWorkspaces.clear()
selectedWorkspaces.add(workspace)
}
})
}
}
}
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
onConfirm(selectedType, selectedWorkspaces.toList())
onDismiss()
}) {
Text("Confirm")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Dismiss")
}
}
)
}
@@ -1,6 +1,9 @@
package com.flux.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Notes
@@ -11,6 +14,8 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.RemoveCircleOutline
import androidx.compose.material.icons.outlined.Analytics
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.ContentCopy
import androidx.compose.material.icons.outlined.ControlPointDuplicate
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Edit
@@ -23,6 +28,7 @@ import androidx.compose.material.icons.outlined.PhotoSizeSelectActual
import androidx.compose.material.icons.outlined.Print
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material.icons.outlined.TaskAlt
import androidx.compose.material.icons.outlined.TrackChanges
import androidx.compose.material3.DropdownMenu
@@ -40,7 +46,9 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.flux.R
import com.flux.data.model.WorkspaceModel
@@ -54,6 +62,9 @@ fun DropdownMenuWithDetails(
onShareNote: () -> Unit,
onSaveNote: () -> Unit,
onPrintNote: () -> Unit,
onConvertNote: () ->Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit,
onDelete: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
@@ -95,6 +106,33 @@ fun DropdownMenuWithDetails(
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clone") },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
onCloneNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
onCopyNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Convert") },
leadingIcon = { Icon(Icons.Outlined.SwapHoriz, contentDescription = null) },
onClick = {
expanded = false
onConvertNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.share)) },
leadingIcon = { Icon(Icons.Outlined.Share, contentDescription = null) },
@@ -154,7 +192,10 @@ fun JournalDropdownMenu(
onShareNote: () -> Unit,
onSaveNote: () -> Unit,
onPrintNote: () -> Unit,
onDelete: () -> Unit
onDelete: () -> Unit,
onConvertNote: () ->Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
@@ -185,6 +226,33 @@ fun JournalDropdownMenu(
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clone") },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
onCloneNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
onCopyNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Convert") },
leadingIcon = { Icon(Icons.Outlined.SwapHoriz, contentDescription = null) },
onClick = {
expanded = false
onConvertNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.share)) },
leadingIcon = { Icon(Icons.Outlined.Share, contentDescription = null) },
@@ -245,81 +313,92 @@ fun SpacesMenu(
onDismiss: () -> Unit
) {
val selectedSpaces = workspace.selectedSpaces
val scrollState = rememberScrollState()
DropdownMenu(
shape = RoundedCornerShape(16.dp),
expanded = expanded,
onDismissRequest = onDismiss
onDismissRequest = onDismiss,
shape = RoundedCornerShape(16.dp),
scrollState = scrollState,
modifier = Modifier.heightIn(max = 300.dp)
) {
if (selectedSpaces.contains(1)) {
@Composable
fun MenuItem(
visible: Boolean,
id: Int,
title: String,
icon: ImageVector
) {
if (!visible) return
DropdownMenuItem(
text = { Text(stringResource(R.string.Notes)) },
leadingIcon = { Icon(Icons.AutoMirrored.Default.Notes, contentDescription = null) },
text = {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 100.dp)
)
},
leadingIcon = {
Icon(icon, null)
},
onClick = {
onConfirm(1)
onDismiss()
}
)
}
if (selectedSpaces.contains(2)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.To_Do)) },
leadingIcon = { Icon(Icons.Outlined.TaskAlt, contentDescription = null) },
onClick = {
onConfirm(2)
onDismiss()
}
)
}
if (selectedSpaces.contains(3)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.Events)) },
leadingIcon = { Icon(Icons.Outlined.Event, contentDescription = null) },
onClick = {
onConfirm(3)
onDismiss()
}
)
}
if (selectedSpaces.contains(4)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.Journal)) },
leadingIcon = { Icon(Icons.Outlined.AutoStories, contentDescription = null) },
onClick = {
onConfirm(4)
onDismiss()
}
)
}
if (selectedSpaces.contains(5)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.Habits)) },
leadingIcon = { Icon(Icons.Outlined.EventAvailable, contentDescription = null) },
onClick = {
onConfirm(5)
onDismiss()
}
)
}
if (selectedSpaces.contains(7)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.progress_tracker)) },
leadingIcon = { Icon(Icons.Outlined.TrackChanges, contentDescription = null) },
onClick = {
onConfirm(7)
onDismiss()
}
)
}
if (selectedSpaces.contains(6)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.Analytics)) },
leadingIcon = { Icon(Icons.Outlined.Analytics, contentDescription = null) },
onClick = {
onConfirm(6)
onConfirm(id)
onDismiss()
}
)
}
MenuItem(
selectedSpaces.contains(1),
1,
stringResource(R.string.Notes),
Icons.AutoMirrored.Default.Notes
)
MenuItem(
selectedSpaces.contains(2),
2,
stringResource(R.string.To_Do),
Icons.Outlined.TaskAlt
)
MenuItem(
selectedSpaces.contains(3),
3,
stringResource(R.string.Events),
Icons.Outlined.Event
)
MenuItem(
selectedSpaces.contains(4),
4,
stringResource(R.string.Journal),
Icons.Outlined.AutoStories
)
MenuItem(
selectedSpaces.contains(5),
5,
stringResource(R.string.Habits),
Icons.Outlined.EventAvailable
)
MenuItem(
selectedSpaces.contains(7),
7,
stringResource(R.string.progress_tracker),
Icons.Outlined.TrackChanges
)
MenuItem(
selectedSpaces.contains(6),
6,
stringResource(R.string.Analytics),
Icons.Outlined.Analytics
)
}
}
@@ -420,4 +499,196 @@ fun WorkspaceMore(
)
}
}
}
@Composable
fun TodoDropdownMenu(
canShare: Boolean,
onShare: () -> Unit,
onPrint: () -> Unit,
onClone: () -> Unit,
onCopy: () -> Unit,
onConvert: () -> Unit,
onDelete: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier) {
IconButton(onClick = { expanded = !expanded }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More options"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
if(canShare){
DropdownMenuItem(
text = { Text(stringResource(R.string.share)) },
leadingIcon = { Icon(Icons.Outlined.Share, contentDescription = null) },
onClick = {
expanded = false
onShare()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.print)) },
leadingIcon = { Icon(Icons.Outlined.Print, contentDescription = null) },
onClick = {
expanded = false
onPrint()
}
)
HorizontalDivider()
}
DropdownMenuItem(
text = { Text("Clone") },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
onClone()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
onCopy()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Convert") },
leadingIcon = { Icon(Icons.Outlined.SwapHoriz, contentDescription = null) },
onClick = {
expanded = false
onConvert()
}
)
HorizontalDivider()
DropdownMenuItem(
colors = MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.error,
leadingIconColor = MaterialTheme.colorScheme.error
),
text = { Text(stringResource(R.string.delete)) },
leadingIcon = { Icon(Icons.Outlined.Delete, contentDescription = null) },
onClick = {
expanded = false
onDelete()
}
)
}
}
}
@Composable
fun EventDropdownMenu(
onDelete: () -> Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier) {
IconButton(onClick = { expanded = !expanded }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More options"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Clone") },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
onCloneNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
onCopyNote()
}
)
HorizontalDivider()
DropdownMenuItem(
colors = MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.error,
leadingIconColor = MaterialTheme.colorScheme.error
),
text = { Text(stringResource(R.string.delete)) },
leadingIcon = { Icon(Icons.Outlined.Delete, contentDescription = null) },
onClick = {
expanded = false
onDelete()
}
)
}
}
}
@Composable
fun HabitDropdownMenu(
onDelete: () -> Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit
) {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier) {
IconButton(onClick = { expanded = !expanded }) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More options"
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Clone") },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
onCloneNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
onCopyNote()
}
)
HorizontalDivider()
DropdownMenuItem(
colors = MenuDefaults.itemColors(
textColor = MaterialTheme.colorScheme.error,
leadingIconColor = MaterialTheme.colorScheme.error
),
text = { Text(stringResource(R.string.delete)) },
leadingIcon = { Icon(Icons.Outlined.Delete, contentDescription = null) },
onClick = {
expanded = false
onDelete()
}
)
}
}
}
@@ -3,7 +3,6 @@ package com.flux.ui.common
import androidx.compose.foundation.layout.PaddingValues
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
@@ -46,6 +45,8 @@ fun HabitScaffold(
onDeleteClicked: () -> Unit,
onBackPressed: () -> Unit,
onEditClicked: () -> Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit,
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
@@ -64,13 +65,11 @@ fun HabitScaffold(
},
actions = {
IconButton(onEditClicked) { Icon(Icons.Default.Edit, null) }
IconButton(onDeleteClicked) {
Icon(
Icons.Default.DeleteOutline,
null,
tint = MaterialTheme.colorScheme.error
)
}
HabitDropdownMenu(
onDeleteClicked,
onCopyNote,
onCloneNote
)
}
)
},
@@ -1,9 +1,13 @@
package com.flux.ui.events
import android.content.Context
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
sealed class TodoEvents {
data class DeleteAllWorkspaceLists(val workspaceId: String) : TodoEvents()
data class DeleteList(val data: TodoModel) : TodoEvents()
data class UpsertList(val data: TodoModel) : TodoEvents()
data class DeleteAllWorkspaceLists(val context: Context, val workspaceId: String) : TodoEvents()
data class DeleteList(val context: Context, val data: TodoModel) : TodoEvents()
data class UpsertList(val context: Context, val isRemovingReminder: Boolean, val data: TodoModel) : TodoEvents()
data class CreateInstance(val listId: String, val workspaceId: String) : TodoEvents()
data class UpsertInstance(val instance: TodoInstance) : TodoEvents()
}
@@ -74,6 +74,7 @@ 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 androidx.compose.ui.text.style.TextOverflow
import java.time.LocalDate
import java.time.YearMonth
@@ -314,7 +315,7 @@ fun EventCard(
.weight(1f)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(title, style = MaterialTheme.typography.titleMedium)
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
Spacer(Modifier.height(4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
@@ -374,6 +375,8 @@ fun getRecurrenceText(context: Context, repeat: RecurrenceRule, startDateTime: L
localDate.format(DateTimeFormatter.ofPattern("MMM dd"))
)
}
else -> ""
}
}
@@ -1,5 +1,6 @@
package com.flux.ui.screens.events
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -14,7 +15,6 @@ import androidx.compose.material.icons.filled.Pending
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.SportsScore
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.outlined.DeleteOutline
import androidx.compose.material.icons.outlined.NotificationsActive
import androidx.compose.material.icons.outlined.Pending
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -48,9 +48,14 @@ import androidx.navigation.NavController
import com.flux.R
import com.flux.data.model.EventModel
import com.flux.data.model.RecurrenceRule
import com.flux.data.model.WorkspaceModel
import com.flux.navigation.NavRoutes
import com.flux.other.DataCopyType
import com.flux.ui.common.DataCopyDialog
import com.flux.ui.common.DeleteAlert
import com.flux.ui.common.EventDropdownMenu
import com.flux.ui.events.TaskEvents
import com.flux.ui.events.WorkspaceEvents
import com.flux.ui.state.Settings
import java.time.ZoneId
import java.time.Instant
@@ -60,12 +65,14 @@ import java.time.format.DateTimeFormatter
@Composable
fun EventDetails(
navController: NavController,
workspaces: List<WorkspaceModel>,
workspaceId: String,
event: EventModel,
isPending: Boolean,
instanceDate: Long,
settings: Settings,
onTaskEvents: (TaskEvents) -> Unit
onTaskEvents: (TaskEvents) -> Unit,
onWorkspaceEvents: (WorkspaceEvents) -> Unit
) {
var title by remember { mutableStateOf(event.title) }
var description by remember { mutableStateOf(event.description) }
@@ -75,6 +82,10 @@ fun EventDetails(
val context = LocalContext.current
val time = event.startDateTime.toFormattedTime(settings.data.is24HourFormat)
var showDeleteDialog by remember { mutableStateOf(false) }
var showDataCopyDialog by remember { mutableStateOf(false) }
val cloneString = stringResource(R.string.clone_created_successfully)
val contentCopiedString = stringResource(R.string.content_copied)
val contentMovedString = stringResource(R.string.content_moved)
if(showDeleteDialog){
DeleteAlert({
@@ -111,13 +122,17 @@ fun EventDetails(
contentDescription = "Edit"
)
}
IconButton({ showDeleteDialog=true }) {
Icon(
Icons.Outlined.DeleteOutline,
null,
tint = MaterialTheme.colorScheme.error
)
}
EventDropdownMenu(
{showDeleteDialog=true},
{showDataCopyDialog=true},
{
onTaskEvents(TaskEvents.UpsertTask(
context,
EventModel(title = "Clone $title", description = description, recurrence = event.recurrence, endDateTime = event.endDateTime, notificationOffset = event.notificationOffset, workspaceId = event.workspaceId)
))
Toast.makeText(context, cloneString, Toast.LENGTH_SHORT).show()
},
)
}
)
},
@@ -185,6 +200,46 @@ fun EventDetails(
}
}
}
if(showDataCopyDialog){
DataCopyDialog(
workspaces.filterNot { it.workspaceId == workspaceId },
{ dataCopyType, selectedWorkspaces ->
if(selectedWorkspaces.isEmpty()) return@DataCopyDialog
when(dataCopyType){
DataCopyType.COPY -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(3)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 3)))
}
onTaskEvents(TaskEvents.UpsertTask(
context,
EventModel(title = title, description = description, recurrence = event.recurrence, endDateTime = event.endDateTime, notificationOffset = event.notificationOffset, workspaceId = workspace.workspaceId)
))
}
Toast.makeText(context, contentCopiedString, Toast.LENGTH_SHORT).show()
}
DataCopyType.MOVE -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(3)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 3)))
}
onTaskEvents(TaskEvents.UpsertTask(
context,
EventModel(title = title, description = description, recurrence = event.recurrence, endDateTime = event.endDateTime, notificationOffset = event.notificationOffset, workspaceId = workspace.workspaceId)
))
}
navController.popBackStack()
Toast.makeText(context, contentMovedString, Toast.LENGTH_SHORT).show()
onTaskEvents(TaskEvents.DeleteTask(event, context))
}
}
}
) { showDataCopyDialog = false }
}
}
@Composable
@@ -55,6 +55,7 @@ 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.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
@@ -89,6 +90,12 @@ fun EventScreen(
val datedEvents = state.datedEvents
.filter { it.workspaceId==workspaceId }
.filter { it.title.contains(query, ignoreCase = true) }
.sortedBy {
Instant.ofEpochMilli(it.startDateTime)
.atZone(ZoneId.systemDefault())
.toLocalTime()
.toSecondOfDay()
}
val monthlyEventCount = computeMonthlyEventDates(state.allEvent.filter { it.workspaceId == workspaceId }, selectedMonth)
val allEventInstances = state.allEventInstances
var showSearchBar by remember { mutableStateOf(false) }
@@ -153,8 +160,9 @@ fun EventScreen(
.fillMaxSize()
.padding(innerPadding)
.padding(12.dp)
.nestedScroll(scrollBehavior.nestedScrollConnection))
{
.nestedScroll(scrollBehavior.nestedScrollConnection),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
item {
if(showSearchBar){ SpaceSearchBar(query, { query=it }, { showSearchBar=false }) }
else {
@@ -205,12 +213,14 @@ fun EventScreen(
onMonthChange = { onEvent(TaskEvents.ChangeMonth(it)) },
onDateChange = { onEvent(TaskEvents.ChangeDate(it)) }
)
Spacer(Modifier.height(6.dp))
}
} else {
item {
DailyViewCalendar(selectedMonth, selectedDate){
onEvent(TaskEvents.ChangeDate(it))
}
Spacer(Modifier.height(6.dp))
}
}
if(datedEvents.isEmpty()) item { EmptyEvents() }
@@ -3,6 +3,7 @@ package com.flux.ui.screens.events
import androidx.compose.foundation.clickable
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
@@ -53,6 +54,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.flux.R
@@ -250,28 +252,50 @@ fun NewEvent(
stringResource(R.string.sunday_short)
)
Row(modifier = Modifier.fillMaxWidth().padding(6.dp)) {
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 7
) {
weekdays.forEachIndexed { index, day ->
val isSelected = index in rule.daysOfWeek
Card(
onClick = {},
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
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
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
)
)
}
}
}
}
else -> {}
}
}
HorizontalDivider(Modifier.fillMaxWidth())
@@ -56,7 +56,6 @@ 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
@@ -114,10 +113,8 @@ 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
@@ -125,9 +122,11 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import com.flux.data.model.isCounted
import com.flux.data.model.isLive
@@ -162,7 +161,9 @@ fun TimerDialog(
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(24.dp)) {
Column(Modifier.padding(16.dp).fillMaxWidth()) {
Column(Modifier
.padding(16.dp)
.fillMaxWidth()) {
NumberPickerRow(Modifier.fillMaxWidth(), hours, minutes, { hours = it }) {
minutes = it
}
@@ -213,6 +214,7 @@ fun CountedHabitStatus(
Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -230,25 +232,26 @@ fun CountedHabitStatus(
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))
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
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,
)
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp)
) {
HabitInfoComponent(
Icons.Outlined.IncompleteCircle,
stringResource(R.string.current),
@@ -270,85 +273,57 @@ fun CountedHabitStatus(
}
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
modifier = Modifier.fillMaxWidth().padding(horizontal = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
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
)
)
val newInstance = HabitInstanceModel( instanceDate = todayEpoch, habitId = habit.id, workspaceId = habit.workspaceId, count = count )
onHabitEvents( HabitEvents.UpdateInstance( newInstance, habit.habitConfig ) )
},
modifier = Modifier.weight(0.5f)
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Remove,
null,
tint = MaterialTheme.colorScheme.primary
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.width(4.dp))
Icon(
Icons.Default.Remove,
null,
tint = MaterialTheme.colorScheme.primary
)
Text(stringResource(R.string.decrement))
}
Text(
text = stringResource(R.string.decrement),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
}
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
)
)
val newInstance = HabitInstanceModel( instanceDate = todayEpoch, habitId = habit.id, workspaceId = habit.workspaceId, count = count )
onHabitEvents( HabitEvents.UpdateInstance( newInstance, habit.habitConfig ) )
},
modifier = Modifier.weight(0.5f)
modifier = Modifier.weight(1f)
) {
Icon(
Icons.Default.Add,
null,
tint = MaterialTheme.colorScheme.primary
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(Modifier.width(4.dp))
Icon(
Icons.Default.Add,
null,
tint = MaterialTheme.colorScheme.primary
)
Text(stringResource(R.string.increment))
}
Text(
text = stringResource(R.string.increment),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false)
)
}
}
}
@@ -403,7 +378,9 @@ fun TimedHabitStatus (
)
Row(
Modifier.fillMaxWidth().padding(top = 8.dp),
Modifier
.fillMaxWidth()
.padding(top = 8.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
@@ -450,7 +427,9 @@ fun TimedHabitStatus (
}
}
NumberPickerRow(Modifier.fillMaxWidth().padding(horizontal = 32.dp), hours, minutes, {hours=it}) { minutes=it }
NumberPickerRow(Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp), hours, minutes, {hours=it}) { minutes=it }
FilledTonalButton(
{
@@ -464,7 +443,9 @@ fun TimedHabitStatus (
)
onHabitEvents(HabitEvents.UpdateInstance(newInstance, habit.habitConfig))
},
modifier = Modifier.fillMaxWidth().padding(horizontal = 32.dp),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
@@ -645,7 +626,9 @@ fun TimedHabitDialog(
Dialog(onDismissRequest = onDismiss) {
Surface(shape = RoundedCornerShape(24.dp)) {
Column(Modifier.padding(16.dp).fillMaxWidth()) {
Column(Modifier
.padding(16.dp)
.fillMaxWidth()) {
SingleChoiceSegmentedButtonRow(modifier = Modifier.padding(vertical = 6.dp)) {
options.forEachIndexed { index, label ->
SegmentedButton(
@@ -662,7 +645,9 @@ fun TimedHabitDialog(
}
NumberPickerRow(
Modifier.fillMaxWidth().padding(horizontal = 32.dp),
Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
hours,
minutes,
{ hours=it }
@@ -728,10 +713,22 @@ fun HabitDetailedInfo(
)
}
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val columns = when {
density.fontScale > 1.5f -> 1
configuration.screenWidthDp < 360 -> 1
configuration.screenWidthDp < 480 -> 2
else -> 3
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 1000.dp)
@@ -847,22 +844,47 @@ fun HabitDetailedInfo(
}
@Composable
fun HabitInfoComponent(icon: ImageVector, title: String, description: String){
fun HabitInfoComponent(
icon: ImageVector,
title: String,
description: String
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
CircleWrapper(MaterialTheme.colorScheme.primaryContainer) {
Icon(
icon,
null,
modifier = Modifier.size(18.dp),
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(24.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)
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.alpha(0.85f),
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(2.dp))
Text(
text = description,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
@@ -967,7 +989,7 @@ fun HabitDateCard(
Card(
modifier = modifier,
shape = shapeManager(radius = radius * 2),
shape = shapeManager(radius = radius),
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor
@@ -981,13 +1003,13 @@ fun HabitDateCard(
) {
Text(
day.uppercase(),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraLight),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.alpha(0.95f)
)
Text(
date.toString(),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight),
modifier = Modifier.alpha(0.95f)
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraLight),
modifier = Modifier.alpha(0.85f)
)
}
}
@@ -1140,7 +1162,7 @@ fun OtherConfigCard(
val text = if (isTimed) { formatDuration(instance?.timeSpent ?: 0L) } else { (instance?.count ?: 0).toString() }
Card(
modifier = modifier,
shape = shapeManager(radius = radius * 2),
shape = shapeManager(radius = radius),
colors = CardDefaults.cardColors(
containerColor = containerColor,
contentColor = contentColor
@@ -1150,25 +1172,24 @@ fun OtherConfigCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
horizontalAlignment = Alignment.CenterHorizontally
) {
Row{
Text(
"$date, ",
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.alpha(0.95f)
)
Text(
day.uppercase(),
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight),
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.alpha(0.95f)
)
}
Text(
text,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.ExtraLight),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraLight),
modifier = Modifier.alpha(0.85f)
)
}
@@ -1214,14 +1235,13 @@ fun HabitCalendarCard(
)
val today = LocalDate.now()
val habitStartDate =
Instant.ofEpochMilli(startDateTime).atZone(ZoneId.systemDefault()).toLocalDate()
val habitEndEpochDay = if (endDateTime == -1L) null
else Instant.ofEpochMilli(endDateTime)
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()
@@ -1275,14 +1295,14 @@ fun HabitCalendarCard(
}
// Days of week row
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.fillMaxWidth().padding(bottom = 6.dp), 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)
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)
)
}
}
@@ -1423,7 +1443,7 @@ fun WeeklyHabitAnalyticsCard(
stringResource(R.string.sunday_short)
)
val dayStatus = daysOfWeek.mapIndexed { index, _ ->
val dayStatus = List(daysOfWeek.size) { index ->
val date = startOfWeek.plusDays(index.toLong())
val epoch = date.toEpochDay()
@@ -2108,7 +2128,9 @@ fun HabitConfigCard(
}
Card(
modifier = Modifier.clip(RoundedCornerShape(50)).padding(horizontal = 2.dp),
modifier = Modifier
.clip(RoundedCornerShape(50))
.padding(horizontal = 2.dp),
shape = RoundedCornerShape(50),
onClick = onClick,
colors = CardDefaults.cardColors(
@@ -1,5 +1,6 @@
package com.flux.ui.screens.habits
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -12,15 +13,21 @@ 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.res.stringResource
import androidx.compose.ui.unit.dp
import com.flux.R
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.data.model.WorkspaceModel
import com.flux.navigation.NavRoutes
import com.flux.other.DataCopyType
import com.flux.ui.common.DataCopyDialog
import com.flux.ui.common.DeleteAlert
import com.flux.ui.common.HabitScaffold
import com.flux.ui.events.HabitEvents
import com.flux.ui.events.WorkspaceEvents
import java.time.LocalDate
@OptIn(ExperimentalMaterial3Api::class)
@@ -30,13 +37,20 @@ fun HabitDetails(
radius: Int,
workspaceId: String,
habit: HabitModel,
workspaces: List<WorkspaceModel>,
habitInstances: List<HabitInstanceModel>,
onHabitEvents: (HabitEvents) -> Unit
onHabitEvents: (HabitEvents) -> Unit,
onWorkspaceEvents: (WorkspaceEvents) -> Unit
) {
val context = LocalContext.current
var showDeleteDialog by remember { mutableStateOf(false) }
val todayEpoch = LocalDate.now().toEpochDay()
val todayInstance = habitInstances.firstOrNull { it.instanceDate == todayEpoch }
val isAllowedByRecurrence = isDateAllowedForHabit(habit.recurrence, todayEpoch)
var showDataCopyDialog by remember { mutableStateOf(false) }
val cloneString = stringResource(R.string.clone_created_successfully)
val contentCopiedString = stringResource(R.string.content_copied)
val contentMovedString = stringResource(R.string.content_moved)
if(showDeleteDialog){
DeleteAlert({
@@ -52,16 +66,21 @@ fun HabitDetails(
onBackPressed = { navController.popBackStack() },
onDeleteClicked = { showDeleteDialog=true },
onEditClicked = { navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, habit.id)) },
onCloneNote = {
onHabitEvents(HabitEvents.UpsertHabit(context, HabitModel(workspaceId = habit.workspaceId, habitConfig = habit.habitConfig, title = "Clone ${habit.title}", description = habit.description, endDateTime = habit.endDateTime, notificationOffset = habit.notificationOffset, recurrence = habit.recurrence)))
Toast.makeText(context, cloneString, Toast.LENGTH_SHORT).show()
},
onCopyNote = { showDataCopyDialog=true },
content = { innerPadding ->
LazyColumn(
Modifier
.padding(innerPadding)
.padding(16.dp)
.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(4.dp)
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item { HabitDetailedInfo(radius, habit, habitInstances) }
if(habit.habitConfig is HabitConfig.Counted) {
if(habit.habitConfig is HabitConfig.Counted && isAllowedByRecurrence) {
item { CountedHabitStatus(radius, habit, todayInstance, onHabitEvents) }
}
if(habit.habitConfig is HabitConfig.Timed) {
@@ -77,4 +96,39 @@ fun HabitDetails(
}
}
)
if(showDataCopyDialog){
DataCopyDialog(
workspaces.filterNot { it.workspaceId == workspaceId },
{ dataCopyType, selectedWorkspaces ->
if(selectedWorkspaces.isEmpty()) return@DataCopyDialog
when(dataCopyType){
DataCopyType.COPY -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(5)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 5)))
}
onHabitEvents(HabitEvents.UpsertHabit(context, HabitModel(workspaceId = workspace.workspaceId, habitConfig = habit.habitConfig, title = habit.title, description = habit.description, endDateTime = habit.endDateTime, notificationOffset = habit.notificationOffset, recurrence = habit.recurrence)))
}
Toast.makeText(context, contentCopiedString, Toast.LENGTH_SHORT).show()
}
DataCopyType.MOVE -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(5)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 5)))
}
onHabitEvents(HabitEvents.UpsertHabit(context, HabitModel(workspaceId = workspace.workspaceId, habitConfig = habit.habitConfig, title = habit.title, description = habit.description, endDateTime = habit.endDateTime, notificationOffset = habit.notificationOffset, recurrence = habit.recurrence)))
}
navController.popBackStack()
Toast.makeText(context, contentMovedString, Toast.LENGTH_SHORT).show()
onHabitEvents(HabitEvents.DeleteHabit(habit, context))
}
}
}
) { showDataCopyDialog = false }
}
}
@@ -234,5 +234,6 @@ fun isDateAllowedForHabit(recurrence: RecurrenceRule, epochDay: Long): Boolean {
is RecurrenceRule.Once -> true
is RecurrenceRule.Monthly -> true
is RecurrenceRule.Yearly -> true
else -> false
}
}
@@ -4,6 +4,7 @@ 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.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -68,6 +69,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.flux.R
@@ -273,11 +275,14 @@ fun NewHabit(
}
item {
Row(
Modifier
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
) {
.padding(6.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
maxItemsInEachRow = 7
) {
weekdays.forEachIndexed { index, day ->
val isSelected = selectedDays.contains(index)
Card(
@@ -291,22 +296,30 @@ fun NewHabit(
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
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
textAlign = TextAlign.Center,
maxLines = 1,
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 8.dp
)
)
}
}
@@ -89,8 +89,18 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import com.flux.R
import com.flux.data.model.LabelModel
import com.flux.data.model.NotesModel
import com.flux.data.model.TodoItem
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
import com.flux.navigation.NavRoutes
import com.flux.other.ConvertType
import com.flux.other.DataCopyType
import com.flux.ui.common.DataCopyDialog
import com.flux.ui.common.DatePickerModal
import com.flux.ui.events.NotesEvents
import com.flux.ui.events.TodoEvents
import com.flux.ui.events.WorkspaceEvents
import com.flux.ui.screens.notes.LinkDialog
import com.flux.ui.screens.notes.ListDialog
import com.flux.ui.screens.notes.MarkdownEditorRow
@@ -107,6 +117,8 @@ import com.flux.ui.screens.notes.TaskItem
@Composable
fun EditJournal(
navController: NavController,
workspaces: List<WorkspaceModel>,
workspaceId: String,
journal: JournalModel,
outline: HeaderNode,
aboutJournal: TextState,
@@ -118,7 +130,10 @@ fun EditJournal(
allLabels: List<LabelModel>,
journalViewModel: JournalViewModel,
settingsViewModel: SettingsViewModel,
onJournalEvents: (JournalEvents) -> Unit
onJournalEvents: (JournalEvents) -> Unit,
onNotesEvents: (NotesEvents) -> Unit,
onTodoEvents: (TodoEvents) -> Unit,
onWorkspaceEvents: (WorkspaceEvents) -> Unit
) {
val textFieldStateSaver = Saver<TextFieldState, String>(
save = { it.text.toString() },
@@ -131,7 +146,7 @@ fun EditJournal(
val scope = rememberCoroutineScope()
val sheetState = rememberModalBottomSheetState()
val hasContent = remember(journal.journalId) { journal.text.isNotBlank() }
val currentWorkspace = workspaces.find { it.workspaceId == workspaceId }
val pagerState = rememberPagerState(
initialPage = if (startWithReadView && hasContent) 1 else 0,
pageCount = { 2 }
@@ -158,8 +173,15 @@ fun EditJournal(
addAll(journal.labels)
}
}
var showDataCopyDialog by remember { mutableStateOf(false) }
var showConvertDialog by remember { mutableStateOf(false) }
val recorder = AudioRecorder(context)
val cloneString = stringResource(R.string.clone_created_successfully)
val contentCopiedString = stringResource(R.string.content_copied)
val contentMovedString = stringResource(R.string.content_moved)
val successString = stringResource(R.string.success)
val rootPicker =
rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
@@ -207,7 +229,7 @@ fun EditJournal(
val hasChanged = newText != journal.text
if (!hasChanged && currentLabelIds.toList()==journal.labels) return
if (!hasChanged && currentLabelIds.toList()==journal.labels && journalDate==journal.dateTime) return
onJournalEvents(
JournalEvents.UpsertEntry(
@@ -249,7 +271,13 @@ fun EditJournal(
onShareNote = { showShareDialog = true },
onSaveNote = { showSaveDialog = true },
onAddLabel = { showLabelDialog = true },
onPrintNote = { printPdf(context as Activity, readWebView, convertMillisToDate(journal.dateTime)+"_"+ convertMillisToTime(journal.dateTime)) }
onPrintNote = { printPdf(context as Activity, readWebView, convertMillisToDate(journal.dateTime)+"_"+ convertMillisToTime(journal.dateTime)) },
onCloneNote = {
onJournalEvents(JournalEvents.UpsertEntry(JournalModel(text = contentState.text.toString(), workspaceId = workspaceId, labels = currentLabelIds)))
Toast.makeText(context, cloneString, Toast.LENGTH_SHORT).show()
},
onCopyNote = { showDataCopyDialog = true },
onConvertNote = { showConvertDialog = true }
)
},
bottomBar = {
@@ -548,6 +576,82 @@ fun EditJournal(
)
}
if(showDataCopyDialog){
DataCopyDialog(
workspaces.filterNot { it.workspaceId == workspaceId },
{ dataCopyType, selectedWorkspaces ->
if(selectedWorkspaces.isEmpty()) return@DataCopyDialog
when(dataCopyType){
DataCopyType.COPY -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(4)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 4)))
}
onJournalEvents(JournalEvents.UpsertEntry(JournalModel(text = contentState.text.toString(), workspaceId = workspace.workspaceId)))
}
Toast.makeText(context, contentCopiedString, Toast.LENGTH_SHORT).show()
}
DataCopyType.MOVE -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(4)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 4)))
}
onJournalEvents(JournalEvents.UpsertEntry(JournalModel(text = contentState.text.toString(), workspaceId = workspace.workspaceId)))
}
navController.popBackStack()
Toast.makeText(context, contentMovedString, Toast.LENGTH_SHORT).show()
onJournalEvents(JournalEvents.DeleteEntry(journal))
}
}
}
) { showDataCopyDialog = false }
}
if(showConvertDialog){
ConvertJournalDialog ({ type ->
when(type){
ConvertType.TODO -> {
val todo = TodoModel(
workspaceId = journal.workspaceId,
title = "Journal ${convertMillisToDate(System.currentTimeMillis())}",
items = contentState.text.toString()
.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { line ->
TodoItem(value = line)
}
.toList()
)
if(!currentWorkspace!!.selectedSpaces.contains(2)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(currentWorkspace.copy(selectedSpaces = currentWorkspace.selectedSpaces + 2)))
}
onTodoEvents(TodoEvents.UpsertList(context, false, todo))
navController.popBackStack()
onJournalEvents(JournalEvents.DeleteEntry(journal))
}
ConvertType.NOTE -> {
if(!currentWorkspace!!.selectedSpaces.contains(1)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(currentWorkspace.copy(selectedSpaces = currentWorkspace.selectedSpaces + 1)))
}
onNotesEvents(NotesEvents.UpsertNote(NotesModel(title = "Journal ${convertMillisToDate(System.currentTimeMillis())}", description = contentState.text.toString(), workspaceId = workspaceId, labels = currentLabelIds)))
navController.popBackStack()
onJournalEvents(JournalEvents.DeleteEntry(journal))
}
else -> {}
}
Toast.makeText(context, successString, Toast.LENGTH_SHORT).show()
showConvertDialog=false
}) {
showConvertDialog=false
}
}
NotesInfoBottomSheet(
isVisible = showAboutNotes,
words = aboutJournal.wordCountWithPunctuation,
@@ -24,6 +24,8 @@ 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.material.icons.automirrored.outlined.Note
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -60,8 +62,10 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.flux.R
import com.flux.data.model.LabelModel
import com.flux.other.ConvertType
import com.flux.other.parseMarkdownContent
import com.flux.ui.common.CategoryRow
import com.flux.ui.common.DateOptionRow
@@ -71,6 +75,7 @@ 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.notes.ExportCard
import com.flux.ui.screens.settings.shapeManager
import kotlin.collections.set
@@ -425,3 +430,31 @@ fun JournalFilterSheet(
)
}
}
@Composable
fun ConvertJournalDialog(
onConfirm: (ConvertType) -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.convert),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 16.dp)
)
ExportCard(Icons.AutoMirrored.Outlined.Note, stringResource(R.string.convert_to_todo)) { onConfirm(ConvertType.TODO) }
ExportCard(Icons.Outlined.AutoStories, stringResource(R.string.convert_to_note)) { onConfirm(ConvertType.NOTE) }
}
}
}
}
@@ -70,6 +70,8 @@ import com.flux.ui.state.JournalState
import com.flux.ui.state.Settings
import java.time.Instant
import java.time.ZoneId
import java.time.format.TextStyle
import java.util.Locale
data class FilterState(
val sort: String? = null,
@@ -95,6 +97,7 @@ fun JournalScreen(
){
val workspaceId = workspace.workspaceId
val isLoading = state.isLoading
val is24HoursFormat = settings.data.is24HourFormat
val radius = settings.data.cornerRadius
val context = LocalContext.current
var query by remember { mutableStateOf("") }
@@ -221,7 +224,7 @@ fun JournalScreen(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.End
) {
val buttonModifier = Modifier.width(120.dp)
val buttonModifier = Modifier.width(140.dp)
ExtendedFloatingActionButton(
modifier = buttonModifier,
@@ -311,7 +314,14 @@ fun JournalScreen(
}
if(allEntries.isEmpty()) item { EmptyJournal() }
items(allEntries) { entry->
JournalCardHeader(convertMillisToDate(entry.dateTime) + ", " + convertMillisToTime(entry.dateTime))
JournalCardHeader("${convertMillisToDay(entry.dateTime)}, ${
convertMillisToDate(entry.dateTime)
}, ${
convertMillisToTime(
entry.dateTime,
is24Hour = is24HoursFormat
)
}")
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
TimelineBody(isLast = false)
JournalPreview(radius, entry.text, allLabels.filter { entry.labels.contains(it.labelId) }) {
@@ -346,3 +356,9 @@ fun JournalScreen(
}
}
fun convertMillisToDay(millis: Long): String {
return Instant.ofEpochMilli(millis)
.atZone(ZoneId.systemDefault())
.dayOfWeek
.getDisplayName(TextStyle.FULL, Locale.getDefault())
}
@@ -53,6 +53,7 @@ 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.automirrored.outlined.List
import androidx.compose.material.icons.automirrored.outlined.Note
import androidx.compose.material.icons.filled.Abc
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
@@ -73,6 +74,7 @@ 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.AutoStories
import androidx.compose.material.icons.outlined.CheckBox
import androidx.compose.material.icons.outlined.Code
import androidx.compose.material.icons.outlined.DataArray
@@ -166,7 +168,6 @@ import com.flux.other.AudioRecorder
import com.flux.other.Constants
import com.flux.other.ExportType
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
@@ -180,6 +181,10 @@ import kotlin.math.cos
import kotlin.math.sin
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import com.flux.other.ConvertType
import com.flux.other.MarkdownBlock
import com.flux.other.MediaChipsRow
import com.flux.other.extractMedia
import com.flux.ui.common.CategoryRow
import com.flux.ui.common.FilterCategory
import com.flux.ui.common.FilterOption
@@ -187,6 +192,7 @@ import com.flux.ui.common.MultiOptionRow
import com.flux.ui.common.OptionRow
import com.flux.ui.common.SelectionType
import kotlin.collections.filter
import kotlin.time.Duration.Companion.milliseconds
// ------------- Dialogs -------------
@Composable
@@ -436,6 +442,34 @@ fun ShareDialog(
}
}
@Composable
fun ConvertNotesDialog(
onConfirm: (ConvertType) -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.convert),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 16.dp)
)
ExportCard(Icons.AutoMirrored.Outlined.Note, stringResource(R.string.convert_to_todo)) { onConfirm(ConvertType.TODO) }
ExportCard(Icons.Outlined.AutoStories, stringResource(R.string.convert_to_journal)) { onConfirm(ConvertType.JOURNAL) }
}
}
}
}
@Composable
fun ExportCard(icon: ImageVector, title: String, onClick: () -> Unit){
Card(
@@ -1041,7 +1075,7 @@ fun StudioRecorderUI(
elapsedMs = System.currentTimeMillis() - start
delay(50)
delay(50.milliseconds)
}
}
@@ -1711,12 +1745,22 @@ fun NotesFilterSheet(
fun NotesPreviewCard(
modifier: Modifier = Modifier,
radius: Int,
notesPreviewMode: Int,
isSelected: Boolean,
note: NotesModel,
labels: List<String>,
onClick: (String) -> Unit,
onLongPressed: () -> Unit
) {
// Extract media once — MarkdownBlock will use the cleaned text internally,
// and we render the chips ourselves at the bottom of the card.
val mediaExtraction = remember(note.description) { extractMedia(note.description) }
val maxHeight = when(notesPreviewMode) {
0 -> 0.dp
1 -> 180.dp
else -> 360.dp
}
Card(
colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)),
modifier = modifier
@@ -1735,6 +1779,7 @@ fun NotesPreviewCard(
.fillMaxWidth()
.padding(bottom = 8.dp)
) {
// Title
Text(
text = note.title,
style = MaterialTheme.typography.bodyLarge,
@@ -1746,16 +1791,33 @@ fun NotesPreviewCard(
.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)
)
// Description — media already stripped inside MarkdownBlock
if(maxHeight!=0.dp){
Box(
Modifier
.fillMaxWidth()
.heightIn(max = maxHeight)
.padding(horizontal = 12.dp, vertical = 4.dp)
) {
MarkdownBlock(
text = note.description,
onClick = { onClick(note.notesId) },
onLongClick = onLongPressed
)
}
// Media chips pinned here, below the text, above labels
if (!mediaExtraction.media.isEmpty) {
MediaChipsRow(
media = mediaExtraction.media,
modifier = Modifier.padding(horizontal = 12.dp),
onClick = { onClick(note.notesId) },
onLongClick = onLongPressed
)
}
}
// Labels
val maxVisibleLabels = 2
val visibleLabels = labels.take(maxVisibleLabels)
val extraCount = labels.size - maxVisibleLabels
@@ -3,6 +3,7 @@ package com.flux.ui.screens.notes
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
@@ -71,8 +72,12 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastJoinToString
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.NotesModel
import com.flux.data.model.TodoItem
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
import com.flux.navigation.NavRoutes
import com.flux.other.Constants
import com.flux.other.HeaderNode
@@ -91,12 +96,19 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import com.flux.other.AudioRecorder
import com.flux.other.ConvertType
import com.flux.other.DataCopyType
import com.flux.ui.common.DataCopyDialog
import com.flux.ui.common.convertMillisToTime
import com.flux.ui.events.JournalEvents
import com.flux.ui.events.TodoEvents
import com.flux.ui.events.WorkspaceEvents
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun NoteDetails(
navController: NavController,
workspaces: List<WorkspaceModel>,
outline: HeaderNode,
aboutNotes: TextState,
workspaceId: String,
@@ -109,7 +121,10 @@ fun NoteDetails(
allLabels: List<LabelModel>,
settingsViewModel: SettingsViewModel,
notesViewModel: NotesViewModel,
onNotesEvents: (NotesEvents) -> Unit
onNotesEvents: (NotesEvents) -> Unit,
onJournalEvents: (JournalEvents) -> Unit,
onTodoEvents: (TodoEvents) -> Unit,
onWorkspaceEvents: (WorkspaceEvents) -> Unit
) {
val textFieldStateSaver = Saver<TextFieldState, String>(
save = { it.text.toString() },
@@ -140,14 +155,21 @@ fun NoteDetails(
var showListDialog by rememberSaveable { mutableStateOf(false) }
var showAudioRecorder by rememberSaveable { mutableStateOf(false) }
var showSelectLabels by rememberSaveable { mutableStateOf(false) }
var showDataCopyDialog by remember { mutableStateOf(false) }
var showConvertDialog by remember { mutableStateOf(false) }
var isPinned by rememberSaveable(note.notesId) { mutableStateOf(note.isPinned) }
val noteLabelIds = rememberSaveable {
mutableStateListOf<String>().apply {
addAll(note.labels)
}
}
val currentWorkspace = workspaces.find { it.workspaceId == workspaceId }
val isReadView by remember { derivedStateOf { pagerState.currentPage == 1 } }
var readWebView by remember { mutableStateOf<WebView?>(null) }
val cloneString = stringResource(R.string.clone_created_successfully)
val contentCopiedString = stringResource(R.string.content_copied)
val contentMovedString = stringResource(R.string.content_moved)
val successString = stringResource(R.string.success)
LaunchedEffect(searchState.searchWord, contentState.text) {
withContext(Dispatchers.Default) {
@@ -255,7 +277,13 @@ fun NoteDetails(
settingsViewModel = settingsViewModel,
rootPicker = rootPicker
) { showSaveNotesDialog = true } },
onPrintNote = { printPdf(context as Activity, readWebView, titleState.text.toString()) }
onPrintNote = { printPdf(context as Activity, readWebView, titleState.text.toString()) },
onCloneNote = {
onNotesEvents(NotesEvents.UpsertNote(NotesModel(title = "Clone ${titleState.text}", description = contentState.text.toString(), workspaceId = workspaceId, labels = noteLabelIds)))
Toast.makeText(context, cloneString, Toast.LENGTH_SHORT).show()
},
onConvertNote = { showConvertDialog = true },
onCopyNote = { showDataCopyDialog = true }
)
},
bottomBar = {
@@ -487,12 +515,90 @@ fun NoteDetails(
}
}
if(showDataCopyDialog){
DataCopyDialog(
workspaces.filterNot { it.workspaceId == workspaceId },
{ dataCopyType, selectedWorkspaces ->
if(selectedWorkspaces.isEmpty()) return@DataCopyDialog
when(dataCopyType){
DataCopyType.COPY -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(1)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 1)))
}
onNotesEvents(NotesEvents.UpsertNote(NotesModel(title = titleState.text.toString(), description = contentState.text.toString(), workspaceId = workspace.workspaceId)))
}
Toast.makeText(context, contentCopiedString, Toast.LENGTH_SHORT).show()
}
DataCopyType.MOVE -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(1)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 1)))
}
onNotesEvents(NotesEvents.UpsertNote(NotesModel(title = titleState.text.toString(), description = contentState.text.toString(), workspaceId = workspace.workspaceId)))
}
navController.popBackStack()
Toast.makeText(context, contentMovedString, Toast.LENGTH_SHORT).show()
onNotesEvents(NotesEvents.DeleteNote(note))
}
}
}
) { showDataCopyDialog = false }
}
if(showAudioRecorder){
RecordAudioDialog(context, recorder, {onNotesEvents(NotesEvents.ImportAudio(context, it!!, contentState))}) {
showAudioRecorder=false
}
}
if(showConvertDialog){
ConvertNotesDialog ({ type ->
when(type){
ConvertType.TODO -> {
val todo = TodoModel(
workspaceId = note.workspaceId,
title = titleState.text.toString(),
items = contentState.text.toString()
.lineSequence()
.map { it.trim() }
.filter { it.isNotEmpty() }
.map { line ->
TodoItem(
value = line
)
}
.toList()
)
if(!currentWorkspace!!.selectedSpaces.contains(2)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(currentWorkspace.copy(selectedSpaces = currentWorkspace.selectedSpaces + 2)))
}
onTodoEvents(TodoEvents.UpsertList(context, false, todo))
navController.popBackStack()
onNotesEvents(NotesEvents.DeleteNote(note))
}
ConvertType.JOURNAL -> {
if(!currentWorkspace!!.selectedSpaces.contains(4)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(currentWorkspace.copy(selectedSpaces = currentWorkspace.selectedSpaces + 4)))
}
onJournalEvents(JournalEvents.UpsertEntry(JournalModel(text = contentState.text.toString(), workspaceId = workspaceId, labels = noteLabelIds)))
navController.popBackStack()
onNotesEvents(NotesEvents.DeleteNote(note))
}
else -> {}
}
Toast.makeText(context, successString, Toast.LENGTH_SHORT).show()
showConvertDialog=false
}) {
showConvertDialog=false
}
}
NotesInfoBottomSheet(
isVisible = showAboutNotes,
words = aboutNotes.wordCountWithPunctuation,
@@ -14,6 +14,7 @@ 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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@@ -101,6 +102,7 @@ fun NotesScreen(
val isGridView = settings.data.isGridView
val radius = settings.data.cornerRadius
val isLoading = state.isLoading
val notesPreviewMode = settings.data.notesPreviewMode
val columns = if(isGridView) 2 else 1
val selectedNotes = remember { mutableStateListOf<NotesModel>() }
val importSuccess = stringResource(R.string.import_success)
@@ -231,7 +233,7 @@ fun NotesScreen(
verticalArrangement = Arrangement.spacedBy(4.dp),
horizontalAlignment = Alignment.End
) {
val buttonModifier = Modifier.width(120.dp)
val buttonModifier = Modifier.width(140.dp)
ExtendedFloatingActionButton(
modifier = buttonModifier,
@@ -275,10 +277,15 @@ fun NotesScreen(
columns = StaggeredGridCells.Fixed(columns),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 12.dp,
bottom = 80.dp
),
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(12.dp)
.nestedScroll(scrollBehavior.nestedScrollConnection)
) {
item(span = StaggeredGridItemSpan.FullLine) {
@@ -354,6 +361,7 @@ fun NotesScreen(
radius = radius,
isSelected = selectedNotes.contains(note),
note = note,
notesPreviewMode = notesPreviewMode,
labels = allLabels.filter { note.labels.contains(it.labelId) }.map { it.value },
onClick = { navController.navigate(NavRoutes.NoteDetails.withArgs(workspaceId, note.notesId)) },
onLongPressed = {
@@ -380,6 +388,7 @@ fun NotesScreen(
radius = radius,
isSelected = selectedNotes.contains(note),
note = note,
notesPreviewMode = notesPreviewMode,
labels = allLabels.filter { note.labels.contains(it.labelId) }.map { it.value },
onClick = { navController.navigate(NavRoutes.NoteDetails.withArgs(workspaceId, note.notesId)) },
onLongPressed = {
@@ -49,7 +49,7 @@ data class MarkdownStyles(
fun fromColorScheme(colorScheme: ColorScheme) = MarkdownStyles(
hexTextColor = colorScheme.onSurface.toArgb().toHexColor(),
hexCodeBackgroundColor = colorScheme.surfaceVariant.toArgb().toHexColor(),
hexPreBackgroundColor = colorScheme.surfaceColorAtElevation(1.dp).toArgb().toHexColor(),
hexPreBackgroundColor = colorScheme.surfaceVariant.toArgb().toHexColor(),
hexQuoteBackgroundColor = colorScheme.secondaryContainer.toArgb().toHexColor(),
hexLinkColor = colorScheme.primary.toArgb().toHexColor(),
hexBorderColor = colorScheme.outline.toArgb().toHexColor(),
@@ -41,7 +41,7 @@ fun TextFieldBuffer.inlineBrackets() = inlineWrap("[", "]")
fun TextFieldBuffer.inlineBraces() = inlineWrap("{", "}")
fun TextFieldBuffer.inlineCode() = inlineWrap("`")
fun TextFieldBuffer.inlineCode() = inlineWrap("```", "```")
fun TextFieldBuffer.inlineMath() = inlineWrap("$")
@@ -1,11 +1,13 @@
package com.flux.ui.screens.progressBoard
import android.widget.Toast
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
@@ -19,7 +21,9 @@ 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.Schedule
import androidx.compose.material.icons.filled.Timelapse
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -28,6 +32,7 @@ import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
@@ -50,6 +55,7 @@ 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.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -67,6 +73,10 @@ 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
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -78,6 +88,7 @@ fun NewBoardItemSheet(
onConfirm: (ProgressBoardModel) -> Unit,
onDelete: (ProgressBoardModel) -> Unit
) {
val context = LocalContext.current
val focusRequesterDesc = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var selectedStatus by remember(progressBoardItem) { mutableIntStateOf(progressBoardItem.status) }
@@ -90,13 +101,50 @@ fun NewBoardItemSheet(
var isChangeIcon by remember { mutableStateOf(false) }
val iconSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var newIcon by remember(progressBoardItem) { mutableIntStateOf(progressBoardItem.icon) }
val startDateString = stringResource(R.string.start_date_after_target_error)
val targetDateString = stringResource(R.string.target_date_before_start_error)
if(showDateSelector){
DatePickerModal({
if (isSelectingStartDate) startDate=it?:-1L
else endDate=it?:-1L
}) {
showDateSelector=false
if (showDateSelector) {
DatePickerModal(
{
val selectedDate = it ?: -1L
if (isSelectingStartDate) {
if (
endDate != -1L &&
selectedDate != -1L &&
selectedDate > endDate
) {
Toast.makeText(
context,
startDateString,
Toast.LENGTH_SHORT
).show()
} else {
startDate = selectedDate
}
} else {
if (
startDate != -1L &&
selectedDate != -1L &&
selectedDate < startDate
) {
Toast.makeText(
context,
targetDateString,
Toast.LENGTH_SHORT
).show()
} else {
endDate = selectedDate
}
}
}
) {
showDateSelector = false
}
}
@@ -266,13 +314,13 @@ fun BoardStatusItem(isSelected: Boolean, status: String, color: Color, onClick:
)
) {
Row(
Modifier.padding(4.dp),
Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.padding(start = 2.dp)
.size(12.dp)
.padding(start = 4.dp)
.size(16.dp)
.clip(RoundedCornerShape(50))
.then(
if (isSelected) {
@@ -365,7 +413,7 @@ fun BoardContainer(
) {
Text(item.title, style = MaterialTheme.typography.bodyMedium)
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FlowRow(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
if (item.startDate != -1L) {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -386,9 +434,14 @@ fun BoardContainer(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(Icons.Default.Flag, null, modifier = Modifier
.size(16.dp)
.alpha(0.75f))
Icon(
Icons.Default.Flag,
null,
modifier = Modifier
.size(16.dp)
.alpha(0.75f)
)
Text(
convertMillisToDate(item.endDate),
modifier = Modifier.alpha(0.75f),
@@ -396,6 +449,45 @@ fun BoardContainer(
)
}
}
if (item.endDate != -1L && item.status != 2) {
val daysLeft = daysLeft(item.endDate)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(
imageVector =
if (daysLeft < 0)
Icons.Default.Warning
else
Icons.Default.Schedule,
contentDescription = null,
modifier = Modifier
.size(16.dp)
.alpha(0.75f)
)
Text(
text = when {
daysLeft > 1 -> stringResource(R.string.days_left, daysLeft)
daysLeft == 1L -> stringResource(R.string.one_day_left)
daysLeft == 0L -> stringResource(R.string.Today)
daysLeft == -1L -> stringResource(R.string.one_day_overdue)
else -> stringResource(R.string.days_overdue, -daysLeft)
},
style = MaterialTheme.typography.labelMedium,
color = when {
daysLeft < 0 -> MaterialTheme.colorScheme.error
daysLeft <= 3 -> MaterialTheme.colorScheme.primary
else -> LocalContentColor.current.copy(alpha = 0.75f)
}
)
}
}
}
}
}
@@ -404,3 +496,12 @@ fun BoardContainer(
}
}
}
fun daysLeft(targetDate: Long): Long {
val today = LocalDate.now()
val target = Instant.ofEpochMilli(targetDate)
.atZone(ZoneId.systemDefault())
.toLocalDate()
return ChronoUnit.DAYS.between(today, target)
}
@@ -67,6 +67,7 @@ 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.RecurrenceRule
import com.flux.data.model.Space
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
@@ -122,6 +123,7 @@ import kotlin.math.absoluteValue
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SearchScreen(navController: NavController, states: States, viewModels: ViewModels){
val context = LocalContext.current
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 }
@@ -139,6 +141,7 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM
)
)
}
val notesPreviewMode = states.settings.data.notesPreviewMode
val radius = states.settings.data.cornerRadius
val is24HourFormat = states.settings.data.is24HourFormat
val isMonthlyView = states.settings.data.isCalendarMonthlyView
@@ -303,17 +306,23 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM
}
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
1 -> searchedNotes(navController, notesPreviewMode, radius, notes, labels)
2 -> searchedTodo(navController, radius, context, expandedTODOIds.value, todoLists, { id->
val todoItem = todoLists.first { it.id == id }
val workspaceId = todoItem.workspaceId
if(todoItem.recurrence is RecurrenceRule.NONE){
expandedTODOIds.value =
if (id in expandedTODOIds.value) expandedTODOIds.value - id
else expandedTODOIds.value + id
}
else{ navController.navigate(NavRoutes.TodoDetail.withArgs(workspaceId, 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)
else -> {}
}
}
}
@@ -354,13 +363,14 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM
}
@OptIn(ExperimentalMaterial3Api::class)
fun LazyListScope.searchedNotes(navController: NavController, radius: Int, notes: List<NotesModel>, labels: List<LabelModel>){
fun LazyListScope.searchedNotes(navController: NavController, notesPreviewMode: Int, radius: Int, notes: List<NotesModel>, labels: List<LabelModel>){
if (notes.isEmpty()) item { EmptyNotes() }
items(notes, key = { it.notesId }) { note ->
NotesPreviewCard(
radius = radius,
isSelected = false,
note = note,
notesPreviewMode = notesPreviewMode,
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)) },
@@ -571,6 +581,7 @@ fun LazyListScope.searchedProgressBoard(
fun LazyListScope.searchedTodo(
navController: NavController,
radius: Int,
context: Context,
expandedTODOIds: Set<String>,
todoList: List<TodoModel>,
onExpandToggle: (String) -> Unit,
@@ -582,6 +593,7 @@ fun LazyListScope.searchedTodo(
navController = navController,
radius = radius,
item = todoItem,
context = context,
workspaceId = todoItem.workspaceId,
isExpanded = todoItem.id in expandedTODOIds,
onExpandToggle = onExpandToggle,
@@ -222,35 +222,50 @@ fun Customize(
}
@Composable
fun OnRadiusClicked(settings: Settings, onExit: (Int) -> Unit) {
val minimalRadius = 5
val settingsRadius = settings.data.cornerRadius
var sliderPosition by remember { mutableFloatStateOf(((settingsRadius - minimalRadius).toFloat() / 30)) }
val realRadius: Int = (((sliderPosition * 100).toInt()) / 3) + minimalRadius
fun OnRadiusClicked(
settings: Settings,
onExit: (Int) -> Unit
) {
var sliderPosition by remember {
mutableFloatStateOf(
settings.data.cornerRadius
.coerceIn(5, 44)
.toFloat()
)
}
val realRadius = sliderPosition.toInt()
@Composable
fun example(shape: RoundedCornerShape) {
fun Example(shape: RoundedCornerShape) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp, 3.dp, 32.dp, 1.dp)
.padding(horizontal = 32.dp, vertical = 3.dp)
.background(
shape = shape,
color = MaterialTheme.colorScheme.surfaceContainerHigh
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = shape
)
.height(62.dp),
.height(62.dp)
)
}
Dialog(onDismissRequest = { onExit(realRadius) }) {
Dialog(
onDismissRequest = {
onExit(realRadius)
}
) {
Column(
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surfaceContainerLow,
shape = RoundedCornerShape(realRadius / 3)
shape = RoundedCornerShape((realRadius / 3).dp)
)
.fillMaxWidth()
.fillMaxSize(0.38f)
) {
Text(
text = stringResource(R.string.Select_radius),
textAlign = TextAlign.Center,
@@ -259,14 +274,50 @@ fun OnRadiusClicked(settings: Settings, onExit: (Int) -> Unit) {
.fillMaxWidth()
.padding(top = 16.dp, bottom = 16.dp)
)
example(shapeManager(radius = realRadius, isFirst = true))
example(shapeManager(radius = realRadius))
example(shapeManager(radius = realRadius, isLast = true))
Example(
shapeManager(
radius = realRadius,
isFirst = true
)
)
Example(
shapeManager(
radius = realRadius
)
)
Example(
shapeManager(
radius = realRadius,
isLast = true
)
)
Spacer(Modifier.height(16.dp))
Text(
text = "$realRadius",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium
)
Slider(
value = sliderPosition,
modifier = Modifier.padding(32.dp, 16.dp, 32.dp, 16.dp),
colors = SliderDefaults.colors(inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHigh),
onValueChange = { newValue -> sliderPosition = newValue }
onValueChange = {
sliderPosition = it
},
valueRange = 5f..44f,
steps = 38,
modifier = Modifier.padding(
horizontal = 32.dp,
vertical = 16.dp
),
colors = SliderDefaults.colors(
inactiveTrackColor = MaterialTheme.colorScheme.surfaceContainerHigh
)
)
}
}
@@ -13,6 +13,7 @@ import androidx.compose.material.icons.automirrored.filled.ChromeReaderMode
import androidx.compose.material.icons.filled.EditNote
import androidx.compose.material.icons.filled.FormatListNumbered
import androidx.compose.material.icons.filled.Spellcheck
import androidx.compose.material.icons.rounded.FontDownload
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -25,10 +26,12 @@ 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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.flux.R
import com.flux.navigation.NavRoutes
import com.flux.ui.common.BasicScaffold
import com.flux.ui.events.SettingEvents
import com.flux.ui.state.Settings
@@ -90,6 +93,21 @@ fun Editor(
)
}
item {
Spacer(Modifier.height(12.dp))
SettingOption(
title = stringResource(R.string.notes_preview),
description = stringResource(R.string.change_preview_setting_for_notes),
icon = Icons.Rounded.FontDownload,
radius = shapeManager(
radius = settings.data.cornerRadius,
isBoth = true
),
actionType = ActionType.CUSTOM,
onCustomClick = { navController.navigate(NavRoutes.NotesPreview.route) }
)
}
item {
Spacer(Modifier.height(12.dp))
AnimatedVisibility(visible = !settings.data.dynamicTheme) {
@@ -117,15 +135,14 @@ fun Editor(
shape = SegmentedButtonDefaults.itemShape(
index = 0,
count = 2
),
modifier = Modifier.weight(1f)
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(Icons.AutoMirrored.Filled.ChromeReaderMode, null)
Text(stringResource(R.string.reading_view))
Text(stringResource(R.string.reading_view), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
@@ -141,15 +158,14 @@ fun Editor(
shape = SegmentedButtonDefaults.itemShape(
index = 1,
count = 2
),
modifier = Modifier.weight(1f)
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
Icon(Icons.Default.EditNote, null)
Text(stringResource(R.string.editing_view))
Text(stringResource(R.string.editing_view), maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@@ -1,6 +1,5 @@
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
@@ -11,12 +10,8 @@ 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
@@ -28,6 +23,7 @@ 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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
@@ -146,59 +142,41 @@ fun Mode(
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
)
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(stringResource(R.string.default_mode))
}
}
)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = 1,
count = 2
),
onClick = {
onSettingsEvents(
SettingEvents.UpdateSettings(
settings.data.copy(
workspaceGridColumns = 2
)
)
},
selected = settings.data.workspaceGridColumns == 1,
label = { Text(stringResource(R.string.default_mode), maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
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(stringResource(R.string.select_mode))
}
}
)
}
)
},
selected = settings.data.workspaceGridColumns == 2,
label = { Text(stringResource(R.string.compact_mode), maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
}
}
}
@@ -0,0 +1,245 @@
package com.flux.ui.screens.settings
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.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.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
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.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.sp
import androidx.navigation.NavController
import com.flux.R
import com.flux.ui.common.BasicScaffold
import com.flux.ui.events.SettingEvents
import com.flux.ui.state.Settings
@Composable
fun NotesPreviewSetting(
navController: NavController,
settings: Settings,
onSettingsEvents: (SettingEvents) -> Unit
){
val radius = settings.data.cornerRadius
val notesPreviewMode = settings.data.notesPreviewMode
val maxHeight = when (notesPreviewMode) {
0 -> 0.dp
1 -> 180.dp
else -> 360.dp
}
BasicScaffold(
title = stringResource(R.string.notes_preview),
onBackClicked = { navController.popBackStack() }
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
verticalItemSpacing = 8.dp,
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(
start = 12.dp,
end = 12.dp,
top = 12.dp,
bottom = 140.dp
),
modifier = Modifier.fillMaxSize()
) {
item {
Card(
colors = CardDefaults.cardColors(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
),
modifier = Modifier.clip(
shapeManager(isBoth = true, radius = radius / 2)
),
shape = shapeManager(isBoth = true, radius = radius / 2),
onClick = {}
) {
Column(
modifier = Modifier
.padding(12.dp)
.heightIn(max = if (maxHeight != 0.dp) maxHeight else 100.dp)
) {
Text(
stringResource(R.string.Title),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
if (maxHeight != 0.dp) {
Text(NOTE1, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
item {
Card(
colors = CardDefaults.cardColors(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
),
modifier = Modifier.clip(
shapeManager(isBoth = true, radius = radius / 2)
),
shape = shapeManager(isBoth = true, radius = radius / 2),
onClick = {}
) {
Column(
modifier = Modifier
.padding(12.dp)
.heightIn(max = if (maxHeight != 0.dp) maxHeight else 100.dp)
) {
Text(
stringResource(R.string.Title),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
if (maxHeight != 0.dp) {
Text(NOTE2, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
item {
Card(
colors = CardDefaults.cardColors(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
),
modifier = Modifier.clip(
shapeManager(isBoth = true, radius = radius / 2)
),
shape = shapeManager(isBoth = true, radius = radius / 2),
onClick = {}
) {
Column(
modifier = Modifier
.padding(12.dp)
.heightIn(max = if (maxHeight != 0.dp) maxHeight else 100.dp)
) {
Text(
stringResource(R.string.Title),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
if (maxHeight != 0.dp) {
Text(NOTE3, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
item {
Card(
colors = CardDefaults.cardColors(
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
),
modifier = Modifier.clip(
shapeManager(isBoth = true, radius = radius / 2)
),
shape = shapeManager(isBoth = true, radius = radius / 2),
onClick = {}
) {
Column(
modifier = Modifier
.padding(12.dp)
.heightIn(max = if (maxHeight != 0.dp) maxHeight else 100.dp)
) {
Text(
stringResource(R.string.Title),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
if (maxHeight != 0.dp) {
Text(NOTE4, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
Card(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(12.dp)
.fillMaxWidth(),
elevation = CardDefaults.cardElevation(8.dp)
) {
Column(
Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
stringResource(R.string.change_height),
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(Modifier.height(12.dp))
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(0, 3),
onClick = { onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(notesPreviewMode = 0))) },
selected = notesPreviewMode == 0,
label = { Text(stringResource(R.string.compact), maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(1, 3),
onClick = { onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(notesPreviewMode = 1))) },
selected = notesPreviewMode == 1,
label = { Text(stringResource(R.string.normal), maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(2, 3),
onClick = { onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(notesPreviewMode = 2))) },
selected = notesPreviewMode == 2,
label = { Text(stringResource(R.string.elongated), maxLines = 1, overflow = TextOverflow.Ellipsis) }
)
}
}
}
}
}
}
const val NOTE1 = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi eu sapien sagittis elit tincidunt feugiat. Interdum et malesuada fames ac ante ipsum primis in faucibus. Nullam ut nibh eu dolor maximus gravida. Integer non dapibus sem. Nullam nec lectus metus. Nullam vitae fermentum ipsum. Interdum et malesuada fames ac ante ipsum primis in faucibus."
const val NOTE2 = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
const val NOTE3 = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi eu sapien sagittis elit tincidunt feugiat. Interdum et malesuada fames ac ante ipsum primis in faucibus."
const val NOTE4 = "Lorem ipsum dolor sit amet"
@@ -465,7 +465,7 @@ fun shapeManager(
isFirst: Boolean = false,
radius: Int
): RoundedCornerShape {
val smallerRadius: Dp = (radius / 5).dp
val smallerRadius: Dp = (radius / 4).dp
val defaultRadius: Dp = radius.dp
return when {
@@ -0,0 +1,347 @@
package com.flux.ui.screens.todo
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
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.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
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.Alarm
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.LockOpen
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.SubdirectoryArrowRight
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
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.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
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.RecurrenceRule
import com.flux.data.model.TodoItem
import com.flux.data.model.TodoModel
import com.flux.ui.common.DeleteAlert
import com.flux.ui.events.TodoEvents
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewTodoList(
navController: NavController,
is24HoursFormat: Boolean,
list: TodoModel,
workspaceId: String,
onTodoEvents: (TodoEvents) -> Unit
) {
val context = LocalContext.current
var title by rememberSaveable { mutableStateOf(list.title) }
val itemList = remember { list.items.toMutableStateList() }
var showDeleteDialog by remember { mutableStateOf(false) }
var recurrence by remember { mutableStateOf(list.recurrence) }
var reminderTime by remember { mutableLongStateOf(list.startDateTime) }
val deleteQueue = remember {
Channel<Pair<Int, TodoItem>>(Channel.UNLIMITED)
}
// True = normal editing mode (locked ordering)
// False = reorder mode (unlocked ordering, drag handles visible)
var isReordering by remember { mutableStateOf(false) }
if (showDeleteDialog) {
DeleteAlert(
{ showDeleteDialog = false },
{
onTodoEvents(TodoEvents.DeleteList(context,list))
navController.popBackStack()
showDeleteDialog = false
}
)
}
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(
lazyListState = lazyListState
) { from, to -> itemList.move(from.index, to.index) }
var isReminderDialogVisible by remember { mutableStateOf(false) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val itemRemovedLabel = stringResource(R.string.item_removed)
val undoLabel = stringResource(R.string.undo)
LaunchedEffect(Unit) {
for ((index, removedItem) in deleteQueue) {
val result = snackbarHostState.showSnackbar(
message = itemRemovedLabel,
actionLabel = undoLabel
)
if (result == SnackbarResult.ActionPerformed) {
val insertIndex = index.coerceAtMost(itemList.size)
itemList.add(insertIndex, removedItem)
}
}
}
Scaffold(
modifier = Modifier.imePadding(),
snackbarHost = { SnackbarHost(hostState = snackbarHostState) },
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
CenterAlignedTopAppBar(
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow),
title = {
BasicTextField(
value = title,
onValueChange = { title = it },
singleLine = true,
readOnly = isReordering,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
Box {
if (title.isBlank()) {
Text(
text = stringResource(R.string.Title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
}
)
},
navigationIcon = {
IconButton({ navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, null)
}
},
actions = {
// Reminder
IconButton(onClick = { isReminderDialogVisible = true }) {
Icon(Icons.Default.Alarm, null)
}
// Lock/unlock reorder button
IconButton(onClick = { isReordering = !isReordering }) {
Icon(if(!isReordering) Icons.Default.Lock else Icons.Default.LockOpen, null)
}
// ✓ in edit mode: persist everything to ViewModel
IconButton(
enabled = title.isNotBlank(),
onClick = {
onTodoEvents(
TodoEvents.UpsertList(
context,
list.recurrence is RecurrenceRule.Weekly && recurrence is RecurrenceRule.NONE,
list.copy(
title = title,
items = itemList.toList(),
workspaceId = workspaceId,
recurrence = recurrence,
startDateTime = reminderTime
)
)
)
isReordering = false
navController.popBackStack()
}
) { Icon(Icons.Default.Check, null) }
}
)
}
) { innerPadding ->
LazyColumn(
state = lazyListState,
modifier = Modifier.padding(innerPadding)
) {
itemsIndexed(
itemList,
key = { _, item -> item.id }
) { _, item ->
ReorderableItem(
state = reorderableState,
key = item.id
) { _ ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.animateItem(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
// Checkbox: only in editing (locked) mode
if (!isReordering) {
Checkbox(
modifier = Modifier
.scale(0.75f)
.size(32.dp),
checked = item.isChecked,
onCheckedChange = { checked ->
val i = itemList.indexOfFirst { it.id == item.id }
if (i >= 0) {
itemList[i] = item.copy(isChecked = checked)
}
}
)
}
BasicTextField(
value = item.value,
onValueChange = { newText ->
val i = itemList.indexOfFirst { it.id == item.id }
if (i >= 0) {
itemList[i] = item.copy(value = newText)
}
},
// Editable only in editing mode, read-only during reorder or view
readOnly = isReordering,
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface
),
modifier = Modifier.weight(1f),
decorationBox = { innerTextField ->
Box {
if (item.value.isBlank()) {
Text(
text = stringResource(R.string.Title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
innerTextField()
}
}
)
// Drag handle: only in reorder (unlocked) mode
if (isReordering) {
IconButton(
onClick = {},
modifier = Modifier
.size(32.dp)
.draggableHandle()
) {
Icon(Icons.Default.Menu, contentDescription = null)
}
}
// Remove button: only in editing (locked) mode
if (!isReordering) {
IconButton(
onClick = {
val index = itemList.indexOfFirst { it.id == item.id }
if (index >= 0) {
val removedItem = itemList[index]
itemList.removeAt(index)
scope.launch { deleteQueue.send(index to removedItem) }
}
},
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.Remove,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
}
}
}
}
}
// Add item button: only in editing (locked) mode
if (!isReordering) {
item {
TextButton(
onClick = { itemList.add(TodoItem()) },
modifier = Modifier.padding(start = 8.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.SubdirectoryArrowRight, null)
Text(stringResource(R.string.Add_Item))
}
}
}
}
}
}
if (isReminderDialogVisible) {
TodoReminderDialog(
is24HourFormat = is24HoursFormat,
reminderTime = reminderTime,
recurrence = recurrence,
onDismiss = { isReminderDialogVisible = false }
) { newRecurrence, newReminderTime ->
recurrence=newRecurrence
reminderTime = newReminderTime
}
}
}
fun <T> MutableList<T>.move(from: Int, to: Int) {
if (from == to) return
if (from !in indices) return
val adjustedTo = when {
to < 0 -> 0
to > lastIndex -> lastIndex
else -> to
}
val item = removeAt(from)
add(adjustedTo, item)
}
@@ -1,71 +1,107 @@
package com.flux.ui.screens.todo
import android.content.Context
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.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.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.automirrored.outlined.Note
import androidx.compose.material.icons.filled.Alarm
import androidx.compose.material.icons.filled.AlarmAdd
import androidx.compose.material.icons.filled.AlarmOff
import androidx.compose.material.icons.filled.AlarmOn
import androidx.compose.material.icons.filled.Analytics
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Circle
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Percent
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
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.surfaceColorAtElevation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.platform.LocalConfiguration
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.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.navigation.NavController
import com.flux.R
import com.flux.data.model.RecurrenceRule
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoItem
import com.flux.data.model.TodoModel
import com.flux.data.model.isCompleted
import com.flux.navigation.NavRoutes
import com.flux.ui.common.DeleteAlert
import com.flux.other.ConvertType
import com.flux.ui.common.TimePicker
import com.flux.ui.common.convertMillisToDate
import com.flux.ui.common.convertMillisToTime
import com.flux.ui.events.TodoEvents
import com.flux.ui.screens.analytics.HeatMapCard
import com.flux.ui.screens.events.toFormattedTime
import com.flux.ui.screens.habits.HabitInfoComponent
import com.flux.ui.screens.notes.ExportCard
import com.flux.ui.screens.settings.shapeManager
import java.time.LocalDate
import java.time.temporal.ChronoUnit
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoExpandableCard(
navController: NavController,
radius: Int,
context: Context,
item: TodoModel,
isExpanded: Boolean,
workspaceId: String,
onExpandToggle: (String) -> Unit,
onTodoEvents: (TodoEvents) -> Unit
) {
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
var selectedItem by remember { mutableStateOf<TodoModel?>(null) }
if (showDeleteDialog && selectedItem != null) {
DeleteAlert(
onConfirmation = {
onTodoEvents(TodoEvents.DeleteList(selectedItem!!))
selectedItem = null
showDeleteDialog = false
},
onDismissRequest = { showDeleteDialog = false }
)
}
Card(
modifier = Modifier.padding(top = 4.dp),
shape = if(isExpanded) shapeManager(isBoth = true, radius=radius) else RoundedCornerShape(50),
@@ -75,6 +111,7 @@ fun TodoExpandableCard(
TodoHeaderRow(
id = item.id,
title = item.title,
isReminderOn = item.recurrence is RecurrenceRule.Weekly,
onExpandToggle = onExpandToggle,
onNavigate = {
navController.navigate(
@@ -85,6 +122,7 @@ fun TodoExpandableCard(
if (isExpanded) {
TodoItems(
context = context,
todoList = item,
workspaceId = workspaceId,
onTodoEvents = onTodoEvents
@@ -98,6 +136,7 @@ fun TodoExpandableCard(
private fun TodoHeaderRow(
id: String,
title: String,
isReminderOn: Boolean,
onExpandToggle: (String) -> Unit,
onNavigate: () -> Unit
) {
@@ -114,16 +153,27 @@ private fun TodoHeaderRow(
fontSize = 16.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(start = 20.dp, end = 3.dp),
modifier = Modifier
.weight(1f)
.padding(start = 20.dp, end = 3.dp),
)
IconButton(onClick = onNavigate) {
Icon(Icons.Default.Edit, null)
Row {
if(isReminderOn){
IconButton(onClick = onNavigate) {
Icon(Icons.Default.Alarm, null)
}
}
IconButton(onClick = onNavigate) {
Icon(Icons.Default.Analytics, null)
}
}
}
}
@Composable
private fun TodoItems(
context: Context,
todoList: TodoModel,
workspaceId: String,
onTodoEvents: (TodoEvents) -> Unit
@@ -138,6 +188,8 @@ private fun TodoItems(
if (updatedItems != todoList.items) {
onTodoEvents(
TodoEvents.UpsertList(
context,
false,
todoList.copy(
items = updatedItems,
workspaceId = workspaceId
@@ -151,32 +203,452 @@ private fun TodoItems(
val allSortedItems = unCheckedItems + checkedItems
LazyColumn(
Modifier.padding(horizontal = 6.dp).padding(top = 4.dp, bottom = 12.dp).heightIn(max = 400.dp),
Modifier
.padding(horizontal = 6.dp)
.padding(top = 4.dp, bottom = 12.dp)
.heightIn(max = 400.dp),
verticalArrangement = Arrangement.spacedBy(2.dp)
) {
items(allSortedItems) { todoItem ->
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(
containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainerHighest
),
onClick = { onToggleCheck(todoItem) }
MaterialListItem(true, todoItem){ onToggleCheck(todoItem) }
}
}
}
@Composable
fun MaterialListItem(
enabled: Boolean = true,
todoItem: TodoItem,
onToggleCheck: () -> Unit
){
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(
containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.surfaceContainerHigh else MaterialTheme.colorScheme.surfaceContainerHighest
),
onClick = onToggleCheck
) {
Row (verticalAlignment = Alignment.CenterVertically) {
IconButton(
onToggleCheck, colors = IconButtonDefaults.iconButtonColors(
containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if (todoItem.isChecked) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)
) {
Row (verticalAlignment = Alignment.CenterVertically) {
IconButton({onToggleCheck(todoItem)}, colors = IconButtonDefaults.iconButtonColors(
containerColor = if(todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
contentColor = if(todoItem.isChecked) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
)) { Icon(if(todoItem.isChecked) Icons.Default.Verified else Icons.Outlined.Circle, null) }
Text(
text = todoItem.value,
style = MaterialTheme.typography.labelLarge,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
modifier = Modifier.weight(1f)
)
if (enabled) { Icon(if (todoItem.isChecked) Icons.Default.Verified else Icons.Outlined.Circle, null) }
else { Icon(Icons.Default.Circle, null, tint = MaterialTheme.colorScheme.primary) }
}
Text(
text = todoItem.value,
style = MaterialTheme.typography.labelLarge,
modifier = Modifier
.weight(1f)
.padding(4.dp)
)
}
}
}
@Composable
fun TodoReminderDialog(
is24HourFormat: Boolean,
reminderTime: Long,
recurrence: RecurrenceRule,
onDismiss: () -> Unit,
onConfirm: (RecurrenceRule, Long) -> Unit
){
var newReminderTime by remember { mutableLongStateOf(reminderTime) }
var currentRecurrence by remember(recurrence) { mutableStateOf(recurrence) }
val selectedDays = remember { mutableStateListOf<Int>() }
var showTimePicker by remember { mutableStateOf(false) }
LaunchedEffect(currentRecurrence) {
selectedDays.clear()
if (currentRecurrence is RecurrenceRule.Weekly) {
selectedDays.addAll(
(currentRecurrence as RecurrenceRule.Weekly).daysOfWeek
)
}
}
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)
)
Dialog(onDismissRequest = onDismiss) {
Card(modifier = Modifier.padding(8.dp)) {
Column(
modifier = Modifier
.padding(16.dp)
.heightIn(min = 180.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
Modifier
.padding(top = 8.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Repeat, null)
Text(stringResource(R.string.repeat), fontWeight = FontWeight.SemiBold)
}
Switch(
modifier = Modifier.scale(0.8f),
checked = currentRecurrence !is RecurrenceRule.NONE, onCheckedChange = {
currentRecurrence = if(it) {
newReminderTime = if(recurrence is RecurrenceRule.NONE) System.currentTimeMillis()
else { reminderTime }
recurrence as? RecurrenceRule.Weekly ?: RecurrenceRule.Weekly()
}
else { RecurrenceRule.NONE }
})
}
if(currentRecurrence is RecurrenceRule.Weekly){
FlowRow(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
weekdays.forEachIndexed { index, day ->
val isSelected = index in selectedDays
Card(
onClick = {
if (isSelected) { if (selectedDays.size > 1) { selectedDays.remove(index) } }
else { selectedDays.add(index) }
},
modifier = Modifier
.width(56.dp)
.height(40.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 }
)
) {
Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
){ Text(day) }
}
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.AlarmAdd, null)
Text(stringResource(R.string.reminder_time), fontWeight = FontWeight.SemiBold)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(newReminderTime.toFormattedTime(is24HourFormat))
FilledTonalIconButton({ showTimePicker = true }) {
Icon(Icons.Default.Create, null)
}
}
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
FilledTonalButton(onDismiss) {
Text(stringResource(R.string.Cancel))
}
Spacer(modifier = Modifier.width(8.dp))
FilledTonalButton({
if(currentRecurrence is RecurrenceRule.Weekly){
onConfirm(RecurrenceRule.Weekly(selectedDays), newReminderTime)
}
else { onConfirm(RecurrenceRule.NONE, -1L) }
onDismiss()
}) {
Text(stringResource(R.string.Confirm))
}
}
}
else {
Spacer(modifier = Modifier.height(4.dp))
Text("Turn on the reminder to remind about this to-do list!", modifier = Modifier
.fillMaxWidth()
.align(Alignment.CenterHorizontally))
Spacer(modifier = Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
FilledTonalButton(onDismiss) {
Text(stringResource(R.string.Cancel))
}
Spacer(modifier = Modifier.width(8.dp))
FilledTonalButton({
onConfirm(RecurrenceRule.NONE, reminderTime)
onDismiss()
}) {
Text(stringResource(R.string.Confirm))
}
}
}
}
}
}
if (showTimePicker) {
TimePicker(
initialTime = newReminderTime,
is24Hour = is24HourFormat,
onConfirm = { newReminderTime = it }
) { showTimePicker = false }
}
}
@Composable
fun TodoDetailedInfo(
radius: Int,
list: TodoModel,
isReminderOn: Boolean = false,
isAllowedToday: Boolean = false,
todayInstance: TodoInstance? = null
){
val items = if(isReminderOn) todayInstance?.items ?: list.items else list.items
val completedNumber = items.filter { it.isChecked }.size
val remainingNumber = items.filter { !it.isChecked }.size
val completedPercentage = (completedNumber * 100f / items.size.coerceAtLeast(1)).toInt()
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.Checklist, null, tint = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(8.dp))
Text(
text = list.title,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.primary
)
}
val configuration = LocalConfiguration.current
val density = LocalDensity.current
val columns = when {
density.fontScale > 1.5f -> 1
configuration.screenWidthDp < 360 -> 1
configuration.screenWidthDp < 480 -> 2
else -> 3
}
LazyVerticalGrid(
columns = GridCells.Fixed(columns),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 1000.dp)
.padding(vertical = 8.dp)
) {
item {
HabitInfoComponent(
Icons.Default.Create,
stringResource(R.string.created),
convertMillisToDate(list.startDateTime)
)
}
item {
HabitInfoComponent(
if(isReminderOn) Icons.Default.AlarmOn else Icons.Default.AlarmOff,
stringResource(R.string.reminder),
if (isReminderOn) stringResource(R.string.on) else stringResource(R.string.off)
)
}
if(isReminderOn){
item {
HabitInfoComponent(
Icons.Default.Alarm,
stringResource(R.string.remind_at),
convertMillisToTime(list.startDateTime)
)
}
item {
HabitInfoComponent(
Icons.Default.DateRange,
stringResource(R.string.scheduled),
if(isAllowedToday) stringResource(R.string.true_text) else stringResource(R.string.false_text)
)
}
}
if((isReminderOn && isAllowedToday) || !isReminderOn){
item {
HabitInfoComponent(
Icons.Outlined.CheckCircle,
stringResource(R.string.completed),
completedNumber.toString()
)
}
item {
HabitInfoComponent(
Icons.Outlined.Circle,
stringResource(R.string.remaining),
remainingNumber.toString()
)
}
item {
HabitInfoComponent(
Icons.Outlined.Percent,
stringResource(R.string.completion),
"$completedPercentage%"
)
}
}
}
}
}
}
@Composable
fun TodoHeatMap(radius: Int, todoModel: TodoModel, instances: List<TodoInstance>){
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, todoModel) {
instances
.groupBy { LocalDate.ofEpochDay(it.instanceDate) }
.mapValues { (_, instancesForDay) ->
instancesForDay.count { instance ->
instance.isCompleted()
}
}
}
// Create week columns with proper day alignment
val weekColumns = mutableListOf<List<LocalDate?>>()
var currentWeek = MutableList<LocalDate?>(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,
stringResource(R.string.this_year),
"",
boxSize,
2,
lazyListState,
weekColumns,
heatMap.toMap()
)
}
@Composable
fun ConvertTODODialog(
onConfirm: (ConvertType) -> Unit,
onDismiss: () -> Unit
) {
Dialog(onDismissRequest = onDismiss) {
Card(
shape = RoundedCornerShape(16.dp),
elevation = CardDefaults.cardElevation(8.dp),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.convert),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 16.dp)
)
ExportCard(Icons.AutoMirrored.Outlined.Note, stringResource(R.string.convert_to_note)) { onConfirm(ConvertType.NOTE) }
ExportCard(Icons.Outlined.AutoStories, stringResource(R.string.convert_to_journal)) { onConfirm(ConvertType.JOURNAL) }
}
}
}
}
@@ -1,212 +1,311 @@
package com.flux.ui.screens.todo
import android.app.Activity
import android.webkit.WebView
import android.widget.Toast
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.SubdirectoryArrowRight
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.Checkbox
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.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBarDefaults
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TodoItem
import com.flux.data.model.TodoModel
import com.flux.ui.common.DeleteAlert
import com.flux.ui.events.TodoEvents
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flux.R
import com.flux.data.model.JournalModel
import com.flux.data.model.NotesModel
import com.flux.data.model.RecurrenceRule
import com.flux.data.model.TodoInstance
import com.flux.data.model.WorkspaceModel
import com.flux.data.model.toHtml
import com.flux.data.model.toMarkdown
import com.flux.data.model.toMarkdownContent
import com.flux.navigation.NavRoutes
import com.flux.other.ConvertType
import com.flux.other.DataCopyType
import com.flux.other.printPdf
import com.flux.other.shareTodo
import com.flux.ui.common.DataCopyDialog
import com.flux.ui.common.TodoDropdownMenu
import com.flux.ui.events.JournalEvents
import com.flux.ui.events.NotesEvents
import com.flux.ui.events.WorkspaceEvents
import java.time.LocalDate
import com.flux.ui.screens.notes.ShareDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TodoDetail(
navController: NavController,
radius: Int,
list: TodoModel,
workspaces: List<WorkspaceModel>,
instances: List<TodoInstance>,
workspaceId: String,
onTodoEvents: (TodoEvents) -> Unit
onTodoEvents: (TodoEvents) -> Unit,
onNotesEvents: (NotesEvents) -> Unit,
onJournalEvents: (JournalEvents) -> Unit,
onWorkspaceEvents: (WorkspaceEvents) -> Unit
) {
var title by rememberSaveable { mutableStateOf(list.title) }
val itemList = rememberSaveable { list.items.toMutableStateList() }
val todayEpoch = LocalDate.now().toEpochDay()
val context = LocalContext.current
val currentWorkspace = workspaces.find { it.workspaceId == workspaceId }
val isReminderOn = list.recurrence is RecurrenceRule.Weekly
var showShareDialog by remember { mutableStateOf(false) }
var showConvertDialog by remember { mutableStateOf(false) }
var showDeleteDialog by remember { mutableStateOf(false) }
var isEditing by remember { mutableStateOf(list.title.isBlank() && list.items.isEmpty()) }
var showDataCopyDialog by remember { mutableStateOf(false) }
val todayInstance = instances.find { it.instanceDate == todayEpoch }
val cloneString = stringResource(R.string.clone_created_successfully)
val contentCopiedString = stringResource(R.string.content_copied)
val contentMovedString = stringResource(R.string.content_moved)
val successString = stringResource(R.string.success)
val webView = WebView(context)
webView.settings.javaScriptEnabled = false
webView.loadDataWithBaseURL(
null,
if(isReminderOn) todayInstance?.toHtml(list.title) ?: list.toHtml() else list.toHtml(),
"text/html",
"UTF-8",
null
)
if (showDeleteDialog) {
DeleteAlert(
{ showDeleteDialog = false },
{
onTodoEvents(TodoEvents.DeleteList(list))
onTodoEvents(TodoEvents.DeleteList(context,list))
navController.popBackStack()
showDeleteDialog = false
}
)
}
fun isAllowed(epochDay: Long): Boolean {
val localDate = LocalDate.ofEpochDay(epochDay)
// Convert to Monday=0, Tuesday=1, ..., Sunday=6 format
val dayOfWeek = (localDate.dayOfWeek.value + 6) % 7
return dayOfWeek in (list.recurrence as RecurrenceRule.Weekly).daysOfWeek
}
LaunchedEffect(list.id, list.recurrence, todayInstance) {
if(isReminderOn && isAllowed(todayEpoch) && todayInstance == null){
onTodoEvents(TodoEvents.CreateInstance(list.id, workspaceId))
}
}
Scaffold(
modifier = Modifier.imePadding(),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
CenterAlignedTopAppBar(
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow),
title = { Text(if (isEditing) stringResource(R.string.Edit_list) else title) },
title = { },
navigationIcon = {
IconButton({ navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, null)
}
},
actions = {
if (isEditing) {
IconButton(
enabled = title.isNotBlank(),
onClick = {
onTodoEvents(
TodoEvents.UpsertList(
list.copy(
title = title,
items = itemList.toList(),
workspaceId = workspaceId
)
)
)
isEditing = false
}
) { Icon(Icons.Default.Check, null) }
} else {
IconButton({ isEditing = true }) { Icon(Icons.Default.Edit, null) }
IconButton({ showDeleteDialog = true }) {
Icon(
Icons.Default.DeleteOutline,
null,
tint = MaterialTheme.colorScheme.error
)
}
IconButton({ navController.navigate(NavRoutes.NewTodoList.withArgs(workspaceId, list.id)) }) {
Icon(Icons.Default.Edit, null)
}
TodoDropdownMenu(
(isReminderOn && isAllowed(todayEpoch) && todayInstance!=null) || !isReminderOn,
{ showShareDialog = true },
{ printPdf(context as Activity, webView, list.title) },
{
onTodoEvents(TodoEvents.UpsertList(context, false,TodoModel(items = list.items, recurrence = list.recurrence, workspaceId = list.workspaceId, title = "Clone ${list.title}")))
Toast.makeText(context, cloneString, Toast.LENGTH_SHORT).show()
},
{ showDataCopyDialog = true },
{ showConvertDialog = true },
{ showDeleteDialog = true}
)
}
)
}
) { innerPadding ->
LazyColumn(Modifier.padding(innerPadding)) {
if (isEditing) {
item {
TextField(
value = title,
onValueChange = { title = it },
singleLine = true,
placeholder = {
Text(stringResource(R.string.Title))
},
textStyle = MaterialTheme.typography.titleLarge,
keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words),
modifier = Modifier.fillMaxWidth(),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = MaterialTheme.colorScheme.surfaceContainer,
unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceContainer,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
)
}
}
LazyColumn(
Modifier.padding(innerPadding).padding(16.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
if(!isReminderOn){
item{ TodoDetailedInfo(radius, list) }
itemsIndexed(itemList, key = { _, item -> item.id }) { index, item ->
if (index >= itemList.size) return@itemsIndexed
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.animateItem(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = item.isChecked,
onCheckedChange = { checked ->
if (isEditing) {
val i = itemList.indexOfFirst { it.id == item.id }
if (i >= 0) itemList[i] = item.copy(isChecked = checked)
}
items(list.items) { todoItem ->
MaterialListItem(true, todoItem){
val updatedItems = list.items.map {
if (it.id == todoItem.id) it.copy(isChecked = !it.isChecked)
else it
}
)
TextField(
value = item.value,
onValueChange = { newText ->
val i = itemList.indexOfFirst { it.id == item.id }
if (i >= 0) itemList[i] = item.copy(value = newText)
},
readOnly = !isEditing,
singleLine = true,
placeholder = { Text(stringResource(R.string.Title)) },
modifier = Modifier.weight(1f),
keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done),
colors = TextFieldDefaults.colors(
focusedIndicatorColor = MaterialTheme.colorScheme.surfaceContainerLow,
unfocusedIndicatorColor = MaterialTheme.colorScheme.surfaceContainerLow,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow,
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
)
if (isEditing) {
IconButton({
val i = itemList.indexOfFirst { it.id == item.id }
if (i >= 0) itemList.removeAt(i)
}) {
Icon(
Icons.Default.Remove,
null,
tint = MaterialTheme.colorScheme.error
if (updatedItems != list.items) {
onTodoEvents(
TodoEvents.UpsertList(
context,
false,
list.copy(
items = updatedItems,
workspaceId = workspaceId
)
)
)
}
}
}
}
if (isEditing) {
item {
TextButton(
onClick = { itemList.add(TodoItem()) },
modifier = Modifier.padding(start = 8.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.SubdirectoryArrowRight, null)
Text(stringResource(R.string.Add_Item))
else if(isAllowed(todayEpoch) && todayInstance!=null) {
item{ TodoDetailedInfo(radius, list, isReminderOn = true, isAllowedToday = true, todayInstance) }
item{ TodoHeatMap(radius, list, instances) }
items(todayInstance.items) { todoItem ->
MaterialListItem(true, todoItem){
val updatedItems = todayInstance.items.map {
if (it.id == todoItem.id) it.copy(isChecked = !it.isChecked)
else it
}
if (updatedItems != todayInstance.items) {
onTodoEvents(TodoEvents.UpsertInstance(todayInstance.copy(items = updatedItems)))
}
}
}
}
else if(!isAllowed(todayEpoch)){
item{
TodoDetailedInfo(radius, list,
isReminderOn = true,
isAllowedToday = false,
todayInstance = todayInstance)
}
item{ TodoHeatMap(radius, list, instances) }
items(list.items) { todoItem ->
MaterialListItem(false, todoItem){}
}
}
}
}
if (showShareDialog) {
ShareDialog(true, {
shareTodo(
context = context,
exportType = it,
list = list,
readWebView = webView
)
showShareDialog = false
}) { showShareDialog = false }
}
if(showDataCopyDialog){
DataCopyDialog(
workspaces.filterNot { it.workspaceId == workspaceId },
{ dataCopyType, selectedWorkspaces ->
if(selectedWorkspaces.isEmpty()) return@DataCopyDialog
when(dataCopyType){
DataCopyType.COPY -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(2)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 2)))
}
onTodoEvents(TodoEvents.UpsertList(context, false,TodoModel(items = list.items, recurrence = list.recurrence, workspaceId = workspace.workspaceId, title = list.title)))
}
Toast.makeText(context, contentCopiedString, Toast.LENGTH_SHORT).show()
}
DataCopyType.MOVE -> {
selectedWorkspaces.forEach { workspace ->
if(!workspace.selectedSpaces.contains(2)){
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(workspace.copy(selectedSpaces = workspace.selectedSpaces + 2)))
}
onTodoEvents(TodoEvents.UpsertList(context, false,TodoModel(items = list.items, recurrence = list.recurrence, workspaceId = workspace.workspaceId, title = list.title)))
}
navController.popBackStack()
Toast.makeText(context, contentMovedString, Toast.LENGTH_SHORT).show()
onTodoEvents(TodoEvents.DeleteList(context, list))
}
}
}
) { showDataCopyDialog = false }
}
if(showConvertDialog){
ConvertTODODialog ({
when(it){
ConvertType.NOTE -> {
if(!currentWorkspace!!.selectedSpaces.contains(1)){
val newSelectedSpaces = currentWorkspace.selectedSpaces + 1
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(currentWorkspace.copy(selectedSpaces = newSelectedSpaces)))
}
onNotesEvents(
NotesEvents.UpsertNote(
NotesModel(
title = list.title,
description = list.toMarkdownContent(),
workspaceId = list.workspaceId
)
)
)
navController.popBackStack()
onTodoEvents(TodoEvents.DeleteList(context,list))
}
ConvertType.JOURNAL -> {
if(!currentWorkspace!!.selectedSpaces.contains(4)){
val newSelectedSpaces = currentWorkspace.selectedSpaces + 4
onWorkspaceEvents(WorkspaceEvents.UpsertSpace(currentWorkspace.copy(selectedSpaces = newSelectedSpaces)))
}
onJournalEvents(
JournalEvents.UpsertEntry(
JournalModel(
text = list.toMarkdown(),
workspaceId = list.workspaceId
)
)
)
navController.popBackStack()
onTodoEvents(TodoEvents.DeleteList(context,list))
}
else -> {}
}
Toast.makeText(context, successString, Toast.LENGTH_SHORT).show()
showConvertDialog=false
}) {
showConvertDialog=false
}
}
}
@@ -27,10 +27,12 @@ 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.RecurrenceRule
import com.flux.data.model.WorkspaceModel
import com.flux.navigation.Loader
import com.flux.navigation.NavRoutes
@@ -42,6 +44,7 @@ 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
import kotlin.collections.sortedBy
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -58,6 +61,7 @@ fun TodoScreen(
onToggleLock: () -> Unit,
onEvent: (TodoEvents) -> Unit
){
val context = LocalContext.current
val workspaceId = workspace.workspaceId
val isLoading = state.isLoading
val radius = settings.data.cornerRadius
@@ -68,7 +72,8 @@ fun TodoScreen(
it.title.contains(query, ignoreCase = true) ||
it.items.any { item -> item.value.contains(query, ignoreCase = true) }
)
}
}.sortedBy { it.startDateTime }
var showSearchBar by remember { mutableStateOf(false) }
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
val expandedTODOIds = rememberSaveable(workspaceId) { mutableStateOf<Set<String>>(emptySet()) }
@@ -92,7 +97,7 @@ fun TodoScreen(
)
},
floatingActionButton = {
FloatingActionButton({ navController.navigate(NavRoutes.TodoDetail.withArgs(workspaceId, "")) }) {
FloatingActionButton({ navController.navigate(NavRoutes.NewTodoList.withArgs(workspaceId, "")) }) {
Icon(Icons.Default.AddTask, null)
}
}
@@ -157,12 +162,18 @@ fun TodoScreen(
navController = navController,
radius = radius,
item = todoItem,
context = context,
workspaceId = workspaceId,
isExpanded = todoItem.id in expandedTODOIds.value,
onExpandToggle = { id->
expandedTODOIds.value =
if (id in expandedTODOIds.value) expandedTODOIds.value - id
else expandedTODOIds.value + id
if(todoItem.recurrence is RecurrenceRule.NONE){
expandedTODOIds.value =
if (id in expandedTODOIds.value) expandedTODOIds.value - id
else expandedTODOIds.value + id
}
else{
navController.navigate(NavRoutes.TodoDetail.withArgs(workspaceId, id))
}
},
onTodoEvents = onEvent
)
@@ -18,6 +18,7 @@ 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.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
@@ -27,7 +28,6 @@ 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
@@ -412,6 +412,9 @@ fun SpacesToolBar(
)
Text(
text = title,
modifier = Modifier.widthIn(max=100.dp),
overflow = TextOverflow.Ellipsis,
maxLines = 1,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
@@ -162,7 +162,7 @@ fun removeSpaceData(
) {
when (spaceId) {
1 -> viewModels.notesViewModel.onEvent(NotesEvents.DeleteAllWorkspaceNotes(workspaceId))
2 -> viewModels.todoViewModel.onEvent(TodoEvents.DeleteAllWorkspaceLists(workspaceId))
2 -> viewModels.todoViewModel.onEvent(TodoEvents.DeleteAllWorkspaceLists(context, workspaceId))
3 -> viewModels.eventViewModel.onEvent(TaskEvents.DeleteAllWorkspaceEvents(workspaceId, context))
4 -> viewModels.journalViewModel.onEvent(JournalEvents.DeleteWorkspaceEntries(workspaceId))
5 -> viewModels.habitViewModel.onEvent(HabitEvents.DeleteAllWorkspaceHabits(workspaceId, context))
@@ -1,9 +1,11 @@
package com.flux.ui.state
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
data class TodoState(
val isLoading: Boolean = true,
val workspaceId: String? = null,
val allLists: List<TodoModel> = emptyList()
val allLists: List<TodoModel> = emptyList(),
val allInstances: List<TodoInstance> = emptyList()
)
@@ -6,6 +6,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flux.data.database.FluxBackup
import com.flux.data.database.FluxDatabase
import com.flux.data.model.RecurrenceRule
import com.flux.data.model.SettingsModel
import com.flux.data.model.toScheduleRequest
import com.flux.data.repository.SettingsRepository
@@ -86,6 +87,7 @@ class BackupViewModel @Inject constructor(
workspaces = db.workspaceDao.getAll(),
notes = db.notesDao.loadAllNotes(),
todos = db.todoDao.loadAllLists(),
todoInstances = db.todoInstanceDao.loadAllInstances(),
habits = db.habitDao.loadAllHabits(),
habitInstances = db.habitInstanceDao.loadAllInstances(),
journals = db.journalDao.loadAllEntries(),
@@ -127,7 +129,15 @@ class BackupViewModel @Inject constructor(
// --- Todos ---
backup.todos.forEach { todo ->
if (!db.todoDao.exists(todo.id)) db.todoDao.upsertList(todo)
if (!db.todoDao.exists(todo.id)) {
if(todo.recurrence== RecurrenceRule.Weekly) scheduleNextReminder(context, todo.toScheduleRequest())
db.todoDao.upsertList(todo)
}
}
backup.todoInstances.forEach { instance ->
if (!db.todoInstanceDao.exists(instance.todoId, instance.instanceDate))
db.todoInstanceDao.upsertTodoInstance(instance)
}
// --- Habits ---
@@ -88,7 +88,7 @@ class JournalViewModel @Inject constructor(
YamlFrontMatterExtension.create()
)
private var parser: Parser = Parser.builder().extensions(extensions).build()
private var renderer: HtmlRenderer = HtmlRenderer.builder().extensions(extensions).build()
private var renderer: HtmlRenderer = HtmlRenderer.builder().extensions(extensions).softbreak("<br>\n").build()
private var lastOutlineContentHash: Int? = null
fun renderMarkdown(markdown: String): String {
@@ -79,7 +79,7 @@ class NotesViewModel @Inject constructor(
)
private var parser: Parser = Parser.builder().extensions(extensions).build()
private var renderer: HtmlRenderer = HtmlRenderer.builder().extensions(extensions).build()
private var renderer: HtmlRenderer = HtmlRenderer.builder().extensions(extensions).softbreak("<br>\n").build()
private var lastOutlineContentHash: Int? = null
fun renderMarkdown(markdown: String): String {
@@ -1,9 +1,15 @@
package com.flux.ui.viewModel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flux.data.model.RecurrenceRule
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import com.flux.data.model.toScheduleRequest
import com.flux.data.repository.TodoRepository
import com.flux.other.cancelReminder
import com.flux.other.scheduleNextReminder
import com.flux.ui.events.TodoEvents
import com.flux.ui.state.TodoState
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -12,7 +18,10 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import java.time.LocalDate
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@@ -33,29 +42,76 @@ class TodoViewModel @Inject constructor(
init {
viewModelScope.launch {
repository.loadTodoData()
.collect { lists -> updateState { it.copy(isLoading = false, allLists = lists) } }
combine(
repository.loadTodoData(),
repository.loadAllTodoInstance()
) { lists, instances ->
updateState {
it.copy(
isLoading = false,
allLists = lists,
allInstances = instances
)
}
}.collect()
}
}
private fun reduce(event: TodoEvents) {
when (event) {
is TodoEvents.DeleteList -> { deleteList(event.data) }
is TodoEvents.UpsertList -> { upsertList(event.data) }
is TodoEvents.DeleteAllWorkspaceLists -> deleteWorkspaceLists(event.workspaceId)
is TodoEvents.DeleteList -> { deleteList(event.context, event.data) }
is TodoEvents.UpsertList -> { upsertList(event.context, event.isRemovingReminder, event.data) }
is TodoEvents.DeleteAllWorkspaceLists -> deleteWorkspaceLists(event.context, event.workspaceId)
is TodoEvents.CreateInstance -> createInstance(event.listId, event.workspaceId)
is TodoEvents.UpsertInstance -> upsertInstance(event.instance)
}
}
private fun deleteWorkspaceLists(workspaceId: String) {
private fun createInstance(listId: String, workspaceId: String) {
val todayEpoch = LocalDate.now().toEpochDay()
val items = _state.value.allLists.find { it.id == listId }!!.items.map { it.copy(isChecked = false) }
val instance = TodoInstance(
todoId = listId,
workspaceId = workspaceId,
instanceDate = todayEpoch,
items = items
)
viewModelScope.launch(Dispatchers.IO) {
if (!repository.existInstance(listId, todayEpoch)) {
repository.upsertInstance(instance)
}
}
}
private fun upsertInstance(instance: TodoInstance) {
viewModelScope.launch(Dispatchers.IO) { repository.upsertInstance(instance) }
}
private fun deleteWorkspaceLists(context: Context, workspaceId: String) {
_state.value.allLists.forEach {data->
if(data.recurrence is RecurrenceRule.Weekly) cancelReminder(context,data.toScheduleRequest())
}
viewModelScope.launch(Dispatchers.IO) { repository.deleteAllWorkspaceLists(workspaceId) }
}
private fun deleteList(data: TodoModel) {
viewModelScope.launch(Dispatchers.IO) { repository.deleteList(data) }
private fun deleteList(context: Context, data: TodoModel) {
viewModelScope.launch(Dispatchers.IO) {
if(data.recurrence is RecurrenceRule.Weekly) cancelReminder(context,data.toScheduleRequest())
repository.deleteList(data)
}
}
private fun upsertList(data: TodoModel) {
private fun upsertList(context: Context, isRemovingRecurrence: Boolean, data: TodoModel) {
viewModelScope.launch(Dispatchers.IO) {
if(data.recurrence is RecurrenceRule.Weekly){
cancelReminder(context,data.toScheduleRequest())
scheduleNextReminder(context, data.toScheduleRequest())
}
if(isRemovingRecurrence){
cancelReminder(context,data.toScheduleRequest())
}
repository.upsertList(data)
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

@@ -475,5 +475,44 @@
<string name="clear">Löschen</string>
<string name="spaces">Bereiche</string>
<string name="workspaces">Arbeitsbereiche</string>
<string name="item_removed">Element entfernt</string>
<string name="undo">Rückgängig</string>
<!-- German (de) -->
<string name="notes_preview">Notizvorschau</string>
<string name="change_preview_setting_for_notes">Vorschaueinstellungen für Notizen ändern</string>
<string name="change_height">Höhe ändern</string>
<string name="normal">Normal</string>
<string name="elongated">Verlängert</string>
<string name="video">Video</string>
<string name="audio">Audio</string>
<string name="compact">Kompakt</string>
<string name="content_copied">Inhalt kopiert!</string>
<string name="content_moved">Inhalt verschoben!</string>
<string name="clone_created_successfully">Klon erfolgreich erstellt</string>
<string name="days_left">Noch %1$d Tage</string>
<string name="one_day_left">Noch 1 Tag</string>
<string name="one_day_overdue">1 Tag überfällig</string>
<string name="days_overdue">%1$d Tage überfällig</string>
<string name="start_date_after_target_error">Das Startdatum darf nicht nach dem Zieldatum liegen</string>
<string name="target_date_before_start_error">Das Zieldatum darf nicht vor dem Startdatum liegen</string>
<string name="convert">Konvertieren</string>
<string name="convert_to_note">In Notiz umwandeln</string>
<string name="convert_to_todo">In Aufgabenliste umwandeln</string>
<string name="convert_to_journal">In Journal umwandeln</string>
<string name="clone">Klonen</string>
<string name="copy">Kopieren</string>
<string name="move">Verschieben</string>
<string name="select_workspaces">Arbeitsbereiche auswählen</string>
<string name="turn_on_reminder">Aktiviere die Erinnerung für diese Aufgabenliste!</string>
<string name="created">Erstellt</string>
<string name="reminder">Erinnerung</string>
<string name="scheduled">Geplant</string>
<string name="true_text">Wahr</string>
<string name="false_text">Falsch</string>
<string name="off">Aus</string>
<string name="completed">Abgeschlossen</string>
<string name="remaining">Verbleibend</string>
</resources>
@@ -481,5 +481,42 @@
<string name="clear">Limpiar</string>
<string name="spaces">Espacios</string>
<string name="workspaces">Espacios de trabajo</string>
<string name="item_removed">Elemento eliminado</string>
<string name="undo">Deshacer</string>
<string name="notes_preview">Vista previa de notas</string>
<string name="change_preview_setting_for_notes">Cambiar la configuración de vista previa de las notas</string>
<string name="change_height">Cambiar altura</string>
<string name="normal">Normal</string>
<string name="elongated">Alargado</string>
<string name="video">Vídeo</string>
<string name="audio">Audio</string>
<string name="compact">Compacto</string>
<string name="content_copied">¡Contenido copiado!</string>
<string name="content_moved">¡Contenido movido!</string>
<string name="clone_created_successfully">Clon creado correctamente</string>
<string name="days_left">Quedan %1$d días</string>
<string name="one_day_left">Queda 1 día</string>
<string name="one_day_overdue">1 día de retraso</string>
<string name="days_overdue">%1$d días de retraso</string>
<string name="start_date_after_target_error">La fecha de inicio no puede ser posterior a la fecha objetivo</string>
<string name="target_date_before_start_error">La fecha objetivo no puede ser anterior a la fecha de inicio</string>
<string name="convert">Convertir</string>
<string name="convert_to_note">Convertir en nota</string>
<string name="convert_to_todo">Convertir en tarea</string>
<string name="convert_to_journal">Convertir en diario</string>
<string name="clone">Clonar</string>
<string name="copy">Copiar</string>
<string name="move">Mover</string>
<string name="select_workspaces">Seleccionar espacios de trabajo</string>
<string name="turn_on_reminder">Activa el recordatorio para esta lista de tareas.</string>
<string name="created">Creado</string>
<string name="reminder">Recordatorio</string>
<string name="scheduled">Programado</string>
<string name="true_text">Verdadero</string>
<string name="false_text">Falso</string>
<string name="off">Desactivado</string>
<string name="completed">Completado</string>
<string name="remaining">Restante</string>
</resources>
@@ -484,5 +484,42 @@
<string name="clear">Effacer</string>
<string name="spaces">Espaces</string>
<string name="workspaces">Espaces de travail</string>
<string name="item_removed">Élément supprimé</string>
<string name="undo">Annuler</string>
<string name="notes_preview">Aperçu des notes</string>
<string name="change_preview_setting_for_notes">Modifier les paramètres daperçu des notes</string>
<string name="change_height">Modifier la hauteur</string>
<string name="normal">Normal</string>
<string name="elongated">Allongé</string>
<string name="video">Vidéo</string>
<string name="audio">Audio</string>
<string name="compact">Compact</string>
<string name="content_copied">Contenu copié !</string>
<string name="content_moved">Contenu déplacé !</string>
<string name="clone_created_successfully">Clone créé avec succès</string>
<string name="days_left">%1$d jours restants</string>
<string name="one_day_left">1 jour restant</string>
<string name="one_day_overdue">1 jour de retard</string>
<string name="days_overdue">%1$d jours de retard</string>
<string name="start_date_after_target_error">La date de début ne peut pas être après la date cible</string>
<string name="target_date_before_start_error">La date cible ne peut pas être avant la date de début</string>
<string name="convert">Convertir</string>
<string name="convert_to_note">Convertir en note</string>
<string name="convert_to_todo">Convertir en tâche</string>
<string name="convert_to_journal">Convertir en journal</string>
<string name="clone">Cloner</string>
<string name="copy">Copier</string>
<string name="move">Déplacer</string>
<string name="select_workspaces">Sélectionner des espaces de travail</string>
<string name="turn_on_reminder">Activez le rappel pour cette liste de tâches !</string>
<string name="created">Créé</string>
<string name="reminder">Rappel</string>
<string name="scheduled">Planifié</string>
<string name="true_text">Vrai</string>
<string name="false_text">Faux</string>
<string name="off">Désactivé</string>
<string name="completed">Terminé</string>
<string name="remaining">Restant</string>
</resources>
@@ -483,4 +483,41 @@
<string name="clear">साफ़ करें</string>
<string name="spaces">स्पेस</string>
<string name="workspaces">वर्कस्पेस</string>
<string name="item_removed">आइटम हटा दिया गया</string>
<string name="undo">पूर्ववत करें</string>
<string name="notes_preview">नोट्स पूर्वावलोकन</string>
<string name="change_preview_setting_for_notes">नोट्स के पूर्वावलोकन की सेटिंग बदलें</string>
<string name="change_height">ऊंचाई बदलें</string>
<string name="normal">सामान्य</string>
<string name="elongated">लंबा</string>
<string name="video">वीडियो</string>
<string name="audio">ऑडियो</string>
<string name="compact">कॉम्पैक्ट</string>
<string name="content_copied">सामग्री कॉपी की गई!</string>
<string name="content_moved">सामग्री स्थानांतरित की गई!</string>
<string name="clone_created_successfully">क्लोन सफलतापूर्वक बनाया गया</string>
<string name="days_left">%1$d दिन शेष</string>
<string name="one_day_left">1 दिन शेष</string>
<string name="one_day_overdue">1 दिन विलंबित</string>
<string name="days_overdue">%1$d दिन विलंबित</string>
<string name="start_date_after_target_error">प्रारंभ तिथि लक्ष्य तिथि के बाद नहीं हो सकती</string>
<string name="target_date_before_start_error">लक्ष्य तिथि प्रारंभ तिथि से पहले नहीं हो सकती</string>
<string name="convert">परिवर्तित करें</string>
<string name="convert_to_note">नोट में बदलें</string>
<string name="convert_to_todo">टू-डू में बदलें</string>
<string name="convert_to_journal">जर्नल में बदलें</string>
<string name="clone">क्लोन</string>
<string name="copy">कॉपी</string>
<string name="move">स्थानांतरित करें</string>
<string name="select_workspaces">वर्कस्पेस चुनें</string>
<string name="turn_on_reminder">इस टू-डू सूची के लिए रिमाइंडर चालू करें!</string>
<string name="created">बनाया गया</string>
<string name="reminder">रिमाइंडर</string>
<string name="scheduled">निर्धारित</string>
<string name="true_text">सही</string>
<string name="false_text">गलत</string>
<string name="off">बंद</string>
<string name="completed">पूर्ण</string>
<string name="remaining">शेष</string>
</resources>
@@ -483,4 +483,42 @@
<string name="clear">Wissen</string>
<string name="spaces">Ruimtes</string>
<string name="workspaces">Werkruimtes</string>
<string name="item_removed">Item verwijderd</string>
<string name="undo">Ongedaan maken</string>
<string name="notes_preview">Notitievoorbeeld</string>
<string name="change_preview_setting_for_notes">Voorbeeldinstellingen voor notities wijzigen</string>
<string name="change_height">Hoogte wijzigen</string>
<string name="normal">Normaal</string>
<string name="elongated">Verlengd</string>
<string name="video">Video</string>
<string name="audio">Audio</string>
<string name="compact">Compact</string>
<string name="content_copied">Inhoud gekopieerd!</string>
<string name="content_moved">Inhoud verplaatst!</string>
<string name="clone_created_successfully">Kloon succesvol aangemaakt</string>
<string name="days_left">Nog %1$d dagen</string>
<string name="one_day_left">Nog 1 dag</string>
<string name="one_day_overdue">1 dag te laat</string>
<string name="days_overdue">%1$d dagen te laat</string>
<string name="start_date_after_target_error">De startdatum mag niet na de einddatum liggen</string>
<string name="target_date_before_start_error">De einddatum mag niet vóór de startdatum liggen</string>
<string name="convert">Converteren</string>
<string name="convert_to_note">Omzetten naar notitie</string>
<string name="convert_to_todo">Omzetten naar takenlijst</string>
<string name="convert_to_journal">Omzetten naar dagboek</string>
<string name="clone">Klonen</string>
<string name="copy">Kopiëren</string>
<string name="move">Verplaatsen</string>
<string name="select_workspaces">Werkruimten selecteren</string>
<string name="turn_on_reminder">Schakel de herinnering in voor deze takenlijst!</string>
<string name="created">Aangemaakt</string>
<string name="reminder">Herinnering</string>
<string name="scheduled">Gepland</string>
<string name="true_text">Waar</string>
<string name="false_text">Onwaar</string>
<string name="off">Uit</string>
<string name="completed">Voltooid</string>
<string name="remaining">Resterend</string>
</resources>
@@ -483,4 +483,42 @@
<string name="clear">Limpar</string>
<string name="spaces">Espaços</string>
<string name="workspaces">Espaços de trabalho</string>
<string name="item_removed">Item removido</string>
<string name="undo">Desfazer</string>
<string name="notes_preview">Pré-visualização de notas</string>
<string name="change_preview_setting_for_notes">Alterar as configurações de pré-visualização das notas</string>
<string name="change_height">Alterar altura</string>
<string name="normal">Normal</string>
<string name="elongated">Alongado</string>
<string name="video">Vídeo</string>
<string name="audio">Áudio</string>
<string name="compact">Compacto</string>
<string name="content_copied">Conteúdo copiado!</string>
<string name="content_moved">Conteúdo movido!</string>
<string name="clone_created_successfully">Clone criado com sucesso</string>
<string name="days_left">Faltam %1$d dias</string>
<string name="one_day_left">Falta 1 dia</string>
<string name="one_day_overdue">1 dia de atraso</string>
<string name="days_overdue">%1$d dias de atraso</string>
<string name="start_date_after_target_error">A data de início não pode ser posterior à data final</string>
<string name="target_date_before_start_error">A data final não pode ser anterior à data de início</string>
<string name="convert">Converter</string>
<string name="convert_to_note">Converter para nota</string>
<string name="convert_to_todo">Converter para tarefa</string>
<string name="convert_to_journal">Converter para diário</string>
<string name="clone">Clonar</string>
<string name="copy">Copiar</string>
<string name="move">Mover</string>
<string name="select_workspaces">Selecionar espaços de trabalho</string>
<string name="turn_on_reminder">Ative o lembrete para esta lista de tarefas!</string>
<string name="created">Criado</string>
<string name="reminder">Lembrete</string>
<string name="scheduled">Agendado</string>
<string name="true_text">Verdadeiro</string>
<string name="false_text">Falso</string>
<string name="off">Desligado</string>
<string name="completed">Concluído</string>
<string name="remaining">Restante</string>
</resources>
@@ -483,4 +483,43 @@
<string name="clear">Очистить</string>
<string name="spaces">Пространства</string>
<string name="workspaces">Рабочие пространства</string>
<string name="item_removed">Элемент удалён</string>
<string name="undo">Отменить</string>
<string name="notes_preview">Предпросмотр заметок</string>
<string name="change_preview_setting_for_notes">Изменить настройки предпросмотра заметок</string>
<string name="change_height">Изменить высоту</string>
<string name="normal">Обычный</string>
<string name="elongated">Удлинённый</string>
<string name="video">Видео</string>
<string name="audio">Аудио</string>
<string name="compact">Компактный</string>
\
<string name="content_copied">Содержимое скопировано!</string>
<string name="content_moved">Содержимое перемещено!</string>
<string name="clone_created_successfully">Клон успешно создан</string>
<string name="days_left">Осталось %1$d дней</string>
<string name="one_day_left">Остался 1 день</string>
<string name="one_day_overdue">Просрочка 1 день</string>
<string name="days_overdue">Просрочка %1$d дней</string>
<string name="start_date_after_target_error">Дата начала не может быть позже целевой даты</string>
<string name="target_date_before_start_error">Целевая дата не может быть раньше даты начала</string>
<string name="convert">Преобразовать</string>
<string name="convert_to_note">Преобразовать в заметку</string>
<string name="convert_to_todo">Преобразовать в задачу</string>
<string name="convert_to_journal">Преобразовать в дневник</string>
<string name="clone">Клонировать</string>
<string name="copy">Копировать</string>
<string name="move">Переместить</string>
<string name="select_workspaces">Выбрать рабочие пространства</string>
<string name="turn_on_reminder">Включите напоминание для этого списка задач!</string>
<string name="created">Создано</string>
<string name="reminder">Напоминание</string>
<string name="scheduled">Запланировано</string>
<string name="true_text">Истина</string>
<string name="false_text">Ложь</string>
<string name="off">Выкл</string>
<string name="completed">Завершено</string>
<string name="remaining">Осталось</string>
</resources>
@@ -489,4 +489,41 @@
<string name="clear">清除</string>
<string name="spaces">空间</string>
<string name="workspaces">工作区</string>
<string name="item_removed">项目已删除</string>
<string name="undo">撤销</string>
<string name="notes_preview">笔记预览</string>
<string name="change_preview_setting_for_notes">更改笔记预览设置</string>
<string name="change_height">更改高度</string>
<string name="normal">普通</string>
<string name="elongated">加长</string>
<string name="video">视频</string>
<string name="audio">音频</string>
<string name="compact">紧凑</string>
<string name="content_copied">内容已复制!</string>
<string name="content_moved">内容已移动!</string>
<string name="clone_created_successfully">克隆创建成功</string>
<string name="days_left">剩余 %1$d 天</string>
<string name="one_day_left">剩余 1 天</string>
<string name="one_day_overdue">逾期 1 天</string>
<string name="days_overdue">逾期 %1$d 天</string>
<string name="start_date_after_target_error">开始日期不能晚于目标日期</string>
<string name="target_date_before_start_error">目标日期不能早于开始日期</string>
<string name="convert">转换</string>
<string name="convert_to_note">转换为笔记</string>
<string name="convert_to_todo">转换为待办事项</string>
<string name="convert_to_journal">转换为日记</string>
<string name="clone">克隆</string>
<string name="copy">复制</string>
<string name="move">移动</string>
<string name="select_workspaces">选择工作区</string>
<string name="turn_on_reminder">为此待办事项列表开启提醒!</string>
<string name="created">已创建</string>
<string name="reminder">提醒</string>
<string name="scheduled">已计划</string>
<string name="true_text"></string>
<string name="false_text"></string>
<string name="off"></string>
<string name="completed">已完成</string>
<string name="remaining">剩余</string>
</resources>
+38
View File
@@ -481,5 +481,43 @@
<string name="clear">Clear</string>
<string name="spaces">Spaces</string>
<string name="workspaces">Workspaces</string>
<string name="item_removed">Item removed</string>
<string name="undo">Undo</string>
<string name="notes_preview">Notes Preview</string>
<string name="change_preview_setting_for_notes">Change Preview Setting for Notes</string>
<string name="change_height">Change Height</string>
<string name="normal">Normal</string>
<string name="elongated">Elongated</string>
<string name="video">Video</string>
<string name="audio">Audio</string>
<string name="compact">Compact</string>
<string name="content_copied">Content Copied!</string>
<string name="content_moved">Content Moved!</string>
<string name="clone_created_successfully">Clone created successfully</string>
<string name="days_left">%1$d days left</string>
<string name="one_day_left">1 day left</string>
<string name="one_day_overdue">1 day overdue</string>
<string name="days_overdue">%1$d days overdue</string>
<string name="start_date_after_target_error">Start date cannot be after target date</string>
<string name="target_date_before_start_error">Target date cannot be before start date</string>
<string name="convert">Convert</string>
<string name="convert_to_note">Convert to Note</string>
<string name="convert_to_todo">Convert to Todo</string>
<string name="convert_to_journal">Convert to Journal</string>
<string name="clone">Clone</string>
<string name="copy">Copy</string>
<string name="move">Move</string>
<string name="select_workspaces">Select Workspaces</string>
<string name="turn_on_reminder">Turn on the reminder to remind about this to-do list!</string>
<string name="created">Created</string>
<string name="reminder">Reminder</string>
<string name="scheduled">Scheduled</string>
<string name="true_text">True</string>
<string name="false_text">False</string>
<string name="off">Off</string>
<string name="completed">Completed</string>
<string name="remaining">Remaining</string>
</resources>
+18 -15
View File
@@ -1,33 +1,34 @@
[versions]
agp = "9.0.1"
agp = "9.2.1"
flexmarkHtml2mdConverter = "0.64.8"
kotlin = "2.3.20"
ksp = "2.3.6"
kotlinxSerializationJson = "1.10.0"
biometric = "1.4.0-alpha05"
gson = "2.13.2"
coreKtx = "1.18.0"
kotlin = "2.4.0"
ksp = "2.3.9"
kotlinxSerializationJson = "1.11.0"
biometric = "1.4.0-alpha07"
gson = "2.14.0"
coreKtx = "1.19.0"
appcompat = "1.7.1"
lifecycleRuntimeKtx = "2.10.0"
lifecycleRuntimeKtx = "2.11.0"
activityCompose = "1.13.0"
composeBom = "2026.03.00"
composeBom = "2026.06.00"
coreSplashscreen = "1.2.0"
navigationCompose = "2.9.7"
navigationCompose = "2.9.8"
reorderable = "3.1.0"
room = "2.8.4"
coilCompose = "2.7.0"
# Common Mark
commonmark = "0.27.1"
commonmark = "0.29.0"
# Hilt
hilt = "2.59.2"
hiltNavigationCompose = "1.3.0"
browser = "1.9.0"
browser = "1.10.0"
documentfile = "1.1.0"
workRuntimeKtx = "2.11.1"
foundationLayout = "1.10.5"
workRuntimeKtx = "2.11.2"
foundationLayout = "1.11.3"
adaptive = "1.2.0"
foundation = "1.11.0"
foundation = "1.11.3"
navigationCommonKtx = "2.9.8"
[libraries]
@@ -56,6 +57,7 @@ hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", ve
#Coil (Image rendering)
coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coilCompose" }
kotlinMetadataWorkaround = { group = "org.jetbrains.kotlin", name = "kotlin-metadata-jvm", version.ref = "kotlin" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
# Commonmark for markdown
@@ -76,6 +78,7 @@ androidx-compose-foundation-layout = { group = "androidx.compose.foundation", na
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" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+7
View File
@@ -0,0 +1,7 @@
feat:
1. Added Undo in Todo Item removal
2. Draggable Items to reorder in todo.
3. Reminder in Todo Items to remind and analyse the list.
fix:
1. Dated Journal entry bug
2. Create Button Text overflow in note/journal.
+7
View File
@@ -0,0 +1,7 @@
feat:
1. Notes Preview Mode to adjust notes height
src:
1. Improved Markdown Render in preview mode for media, links and codeblocks
fix:
1. Progress Tracker date bug.
2. Automatically detect line break in editor
+9
View File
@@ -0,0 +1,9 @@
feat:
1. Content copy/move to another workspaces
2. Various export options in todo
3. Clone a data point
4. Responsive UI for various display size.
5. Day addition in journal timeline with 24-hour format support
fix:
1. text overflow at various places
+1
View File
@@ -11,6 +11,7 @@ pluginManagement {
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {