Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6f5e24224 | ||
|
|
4ce8d319a6 | ||
|
|
b9dc0d0f71 | ||
|
|
6154e09516 | ||
|
|
5493c78ec6 | ||
|
|
293a8f34ba | ||
|
|
14a462afba | ||
|
|
a2b0540403 | ||
|
|
575d44485b | ||
|
|
0b5e2fc3b2 | ||
|
|
44090314aa | ||
|
|
934f9404fd | ||
|
|
a8e3c7e032 | ||
|
|
c56ae71d4b | ||
|
|
e9af514a82 | ||
|
|
a081793540 | ||
|
|
f869748a92 | ||
|
|
09428dac96 | ||
|
|
8c5d3b4fcc | ||
|
|
17cf9c4b29 | ||
|
|
5ef44cc247 | ||
|
|
6fe76ff875 | ||
|
|
3925e90582 | ||
|
|
3e6582a0f9 | ||
|
|
d6a9d21d0b | ||
|
|
efca669e88 | ||
|
|
5b3a39d516 |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
+222
@@ -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 (Model–View–Intent)** 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.
|
||||
@@ -21,53 +21,73 @@
|
||||
|
||||
</div>
|
||||
|
||||
|  |  |
|
||||
|:---------------------------------------:|:-----------------------------------------:|
|
||||
|  |  |
|
||||
|  |  |
|
||||
|  |  |
|
||||
| :----------------------------------------------------------------: | :----------------------------------------------------------------: |
|
||||
|  |  |
|
||||
|  |  |
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Release numbers
|
||||
|
||||
[](https://github.com/chindaronit/Flux/releases)
|
||||
[](https://github.com/chindaronit/Flux/blob/main/LICENSE)
|
||||
[](https://github.com/chindaronit/Flux/releases)
|
||||
|
||||
</div>
|
||||
|
||||
## 🎉 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
|
||||
|
||||
- **Programming Languages**: Kotlin
|
||||
- **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
|
||||
|
||||
- **MVI**: Model View ViewModel
|
||||
|
||||
## 📚 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
|
||||
|
||||
@@ -85,6 +105,18 @@ In Android Studio, select `Run > Run 'app'` to start the application.
|
||||
Any form of contribution is welcome! If you find a bug or have a new feature request, please create
|
||||
an issue. If you want to contribute code directly to this project, you can create a pull request.
|
||||
|
||||
<div align="center">
|
||||
|
||||
## Credits.
|
||||
|
||||
[](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"/>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
## ⚠️ License
|
||||
|
||||
```text
|
||||
@@ -104,3 +136,8 @@ GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
```
|
||||
|
||||
<div align="center">
|
||||
<img src=".github/india.jpg" alt="India" height="30" style="border-radius:50%; vertical-align:middle;"> MADE IN INDIA
|
||||
</div>
|
||||
|
||||
+12
-5
@@ -8,14 +8,14 @@ plugins {
|
||||
|
||||
android {
|
||||
namespace = "com.flux"
|
||||
compileSdk = 36
|
||||
compileSdk = 37
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.flux"
|
||||
minSdk = 29
|
||||
targetSdk = 36
|
||||
versionCode = 8
|
||||
versionName = "3.1.2"
|
||||
targetSdk = 37
|
||||
versionCode = 14
|
||||
versionName = "3.1.8"
|
||||
}
|
||||
|
||||
dependenciesInfo {
|
||||
@@ -100,9 +100,13 @@ dependencies {
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.androidx.work.runtime.ktx)
|
||||
implementation(libs.androidx.compose.foundation.layout)
|
||||
implementation(libs.androidx.compose.adaptive)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.navigation.common.ktx)
|
||||
|
||||
// Hilt
|
||||
ksp(libs.hilt.android.compiler)
|
||||
ksp(libs.kotlinMetadataWorkaround)
|
||||
implementation(libs.hilt.android)
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
|
||||
@@ -118,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)
|
||||
@@ -130,4 +134,7 @@ dependencies {
|
||||
implementation(libs.commonmark.ext.yaml.front.matter)
|
||||
implementation(libs.commonmark)
|
||||
implementation(libs.flexmarkHtml2mdConverter)
|
||||
|
||||
// draggable list
|
||||
implementation(libs.reorderable)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,19 +22,24 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.flux.navigation.AppNavHost
|
||||
import com.flux.navigation.Loader
|
||||
import com.flux.other.Constants.Other
|
||||
import com.flux.other.createNotificationChannel
|
||||
import com.flux.ui.effects.ScreenEffect
|
||||
import com.flux.ui.state.States
|
||||
import com.flux.ui.theme.FluxTheme
|
||||
import com.flux.ui.viewModel.BackupViewModel
|
||||
import com.flux.ui.viewModel.EventViewModel
|
||||
import com.flux.ui.viewModel.HabitViewModel
|
||||
import com.flux.ui.viewModel.JournalViewModel
|
||||
import com.flux.ui.viewModel.LabelViewModel
|
||||
import com.flux.ui.viewModel.NotesViewModel
|
||||
import com.flux.ui.viewModel.ProgressBoardViewModel
|
||||
import com.flux.ui.viewModel.SettingsViewModel
|
||||
import com.flux.ui.viewModel.TodoViewModel
|
||||
import com.flux.ui.viewModel.ViewModels
|
||||
import com.flux.ui.viewModel.WorkspaceViewModel
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -45,6 +50,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private var keepSplashScreen = mutableStateOf(true)
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -66,6 +72,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val todoViewModel: TodoViewModel = hiltViewModel()
|
||||
val journalViewModel: JournalViewModel = hiltViewModel()
|
||||
val backupViewModel: BackupViewModel = hiltViewModel()
|
||||
val labelViewModel: LabelViewModel = hiltViewModel()
|
||||
val progressBoardViewModel: ProgressBoardViewModel = hiltViewModel()
|
||||
|
||||
// States
|
||||
val settings by settingsViewModel.state.collectAsState()
|
||||
@@ -75,6 +83,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val habitState by habitViewModel.state.collectAsStateWithLifecycle()
|
||||
val todoState by todoViewModel.state.collectAsStateWithLifecycle()
|
||||
val journalState by journalViewModel.state.collectAsStateWithLifecycle()
|
||||
val labelState by labelViewModel.state.collectAsStateWithLifecycle()
|
||||
val progressBoardState by progressBoardViewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Stop splash screen when settings are loaded
|
||||
LaunchedEffect(settings.isLoading) { keepSplashScreen.value = settings.isLoading }
|
||||
@@ -86,28 +96,37 @@ class MainActivity : AppCompatActivity() {
|
||||
)
|
||||
|
||||
if (!settings.isLoading) {
|
||||
FluxTheme(settings) {
|
||||
FluxTheme(settings, settingsViewModel::onEvent) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerLow
|
||||
) {
|
||||
AppNavHost(
|
||||
navController = rememberNavController(),
|
||||
snackbarHostState = snackBarHostState,
|
||||
settingsViewModel = settingsViewModel,
|
||||
notesViewModel = notesViewModel,
|
||||
workspaceViewModel = workspaceViewModel,
|
||||
eventViewModel = eventViewModel,
|
||||
habitViewModel = habitViewModel,
|
||||
todoViewModel = todoViewModel,
|
||||
journalViewModel = journalViewModel,
|
||||
backupViewModel = backupViewModel,
|
||||
settings = settings,
|
||||
notesState = notesState,
|
||||
workspaceState = workspaceState,
|
||||
eventState = eventState,
|
||||
habitState = habitState,
|
||||
todoState = todoState,
|
||||
journalState = journalState
|
||||
viewModels = ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel,
|
||||
labelViewModel,
|
||||
progressBoardViewModel
|
||||
),
|
||||
states = States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
progressBoardState,
|
||||
labelState,
|
||||
settings
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ interface EventDao {
|
||||
@Query("Delete FROM EventModel where workspaceId = :workspaceId")
|
||||
suspend fun deleteAllWorkspaceEvents(workspaceId: String)
|
||||
|
||||
@Query("SELECT * FROM EventModel WHERE workspaceId = :workspaceId")
|
||||
fun loadAllEvents(workspaceId: String): Flow<List<EventModel>>
|
||||
@Query("SELECT * FROM EventModel")
|
||||
fun loadEventData(): Flow<List<EventModel>>
|
||||
|
||||
@Query("SELECT * FROM EventModel")
|
||||
suspend fun loadAllEvents(): List<EventModel>
|
||||
|
||||
@@ -25,8 +25,8 @@ interface EventInstanceDao {
|
||||
@Query("DELETE FROM EventInstanceModel WHERE workspaceId = :workspaceId")
|
||||
suspend fun deleteAllWorkspaceInstance(workspaceId: String)
|
||||
|
||||
@Query("SELECT * FROM EventInstanceModel where workspaceId in (:workspaceId)")
|
||||
fun loadAllWorkspaceInstances(workspaceId: String): Flow<List<EventInstanceModel>>
|
||||
@Query("SELECT * FROM EventInstanceModel")
|
||||
fun loadEventInstanceData(): Flow<List<EventInstanceModel>>
|
||||
|
||||
@Query("SELECT * FROM EventInstanceModel")
|
||||
suspend fun getAll(): List<EventInstanceModel>
|
||||
|
||||
@@ -19,14 +19,17 @@ interface HabitInstanceDao {
|
||||
@Query("DELETE FROM HabitInstanceModel WHERE habitId IN (:habitId)")
|
||||
suspend fun deleteAllInstances(habitId: String)
|
||||
|
||||
@Query("SELECT * FROM HabitInstanceModel WHERE habitId = :habitId AND instanceDate = :date LIMIT 1")
|
||||
suspend fun getHabitInstance(habitId: String, date: Long): HabitInstanceModel?
|
||||
|
||||
@Delete
|
||||
suspend fun deleteInstance(habitInstance: HabitInstanceModel)
|
||||
|
||||
@Query("DELETE FROM HabitInstanceModel WHERE workspaceId = :workspaceId")
|
||||
suspend fun deleteAllWorkspaceInstance(workspaceId: String)
|
||||
|
||||
@Query("SELECT * FROM HabitInstanceModel where workspaceId in (:workspaceId)")
|
||||
fun loadAllInstances(workspaceId: String): Flow<List<HabitInstanceModel>>
|
||||
@Query("SELECT * FROM HabitInstanceModel")
|
||||
fun loadHabitInstanceData(): Flow<List<HabitInstanceModel>>
|
||||
|
||||
@Query("SELECT * FROM HabitInstanceModel")
|
||||
suspend fun loadAllInstances(): List<HabitInstanceModel>
|
||||
|
||||
@@ -22,9 +22,9 @@ interface HabitsDao {
|
||||
@Query("DELETE FROM HabitModel WHERE workspaceId = :workspaceId")
|
||||
suspend fun deleteAllWorkspaceHabit(workspaceId: String)
|
||||
|
||||
@Query("SELECT * FROM HabitModel WHERE workspaceId = :workspaceId")
|
||||
fun loadAllHabitsOfWorkspace(workspaceId: String): Flow<List<HabitModel>>
|
||||
@Query("SELECT * FROM HabitModel")
|
||||
fun loadHabitData(): Flow<List<HabitModel>>
|
||||
|
||||
@Query("Select * FROM HabitModel")
|
||||
suspend fun loadAllHabits(): List<HabitModel>
|
||||
fun loadAllHabits(): List<HabitModel>
|
||||
}
|
||||
|
||||
@@ -22,14 +22,8 @@ interface JournalDao {
|
||||
@Query("Delete FROM JournalModel where workspaceId = :workspaceId")
|
||||
suspend fun deleteAllWorkspaceEntries(workspaceId: String)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM JournalModel
|
||||
WHERE workspaceId = :workspaceId
|
||||
ORDER BY dateTime DESC
|
||||
"""
|
||||
)
|
||||
fun loadAllEntries(workspaceId: String): Flow<List<JournalModel>>
|
||||
@Query("SELECT * FROM JournalModel ORDER BY dateTime DESC")
|
||||
fun loadJournalData(): Flow<List<JournalModel>>
|
||||
|
||||
@Query("SELECT * FROM JournalModel")
|
||||
suspend fun loadAllEntries(): List<JournalModel>
|
||||
|
||||
@@ -22,8 +22,8 @@ interface LabelDao {
|
||||
@Query("DELETE FROM LabelModel WHERE workspaceId = :workspaceId")
|
||||
suspend fun deleteAllWorkspaceLabels(workspaceId: String)
|
||||
|
||||
@Query("SELECT * FROM LabelModel where workspaceId IN (:workspaceId)")
|
||||
fun loadAllLabels(workspaceId: String): Flow<List<LabelModel>>
|
||||
@Query("SELECT * FROM LabelModel")
|
||||
fun loadAllLabels(): Flow<List<LabelModel>>
|
||||
|
||||
@Query("SELECT * FROM LabelModel")
|
||||
suspend fun getAll(): List<LabelModel>
|
||||
|
||||
@@ -28,8 +28,8 @@ interface NotesDao {
|
||||
@Query("DELETE FROM NotesModel WHERE workspaceId = :workspaceId")
|
||||
suspend fun deleteAllWorkspaceNotes(workspaceId: String)
|
||||
|
||||
@Query("SELECT * FROM NotesModel where workspaceId IN (:workspaceId)")
|
||||
fun loadAllNotes(workspaceId: String): Flow<List<NotesModel>>
|
||||
@Query("SELECT * FROM NotesModel ORDER by lastEdited DESC")
|
||||
fun loadNotesData(): Flow<List<NotesModel>>
|
||||
|
||||
@Query("SELECT * FROM NotesModel")
|
||||
fun loadAllNotes(): List<NotesModel>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.flux.data.dao
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Delete
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import com.flux.data.model.ProgressBoardModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ProgressBoardDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertBoardItem(item: ProgressBoardModel)
|
||||
|
||||
@Query("SELECT * FROM ProgressBoardModel")
|
||||
fun getProgressBoardData(): Flow<List<ProgressBoardModel>>
|
||||
|
||||
@Delete
|
||||
suspend fun deleteBoardItem(item: ProgressBoardModel)
|
||||
|
||||
@Query("Delete FROM ProgressBoardModel where workspaceId = :workspaceId")
|
||||
suspend fun deleteBoardItemsByWorkspace(workspaceId: String)
|
||||
|
||||
@Query("SELECT EXISTS(SELECT 1 FROM ProgressBoardModel WHERE itemId = :id)")
|
||||
suspend fun exists(id: String): Boolean
|
||||
|
||||
@Query("SELECT * FROM ProgressBoardModel")
|
||||
fun getAllBoardItems(): List<ProgressBoardModel>
|
||||
}
|
||||
@@ -22,8 +22,8 @@ interface TodoDao {
|
||||
@Query("DELETE FROM TodoModel WHERE workspaceId = :workspaceId")
|
||||
fun deleteAllWorkspaceLists(workspaceId: String)
|
||||
|
||||
@Query("SELECT * FROM TodoModel where workspaceId IN (:workspaceId)")
|
||||
fun loadAllLists(workspaceId: String): Flow<List<TodoModel>>
|
||||
@Query("SELECT * FROM TodoModel")
|
||||
fun loadTodoData(): Flow<List<TodoModel>>
|
||||
|
||||
@Query("SELECT * FROM TodoModel")
|
||||
suspend fun loadAllLists(): List<TodoModel>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import com.flux.data.model.HabitModel
|
||||
import com.flux.data.model.JournalModel
|
||||
import com.flux.data.model.LabelModel
|
||||
import com.flux.data.model.NotesModel
|
||||
import com.flux.data.model.ProgressBoardModel
|
||||
import com.flux.data.model.SettingsModel
|
||||
import com.flux.data.model.TodoInstance
|
||||
import com.flux.data.model.TodoModel
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -17,11 +19,13 @@ 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(),
|
||||
val labels: List<LabelModel> = emptyList(),
|
||||
val events: List<EventModel> = emptyList(),
|
||||
val eventInstances: List<EventInstanceModel> = emptyList(),
|
||||
val progressBoardItems: List<ProgressBoardModel> = emptyList(),
|
||||
val settings: SettingsModel = SettingsModel()
|
||||
)
|
||||
@@ -12,8 +12,10 @@ import com.flux.data.dao.HabitsDao
|
||||
import com.flux.data.dao.JournalDao
|
||||
import com.flux.data.dao.LabelDao
|
||||
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
|
||||
@@ -23,7 +25,9 @@ import com.flux.data.model.HabitModel
|
||||
import com.flux.data.model.JournalModel
|
||||
import com.flux.data.model.LabelModel
|
||||
import com.flux.data.model.NotesModel
|
||||
import com.flux.data.model.ProgressBoardModel
|
||||
import com.flux.data.model.SettingsModel
|
||||
import com.flux.data.model.TodoInstance
|
||||
import com.flux.data.model.TodoModel
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
import com.google.gson.Gson
|
||||
@@ -33,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],
|
||||
version = 5,
|
||||
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)
|
||||
@@ -49,282 +53,363 @@ abstract class FluxDatabase : RoomDatabase() {
|
||||
abstract val journalDao: JournalDao
|
||||
abstract val todoDao: TodoDao
|
||||
abstract val labelDao: LabelDao
|
||||
abstract val progressBoardDao: ProgressBoardDao
|
||||
abstract val todoInstanceDao: TodoInstanceDao
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.safeExec(sql: String) {
|
||||
try { execSQL(sql) } catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.columnExists(table: String, column: String): Boolean {
|
||||
query("PRAGMA table_info($table)").use { cursor ->
|
||||
val nameIndex = cursor.getColumnIndex("name")
|
||||
while (cursor.moveToNext()) {
|
||||
if (cursor.getString(nameIndex).equals(column, ignoreCase = true)) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private fun SupportSQLiteDatabase.tableExists(table: String): Boolean {
|
||||
query("SELECT name FROM sqlite_master WHERE type='table' AND name='$table'").use {
|
||||
return it.moveToFirst()
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
// Add fontNumber column with default value 0
|
||||
db.execSQL("ALTER TABLE SettingsModel ADD COLUMN fontNumber INTEGER NOT NULL DEFAULT 0")
|
||||
if (!db.columnExists("SettingsModel", "fontNumber"))
|
||||
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN fontNumber INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE NotesModel ADD COLUMN images TEXT NOT NULL DEFAULT '[]'")
|
||||
db.execSQL("ALTER TABLE HabitModel ADD COLUMN endDateTime INTEGER NOT NULL DEFAULT -1")
|
||||
db.execSQL("ALTER TABLE EventModel ADD COLUMN endDateTime INTEGER NOT NULL DEFAULT -1")
|
||||
if (!db.columnExists("NotesModel", "images"))
|
||||
db.safeExec("ALTER TABLE NotesModel ADD COLUMN images TEXT NOT NULL DEFAULT '[]'")
|
||||
if (!db.columnExists("HabitModel", "endDateTime"))
|
||||
db.safeExec("ALTER TABLE HabitModel ADD COLUMN endDateTime INTEGER NOT NULL DEFAULT -1")
|
||||
if (!db.columnExists("EventModel", "endDateTime"))
|
||||
db.safeExec("ALTER TABLE EventModel ADD COLUMN endDateTime INTEGER NOT NULL DEFAULT -1")
|
||||
}
|
||||
}
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
|
||||
val MIGRATION_3_4 = object : Migration(3, 4) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
|
||||
/* ============================================================
|
||||
1. WorkspaceModel — selectedSpaces normalization (CRASH FIX)
|
||||
============================================================ */
|
||||
|
||||
db.execSQL("""
|
||||
ALTER TABLE WorkspaceModel
|
||||
ADD COLUMN selectedSpaces_new TEXT NOT NULL DEFAULT ''
|
||||
""")
|
||||
|
||||
val wsCursor = db.query(
|
||||
"SELECT workspaceId, selectedSpaces FROM WorkspaceModel"
|
||||
)
|
||||
// 1. WorkspaceModel selectedSpaces normalization
|
||||
if (!db.columnExists("WorkspaceModel", "selectedSpaces_new")) {
|
||||
db.safeExec("ALTER TABLE WorkspaceModel ADD COLUMN selectedSpaces_new TEXT NOT NULL DEFAULT ''")
|
||||
}
|
||||
|
||||
val wsCursor = db.query("SELECT workspaceId, selectedSpaces FROM WorkspaceModel")
|
||||
while (wsCursor.moveToNext()) {
|
||||
|
||||
val id = wsCursor.getString(0)
|
||||
val raw = wsCursor.getString(1) ?: ""
|
||||
|
||||
// ---- SAFE PARSE (CSV + JSON tolerant) ----
|
||||
val spaces = try {
|
||||
Json.decodeFromString<List<Int>>(raw).toMutableSet()
|
||||
} catch (_: Exception) {
|
||||
raw.split(",")
|
||||
.mapNotNull { it.trim().toIntOrNull() }
|
||||
.toMutableSet()
|
||||
raw.split(",").mapNotNull { it.trim().toIntOrNull() }.toMutableSet()
|
||||
}
|
||||
|
||||
// Merge Calendar (4) → Events (3)
|
||||
if (spaces.remove(4)) spaces.add(3)
|
||||
|
||||
// Shift IDs > 4
|
||||
val normalized = spaces.map {
|
||||
if (it > 4) it - 1 else it
|
||||
}.toSet()
|
||||
|
||||
// Store as CSV (since converter still CSV)
|
||||
val newCsv = normalized.joinToString(",")
|
||||
|
||||
db.execSQL(
|
||||
"UPDATE WorkspaceModel SET selectedSpaces_new = ? WHERE workspaceId = ?",
|
||||
arrayOf(newCsv, id)
|
||||
)
|
||||
val normalized = spaces.map { if (it > 4) it - 1 else it }.toSet()
|
||||
db.safeExec("UPDATE WorkspaceModel SET selectedSpaces_new = '${normalized.joinToString(",")}' WHERE workspaceId = '$id'")
|
||||
}
|
||||
|
||||
wsCursor.close()
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE WorkspaceModel_new (
|
||||
workspaceId TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
colorInd INTEGER NOT NULL,
|
||||
cover TEXT NOT NULL,
|
||||
icon INTEGER NOT NULL,
|
||||
passKey TEXT NOT NULL,
|
||||
isPinned INTEGER NOT NULL,
|
||||
selectedSpaces TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
if (!db.tableExists("WorkspaceModel_new")) {
|
||||
db.safeExec("""
|
||||
CREATE TABLE WorkspaceModel_new (
|
||||
workspaceId TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
colorInd INTEGER NOT NULL,
|
||||
cover TEXT NOT NULL,
|
||||
icon INTEGER NOT NULL,
|
||||
passKey TEXT NOT NULL,
|
||||
isPinned INTEGER NOT NULL,
|
||||
selectedSpaces TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
db.safeExec("""
|
||||
INSERT INTO WorkspaceModel_new
|
||||
SELECT workspaceId, title, description, colorInd, cover, icon, passKey, isPinned, selectedSpaces_new
|
||||
FROM WorkspaceModel
|
||||
""")
|
||||
db.safeExec("DROP TABLE WorkspaceModel")
|
||||
db.safeExec("ALTER TABLE WorkspaceModel_new RENAME TO WorkspaceModel")
|
||||
}
|
||||
|
||||
db.execSQL("""
|
||||
INSERT INTO WorkspaceModel_new
|
||||
SELECT
|
||||
workspaceId, title, description, colorInd,
|
||||
cover, icon, passKey, isPinned, selectedSpaces_new
|
||||
FROM WorkspaceModel
|
||||
""")
|
||||
|
||||
db.execSQL("DROP TABLE WorkspaceModel")
|
||||
db.execSQL("ALTER TABLE WorkspaceModel_new RENAME TO WorkspaceModel")
|
||||
|
||||
|
||||
/* ============================================================
|
||||
2. SettingsModel columns (4→6)
|
||||
============================================================ */
|
||||
|
||||
db.execSQL(
|
||||
"ALTER TABLE SettingsModel ADD COLUMN storageRootUri TEXT"
|
||||
)
|
||||
|
||||
db.execSQL("""
|
||||
ALTER TABLE SettingsModel
|
||||
ADD COLUMN startWithReadView INTEGER NOT NULL DEFAULT 0
|
||||
""")
|
||||
|
||||
db.execSQL("""
|
||||
ALTER TABLE SettingsModel
|
||||
ADD COLUMN isLineNumbersVisible INTEGER NOT NULL DEFAULT 0
|
||||
""")
|
||||
|
||||
db.execSQL("""
|
||||
ALTER TABLE SettingsModel
|
||||
ADD COLUMN isLintValid INTEGER NOT NULL DEFAULT 0
|
||||
""")
|
||||
|
||||
|
||||
/* ============================================================
|
||||
3. TodoItem ID migration (6→7)
|
||||
============================================================ */
|
||||
// 2. SettingsModel columns
|
||||
if (!db.columnExists("SettingsModel", "storageRootUri"))
|
||||
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN storageRootUri TEXT")
|
||||
if (!db.columnExists("SettingsModel", "startWithReadView"))
|
||||
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN startWithReadView INTEGER NOT NULL DEFAULT 0")
|
||||
if (!db.columnExists("SettingsModel", "isLineNumbersVisible"))
|
||||
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN isLineNumbersVisible INTEGER NOT NULL DEFAULT 0")
|
||||
if (!db.columnExists("SettingsModel", "isLintValid"))
|
||||
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN isLintValid INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
// 3. TodoItem ID migration
|
||||
val todoCursor = db.query("SELECT id, items FROM TodoModel")
|
||||
val gson = Gson()
|
||||
|
||||
while (todoCursor.moveToNext()) {
|
||||
|
||||
val todoId = todoCursor.getString(0)
|
||||
val itemsJson = todoCursor.getString(1)
|
||||
|
||||
try {
|
||||
data class OldTodoItem(
|
||||
val value: String,
|
||||
val isChecked: Boolean
|
||||
)
|
||||
|
||||
data class NewTodoItem(
|
||||
val id: String,
|
||||
val value: String,
|
||||
val isChecked: Boolean
|
||||
)
|
||||
|
||||
data class OldTodoItem(val value: String, val isChecked: Boolean)
|
||||
data class NewTodoItem(val id: String, val value: String, val isChecked: Boolean)
|
||||
val type = object : TypeToken<List<OldTodoItem>>() {}.type
|
||||
|
||||
val oldItems: List<OldTodoItem> =
|
||||
gson.fromJson(itemsJson, type) ?: continue
|
||||
|
||||
val newItems = oldItems.map {
|
||||
NewTodoItem(
|
||||
id = UUID.randomUUID().toString(),
|
||||
value = it.value,
|
||||
isChecked = it.isChecked
|
||||
)
|
||||
}
|
||||
|
||||
db.execSQL(
|
||||
"UPDATE TodoModel SET items = ? WHERE id = ?",
|
||||
arrayOf(gson.toJson(newItems), todoId)
|
||||
)
|
||||
|
||||
} catch (_: Exception) {
|
||||
// Skip malformed rows
|
||||
}
|
||||
val oldItems: List<OldTodoItem> = gson.fromJson(itemsJson, type) ?: continue
|
||||
// Skip if already migrated (items already have id field)
|
||||
if (itemsJson.contains("\"id\"")) continue
|
||||
val newItems = oldItems.map { NewTodoItem(UUID.randomUUID().toString(), it.value, it.isChecked) }
|
||||
db.execSQL("UPDATE TodoModel SET items = ? WHERE id = ?", arrayOf(gson.toJson(newItems), todoId))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
todoCursor.close()
|
||||
|
||||
// 4. Journal + Notes table rebuild
|
||||
if (!db.tableExists("journalmodel_new")) {
|
||||
db.safeExec("""
|
||||
CREATE TABLE journalmodel_new (
|
||||
journalId TEXT NOT NULL,
|
||||
workspaceId TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
dateTime INTEGER NOT NULL,
|
||||
PRIMARY KEY(journalId)
|
||||
)
|
||||
""")
|
||||
db.safeExec("INSERT INTO journalmodel_new SELECT journalId, workspaceId, text, dateTime FROM JournalModel")
|
||||
db.safeExec("DROP TABLE JournalModel")
|
||||
db.safeExec("ALTER TABLE journalmodel_new RENAME TO JournalModel")
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
4. Journal + Notes table rebuild (7→8)
|
||||
============================================================ */
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE journalmodel_new (
|
||||
journalId TEXT NOT NULL,
|
||||
workspaceId TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
dateTime INTEGER NOT NULL,
|
||||
PRIMARY KEY(journalId)
|
||||
)
|
||||
""")
|
||||
|
||||
db.execSQL("""
|
||||
INSERT INTO journalmodel_new
|
||||
SELECT journalId, workspaceId, text, dateTime
|
||||
FROM JournalModel
|
||||
""")
|
||||
|
||||
db.execSQL("DROP TABLE JournalModel")
|
||||
db.execSQL(
|
||||
"ALTER TABLE journalmodel_new RENAME TO JournalModel"
|
||||
)
|
||||
|
||||
|
||||
db.execSQL("""
|
||||
CREATE TABLE NotesModel_new (
|
||||
notesId TEXT NOT NULL,
|
||||
workspaceId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
isPinned INTEGER NOT NULL,
|
||||
labels TEXT NOT NULL,
|
||||
lastEdited INTEGER NOT NULL,
|
||||
PRIMARY KEY(notesId)
|
||||
)
|
||||
""")
|
||||
|
||||
db.execSQL("""
|
||||
INSERT INTO NotesModel_new
|
||||
SELECT
|
||||
notesId,
|
||||
workspaceId,
|
||||
title,
|
||||
description,
|
||||
isPinned,
|
||||
labels,
|
||||
lastEdited
|
||||
FROM NotesModel
|
||||
""")
|
||||
|
||||
db.execSQL("DROP TABLE NotesModel")
|
||||
db.execSQL(
|
||||
"ALTER TABLE NotesModel_new RENAME TO NotesModel"
|
||||
)
|
||||
|
||||
|
||||
/* ============================================================
|
||||
5. HTML → Markdown migration (8→9)
|
||||
============================================================ */
|
||||
if (!db.tableExists("NotesModel_new")) {
|
||||
db.safeExec("""
|
||||
CREATE TABLE NotesModel_new (
|
||||
notesId TEXT NOT NULL,
|
||||
workspaceId TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
isPinned INTEGER NOT NULL,
|
||||
labels TEXT NOT NULL,
|
||||
lastEdited INTEGER NOT NULL,
|
||||
PRIMARY KEY(notesId)
|
||||
)
|
||||
""")
|
||||
db.safeExec("""
|
||||
INSERT INTO NotesModel_new
|
||||
SELECT notesId, workspaceId, title, description, isPinned, labels, lastEdited
|
||||
FROM NotesModel
|
||||
""")
|
||||
db.safeExec("DROP TABLE NotesModel")
|
||||
db.safeExec("ALTER TABLE NotesModel_new RENAME TO NotesModel")
|
||||
}
|
||||
|
||||
// 5. HTML → Markdown migration
|
||||
val converter = FlexmarkHtmlConverter.builder().build()
|
||||
|
||||
db.query(
|
||||
"SELECT notesId, description FROM NotesModel"
|
||||
).use { cursor ->
|
||||
|
||||
db.query("SELECT notesId, description FROM NotesModel").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
|
||||
val id = cursor.getString(0)
|
||||
val html = cursor.getString(1) ?: continue
|
||||
|
||||
if (html.contains("<")) {
|
||||
|
||||
val markdown = converter.convert(html)
|
||||
|
||||
db.execSQL(
|
||||
"UPDATE NotesModel SET description = ? WHERE notesId = ?",
|
||||
arrayOf(markdown, id)
|
||||
)
|
||||
db.execSQL("UPDATE NotesModel SET description = ? WHERE notesId = ?",
|
||||
arrayOf(converter.convert(html), id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
db.query(
|
||||
"SELECT journalId, text FROM JournalModel"
|
||||
).use { cursor ->
|
||||
|
||||
db.query("SELECT journalId, text FROM JournalModel").use { cursor ->
|
||||
while (cursor.moveToNext()) {
|
||||
|
||||
val id = cursor.getString(0)
|
||||
val html = cursor.getString(1) ?: continue
|
||||
|
||||
if (html.contains("<")) {
|
||||
|
||||
val markdown = converter.convert(html)
|
||||
|
||||
db.execSQL(
|
||||
"UPDATE JournalModel SET text = ? WHERE journalId = ?",
|
||||
arrayOf(markdown, id)
|
||||
)
|
||||
db.execSQL("UPDATE JournalModel SET text = ? WHERE journalId = ?",
|
||||
arrayOf(converter.convert(html), id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Migration_4_5 = object : Migration(4, 5) {
|
||||
val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE SettingsModel ADD COLUMN backupFrequency INTEGER NOT NULL DEFAULT 0")
|
||||
if (!db.columnExists("SettingsModel", "backupFrequency"))
|
||||
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN backupFrequency INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.safeExec("""
|
||||
CREATE TABLE IF NOT EXISTS `ProgressBoardModel` (
|
||||
`itemId` TEXT NOT NULL,
|
||||
`workspaceId` TEXT NOT NULL,
|
||||
`title` TEXT NOT NULL,
|
||||
`description` TEXT NOT NULL,
|
||||
`startDate` INTEGER NOT NULL,
|
||||
`endDate` INTEGER NOT NULL,
|
||||
`icon` INTEGER NOT NULL,
|
||||
`status` INTEGER NOT NULL,
|
||||
PRIMARY KEY(`itemId`)
|
||||
)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
if (!db.columnExists("SettingsModel", "useSystemTimeFormat"))
|
||||
db.safeExec("ALTER TABLE SettingsModel ADD COLUMN useSystemTimeFormat INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
|
||||
val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
|
||||
// ---------------------- HabitModel rebuild ----------------------
|
||||
if (!db.tableExists("HabitModel_new")) {
|
||||
|
||||
db.safeExec("""
|
||||
CREATE TABLE HabitModel_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
recurrence TEXT NOT NULL,
|
||||
startDateTime INTEGER NOT NULL,
|
||||
endDateTime INTEGER NOT NULL,
|
||||
notificationOffset INTEGER NOT NULL,
|
||||
workspaceId TEXT NOT NULL,
|
||||
habitConfig TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
// Default HabitConfig → Simple
|
||||
val defaultConfig = """{"type":"Simple"}"""
|
||||
|
||||
db.safeExec("""
|
||||
INSERT INTO HabitModel_new (
|
||||
id, title, description, recurrence,
|
||||
startDateTime, endDateTime,
|
||||
notificationOffset, workspaceId, habitConfig
|
||||
)
|
||||
SELECT
|
||||
id, title, description, recurrence,
|
||||
startDateTime, endDateTime,
|
||||
notificationOffset, workspaceId,
|
||||
'$defaultConfig'
|
||||
FROM HabitModel
|
||||
""")
|
||||
|
||||
db.safeExec("DROP TABLE HabitModel")
|
||||
db.safeExec("ALTER TABLE HabitModel_new RENAME TO HabitModel")
|
||||
}
|
||||
|
||||
if (!db.columnExists("HabitInstanceModel", "timeSpent"))
|
||||
db.safeExec("ALTER TABLE HabitInstanceModel ADD COLUMN timeSpent INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
if (!db.columnExists("HabitInstanceModel", "isRunning"))
|
||||
db.safeExec("ALTER TABLE HabitInstanceModel ADD COLUMN isRunning INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
if (!db.columnExists("HabitInstanceModel", "count"))
|
||||
db.safeExec("ALTER TABLE HabitInstanceModel ADD COLUMN count INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
if (!db.tableExists("WorkspaceModel_new")) {
|
||||
db.safeExec("""
|
||||
CREATE TABLE WorkspaceModel_new (
|
||||
workspaceId TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
colorInd INTEGER NOT NULL,
|
||||
cover TEXT NOT NULL,
|
||||
icon INTEGER NOT NULL,
|
||||
passKey TEXT,
|
||||
isPinned INTEGER NOT NULL,
|
||||
selectedSpaces TEXT NOT NULL
|
||||
)
|
||||
""".trimIndent())
|
||||
|
||||
db.safeExec("""
|
||||
INSERT INTO WorkspaceModel_new (
|
||||
workspaceId, title, description, colorInd,
|
||||
cover, icon, passKey, isPinned, selectedSpaces
|
||||
)
|
||||
SELECT
|
||||
workspaceId, title, description, colorInd,
|
||||
cover, icon,
|
||||
CASE WHEN passKey = '' THEN NULL ELSE passKey END,
|
||||
isPinned, selectedSpaces
|
||||
FROM WorkspaceModel
|
||||
""".trimIndent())
|
||||
|
||||
db.safeExec("DROP TABLE WorkspaceModel")
|
||||
db.safeExec("ALTER TABLE WorkspaceModel_new RENAME TO WorkspaceModel")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ class Converter {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
coerceInputValues = true
|
||||
classDiscriminator = "type"
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
@@ -84,6 +85,21 @@ class Converter {
|
||||
val listType = object : TypeToken<List<String>>() {}.type
|
||||
return Gson().fromJson(value, listType)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun fromConfig(config: HabitConfig): String {
|
||||
return json.encodeToString(config)
|
||||
}
|
||||
|
||||
@TypeConverter
|
||||
fun toConfig(value: String): HabitConfig {
|
||||
return try {
|
||||
json.decodeFromString(value)
|
||||
} catch (
|
||||
_: Exception) {
|
||||
HabitConfig.Simple // fallback (critical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper class for parsing old data format (without id field)
|
||||
|
||||
@@ -12,18 +12,15 @@ import java.time.temporal.ChronoUnit
|
||||
@Serializable
|
||||
@Entity
|
||||
data class EventModel(
|
||||
@PrimaryKey
|
||||
override val id: String = UUID.randomUUID().toString(),
|
||||
override val title: String = "",
|
||||
override val description: String = "",
|
||||
override val recurrence: RecurrenceRule = RecurrenceRule.Custom(),
|
||||
override val startDateTime: Long = System.currentTimeMillis(),
|
||||
override val endDateTime: Long = -1L,
|
||||
override val notificationOffset: Long = 0L,
|
||||
override val workspaceId: String = ""
|
||||
) : ReminderItem {
|
||||
override val type: ReminderType get() = ReminderType.EVENT
|
||||
}
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val recurrence: RecurrenceRule = RecurrenceRule.Custom(),
|
||||
val startDateTime: Long = System.currentTimeMillis(),
|
||||
val endDateTime: Long = -1L,
|
||||
val notificationOffset: Long = 0L,
|
||||
val workspaceId: String = ""
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Entity(primaryKeys = ["eventId", "instanceDate"])
|
||||
@@ -33,6 +30,11 @@ data class EventInstanceModel(
|
||||
val instanceDate: Long = LocalDate.now().toEpochDay()
|
||||
)
|
||||
|
||||
fun EventModel.isLive(): Boolean {
|
||||
if (endDateTime == -1L) return true
|
||||
return endDateTime > System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun EventModel.occursOn(date: LocalDate): Boolean {
|
||||
val eventStart = Instant.ofEpochMilli(startDateTime)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
@@ -71,5 +73,7 @@ fun EventModel.occursOn(date: LocalDate): Boolean {
|
||||
date.dayOfMonth == eventStart.dayOfMonth &&
|
||||
date.month == eventStart.month
|
||||
}
|
||||
|
||||
else -> return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.flux.data.model
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
sealed class HabitConfig {
|
||||
|
||||
@Serializable
|
||||
@SerialName("Simple")
|
||||
object Simple : HabitConfig()
|
||||
|
||||
@Serializable
|
||||
@SerialName("Counted")
|
||||
data class Counted(
|
||||
val goal: Int = 2,
|
||||
val unit: String = "",
|
||||
val intervalMillis: Long = 60_000L, // reminder interval
|
||||
val activeStartTime: Long = System.currentTimeMillis(), // millis of day
|
||||
val activeEndTime: Long = System.currentTimeMillis()
|
||||
) : HabitConfig()
|
||||
|
||||
@Serializable
|
||||
@SerialName("Timed")
|
||||
data class Timed(val durationMillis: Long = 60_000L) : HabitConfig()
|
||||
}
|
||||
@@ -9,29 +9,46 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
@Entity
|
||||
data class HabitModel(
|
||||
@PrimaryKey
|
||||
override val id: String = UUID.randomUUID().toString(),
|
||||
override val title: String = "",
|
||||
override val description: String = "",
|
||||
override val recurrence: RecurrenceRule = RecurrenceRule.Weekly(),
|
||||
override val startDateTime: Long = System.currentTimeMillis(),
|
||||
override val endDateTime: Long = -1L,
|
||||
override val notificationOffset: Long = 0L,
|
||||
override val workspaceId: String = "",
|
||||
val bestStreak: Long = 0L
|
||||
) : ReminderItem {
|
||||
override val type: ReminderType get() = ReminderType.HABIT
|
||||
}
|
||||
@PrimaryKey val id: String = UUID.randomUUID().toString(),
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val recurrence: RecurrenceRule = RecurrenceRule.Weekly(),
|
||||
val startDateTime: Long = System.currentTimeMillis(),
|
||||
val endDateTime: Long = -1L,
|
||||
val notificationOffset: Long = 0L,
|
||||
val workspaceId: String = "",
|
||||
val habitConfig: HabitConfig = HabitConfig.Simple
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@Entity(primaryKeys = ["habitId", "instanceDate"])
|
||||
data class HabitInstanceModel(
|
||||
val habitId: String = "",
|
||||
val workspaceId: String = "",
|
||||
val instanceDate: Long = LocalDate.now().toEpochDay()
|
||||
val instanceDate: Long = LocalDate.now().toEpochDay(),
|
||||
|
||||
// timed
|
||||
val timeSpent: Long = 0L,
|
||||
val isRunning: Boolean = false,
|
||||
|
||||
// counted
|
||||
val count: Int = 0
|
||||
)
|
||||
|
||||
fun HabitModel.isLive(): Boolean {
|
||||
if (endDateTime == -1L) return true
|
||||
return endDateTime > System.currentTimeMillis()
|
||||
}
|
||||
|
||||
val HabitModel.isTimed get() = habitConfig is HabitConfig.Timed
|
||||
val HabitModel.isCounted get() = habitConfig is HabitConfig.Counted
|
||||
|
||||
fun HabitInstanceModel.isCompleted(habit: HabitModel): Boolean {
|
||||
return when (val config = habit.habitConfig) {
|
||||
is HabitConfig.Simple -> true
|
||||
|
||||
is HabitConfig.Counted -> count >= config.goal
|
||||
|
||||
is HabitConfig.Timed -> timeSpent >= config.durationMillis
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,8 @@ data class JournalModel(
|
||||
val journalId: String = UUID.randomUUID().toString(),
|
||||
val workspaceId: String = "",
|
||||
val text: String = "",
|
||||
val dateTime: Long = System.currentTimeMillis()
|
||||
val dateTime: Long = System.currentTimeMillis(),
|
||||
val labels: List<String> = emptyList()
|
||||
)
|
||||
|
||||
fun JournalModel.writtenOnDate(date: LocalDate): Boolean {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.flux.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.util.UUID
|
||||
|
||||
@Serializable
|
||||
@Entity
|
||||
data class ProgressBoardModel(
|
||||
@PrimaryKey
|
||||
val itemId: String = UUID.randomUUID().toString(),
|
||||
val workspaceId: String = "",
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val startDate: Long = -1L,
|
||||
val endDate: Long = -1L,
|
||||
val icon: Int = 7,
|
||||
val status: Int = 0
|
||||
)
|
||||
@@ -2,10 +2,11 @@ package com.flux.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
enum class ReminderType { EVENT, HABIT }
|
||||
|
||||
@Serializable
|
||||
sealed class RecurrenceRule {
|
||||
@Serializable
|
||||
object NONE: RecurrenceRule()
|
||||
|
||||
@Serializable
|
||||
object Once : RecurrenceRule()
|
||||
|
||||
@@ -20,16 +21,4 @@ sealed class RecurrenceRule {
|
||||
|
||||
@Serializable
|
||||
data class Custom(val everyXDays: Int = 1) : RecurrenceRule()
|
||||
}
|
||||
|
||||
interface ReminderItem {
|
||||
val id: String
|
||||
val title: String
|
||||
val description: String
|
||||
val recurrence: RecurrenceRule
|
||||
val type: ReminderType
|
||||
val startDateTime: Long
|
||||
val endDateTime: Long
|
||||
val workspaceId: String
|
||||
val notificationOffset: Long
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.flux.data.model
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.flux.other.ReminderReceiver
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
enum class ReminderType { EVENT, HABIT, TODO }
|
||||
|
||||
data class ScheduleRequest(
|
||||
val itemId: String,
|
||||
val itemType: ReminderType,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val recurrence: RecurrenceRule,
|
||||
val startDateTime: Long,
|
||||
val endDateTime: Long,
|
||||
val notificationOffset: Long,
|
||||
val workspaceId: String,
|
||||
val habitConfig: HabitConfig? = null
|
||||
) {
|
||||
companion object {
|
||||
fun fromIntent(intent: Intent): ScheduleRequest? {
|
||||
return try {
|
||||
ScheduleRequest(
|
||||
itemId = intent.getStringExtra("itemId") ?: return null,
|
||||
itemType = ReminderType.valueOf(
|
||||
intent.getStringExtra("itemType") ?: return null
|
||||
),
|
||||
title = intent.getStringExtra("title") ?: "",
|
||||
description = intent.getStringExtra("description") ?: "",
|
||||
recurrence = Json.decodeFromString(
|
||||
intent.getStringExtra("recurrence") ?: return null
|
||||
),
|
||||
startDateTime = intent.getLongExtra("startDateTime", -1),
|
||||
endDateTime = intent.getLongExtra("endDateTime", -1),
|
||||
notificationOffset = intent.getLongExtra("notificationOffset", 0),
|
||||
workspaceId = intent.getStringExtra("workspaceId") ?: "",
|
||||
habitConfig = intent.getStringExtra("habitConfig")
|
||||
?.let { Json.decodeFromString<HabitConfig>(it) }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun HabitModel.toScheduleRequest() = ScheduleRequest(
|
||||
itemId = id,
|
||||
itemType = ReminderType.HABIT,
|
||||
title = title,
|
||||
description = description,
|
||||
recurrence = recurrence,
|
||||
startDateTime = startDateTime,
|
||||
endDateTime = endDateTime,
|
||||
notificationOffset = notificationOffset,
|
||||
workspaceId = workspaceId,
|
||||
habitConfig = habitConfig
|
||||
)
|
||||
|
||||
fun EventModel.toScheduleRequest() = ScheduleRequest(
|
||||
itemId = id,
|
||||
itemType = ReminderType.EVENT,
|
||||
title = title,
|
||||
description = description,
|
||||
recurrence = recurrence,
|
||||
startDateTime = startDateTime,
|
||||
endDateTime = endDateTime,
|
||||
notificationOffset = notificationOffset,
|
||||
workspaceId = workspaceId
|
||||
)
|
||||
|
||||
fun 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)
|
||||
putExtra("itemType", itemType.name)
|
||||
putExtra("title", title)
|
||||
putExtra("description", description)
|
||||
putExtra("recurrence", Json.encodeToString(recurrence))
|
||||
putExtra("startDateTime", startDateTime)
|
||||
putExtra("endDateTime", endDateTime)
|
||||
putExtra("notificationOffset", notificationOffset)
|
||||
putExtra("workspaceId", workspaceId)
|
||||
habitConfig?.let { putExtra("habitConfig", Json.encodeToString(it)) }
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ data class SettingsModel(
|
||||
val amoledTheme: Boolean = false,
|
||||
val isScreenProtection: Boolean = false,
|
||||
val workspaceGridColumns: Int = 1,
|
||||
val useSystemTimeFormat: Boolean = false,
|
||||
val is24HourFormat: Boolean = false,
|
||||
val themeNumber: Int = 0,
|
||||
val fontNumber: Int = 0,
|
||||
@@ -27,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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.AutoStories
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material.icons.filled.TrackChanges
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -26,7 +27,7 @@ data class WorkspaceModel(
|
||||
val colorInd: Int = 0,
|
||||
val cover: String = "",
|
||||
val icon: Int = 48,
|
||||
val passKey: String = "",
|
||||
val passKey: String? = null,
|
||||
val isPinned: Boolean = false,
|
||||
val selectedSpaces: List<Int> = emptyList()
|
||||
)
|
||||
@@ -45,6 +46,7 @@ fun getSpacesList(): List<Space> {
|
||||
Space(3, stringResource(R.string.Events), Icons.Default.Event),
|
||||
Space(4, stringResource(R.string.Journal), Icons.Default.AutoStories),
|
||||
Space(5, stringResource(R.string.Habits), Icons.Default.EventAvailable),
|
||||
Space(6, stringResource(R.string.Analytics), Icons.Default.Analytics)
|
||||
Space(6, stringResource(R.string.Analytics), Icons.Default.Analytics),
|
||||
Space(7, stringResource(R.string.progress_tracker), Icons.Default.TrackChanges)
|
||||
)
|
||||
}
|
||||
@@ -11,6 +11,6 @@ interface EventRepository {
|
||||
suspend fun deleteEventInstance(eventInstanceModel: EventInstanceModel)
|
||||
suspend fun upsertEventInstance(eventInstanceModel: EventInstanceModel)
|
||||
suspend fun loadAllEvents(): List<EventModel>
|
||||
fun loadAllWorkspaceEvents(workspaceId: String): Flow<List<EventModel>>
|
||||
fun loadAllEventInstances(workspaceId: String): Flow<List<EventInstanceModel>>
|
||||
fun loadEventData(): Flow<List<EventModel>>
|
||||
fun loadEventInstanceData(): Flow<List<EventInstanceModel>>
|
||||
}
|
||||
@@ -29,12 +29,12 @@ class EventRepositoryImpl @Inject constructor(
|
||||
return eventDao.loadAllEvents()
|
||||
}
|
||||
|
||||
override fun loadAllWorkspaceEvents(workspaceId: String): Flow<List<EventModel>> {
|
||||
return eventDao.loadAllEvents(workspaceId)
|
||||
override fun loadEventData(): Flow<List<EventModel>> {
|
||||
return eventDao.loadEventData()
|
||||
}
|
||||
|
||||
override fun loadAllEventInstances(workspaceId: String): Flow<List<EventInstanceModel>> {
|
||||
return eventInstanceDao.loadAllWorkspaceInstances(workspaceId)
|
||||
override fun loadEventInstanceData(): Flow<List<EventInstanceModel>> {
|
||||
return eventInstanceDao.loadEventInstanceData()
|
||||
}
|
||||
|
||||
override suspend fun deleteEvent(event: EventModel) {
|
||||
|
||||
@@ -5,12 +5,13 @@ import com.flux.data.model.HabitModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface HabitRepository {
|
||||
suspend fun getHabitInstance(habitId: String, instanceDate: Long): HabitInstanceModel?
|
||||
suspend fun upsertHabit(habit: HabitModel)
|
||||
suspend fun deleteHabit(habit: HabitModel)
|
||||
suspend fun deleteAllWorkspaceHabit(workspaceId: String)
|
||||
suspend fun deleteInstance(habitInstance: HabitInstanceModel)
|
||||
suspend fun upsertHabitInstance(habitInstance: HabitInstanceModel)
|
||||
suspend fun loadAllHabits(): List<HabitModel>
|
||||
fun loadAllHabitsOfWorkspace(workspaceId: String): Flow<List<HabitModel>>
|
||||
fun loadAllHabitInstance(workspaceId: String): Flow<List<HabitInstanceModel>>
|
||||
fun loadHabitData(): Flow<List<HabitModel>>
|
||||
fun loadHabitInstanceData(): Flow<List<HabitInstanceModel>>
|
||||
}
|
||||
@@ -13,6 +13,11 @@ class HabitRepositoryImpl @Inject constructor(
|
||||
private val dao: HabitsDao,
|
||||
private val instanceDao: HabitInstanceDao
|
||||
) : HabitRepository {
|
||||
|
||||
override suspend fun getHabitInstance(habitId: String, instanceDate: Long): HabitInstanceModel? {
|
||||
return withContext(Dispatchers.IO) { instanceDao.getHabitInstance(habitId, instanceDate) }
|
||||
}
|
||||
|
||||
override suspend fun upsertHabit(habit: HabitModel) {
|
||||
return withContext(Dispatchers.IO) { dao.upsertHabit(habit) }
|
||||
}
|
||||
@@ -29,12 +34,12 @@ class HabitRepositoryImpl @Inject constructor(
|
||||
return withContext(Dispatchers.IO) { dao.loadAllHabits() }
|
||||
}
|
||||
|
||||
override fun loadAllHabitInstance(workspaceId: String): Flow<List<HabitInstanceModel>> {
|
||||
return instanceDao.loadAllInstances(workspaceId)
|
||||
override fun loadHabitInstanceData(): Flow<List<HabitInstanceModel>> {
|
||||
return instanceDao.loadHabitInstanceData()
|
||||
}
|
||||
|
||||
override fun loadAllHabitsOfWorkspace(workspaceId: String): Flow<List<HabitModel>> {
|
||||
return dao.loadAllHabitsOfWorkspace(workspaceId)
|
||||
override fun loadHabitData(): Flow<List<HabitModel>> {
|
||||
return dao.loadHabitData()
|
||||
}
|
||||
|
||||
override suspend fun deleteHabit(habit: HabitModel) {
|
||||
|
||||
@@ -7,5 +7,5 @@ interface JournalRepository {
|
||||
suspend fun upsertEntry(entry: JournalModel)
|
||||
suspend fun deleteEntry(entry: JournalModel)
|
||||
suspend fun deleteAllWorkspaceEntry(workspaceId: String)
|
||||
fun loadAllEntries(workspaceId: String): Flow<List<JournalModel>>
|
||||
fun loadJournalData(): Flow<List<JournalModel>>
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class JournalRepositoryImpl @Inject constructor(
|
||||
return withContext(Dispatchers.IO) { dao.deleteAllWorkspaceEntries(workspaceId) }
|
||||
}
|
||||
|
||||
override fun loadAllEntries(workspaceId: String): Flow<List<JournalModel>> {
|
||||
return dao.loadAllEntries(workspaceId)
|
||||
override fun loadJournalData(): Flow<List<JournalModel>> {
|
||||
return dao.loadJournalData()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.flux.data.repository
|
||||
|
||||
import com.flux.data.model.LabelModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface LabelRepository {
|
||||
suspend fun upsertLabel(label: LabelModel)
|
||||
suspend fun deleteLabel(label: LabelModel)
|
||||
suspend fun deleteAllWorkspaceLabels(workspaceId: String)
|
||||
fun loadAllLabels(): Flow<List<LabelModel>>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.flux.data.repository
|
||||
|
||||
import com.flux.data.dao.LabelDao
|
||||
import com.flux.data.model.LabelModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class LabelRepositoryImpl @Inject constructor (
|
||||
private val dao: LabelDao
|
||||
) : LabelRepository {
|
||||
override suspend fun upsertLabel(label: LabelModel) {
|
||||
return withContext(Dispatchers.IO) { dao.upsertLabel(label) }
|
||||
}
|
||||
|
||||
override suspend fun deleteLabel(label: LabelModel) {
|
||||
return withContext(Dispatchers.IO) { dao.deleteLabel(label) }
|
||||
}
|
||||
|
||||
override suspend fun deleteAllWorkspaceLabels(workspaceId: String) {
|
||||
return withContext(Dispatchers.IO) { dao.deleteAllWorkspaceLabels(workspaceId) }
|
||||
}
|
||||
|
||||
override fun loadAllLabels(): Flow<List<LabelModel>> {
|
||||
return dao.loadAllLabels()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,13 @@
|
||||
package com.flux.data.repository
|
||||
|
||||
import com.flux.data.model.LabelModel
|
||||
import com.flux.data.model.NotesModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NoteRepository {
|
||||
suspend fun upsertNote(note: NotesModel)
|
||||
suspend fun upsertLabel(label: LabelModel)
|
||||
suspend fun upsertNotes(notes: List<NotesModel>)
|
||||
suspend fun deleteNote(note: NotesModel)
|
||||
suspend fun deleteLabel(label: LabelModel)
|
||||
suspend fun deleteNotes(notes: List<String>)
|
||||
suspend fun deleteAllWorkspaceNotes(workspaceId: String)
|
||||
fun loadAllNotes(workspaceId: String): Flow<List<NotesModel>>
|
||||
fun loadAllLabels(workspaceId: String): Flow<List<LabelModel>>
|
||||
fun loadNotesData(): Flow<List<NotesModel>>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package com.flux.data.repository
|
||||
|
||||
import com.flux.data.dao.LabelDao
|
||||
import com.flux.data.dao.NotesDao
|
||||
import com.flux.data.model.LabelModel
|
||||
import com.flux.data.model.NotesModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -17,10 +16,6 @@ class NoteRepositoryImpl @Inject constructor(
|
||||
return withContext(Dispatchers.IO) { notesDao.upsertNote(note) }
|
||||
}
|
||||
|
||||
override suspend fun upsertLabel(label: LabelModel) {
|
||||
return withContext(Dispatchers.IO) { labelDao.upsertLabel(label) }
|
||||
}
|
||||
|
||||
override suspend fun upsertNotes(notes: List<NotesModel>) {
|
||||
return withContext(Dispatchers.IO) { notesDao.upsertNotes(notes) }
|
||||
}
|
||||
@@ -29,20 +24,12 @@ class NoteRepositoryImpl @Inject constructor(
|
||||
return withContext(Dispatchers.IO) { notesDao.deleteNote(note) }
|
||||
}
|
||||
|
||||
override suspend fun deleteLabel(label: LabelModel) {
|
||||
return withContext(Dispatchers.IO) { labelDao.deleteLabel(label) }
|
||||
}
|
||||
|
||||
override suspend fun deleteNotes(notes: List<String>) {
|
||||
return withContext(Dispatchers.IO) { notesDao.deleteNotes(notes) }
|
||||
}
|
||||
|
||||
override fun loadAllNotes(workspaceId: String): Flow<List<NotesModel>> {
|
||||
return notesDao.loadAllNotes(workspaceId)
|
||||
}
|
||||
|
||||
override fun loadAllLabels(workspaceId: String): Flow<List<LabelModel>> {
|
||||
return labelDao.loadAllLabels(workspaceId)
|
||||
override fun loadNotesData(): Flow<List<NotesModel>> {
|
||||
return notesDao.loadNotesData()
|
||||
}
|
||||
|
||||
override suspend fun deleteAllWorkspaceNotes(workspaceId: String) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.flux.data.repository
|
||||
|
||||
import com.flux.data.model.ProgressBoardModel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface ProgressBoardRepository {
|
||||
suspend fun upsertBoardItem(item: ProgressBoardModel)
|
||||
suspend fun deleteBoardItem(item: ProgressBoardModel)
|
||||
suspend fun deleteBoardItemsByWorkspace(workspaceId: String)
|
||||
fun getProgressBoardData(): Flow<List<ProgressBoardModel>>
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.flux.data.repository
|
||||
|
||||
import com.flux.data.dao.ProgressBoardDao
|
||||
import com.flux.data.model.ProgressBoardModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class ProgressBoardRepositoryImpl @Inject constructor(
|
||||
private val dao: ProgressBoardDao
|
||||
): ProgressBoardRepository {
|
||||
override suspend fun upsertBoardItem(item: ProgressBoardModel) {
|
||||
return withContext(Dispatchers.IO) { dao.upsertBoardItem(item) }
|
||||
}
|
||||
|
||||
override suspend fun deleteBoardItem(item: ProgressBoardModel) {
|
||||
return withContext(Dispatchers.IO) { dao.deleteBoardItem(item) }
|
||||
}
|
||||
|
||||
override suspend fun deleteBoardItemsByWorkspace(workspaceId: String) {
|
||||
return withContext(Dispatchers.IO) { dao.deleteBoardItemsByWorkspace(workspaceId) }
|
||||
}
|
||||
|
||||
override fun getProgressBoardData(): Flow<List<ProgressBoardModel>> {
|
||||
return dao.getProgressBoardData()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
fun loadAllLists(workspaceId: String): Flow<List<TodoModel>>
|
||||
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,10 +10,11 @@ import kotlinx.coroutines.withContext
|
||||
import javax.inject.Inject
|
||||
|
||||
class TodoRepositoryImpl @Inject constructor(
|
||||
val dao: TodoDao
|
||||
val dao: TodoDao,
|
||||
val instanceDao: TodoInstanceDao
|
||||
) : TodoRepository {
|
||||
override fun loadAllLists(workspaceId: String): Flow<List<TodoModel>> {
|
||||
return dao.loadAllLists(workspaceId)
|
||||
override fun loadTodoData(): Flow<List<TodoModel>> {
|
||||
return dao.loadTodoData()
|
||||
}
|
||||
|
||||
override suspend fun upsertList(list: TodoModel) {
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.flux.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.work.WorkManager
|
||||
import com.flux.data.dao.EventDao
|
||||
import com.flux.data.dao.EventInstanceDao
|
||||
import com.flux.data.dao.HabitInstanceDao
|
||||
@@ -9,14 +10,22 @@ import com.flux.data.dao.HabitsDao
|
||||
import com.flux.data.dao.JournalDao
|
||||
import com.flux.data.dao.LabelDao
|
||||
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
|
||||
import com.flux.data.database.MIGRATION_2_3
|
||||
import com.flux.data.database.MIGRATION_3_4
|
||||
import com.flux.data.database.Migration_4_5
|
||||
import com.flux.data.database.MIGRATION_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
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -36,7 +45,16 @@ object DataModule {
|
||||
FluxDatabase::class.java,
|
||||
"FluxDatabase"
|
||||
)
|
||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, Migration_4_5)
|
||||
.addMigrations(MIGRATION_1_2,
|
||||
MIGRATION_2_3,
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
MIGRATION_9_10
|
||||
)
|
||||
.build()
|
||||
|
||||
@Singleton
|
||||
@@ -75,7 +93,19 @@ 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
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideProgressBoardDao(db: FluxDatabase): ProgressBoardDao = db.progressBoardDao
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
fun provideBackupManager(@ApplicationContext context: Context): BackupManager = BackupManager(WorkManager.getInstance(context))
|
||||
}
|
||||
|
||||
@@ -6,8 +6,12 @@ import com.flux.data.repository.HabitRepository
|
||||
import com.flux.data.repository.HabitRepositoryImpl
|
||||
import com.flux.data.repository.JournalRepository
|
||||
import com.flux.data.repository.JournalRepositoryImpl
|
||||
import com.flux.data.repository.LabelRepository
|
||||
import com.flux.data.repository.LabelRepositoryImpl
|
||||
import com.flux.data.repository.NoteRepository
|
||||
import com.flux.data.repository.NoteRepositoryImpl
|
||||
import com.flux.data.repository.ProgressBoardRepository
|
||||
import com.flux.data.repository.ProgressBoardRepositoryImpl
|
||||
import com.flux.data.repository.SettingsRepository
|
||||
import com.flux.data.repository.SettingsRepositoryImpl
|
||||
import com.flux.data.repository.TodoRepository
|
||||
@@ -65,4 +69,16 @@ abstract class RepositoryModule {
|
||||
abstract fun bindJournalRepository(
|
||||
impl: JournalRepositoryImpl
|
||||
): JournalRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProgressBoardRepository(
|
||||
impl: ProgressBoardRepositoryImpl
|
||||
): ProgressBoardRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindLabelRepository(
|
||||
impl: LabelRepositoryImpl
|
||||
): LabelRepository
|
||||
}
|
||||
@@ -7,12 +7,12 @@ import androidx.navigation.NavBackStackEntry
|
||||
import androidx.navigation.NavDeepLink
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.compose.composable
|
||||
import com.flux.ui.components.defaultScreenEnterAnimation
|
||||
import com.flux.ui.components.defaultScreenExitAnimation
|
||||
import com.flux.ui.components.slideFromBottomEnter
|
||||
import com.flux.ui.components.slideScreenEnterAnimation
|
||||
import com.flux.ui.components.slideScreenExitAnimation
|
||||
import com.flux.ui.components.slideToBottomExit
|
||||
import com.flux.ui.common.defaultScreenEnterAnimation
|
||||
import com.flux.ui.common.defaultScreenExitAnimation
|
||||
import com.flux.ui.common.slideFromBottomEnter
|
||||
import com.flux.ui.common.slideScreenEnterAnimation
|
||||
import com.flux.ui.common.slideScreenExitAnimation
|
||||
import com.flux.ui.common.slideToBottomExit
|
||||
|
||||
|
||||
fun NavGraphBuilder.animatedComposable(
|
||||
|
||||
@@ -8,47 +8,13 @@ import androidx.navigation.NamedNavArgument
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.flux.ui.state.EventState
|
||||
import com.flux.ui.state.HabitState
|
||||
import com.flux.ui.state.JournalState
|
||||
import com.flux.ui.state.NotesState
|
||||
import com.flux.ui.state.Settings
|
||||
import com.flux.ui.state.States
|
||||
import com.flux.ui.state.TodoState
|
||||
import com.flux.ui.state.WorkspaceState
|
||||
import com.flux.ui.viewModel.BackupViewModel
|
||||
import com.flux.ui.viewModel.EventViewModel
|
||||
import com.flux.ui.viewModel.HabitViewModel
|
||||
import com.flux.ui.viewModel.JournalViewModel
|
||||
import com.flux.ui.viewModel.NotesViewModel
|
||||
import com.flux.ui.viewModel.SettingsViewModel
|
||||
import com.flux.ui.viewModel.TodoViewModel
|
||||
import com.flux.ui.viewModel.ViewModels
|
||||
import com.flux.ui.viewModel.WorkspaceViewModel
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@Composable
|
||||
fun AppNavHost(
|
||||
navController: NavHostController = rememberNavController(),
|
||||
snackbarHostState: SnackbarHostState,
|
||||
settingsViewModel: SettingsViewModel,
|
||||
notesViewModel: NotesViewModel,
|
||||
workspaceViewModel: WorkspaceViewModel,
|
||||
eventViewModel: EventViewModel,
|
||||
habitViewModel: HabitViewModel,
|
||||
todoViewModel: TodoViewModel,
|
||||
journalViewModel: JournalViewModel,
|
||||
backupViewModel: BackupViewModel,
|
||||
settings: Settings,
|
||||
notesState: NotesState,
|
||||
workspaceState: WorkspaceState,
|
||||
eventState: EventState,
|
||||
habitState: HabitState,
|
||||
todoState: TodoState,
|
||||
journalState: JournalState
|
||||
) {
|
||||
fun AppNavHost(navController: NavHostController, snackbarHostState: SnackbarHostState, viewModels: ViewModels, states: States) {
|
||||
NavHost(navController, startDestination = NavRoutes.AuthScreen.route) {
|
||||
NotesScreens.forEach { (route, screen) ->
|
||||
val arguments = mutableListOf<NamedNavArgument>()
|
||||
@@ -75,25 +41,8 @@ fun AppNavHost(
|
||||
navController,
|
||||
notesId,
|
||||
workspaceId,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
)
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -102,15 +51,7 @@ fun AppNavHost(
|
||||
animatedComposable(route) {
|
||||
screen(
|
||||
navController,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
)
|
||||
states
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -119,25 +60,8 @@ fun AppNavHost(
|
||||
animatedComposable(route) {
|
||||
screen(
|
||||
navController,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
)
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -176,25 +100,8 @@ fun AppNavHost(
|
||||
journalId,
|
||||
journalDateTime,
|
||||
workspaceId,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
)
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -224,25 +131,8 @@ fun AppNavHost(
|
||||
navController,
|
||||
listId,
|
||||
workspaceId,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
)
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -272,82 +162,20 @@ fun AppNavHost(
|
||||
navController,
|
||||
habitId,
|
||||
workspaceId,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
)
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsScreens.forEach { (route, screen) ->
|
||||
if (route == NavRoutes.Settings.route) {
|
||||
slideInComposable(route) {
|
||||
screen(
|
||||
navController,
|
||||
snackbarHostState,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
animatedComposable(route) {
|
||||
screen(
|
||||
navController,
|
||||
snackbarHostState,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
)
|
||||
)
|
||||
}
|
||||
animatedComposable(route) {
|
||||
screen(
|
||||
navController,
|
||||
snackbarHostState,
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,7 +216,13 @@ fun AppNavHost(
|
||||
val instanceDate = entry.arguments?.getLong("instanceDate") ?: 0L
|
||||
val eventDate = entry.arguments?.getLong("eventDate") ?: 0L
|
||||
|
||||
screen(navController, States(notesState, eventState, habitState, todoState, workspaceState, journalState, settings), ViewModels(notesViewModel, eventViewModel, todoViewModel, habitViewModel, workspaceViewModel, journalViewModel, settingsViewModel, backupViewModel), eventId, workspaceId, instanceDate, eventDate)
|
||||
screen(navController,
|
||||
states,
|
||||
viewModels,
|
||||
eventId,
|
||||
workspaceId,
|
||||
instanceDate,
|
||||
eventDate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,25 +241,8 @@ fun AppNavHost(
|
||||
|
||||
screen(
|
||||
navController,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
),
|
||||
states,
|
||||
viewModels,
|
||||
workspaceId
|
||||
)
|
||||
}
|
||||
@@ -446,29 +263,22 @@ fun AppNavHost(
|
||||
screen(
|
||||
navController,
|
||||
snackbarHostState,
|
||||
States(
|
||||
notesState,
|
||||
eventState,
|
||||
habitState,
|
||||
todoState,
|
||||
workspaceState,
|
||||
journalState,
|
||||
settings
|
||||
),
|
||||
ViewModels(
|
||||
notesViewModel,
|
||||
eventViewModel,
|
||||
todoViewModel,
|
||||
habitViewModel,
|
||||
workspaceViewModel,
|
||||
journalViewModel,
|
||||
settingsViewModel,
|
||||
backupViewModel
|
||||
),
|
||||
states,
|
||||
viewModels,
|
||||
id
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SearchScreens.forEach { (route, screen) ->
|
||||
animatedComposable(route) {
|
||||
screen(
|
||||
navController,
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,27 +10,33 @@ import com.flux.data.model.HabitModel
|
||||
import com.flux.data.model.JournalModel
|
||||
import com.flux.data.model.NotesModel
|
||||
import com.flux.data.model.TodoModel
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
import com.flux.ui.screens.auth.AuthScreen
|
||||
import com.flux.ui.screens.events.EventDetails
|
||||
import com.flux.ui.screens.events.NewEvent
|
||||
import com.flux.ui.screens.habits.HabitDetails
|
||||
import com.flux.ui.screens.habits.NewHabit
|
||||
import com.flux.ui.screens.journal.EditJournal
|
||||
import com.flux.ui.screens.notes.EditLabels
|
||||
import com.flux.ui.screens.labels.EditLabels
|
||||
import com.flux.ui.screens.notes.NoteDetails
|
||||
import com.flux.ui.screens.search.SearchScreen
|
||||
import com.flux.ui.screens.settings.About
|
||||
import com.flux.ui.screens.settings.Contact
|
||||
import com.flux.ui.screens.settings.Customize
|
||||
import com.flux.ui.screens.settings.Data
|
||||
import com.flux.ui.screens.settings.Editor
|
||||
import com.flux.ui.screens.settings.Languages
|
||||
import com.flux.ui.screens.settings.Mode
|
||||
import com.flux.ui.screens.settings.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.WorkSpaces
|
||||
import com.flux.ui.screens.workspaces.NewWorkspaceScreen
|
||||
import com.flux.ui.screens.workspaces.WorkspaceDetails
|
||||
import com.flux.ui.screens.workspaces.WorkspaceHomeScreen
|
||||
import com.flux.ui.state.States
|
||||
import com.flux.ui.viewModel.ViewModels
|
||||
|
||||
@@ -38,6 +44,7 @@ sealed class NavRoutes(val route: String) {
|
||||
data object AuthScreen : NavRoutes("biometric") // auth screen
|
||||
data object StorageSelection : NavRoutes("storageSelection") // Storage Selection
|
||||
data object Workspace : NavRoutes("workspace") // workspaces
|
||||
data object NewWorkspace: NavRoutes("workspace/edit") // edit workspace
|
||||
data object WorkspaceHome : NavRoutes("workspace/details")
|
||||
data object EditLabels : NavRoutes("workspace/labels/edit") //Labels
|
||||
data object NoteDetails : NavRoutes("workspace/note/details") // Notes
|
||||
@@ -45,8 +52,11 @@ 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")
|
||||
data object Search : NavRoutes("workspace/search")
|
||||
|
||||
// Settings
|
||||
data object Settings : NavRoutes("settings")
|
||||
@@ -58,6 +68,8 @@ sealed class NavRoutes(val route: String) {
|
||||
data object Contact : NavRoutes("settings/contact")
|
||||
data object Backup : NavRoutes("setting/backup")
|
||||
data object Editor : NavRoutes("setting/editor")
|
||||
data object Mode : NavRoutes("setting/mode")
|
||||
data object NotesPreview : NavRoutes("setting/editor/notesPreview")
|
||||
|
||||
fun withArgs(vararg args: Any): String {
|
||||
return buildString {
|
||||
@@ -82,11 +94,18 @@ val StorageSelectionScreen = mapOf<String, @Composable (navController: NavContro
|
||||
}
|
||||
)
|
||||
|
||||
val SearchScreens = mapOf<String, @Composable (navController: NavController, states: States, viewModels: ViewModels) -> Unit>(
|
||||
NavRoutes.Search.route to { navController, states, viewModels ->
|
||||
SearchScreen(navController, states, viewModels)
|
||||
}
|
||||
)
|
||||
|
||||
val NotesScreens =
|
||||
mapOf<String, @Composable (navController: NavController, notesId: String, workspaceId: String, states: States, viewModels: ViewModels) -> Unit>(
|
||||
NavRoutes.NoteDetails.route + "/{workspaceId}" + "/{notesId}" to { navController, notesId, workspaceId, states, viewModel ->
|
||||
NoteDetails(
|
||||
navController,
|
||||
states.workspaceState.allWorkspaces,
|
||||
states.notesState.outline,
|
||||
states.notesState.textState,
|
||||
workspaceId,
|
||||
@@ -94,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.notesState.allLabels.filter { it.workspaceId == workspaceId },
|
||||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -113,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 ->
|
||||
@@ -132,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
|
||||
)
|
||||
@@ -145,7 +182,9 @@ val JournalScreens =
|
||||
NavRoutes.EditJournal.route + "/{workspaceId}" + "/{journalId}" + "/{journalDateTime}" to { navController, journalId, journalDateTime, workspaceId, states, viewModel ->
|
||||
EditJournal(
|
||||
navController,
|
||||
states.journalState.allEntries.find { it.journalId == journalId } ?: JournalModel(workspaceId = workspaceId, dateTime = journalDateTime),
|
||||
states.workspaceState.allWorkspaces,
|
||||
workspaceId,
|
||||
states.journalState.data.find { it.journalId == journalId } ?: JournalModel(workspaceId = workspaceId, dateTime = journalDateTime),
|
||||
states.journalState.outline,
|
||||
states.journalState.textState,
|
||||
states.settings.data.isDarkMode,
|
||||
@@ -153,9 +192,13 @@ val JournalScreens =
|
||||
states.settings.data.isLineNumbersVisible,
|
||||
states.settings.data.startWithReadView,
|
||||
states.settings.data.storageRootUri,
|
||||
states.labelState.allLabels.filter { it.workspaceId==workspaceId },
|
||||
viewModel.journalViewModel,
|
||||
viewModel.settingsViewModel,
|
||||
viewModel.journalViewModel::onEvent
|
||||
viewModel.journalViewModel::onEvent,
|
||||
viewModel.notesViewModel::onEvent,
|
||||
viewModel.todoViewModel::onEvent,
|
||||
viewModel.workspaceViewModel::onEvent
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -189,6 +232,12 @@ val SettingsScreens =
|
||||
},
|
||||
NavRoutes.Theme.route to { navController, _, states, viewModels ->
|
||||
Themes(navController, states.settings, viewModels.settingsViewModel::onEvent)
|
||||
},
|
||||
NavRoutes.Mode.route to { navController, _, states, viewModels ->
|
||||
Mode(navController, states.settings, viewModels.settingsViewModel::onEvent)
|
||||
},
|
||||
NavRoutes.NotesPreview.route to { navController, _, states, viewModels ->
|
||||
NotesPreviewSetting(navController, states.settings, viewModels.settingsViewModel::onEvent)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -197,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 ->
|
||||
@@ -219,50 +270,28 @@ val EventScreens =
|
||||
val WorkspaceScreens =
|
||||
mapOf<String, @Composable (navController: NavController, snackbarHostState: SnackbarHostState, states: States, viewModels: ViewModels, workspaceId: String) -> Unit>(
|
||||
NavRoutes.Workspace.route to { navController, snackbarHostState, states, viewModels, _ ->
|
||||
WorkSpaces(
|
||||
WorkspaceHomeScreen(
|
||||
snackbarHostState,
|
||||
navController,
|
||||
states.settings.data.workspaceGridColumns,
|
||||
states.settings.data.cornerRadius,
|
||||
states.workspaceState.allSpaces,
|
||||
states,
|
||||
viewModels
|
||||
)
|
||||
},
|
||||
|
||||
NavRoutes.NewWorkspace.route + "/{workspaceId}" to { navController, _, states, viewModels, workspaceId ->
|
||||
NewWorkspaceScreen (
|
||||
navController,
|
||||
states.workspaceState.allWorkspaces.find { it.workspaceId==workspaceId }?: WorkspaceModel(),
|
||||
viewModels.workspaceViewModel::onEvent
|
||||
)
|
||||
},
|
||||
|
||||
NavRoutes.WorkspaceHome.route + "/{workspaceId}" to { navController, _, states, viewModels, workspaceId ->
|
||||
WorkspaceDetails(
|
||||
navController,
|
||||
states.notesState.allLabels.filter { it.workspaceId == workspaceId },
|
||||
states.settings,
|
||||
states.notesState.isNotesLoading,
|
||||
states.eventState.isDatedEventLoading,
|
||||
states.todoState.isLoading,
|
||||
states.journalState.isLoading,
|
||||
states.habitState.isLoading,
|
||||
states.workspaceState.allSpaces.first { it.workspaceId == workspaceId },
|
||||
states.eventState.allEvent,
|
||||
states.notesState.allNotes.filter { it.workspaceId == workspaceId },
|
||||
states.notesState.selectedNotes,
|
||||
states.eventState.selectedYearMonth,
|
||||
states.eventState.selectedDate,
|
||||
states.eventState.monthlyEventDates,
|
||||
states.journalState.selectedYearMonth,
|
||||
states.journalState.selectedDate,
|
||||
states.journalState.monthlyJournalCount,
|
||||
states.eventState.datedEvents,
|
||||
states.habitState.allHabits,
|
||||
states.todoState.allLists,
|
||||
states.journalState.datedEntries,
|
||||
states.journalState.allEntries,
|
||||
states.habitState.allInstances,
|
||||
states.eventState.allEventInstances,
|
||||
viewModels.settingsViewModel,
|
||||
viewModels.workspaceViewModel::onEvent,
|
||||
viewModels.notesViewModel::onEvent,
|
||||
viewModels.eventViewModel::onEvent,
|
||||
viewModels.habitViewModel::onEvent,
|
||||
viewModels.todoViewModel::onEvent,
|
||||
viewModels.journalViewModel::onEvent,
|
||||
viewModels.settingsViewModel::onEvent
|
||||
states,
|
||||
states.workspaceState.allWorkspaces.find { it.workspaceId==workspaceId }?: WorkspaceModel(workspaceId=workspaceId),
|
||||
viewModels
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -272,10 +301,10 @@ val LabelScreens =
|
||||
NavRoutes.EditLabels.route + "/{workspaceId}" to { navController, states, viewModels, workspaceId ->
|
||||
EditLabels(
|
||||
navController,
|
||||
states.notesState.isLabelsLoading,
|
||||
states.labelState.isLoading,
|
||||
workspaceId,
|
||||
states.notesState.allLabels,
|
||||
viewModels.notesViewModel::onEvent
|
||||
states.labelState.allLabels.filter { it.workspaceId==workspaceId },
|
||||
viewModels.labelViewModel::onEvent
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -35,6 +35,5 @@ class BackupManager(
|
||||
|
||||
companion object {
|
||||
const val BACKUP_WORK_NAME = "database_backup_work"
|
||||
const val BACKUP_FREQUENCY_KEY = "backup_frequency"
|
||||
}
|
||||
}
|
||||
@@ -33,17 +33,19 @@ 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)
|
||||
val labelDao = DataModule.provideLabelDao(fluxDatabase)
|
||||
val eventDao = DataModule.provideEventDao(fluxDatabase)
|
||||
val eventInstanceDao = DataModule.provideEventInstanceDao(fluxDatabase)
|
||||
val progressBoardDao = DataModule.provideProgressBoardDao(fluxDatabase)
|
||||
val settings = settingsDao.loadSetting()
|
||||
val rootUri = settings?.storageRootUri?.toUri()?: "".toUri()
|
||||
|
||||
val openNoteDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX)
|
||||
val backupDir = openNoteDir?.let { dir ->
|
||||
val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX)
|
||||
val backupDir = baseDir?.let { dir ->
|
||||
getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_BACKUP)
|
||||
}
|
||||
backupDir?.let { dir ->
|
||||
@@ -51,13 +53,15 @@ class BackupWorker(
|
||||
workspaces = workspaceDao.getAll(),
|
||||
notes = notesDao.loadAllNotes(),
|
||||
todos = todoDao.loadAllLists(),
|
||||
todoInstances = todoInstanceDao.loadAllInstances(),
|
||||
habits = habitDao.loadAllHabits(),
|
||||
habitInstances = habitInstanceDao.loadAllInstances(),
|
||||
journals = journalDao.loadAllEntries(),
|
||||
labels = labelDao.getAll(),
|
||||
events = eventDao.loadAllEvents(),
|
||||
eventInstances = eventInstanceDao.getAll(),
|
||||
settings = settingsDao.loadSetting()?: SettingsModel()
|
||||
settings = settingsDao.loadSetting()?: SettingsModel(),
|
||||
progressBoardItems = progressBoardDao.getAllBoardItems()
|
||||
)
|
||||
val json = Json.encodeToString(FluxBackup.serializer(), backup)
|
||||
|
||||
|
||||
@@ -3,10 +3,13 @@ package com.flux.other
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.flux.data.model.ReminderItem
|
||||
import com.flux.data.model.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
|
||||
@@ -16,38 +19,49 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
|
||||
intent.action == Intent.ACTION_LOCKED_BOOT_COMPLETED
|
||||
) {
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
|
||||
if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
|
||||
intent.action != Intent.ACTION_LOCKED_BOOT_COMPLETED
|
||||
) return
|
||||
|
||||
val pendingResult = goAsync()
|
||||
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val reminders = getStoredReminders(context)
|
||||
|
||||
// Reschedule each reminder at its next occurrence
|
||||
reminders.forEach { reminder ->
|
||||
scheduleNextReminder(context, reminder)
|
||||
reminders.forEach { request ->
|
||||
scheduleNextReminder(context, request)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Load reminders from repositories ---
|
||||
private suspend fun getStoredReminders(context: Context): List<ReminderItem> {
|
||||
// ---------------------- LOAD + CONVERT ----------------------
|
||||
|
||||
private suspend fun getStoredReminders(context: Context): List<ScheduleRequest> {
|
||||
|
||||
val entryPoint = EntryPointAccessors.fromApplication(
|
||||
context,
|
||||
ReceiverEntryPoint::class.java
|
||||
)
|
||||
val habitRepository = entryPoint.habitRepository()
|
||||
val eventRepository = entryPoint.eventRepository()
|
||||
|
||||
val habits = habitRepository.loadAllHabits()
|
||||
val events = eventRepository.loadAllEvents()
|
||||
val habitRepo = entryPoint.habitRepository()
|
||||
val eventRepo = entryPoint.eventRepository()
|
||||
val todoRepo = entryPoint.todoRepository()
|
||||
|
||||
return habits.filter { it.isLive() } + events
|
||||
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 + todos
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +70,5 @@ class BootReceiver : BroadcastReceiver() {
|
||||
interface ReceiverEntryPoint {
|
||||
fun habitRepository(): HabitRepository
|
||||
fun eventRepository(): EventRepository
|
||||
fun todoRepository(): TodoRepository
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ object Constants {
|
||||
|
||||
object Other {
|
||||
const val SIDE_EFFECT_KEY = "side_effect"
|
||||
const val ACTION_MARK_DONE = "flux.action.MARK_DONE"
|
||||
}
|
||||
|
||||
object Editor {
|
||||
@@ -66,7 +67,7 @@ object Constants {
|
||||
}
|
||||
}
|
||||
|
||||
enum class ExportType {
|
||||
enum class ExportType {
|
||||
TXT,
|
||||
MARKDOWN,
|
||||
HTML,
|
||||
@@ -74,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 =
|
||||
@@ -200,15 +213,15 @@ val HEADER_LINE_STYLES = listOf(
|
||||
|
||||
val HEADER_STYLES = listOf(
|
||||
SpanStyle(
|
||||
fontSize = 32.sp,
|
||||
fontSize = 22.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSynthesis = FontSynthesis.Weight
|
||||
), SpanStyle(
|
||||
fontSize = 24.sp,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSynthesis = FontSynthesis.Weight
|
||||
), SpanStyle(
|
||||
fontSize = 18.72.sp,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.ExtraBold,
|
||||
fontSynthesis = FontSynthesis.Weight
|
||||
), SpanStyle(
|
||||
@@ -216,11 +229,11 @@ val HEADER_STYLES = listOf(
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSynthesis = FontSynthesis.Weight
|
||||
), SpanStyle(
|
||||
fontSize = 13.28.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSynthesis = FontSynthesis.Weight
|
||||
), SpanStyle(
|
||||
fontSize = 12.sp,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSynthesis = FontSynthesis.Weight
|
||||
)
|
||||
@@ -238,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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
package com.flux.other
|
||||
|
||||
sealed class EditAction {
|
||||
data class TitleChanged(val old: String, val new: String) : EditAction()
|
||||
data class DescriptionChanged(val old: String, val new: String) : EditAction()
|
||||
}
|
||||
@@ -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: 
|
||||
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  — 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.flux.other
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.AlarmManager
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
@@ -12,7 +13,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
@@ -22,268 +22,279 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.flux.R
|
||||
import com.flux.data.model.EventInstanceModel
|
||||
import com.flux.data.model.HabitConfig
|
||||
import com.flux.data.model.HabitInstanceModel
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.data.model.ReminderItem
|
||||
import com.flux.data.model.ReminderType
|
||||
import com.flux.data.model.ScheduleRequest
|
||||
import com.flux.data.model.toIntent
|
||||
import com.flux.other.Constants.Other.ACTION_MARK_DONE
|
||||
import dagger.hilt.android.EntryPointAccessors
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.time.Duration
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.Instant
|
||||
|
||||
class ReminderReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
const val ACTION_MARK_DONE = "flux.action.MARK_DONE"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
// ─────────────────────────────────────────────
|
||||
// MARK DONE ACTION
|
||||
// ─────────────────────────────────────────────
|
||||
if (intent.action == ACTION_MARK_DONE) {
|
||||
val id = intent.getStringExtra("ID") ?: return
|
||||
val type = intent.getStringExtra("TYPE") ?: return
|
||||
val workspaceId = intent.getStringExtra("WORKSPACE_ID") ?: return
|
||||
|
||||
markDone(context, type, id, workspaceId)
|
||||
if (intent.action == ACTION_MARK_DONE) {
|
||||
MarkDoneHandler.handle(context, intent)
|
||||
return
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// EXTRACT PAYLOAD
|
||||
// ─────────────────────────────────────────────
|
||||
val title =
|
||||
intent.getStringExtra("TITLE") ?: "Reminder"
|
||||
val request = ScheduleRequest.fromIntent(intent) ?: return
|
||||
|
||||
val description =
|
||||
intent.getStringExtra("DESCRIPTION")
|
||||
?: "It's time to complete pending things"
|
||||
NotificationDispatcher.notify(context, request)
|
||||
|
||||
val id = intent.getStringExtra("ID") ?: return
|
||||
val workspaceId =
|
||||
intent.getStringExtra("WORKSPACE_ID") ?: ""
|
||||
|
||||
val type =
|
||||
intent.getStringExtra("TYPE") ?: "EVENT"
|
||||
|
||||
val endTimeInMillis =
|
||||
intent.getLongExtra("ENDTIME", -1L)
|
||||
|
||||
val startDateTime =
|
||||
intent.getLongExtra("START_TIME", -1L)
|
||||
|
||||
val offset =
|
||||
intent.getLongExtra("OFFSET", 0L)
|
||||
|
||||
// Guard against corrupted / legacy alarms
|
||||
if (startDateTime <= 0L) return
|
||||
|
||||
// Deserialize recurrence rule
|
||||
val recurrenceJson =
|
||||
intent.getStringExtra("RECURRENCE")
|
||||
|
||||
val recurrence =
|
||||
recurrenceJson?.let {
|
||||
Json.decodeFromString<RecurrenceRule>(it)
|
||||
} ?: RecurrenceRule.Once
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// SHOW NOTIFICATION
|
||||
// ─────────────────────────────────────────────
|
||||
val icon =
|
||||
if (type == "EVENT")
|
||||
R.drawable.check_list
|
||||
else
|
||||
R.drawable.calendar_check
|
||||
|
||||
val notificationId =
|
||||
getUniqueRequestCode(type, id)
|
||||
|
||||
val doneIntent =
|
||||
Intent(context, ReminderReceiver::class.java).apply {
|
||||
action = ACTION_MARK_DONE
|
||||
putExtra("ID", id)
|
||||
putExtra("TYPE", type)
|
||||
putExtra("WORKSPACE_ID", workspaceId)
|
||||
}
|
||||
|
||||
val donePendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId,
|
||||
doneIntent,
|
||||
PendingIntent.FLAG_IMMUTABLE or
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
val notification =
|
||||
NotificationCompat.Builder(
|
||||
context,
|
||||
"notification_channel"
|
||||
)
|
||||
.setSmallIcon(icon)
|
||||
.setContentTitle(title)
|
||||
.setContentText(description)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.addAction(
|
||||
R.drawable.check_list,
|
||||
"Done",
|
||||
donePendingIntent
|
||||
)
|
||||
.build()
|
||||
|
||||
val manager =
|
||||
context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
|
||||
manager.notify(notificationId, notification)
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// BUILD REMINDER ITEM
|
||||
// ─────────────────────────────────────────────
|
||||
val item = object : ReminderItem {
|
||||
override val id = id
|
||||
override val title = title
|
||||
override val description = description
|
||||
override val recurrence = recurrence
|
||||
override val type =
|
||||
ReminderType.valueOf(type)
|
||||
override val startDateTime = startDateTime
|
||||
override val endDateTime = endTimeInMillis
|
||||
override val workspaceId = workspaceId
|
||||
override val notificationOffset = offset
|
||||
if (request.recurrence !is RecurrenceRule.Once) {
|
||||
scheduleNextReminder(context, request)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// RESCHEDULE NEXT OCCURRENCE
|
||||
// ─────────────────────────────────────────────
|
||||
scheduleNextReminder(context, item)
|
||||
}
|
||||
}
|
||||
|
||||
private fun markDone(
|
||||
context: Context,
|
||||
type: String,
|
||||
id: String,
|
||||
workspaceId: String
|
||||
) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val entryPoint =
|
||||
EntryPointAccessors.fromApplication(
|
||||
context,
|
||||
ReceiverEntryPoint::class.java
|
||||
)
|
||||
object MarkDoneHandler {
|
||||
fun handle(context: Context, intent: Intent) {
|
||||
val itemId = intent.getStringExtra("itemId") ?: return
|
||||
val itemType = intent.getStringExtra("itemType") ?: return
|
||||
val workspaceId = intent.getStringExtra("workspaceId") ?: return
|
||||
val markedDone = context.getString(R.string.marked_done)
|
||||
val failed = context.getString(R.string.failed)
|
||||
val notificationId = (itemId + itemType).hashCode()
|
||||
|
||||
when (type) {
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
val entryPoint = EntryPointAccessors
|
||||
.fromApplication(context, ReceiverEntryPoint::class.java)
|
||||
|
||||
"EVENT" ->
|
||||
entryPoint.eventRepository()
|
||||
.upsertEventInstance(
|
||||
EventInstanceModel(
|
||||
eventId = id,
|
||||
workspaceId = workspaceId
|
||||
)
|
||||
)
|
||||
when (itemType) {
|
||||
"EVENT" -> entryPoint.eventRepository()
|
||||
.upsertEventInstance(EventInstanceModel(itemId, workspaceId))
|
||||
|
||||
else ->
|
||||
entryPoint.habitRepository()
|
||||
.upsertHabitInstance(
|
||||
HabitInstanceModel(
|
||||
habitId = id,
|
||||
workspaceId = workspaceId
|
||||
)
|
||||
)
|
||||
"HABIT" -> {
|
||||
val config = intent.getStringExtra("habitConfig")
|
||||
?.let { runCatching { Json.decodeFromString<HabitConfig>(it) }.getOrNull() }
|
||||
|
||||
val repo = entryPoint.habitRepository()
|
||||
|
||||
if (config is HabitConfig.Counted) {
|
||||
val today = LocalDate.now().toEpochDay()
|
||||
val existing = repo.getHabitInstance(itemId, today)
|
||||
val updated = existing
|
||||
?.copy(count = existing.count + 1)
|
||||
?: HabitInstanceModel(habitId = itemId, workspaceId = workspaceId, instanceDate = today, count = 1)
|
||||
repo.upsertHabitInstance(updated)
|
||||
} else { repo.upsertHabitInstance(HabitInstanceModel(itemId, workspaceId)) }
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, markedDone, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
} catch (_: Exception) {
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(context, failed, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Done!",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.cancel(notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
object NotificationDispatcher {
|
||||
|
||||
private const val CHANNEL_ID = "notification_channel"
|
||||
|
||||
fun notify(context: Context, request: ScheduleRequest) {
|
||||
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
|
||||
putExtra("itemId", request.itemId)
|
||||
putExtra("itemType", request.itemType.name)
|
||||
putExtra("workspaceId", request.workspaceId)
|
||||
request.habitConfig?.let { // ADD
|
||||
putExtra("habitConfig", Json.encodeToString(it))
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
val donePendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
getUniqueRequestCode(request.itemType.name, request.itemId),
|
||||
doneIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
e.printStackTrace()
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
.setContentTitle(request.title)
|
||||
.setContentText(request.description)
|
||||
.setSmallIcon(
|
||||
if (isHabit) R.drawable.routine
|
||||
else if(isEvent) R.drawable.calendar_check
|
||||
else R.drawable.to_do_list
|
||||
)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
"Failed to mark done",
|
||||
Toast.LENGTH_LONG
|
||||
).show()
|
||||
}
|
||||
// persistent notification
|
||||
builder.setOngoing(true).setAutoCancel(false)
|
||||
if (request.itemType != ReminderType.TODO) {
|
||||
builder.addAction(
|
||||
R.drawable.check_list,
|
||||
"Done",
|
||||
donePendingIntent
|
||||
)
|
||||
}
|
||||
|
||||
val notification = builder.build()
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify((request.itemId + request.itemType.name).hashCode(), notification)
|
||||
}
|
||||
|
||||
private fun ensureChannel(context: Context) {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Reminders",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
enableVibration(true)
|
||||
}
|
||||
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleReminder(context: Context, request: ScheduleRequest, triggerAt: Long) {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
if (!alarmManager.canScheduleExactAlarms()) {
|
||||
requestExactAlarmPermission(context)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val manager =
|
||||
context.getSystemService(
|
||||
Context.NOTIFICATION_SERVICE
|
||||
) as NotificationManager
|
||||
val intent = request.toIntent(context)
|
||||
|
||||
manager.cancel(
|
||||
getUniqueRequestCode(type, id)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
getUniqueRequestCode(request.itemType.name, request.itemId),
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
alarmManager.setExactAndAllowWhileIdle(
|
||||
AlarmManager.RTC_WAKEUP,
|
||||
triggerAt,
|
||||
pendingIntent
|
||||
)
|
||||
}
|
||||
|
||||
private val zone: ZoneId get() = ZoneId.systemDefault()
|
||||
|
||||
fun scheduleNextReminder(
|
||||
context: Context,
|
||||
item: ReminderItem
|
||||
request: ScheduleRequest
|
||||
) {
|
||||
when (val config = request.habitConfig) {
|
||||
is HabitConfig.Counted -> { scheduleNextCountedReminder(context, request, config) }
|
||||
else -> { scheduleStandardReminder(context, request) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleStandardReminder(
|
||||
context: Context,
|
||||
request: ScheduleRequest
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
var candidate = getNextOccurrence(request.recurrence, request.startDateTime) ?: return
|
||||
var finalTime: Long
|
||||
|
||||
if (item.startDateTime <= 0L) return
|
||||
|
||||
var candidate =
|
||||
getNextOccurrence(
|
||||
item.recurrence,
|
||||
item.startDateTime
|
||||
) ?: return
|
||||
|
||||
var finalTime: Long?
|
||||
|
||||
// Catch-up loop (handles missed alarms / backups)
|
||||
while (true) {
|
||||
|
||||
val alarmTime =
|
||||
candidate - item.notificationOffset
|
||||
|
||||
if (alarmTime > now) {
|
||||
finalTime = alarmTime
|
||||
break
|
||||
}
|
||||
|
||||
candidate =
|
||||
getNextOccurrence(
|
||||
item.recurrence,
|
||||
candidate
|
||||
) ?: return
|
||||
val alarmTime = candidate - request.notificationOffset
|
||||
if (alarmTime > now) { finalTime = alarmTime; break }
|
||||
candidate = getNextOccurrence(request.recurrence, candidate) ?: return
|
||||
}
|
||||
|
||||
if (item.endDateTime != -1L &&
|
||||
finalTime > item.endDateTime
|
||||
) return
|
||||
if (request.endDateTime != -1L && finalTime > request.endDateTime) return
|
||||
scheduleReminder(context, request, finalTime)
|
||||
}
|
||||
|
||||
scheduleReminder(
|
||||
context = context,
|
||||
id = item.id,
|
||||
type = item.type.toString(),
|
||||
recurrence = item.recurrence,
|
||||
timeInMillis = finalTime,
|
||||
endTimeInMillis = item.endDateTime,
|
||||
startDateTime = item.startDateTime,
|
||||
notificationOffset = item.notificationOffset,
|
||||
workspaceId = item.workspaceId,
|
||||
title = item.title,
|
||||
description = item.description
|
||||
)
|
||||
private fun scheduleNextCountedReminder(
|
||||
context: Context,
|
||||
request: ScheduleRequest,
|
||||
config: HabitConfig.Counted
|
||||
) {
|
||||
val now = ZonedDateTime.now(zone)
|
||||
val nextTrigger = calculateNextCountedTrigger(now, request, config) ?: run { return }
|
||||
|
||||
if (request.endDateTime != -1L) {
|
||||
val endDate = Instant.ofEpochMilli(request.endDateTime).atZone(zone).toLocalDate()
|
||||
val finalAllowed = ZonedDateTime.of(endDate, millisToLocalTime(config.activeEndTime), zone)
|
||||
|
||||
if (nextTrigger.isAfter(finalAllowed)) { return }
|
||||
}
|
||||
|
||||
scheduleReminder(context, request, nextTrigger.toInstant().toEpochMilli())
|
||||
}
|
||||
|
||||
private fun calculateNextCountedTrigger(
|
||||
now: ZonedDateTime,
|
||||
request: ScheduleRequest,
|
||||
config: HabitConfig.Counted
|
||||
): ZonedDateTime? {
|
||||
val recurrence = request.recurrence as? RecurrenceRule.Weekly ?: run { return null }
|
||||
|
||||
var currentDate = now.toLocalDate()
|
||||
|
||||
repeat(14) {
|
||||
// Monday=0 ... Sunday=6
|
||||
val currentDayOfWeek = currentDate.dayOfWeek.value - 1
|
||||
val isValidDay = currentDayOfWeek in recurrence.daysOfWeek
|
||||
|
||||
if (isValidDay) {
|
||||
val startTime = millisToLocalTime(config.activeStartTime)
|
||||
val endTime = millisToLocalTime(config.activeEndTime)
|
||||
|
||||
val windowStart = ZonedDateTime.of(currentDate, startTime, zone)
|
||||
val windowEnd = ZonedDateTime.of(currentDate, endTime, zone)
|
||||
|
||||
when {
|
||||
now.isBefore(windowStart) || now.isEqual(windowStart) -> { return windowStart }
|
||||
now.isBefore(windowEnd) -> {
|
||||
val elapsedMillis = Duration.between(windowStart, now).toMillis()
|
||||
val intervalsPassed = elapsedMillis / config.intervalMillis + 1
|
||||
val nextSlot = windowStart.plus(Duration.ofMillis(intervalsPassed * config.intervalMillis))
|
||||
if (nextSlot.isBefore(windowEnd) || nextSlot.isEqual(windowEnd)) {
|
||||
return nextSlot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentDate = currentDate.plusDays(1)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private fun millisToLocalTime(millis: Long): LocalTime {
|
||||
return Instant.ofEpochMilli(millis).atZone(zone).toLocalTime()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@@ -294,6 +305,9 @@ fun createNotificationChannel(context: Context) {
|
||||
|
||||
val importance = NotificationManager.IMPORTANCE_HIGH
|
||||
val channel = NotificationChannel("notification_channel", "Reminders", importance)
|
||||
channel.enableVibration(true)
|
||||
channel.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
@@ -323,98 +337,15 @@ fun canScheduleReminder(context: Context): Boolean {
|
||||
}
|
||||
}
|
||||
|
||||
fun scheduleReminder(
|
||||
context: Context,
|
||||
id: String,
|
||||
type: String,
|
||||
recurrence: RecurrenceRule,
|
||||
timeInMillis: Long,
|
||||
endTimeInMillis: Long,
|
||||
startDateTime: Long,
|
||||
notificationOffset: Long,
|
||||
workspaceId: String,
|
||||
title: String,
|
||||
description: String
|
||||
) {
|
||||
try {
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
val intent = createReminderIntent(context, id, type, title, description, workspaceId, startDateTime, notificationOffset, endTimeInMillis, recurrence)
|
||||
val requestCode = getUniqueRequestCode(type, id)
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
|
||||
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeInMillis, pendingIntent)
|
||||
} catch (e: SecurityException) {
|
||||
e.printStackTrace()
|
||||
Handler(context.mainLooper).post {
|
||||
Toast.makeText(context, context.getString(R.string.Error_Schedule_Alarm_Failed), Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createReminderIntent(
|
||||
context: Context,
|
||||
id: String,
|
||||
type: String,
|
||||
title: String,
|
||||
description: String,
|
||||
workspaceId: String,
|
||||
startDateTime: Long,
|
||||
notificationOffset: Long,
|
||||
endTimeInMillis: Long,
|
||||
recurrence: RecurrenceRule
|
||||
): Intent {
|
||||
return Intent(context, ReminderReceiver::class.java).apply {
|
||||
putExtra("TITLE", title)
|
||||
putExtra("DESCRIPTION", description)
|
||||
putExtra("ID", id)
|
||||
putExtra("TYPE", type)
|
||||
putExtra("RECURRENCE", Json.encodeToString(recurrence))
|
||||
putExtra("ENDTIME", endTimeInMillis)
|
||||
putExtra("WORKSPACE_ID", workspaceId)
|
||||
putExtra("START_TIME", startDateTime)
|
||||
putExtra("OFFSET", notificationOffset)
|
||||
}
|
||||
}
|
||||
|
||||
fun getUniqueRequestCode(type: String, uuid: String): Int {
|
||||
return (type + uuid).hashCode()
|
||||
}
|
||||
|
||||
fun cancelReminder(
|
||||
context: Context,
|
||||
id: String,
|
||||
type: String,
|
||||
title: String,
|
||||
description: String,
|
||||
workspaceId: String,
|
||||
endTimeInMillis: Long,
|
||||
startDateTime: Long,
|
||||
notificationOffset: Long,
|
||||
recurrence: RecurrenceRule
|
||||
request: ScheduleRequest
|
||||
) {
|
||||
try {
|
||||
val intent = createReminderIntent(
|
||||
context = context,
|
||||
id = id,
|
||||
type = type,
|
||||
title = title,
|
||||
description = description,
|
||||
recurrence = recurrence,
|
||||
workspaceId = workspaceId,
|
||||
endTimeInMillis = endTimeInMillis,
|
||||
startDateTime = startDateTime,
|
||||
notificationOffset = notificationOffset
|
||||
)
|
||||
val requestCode = getUniqueRequestCode(type, id)
|
||||
val intent = request.toIntent(context)
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
getUniqueRequestCode(request.itemType.name, request.itemId),
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||
)
|
||||
@@ -449,4 +380,8 @@ fun openAppNotificationSettings(context: Context) {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
fun getUniqueRequestCode(type: String, uuid: String): Int {
|
||||
return (type + uuid).hashCode()
|
||||
}
|
||||
@@ -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
|
||||
@@ -59,6 +67,16 @@ import org.commonmark.node.Text
|
||||
import org.commonmark.parser.IncludeSourceSpans
|
||||
import org.commonmark.parser.Parser
|
||||
import androidx.core.net.toUri
|
||||
import com.flux.data.model.EventModel
|
||||
import com.flux.data.model.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
|
||||
|
||||
fun Int.toHexColor(): String {
|
||||
return String.format("#%06X", 0xFFFFFF and this)
|
||||
@@ -256,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,
|
||||
@@ -429,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>()
|
||||
@@ -442,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() {
|
||||
@@ -546,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)
|
||||
@@ -616,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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -658,98 +896,329 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
fun computeMonthlyEventDates(
|
||||
events: List<EventModel>,
|
||||
yearMonth: YearMonth
|
||||
): Map<LocalDate, Int> {
|
||||
val monthStart = yearMonth.atDay(1)
|
||||
val monthEnd = yearMonth.atEndOfMonth()
|
||||
|
||||
if (monthEnd < monthStart) return emptyMap()
|
||||
|
||||
return (0..ChronoUnit.DAYS.between(monthStart, monthEnd))
|
||||
.map { monthStart.plusDays(it) }
|
||||
.associateWith { date -> events.count { event -> event.occursOn(date) } }
|
||||
.filter { (_, count) -> count > 0 }
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.flux.ui.components
|
||||
package com.flux.ui.common
|
||||
|
||||
import androidx.compose.animation.EnterTransition
|
||||
import androidx.compose.animation.ExitTransition
|
||||
@@ -0,0 +1,637 @@
|
||||
package com.flux.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Deselect
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Summarize
|
||||
import androidx.compose.material.icons.outlined.Home
|
||||
import androidx.compose.material.icons.outlined.PushPin
|
||||
import androidx.compose.material.icons.outlined.Search
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.TopAppBarScrollBehavior
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.lerp
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavDestination
|
||||
import androidx.navigation.NavDestination.Companion.hierarchy
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import coil.compose.AsyncImage
|
||||
import com.flux.R
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.other.icons
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NoteDetailsTopBar(
|
||||
isPinned: Boolean,
|
||||
isSearching: Boolean,
|
||||
isReadView: Boolean,
|
||||
onBackPressed: () -> Unit,
|
||||
onOutlineClicked: () -> Unit,
|
||||
onReadClick: () -> Unit,
|
||||
onEditClick: ()->Unit,
|
||||
onDelete: () -> Unit,
|
||||
onAddLabel: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onAboutClicked: () -> Unit,
|
||||
onShareNote: () -> Unit,
|
||||
onSaveNote: () -> Unit,
|
||||
onPrintNote: () -> Unit,
|
||||
onConvertNote: () ->Unit,
|
||||
onCopyNote: () -> Unit,
|
||||
onCloneNote: () -> Unit
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
6.dp
|
||||
),
|
||||
RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp))
|
||||
.clickable { onEditClick() }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(1.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
6.dp
|
||||
),
|
||||
RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp))
|
||||
.clickable { onReadClick() }
|
||||
.padding(8.dp)
|
||||
) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } },
|
||||
actions = {
|
||||
if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } }
|
||||
IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) }
|
||||
DropdownMenuWithDetails(isPinned, onTogglePinned, onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onConvertNote, onCopyNote, onCloneNote, onDelete) }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JournalDetailsTopBar(
|
||||
isSearching: Boolean,
|
||||
isReadView: Boolean,
|
||||
onBackPressed: () -> Unit,
|
||||
onOutlineClicked: () -> Unit,
|
||||
onReadClick: () -> Unit,
|
||||
onEditClick: ()->Unit,
|
||||
onDelete: () -> Unit,
|
||||
onAddLabel: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onAboutClicked: () -> Unit,
|
||||
onShareNote: () -> Unit,
|
||||
onSaveNote: () -> Unit,
|
||||
onPrintNote: () -> Unit,
|
||||
onConvertNote: () ->Unit,
|
||||
onCopyNote: () -> Unit,
|
||||
onCloneNote: () -> Unit
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
6.dp
|
||||
),
|
||||
RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp))
|
||||
.clickable { onEditClick() }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(1.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if (isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(
|
||||
6.dp
|
||||
),
|
||||
RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp))
|
||||
.clickable { onReadClick() }
|
||||
.padding(8.dp)
|
||||
) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } },
|
||||
actions = {
|
||||
if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } }
|
||||
IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) }
|
||||
JournalDropdownMenu(onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete, onConvertNote, onCopyNote, onCloneNote)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun SelectedToolBarRow(
|
||||
showDeleteOption: Boolean = true,
|
||||
selectionCount: Int,
|
||||
isAllSelected: Boolean,
|
||||
isAllPinned: Boolean,
|
||||
onClear: () -> Unit,
|
||||
onDelete: () -> Unit = {},
|
||||
onTogglePin: () -> Unit,
|
||||
onToggleSelection: () -> Unit,
|
||||
){
|
||||
val shape = if (!showDeleteOption) RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp) else RectangleShape
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClear){
|
||||
Icon(
|
||||
Icons.Filled.Clear,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text(selectionCount.toString())
|
||||
}
|
||||
|
||||
Row(Modifier.padding(end = 8.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp))
|
||||
.clickable { onTogglePin() }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
if(isAllPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.width(1.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
shape
|
||||
)
|
||||
.clip(shape)
|
||||
.clickable { onToggleSelection() }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
if (isAllSelected) Icons.Default.Deselect else Icons.Default.SelectAll,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteOption){
|
||||
Spacer(Modifier.width(1.dp))
|
||||
Box(Modifier
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp))
|
||||
.clickable { onDelete() }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.DeleteOutline,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CompactCard(icon: ImageVector, title: String, onClick: () -> Unit){
|
||||
Card(
|
||||
modifier = Modifier.clip(RoundedCornerShape(50)),
|
||||
shape = RoundedCornerShape(50),
|
||||
onClick = onClick,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.size(32.dp),
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
null,
|
||||
Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
title,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.widthIn(max = 150.dp),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SpaceTopBar(
|
||||
scrollBehavior: TopAppBarScrollBehavior,
|
||||
title: String,
|
||||
description: String,
|
||||
cover: String,
|
||||
icon: Int,
|
||||
isLocked: Boolean = false,
|
||||
onAddCover: () -> Unit,
|
||||
onRemoveCover: () -> Unit,
|
||||
onEditWorkspace: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
onDeleteWorkspace: () -> Unit,
|
||||
onToggleLock: () -> Unit
|
||||
) {
|
||||
val hasCover = cover.isNotBlank()
|
||||
val hasDescription = description.isNotBlank()
|
||||
val density = LocalDensity.current
|
||||
|
||||
val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val coverHeightDp = 180.dp
|
||||
val toolbarContentDp = 48.dp
|
||||
val toolbarHeight = toolbarContentDp + statusBarHeight
|
||||
|
||||
// Derive title section height purely from typography — no measurement callback needed.
|
||||
// Layout: vertical padding (6dp top + 6dp bottom) + icon/title row + optional (2dp spacer + description row)
|
||||
val titleStyle = MaterialTheme.typography.titleLarge
|
||||
val bodyStyle = MaterialTheme.typography.bodyMedium
|
||||
val titleLineHeightDp = with(density) { titleStyle.lineHeight.toDp() }
|
||||
val bodyLineHeightDp = with(density) { bodyStyle.lineHeight.toDp() }
|
||||
val titleRowDp = maxOf(24.dp, titleLineHeightDp) // Icon default size is 24.dp
|
||||
val titleSectionHeightDp = 12.dp + titleRowDp + if (hasDescription) 2.dp + bodyLineHeightDp else 0.dp
|
||||
|
||||
val expandedHeightDp = if (hasCover)
|
||||
coverHeightDp + titleSectionHeightDp + statusBarHeight - 24.dp
|
||||
else
|
||||
toolbarHeight + titleSectionHeightDp + 28.dp
|
||||
|
||||
val expandedPx = with(density) { expandedHeightDp.toPx() }
|
||||
val collapsedPx = with(density) { toolbarHeight.toPx() }
|
||||
|
||||
SideEffect {
|
||||
val limit = collapsedPx - expandedPx
|
||||
if (scrollBehavior.state.heightOffsetLimit != limit) {
|
||||
scrollBehavior.state.heightOffsetLimit = limit
|
||||
}
|
||||
}
|
||||
|
||||
val fraction = scrollBehavior.state.collapsedFraction
|
||||
val currentHeight = lerp(expandedHeightDp, toolbarHeight, fraction)
|
||||
val coverAlpha = (1f - fraction * 1.5f).coerceIn(0f, 1f)
|
||||
val expandedTitleAlpha = (1f - fraction * 2f).coerceIn(0f, 1f)
|
||||
val collapsedTitleAlpha = ((fraction - 0.5f) * 2f).coerceIn(0f, 1f)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(currentHeight)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
// ── Cover + title/description stacked flush ───────────────────
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (hasCover) {
|
||||
AsyncImage(
|
||||
model = cover,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(lerp(coverHeightDp, 0.dp, fraction))
|
||||
.alpha(coverAlpha)
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(toolbarHeight))
|
||||
}
|
||||
|
||||
// Title + description — always visible, fades on scroll
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.alpha(expandedTitleAlpha)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(icons[icon], null)
|
||||
Text(
|
||||
text = title,
|
||||
style = titleStyle,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
if (hasDescription) {
|
||||
Text(
|
||||
text = description,
|
||||
style = bodyStyle,
|
||||
fontWeight = FontWeight.ExtraLight,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Toolbar — overlaid, always on top ─────────────────────────
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(toolbarHeight)
|
||||
.padding(top = statusBarHeight, start = 8.dp, end = 8.dp)
|
||||
.align(Alignment.TopStart),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onBackPressed,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp)
|
||||
.alpha(collapsedTitleAlpha)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (hasDescription) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.ExtraLight,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
WorkspaceMore(
|
||||
isCoverAdded = hasCover,
|
||||
isLocked = isLocked,
|
||||
onDelete = onDeleteWorkspace,
|
||||
onEditDetails = onEditWorkspace,
|
||||
onRemoveCover = onRemoveCover,
|
||||
onAddCover = onAddCover,
|
||||
onToggleLock = onToggleLock
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Destination(
|
||||
val title: String,
|
||||
val route: String,
|
||||
val selectedIcon: ImageVector,
|
||||
val unselectedIcon: ImageVector,
|
||||
) {
|
||||
data object Home : Destination(title = "Home", route = NavRoutes.Workspace.route, selectedIcon = Icons.Filled.Home, unselectedIcon = Icons.Outlined.Home)
|
||||
data object Search : Destination(title = "Search", route = NavRoutes.Search.route, selectedIcon = Icons.Filled.Search, unselectedIcon = Icons.Outlined.Search)
|
||||
data object Settings : Destination(title = "Settings", route = NavRoutes.Settings.route, selectedIcon = Icons.Filled.Settings, unselectedIcon = Icons.Outlined.Settings)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomBar(modifier: Modifier, navController: NavController) {
|
||||
val screens = listOf(Destination.Home, Destination.Search, Destination.Settings)
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
screens.forEach { screen->
|
||||
BottomBarCard(
|
||||
screen = screen,
|
||||
currentDestination = currentDestination,
|
||||
navController = navController
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
IconButton(
|
||||
{navController.navigate(NavRoutes.NewWorkspace.withArgs(""))},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
) { Icon(Icons.Default.Add, null) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BottomBarCard(
|
||||
screen: Destination,
|
||||
currentDestination: NavDestination?,
|
||||
navController: NavController
|
||||
){
|
||||
val selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true
|
||||
|
||||
val containerColor = if(selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary
|
||||
val contentColor = if(selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onPrimary
|
||||
|
||||
Card(
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
onClick = { if (currentDestination?.route != screen.route) { navController.navigate(screen.route) } },
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
),
|
||||
elevation= CardDefaults.cardElevation(0.dp),
|
||||
shape = RoundedCornerShape(50)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(if(selected) screen.selectedIcon else screen.unselectedIcon, null)
|
||||
if(selected) Text(screen.title, style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpaceSearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onCloseClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: String = stringResource(R.string.Search_Here)
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp))
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton({}){
|
||||
Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(6.dp))
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier.weight(1f).padding(vertical = 2.dp),
|
||||
decorationBox = { innerTextField ->
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
|
||||
IconButton( {
|
||||
onCloseClicked()
|
||||
onQueryChange("")
|
||||
}) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package com.flux.ui.common
|
||||
|
||||
import android.text.format.DateUtils
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.R
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.other.icons
|
||||
import com.flux.other.workspaceIconList
|
||||
import com.flux.ui.screens.events.formatCustom
|
||||
import com.flux.ui.screens.events.formatMonthly
|
||||
import com.flux.ui.screens.events.formatOnce
|
||||
import com.flux.ui.screens.events.formatYearly
|
||||
import java.util.Calendar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChangeIconSheet(
|
||||
isVisible: Boolean,
|
||||
sheetState: SheetState,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Int) -> Unit
|
||||
) {
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { onDismiss() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp)
|
||||
) {
|
||||
items(workspaceIconList) { item ->
|
||||
Text(
|
||||
item.title,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
item.icons.forEach { index ->
|
||||
IconButton(
|
||||
{
|
||||
onConfirm(index)
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Icon(icons[index], null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecurrenceRule.label(): String = when (this) {
|
||||
is RecurrenceRule.Once -> stringResource(R.string.Once)
|
||||
is RecurrenceRule.Custom -> stringResource(R.string.Custom)
|
||||
is RecurrenceRule.Weekly -> stringResource(R.string.Weekly)
|
||||
is RecurrenceRule.Monthly -> stringResource(R.string.Monthly)
|
||||
is RecurrenceRule.Yearly -> stringResource(R.string.Yearly)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecurrenceBottomSheet(
|
||||
isVisible: Boolean,
|
||||
sheetState: SheetState,
|
||||
startDateTime: Long,
|
||||
onDismiss: () -> Unit,
|
||||
currentRule: RecurrenceRule,
|
||||
onRuleChange: (RecurrenceRule, Long) -> Unit
|
||||
) {
|
||||
if (!isVisible) return
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var selectedDateTime by remember { mutableLongStateOf(startDateTime) }
|
||||
var tempRule by remember(currentRule) { mutableStateOf(currentRule) }
|
||||
|
||||
val options = listOf(
|
||||
RecurrenceRule.Once,
|
||||
RecurrenceRule.Weekly(),
|
||||
RecurrenceRule.Monthly,
|
||||
RecurrenceRule.Yearly,
|
||||
RecurrenceRule.Custom()
|
||||
)
|
||||
|
||||
if (showDatePicker) {
|
||||
DatePickerModal(onDateSelected = { newDateMillis ->
|
||||
if (newDateMillis != null) {
|
||||
val timeOfDay = selectedDateTime % DateUtils.DAY_IN_MILLIS
|
||||
selectedDateTime = newDateMillis + timeOfDay
|
||||
}
|
||||
}, onDismiss = { showDatePicker = false })
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
modifier = Modifier.heightIn(min = 300.dp),
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
LazyColumn(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Repeat, null)
|
||||
Text(
|
||||
stringResource(R.string.repeat),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
) {
|
||||
items(options.size) { index ->
|
||||
val option = options[index]
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option::class == tempRule::class,
|
||||
onClick = { tempRule = option }
|
||||
)
|
||||
Text(option.label(), modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
when (val rule = tempRule) {
|
||||
is RecurrenceRule.Once -> {
|
||||
Row(Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatOnce(selectedDateTime))
|
||||
IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)) {
|
||||
Icon(Icons.Default.Edit, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Custom -> {
|
||||
OutlinedTextField(
|
||||
value = rule.everyXDays.toString(),
|
||||
onValueChange = { new -> new.toIntOrNull()?.let { tempRule = rule.copy(everyXDays = it) } },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
label = { Text(formatCustom(rule)) },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
is RecurrenceRule.Weekly -> {
|
||||
val weekdays = listOf(
|
||||
stringResource(R.string.monday_short),
|
||||
stringResource(R.string.tuesday_short),
|
||||
stringResource(R.string.wednesday_short),
|
||||
stringResource(R.string.thursday_short),
|
||||
stringResource(R.string.friday_short),
|
||||
stringResource(R.string.saturday_short),
|
||||
stringResource(R.string.sunday_short)
|
||||
)
|
||||
|
||||
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 = {},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp),
|
||||
|
||||
contentColor =
|
||||
if (isSelected)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = day,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 12.dp,
|
||||
vertical = 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Monthly -> {
|
||||
Row(Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMonthly(selectedDateTime))
|
||||
IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp)
|
||||
)) {
|
||||
Icon(Icons.Default.Edit, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Yearly -> {
|
||||
Row(Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatYearly(selectedDateTime))
|
||||
IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp)
|
||||
)) {
|
||||
Icon(Icons.Default.Edit, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.Dismiss))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
onRuleChange(tempRule, adjustStartDate(tempRule, selectedDateTime))
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.Confirm))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun adjustStartDate(rule: RecurrenceRule, startDateTime: Long): Long {
|
||||
return when (rule) {
|
||||
is RecurrenceRule.Once -> startDateTime
|
||||
is RecurrenceRule.Monthly -> startDateTime
|
||||
is RecurrenceRule.Yearly -> startDateTime
|
||||
is RecurrenceRule.Custom -> startDateTime
|
||||
is RecurrenceRule.Weekly -> {
|
||||
val cal = Calendar.getInstance().apply { timeInMillis = startDateTime }
|
||||
|
||||
// Calendar days: Sunday = 1, Monday = 2, ... Saturday = 7
|
||||
val today = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7 // shift so Monday=0, Sunday=6
|
||||
|
||||
if (today in rule.daysOfWeek) {
|
||||
startDateTime
|
||||
} else {
|
||||
// find next closest match
|
||||
var offset = 1
|
||||
while (true) {
|
||||
val nextDay = (today + offset) % 7
|
||||
if (nextDay in rule.daysOfWeek) {
|
||||
cal.add(Calendar.DAY_OF_YEAR, offset)
|
||||
return cal.timeInMillis
|
||||
}
|
||||
offset++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
} as Long
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.flux.ui.components
|
||||
package com.flux.ui.common
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Close
|
||||
@@ -0,0 +1,406 @@
|
||||
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
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.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
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.EditCalendar
|
||||
import androidx.compose.material.icons.filled.FontDownload
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.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
|
||||
import androidx.compose.material3.TimeInput
|
||||
import androidx.compose.material3.TimePicker
|
||||
import androidx.compose.material3.rememberDatePickerState
|
||||
import androidx.compose.material3.rememberTimePickerState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.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))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun DatePickerModal(
|
||||
onDateSelected: (Long?) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
val datePickerState = rememberDatePickerState()
|
||||
|
||||
DatePickerDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
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))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.Cancel))
|
||||
}
|
||||
}
|
||||
) {
|
||||
DatePicker(state = datePickerState)
|
||||
}
|
||||
}
|
||||
|
||||
fun convertMillisToTime(millis: Long, is24Hour: Boolean = false): String {
|
||||
val pattern = if (is24Hour) "HH:mm" else "hh:mm a"
|
||||
val formatter = SimpleDateFormat(pattern, Locale.getDefault())
|
||||
return formatter.format(Date(millis))
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TimePicker(
|
||||
initialTime: Long,
|
||||
is24Hour: Boolean = false,
|
||||
onConfirm: (Long) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val currentTime = Calendar.getInstance().apply { timeInMillis = initialTime }
|
||||
val timePickerState = rememberTimePickerState(
|
||||
initialHour = currentTime.get(Calendar.HOUR_OF_DAY),
|
||||
initialMinute = currentTime.get(Calendar.MINUTE),
|
||||
is24Hour = is24Hour,
|
||||
)
|
||||
var showDial by remember { mutableStateOf(true) }
|
||||
val toggleIcon = if (showDial) { Icons.Filled.EditCalendar } else { Icons.Filled.AccessTime }
|
||||
|
||||
TimePickerDialog(
|
||||
onDismiss = onDismiss,
|
||||
onConfirm = {
|
||||
val calendar = Calendar.getInstance().apply {
|
||||
timeInMillis = initialTime
|
||||
set(Calendar.HOUR_OF_DAY, timePickerState.hour)
|
||||
set(Calendar.MINUTE, timePickerState.minute)
|
||||
set(Calendar.SECOND, 0)
|
||||
set(Calendar.MILLISECOND, 0)
|
||||
}
|
||||
onConfirm(calendar.timeInMillis)
|
||||
onDismiss()
|
||||
},
|
||||
toggle = {
|
||||
IconButton(onClick = { showDial = !showDial }) {
|
||||
Icon(
|
||||
imageVector = toggleIcon,
|
||||
contentDescription = "Time picker type toggle",
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
if (showDial) {
|
||||
TimePicker(timePickerState)
|
||||
} else {
|
||||
TimeInput(timePickerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the onDismiss() call from TimePickerDialog's onConfirm button
|
||||
@Composable
|
||||
fun TimePickerDialog(
|
||||
title: String = stringResource(R.string.Select_Time),
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: () -> Unit,
|
||||
toggle: @Composable () -> Unit = {},
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Dialog(
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
Surface(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
tonalElevation = 6.dp,
|
||||
modifier =
|
||||
Modifier
|
||||
.width(IntrinsicSize.Min)
|
||||
.height(IntrinsicSize.Min)
|
||||
.background(
|
||||
shape = MaterialTheme.shapes.extraLarge,
|
||||
color = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 20.dp),
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
content()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.height(40.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
toggle()
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.Cancel)) }
|
||||
TextButton(onClick = onConfirm) { Text(stringResource(R.string.Set)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteAlert(
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirmation: () -> Unit,
|
||||
dialogTitle: String = stringResource(R.string.deleteDialogTitle),
|
||||
dialogText: String = stringResource(R.string.deleteDialogText),
|
||||
icon: ImageVector = Icons.Default.DeleteOutline,
|
||||
) {
|
||||
AlertDialog(
|
||||
icon = {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = "Delete Icon",
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = { Text(text = dialogTitle) },
|
||||
text = { Text(text = dialogText) },
|
||||
onDismissRequest = { onDismissRequest() },
|
||||
confirmButton = { TextButton(onClick = { onConfirmation() }) { Text(stringResource(R.string.Confirm)) } },
|
||||
dismissButton = { TextButton(onClick = { onDismissRequest() }) { Text(stringResource(R.string.Dismiss)) } }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FontDialog(
|
||||
selectedFont: Int,
|
||||
onSelectFont: (Int)->Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
){
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Icon(Icons.Default.FontDownload, null, modifier = Modifier.size(36.dp))
|
||||
Text(stringResource(R.string.Fonts), style = MaterialTheme.typography.titleLarge)
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
FONTS.forEachIndexed { index, font ->
|
||||
val containerColor =
|
||||
if (selectedFont == index) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val contentColor =
|
||||
if (selectedFont == index) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
Card(
|
||||
onClick = {
|
||||
onSelectFont(index)
|
||||
onDismissRequest()
|
||||
},
|
||||
shape =
|
||||
if (selectedFont == index) RoundedCornerShape(50)
|
||||
else RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
)
|
||||
) {
|
||||
Text(font, modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
+362
-100
@@ -1,17 +1,21 @@
|
||||
package com.flux.ui.components
|
||||
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
|
||||
import androidx.compose.material.icons.automirrored.outlined.Label
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.KeyboardDoubleArrowRight
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.RemoveCircleOutline
|
||||
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
|
||||
@@ -24,7 +28,9 @@ 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
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -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) },
|
||||
@@ -149,11 +187,15 @@ fun DropdownMenuWithDetails(
|
||||
|
||||
@Composable
|
||||
fun JournalDropdownMenu(
|
||||
onAddLabel: () -> Unit,
|
||||
onAboutClicked: () -> Unit,
|
||||
onShareNote: () -> Unit,
|
||||
onSaveNote: () -> Unit,
|
||||
onPrintNote: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
onDelete: () -> Unit,
|
||||
onConvertNote: () ->Unit,
|
||||
onCopyNote: () -> Unit,
|
||||
onCloneNote: () -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -168,6 +210,49 @@ fun JournalDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false }
|
||||
) {
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.Labels)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Outlined.Label,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = { Icon(Icons.Default.Add, contentDescription = null) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onAddLabel()
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text("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) },
|
||||
@@ -228,90 +313,106 @@ 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(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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WorkspaceMore(
|
||||
isLocked: Boolean,
|
||||
isCoverAdded: Boolean,
|
||||
showEditLabel: Boolean,
|
||||
isPinned: Boolean,
|
||||
isLocked: Boolean = false,
|
||||
isCoverAdded: Boolean = false,
|
||||
onEditDetails: () -> Unit,
|
||||
onEditLabel: () -> Unit,
|
||||
onRemoveCover: () -> Unit,
|
||||
onAddCover: () -> Unit,
|
||||
onRemoveCover: () -> Unit = {},
|
||||
onAddCover: () -> Unit = {},
|
||||
onDelete: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleLock: () -> Unit
|
||||
) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier) {
|
||||
IconButton(
|
||||
onClick = { expanded = true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
@@ -331,19 +432,7 @@ fun WorkspaceMore(
|
||||
onEditDetails()
|
||||
}
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (isPinned) stringResource(R.string.Unpin) else stringResource(R.string.Pin)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (isPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
expanded = false
|
||||
onTogglePinned()
|
||||
}
|
||||
)
|
||||
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.Change_Cover)) },
|
||||
leadingIcon = {
|
||||
@@ -377,25 +466,6 @@ fun WorkspaceMore(
|
||||
)
|
||||
}
|
||||
|
||||
if (showEditLabel) {
|
||||
HorizontalDivider()
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.Labels)) },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Outlined.Label,
|
||||
contentDescription = null
|
||||
)
|
||||
},
|
||||
trailingIcon = { Icon(Icons.Default.KeyboardDoubleArrowRight, null) },
|
||||
onClick = {
|
||||
expanded = false
|
||||
onEditLabel()
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
@@ -430,3 +500,195 @@ 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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.flux.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material.icons.filled.AutoStories
|
||||
import androidx.compose.material.icons.filled.Checklist
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.HourglassEmpty
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.R
|
||||
|
||||
@Composable
|
||||
fun EmptyNotes() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Notes,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Notes))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyLabels() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Label,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Labels))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyHabits() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(Icons.Default.EventAvailable, null, modifier = Modifier.size(48.dp))
|
||||
Text(stringResource(R.string.Empty_Habits))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyJournal() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AutoStories,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Journal))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyEvents() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.TaskAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Events))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyTodoList() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Checklist,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Lists))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyProgressItems() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.HourglassEmpty,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.empty_no_data_found))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyLanguage() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.oops_no_language_found))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyValue() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.HourglassEmpty,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.empty_no_data_found))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package com.flux.ui.common
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
|
||||
enum class SelectionType {
|
||||
SINGLE,
|
||||
MULTIPLE,
|
||||
DATE,
|
||||
RECURRENCE
|
||||
}
|
||||
|
||||
data class FilterOption(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val date: Long?= null,
|
||||
val recurrenceRule: RecurrenceRule? = null
|
||||
)
|
||||
|
||||
data class FilterCategory(
|
||||
val name: String,
|
||||
val options: List<FilterOption>,
|
||||
val type: SelectionType
|
||||
)
|
||||
|
||||
data class SearchFilterOption(
|
||||
val id: String,
|
||||
val label: String
|
||||
)
|
||||
|
||||
data class SearchFilterCategory(
|
||||
val name: String,
|
||||
val options: List<SearchFilterOption>,
|
||||
val type: SelectionType
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CategoryRow(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.then(
|
||||
if (isSelected)
|
||||
Modifier.drawLeftBorder(
|
||||
MaterialTheme.colorScheme.primary,
|
||||
4.dp
|
||||
)
|
||||
else Modifier
|
||||
)
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OptionRow(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, Modifier.weight(1f))
|
||||
RadioButton(selected = isSelected, onClick = onClick)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DateOptionRow(
|
||||
date: Long?,
|
||||
label: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().clickable { onClick() }.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(label)
|
||||
if(date!=null) Text(convertMillisToDate(date), style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
|
||||
IconButton(onClick) {
|
||||
Icon(
|
||||
Icons.Default.DateRange,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MultiOptionRow(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label, Modifier.weight(1f))
|
||||
Checkbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onClick() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.drawLeftBorder(color: Color, width: Dp): Modifier = this.drawWithContent {
|
||||
drawContent()
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(0f, 0f),
|
||||
end = Offset(0f, size.height),
|
||||
strokeWidth = width.toPx()
|
||||
)
|
||||
}
|
||||
+34
-41
@@ -1,11 +1,9 @@
|
||||
package com.flux.ui.components
|
||||
package com.flux.ui.common
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -16,10 +14,6 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -36,10 +30,7 @@ fun BasicScaffold(
|
||||
title = { Text(title) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, null)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -51,11 +42,11 @@ fun BasicScaffold(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HabitScaffold(
|
||||
title: String,
|
||||
description: String,
|
||||
onDeleteClicked: () -> Unit,
|
||||
onBackPressed: () -> Unit,
|
||||
onEditClicked: () -> Unit,
|
||||
onCopyNote: () -> Unit,
|
||||
onCloneNote: () -> Unit,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -63,26 +54,7 @@ fun HabitScaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
title,
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(end = 3.dp)
|
||||
)
|
||||
if(description.isNotBlank()){
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Light),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(end = 3.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(onBackPressed) {
|
||||
Icon(
|
||||
@@ -93,16 +65,37 @@ 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
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EditorScaffold(
|
||||
title: String,
|
||||
canSave: Boolean = false,
|
||||
onBackPressed: () -> Unit,
|
||||
onDone: () -> Unit,
|
||||
content: @Composable (PaddingValues) -> Unit
|
||||
) {
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
title = { Text(title) },
|
||||
navigationIcon = { IconButton(onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } },
|
||||
actions = { IconButton(onDone, enabled = canSave) { Icon(Icons.Default.Done, null) } }
|
||||
)
|
||||
},
|
||||
content = content
|
||||
)
|
||||
}
|
||||
+27
-103
@@ -1,22 +1,10 @@
|
||||
package com.flux.ui.components
|
||||
package com.flux.ui.common
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -33,7 +21,6 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -43,81 +30,14 @@ import androidx.compose.ui.semantics.traversalIndex
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.R
|
||||
|
||||
@Composable
|
||||
fun NotesSearchBar(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onCloseClicked: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
placeholder: String = stringResource(R.string.Search_Here)
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.height(40.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp))
|
||||
.padding(horizontal = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Search,
|
||||
contentDescription = "Search",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
Spacer(Modifier.width(6.dp))
|
||||
|
||||
BasicTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 2.dp), // keeps text centered vertically
|
||||
decorationBox = { innerTextField ->
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
)
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
onCloseClicked()
|
||||
onQueryChange("")
|
||||
},
|
||||
modifier = Modifier.size(24.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Close,
|
||||
contentDescription = "Clear",
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GeneralSearchBar(
|
||||
textFieldState: TextFieldState,
|
||||
leadingIcon: ImageVector,
|
||||
trailingIcon: ImageVector,
|
||||
onLeadingIconClicked: () -> Unit,
|
||||
onTrailingIconClicked: () -> Unit,
|
||||
leadingIcon: ImageVector? = null,
|
||||
trailingIcon: ImageVector? = null,
|
||||
onLeadingIconClicked: () -> Unit = {},
|
||||
onTrailingIconClicked: () -> Unit = {},
|
||||
onSearch: (String) -> Unit,
|
||||
onCloseClicked: () -> Unit,
|
||||
) {
|
||||
@@ -171,13 +91,13 @@ fun GeneralSearchBar(
|
||||
@Composable
|
||||
fun GeneralSearchInputField(
|
||||
query: String,
|
||||
leadingIcon: ImageVector,
|
||||
trailingIcon: ImageVector,
|
||||
leadingIcon: ImageVector?,
|
||||
trailingIcon: ImageVector?,
|
||||
onQueryChange: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onSearchClosed: () -> Unit,
|
||||
onLeadingIconClicked: () -> Unit,
|
||||
onTrailingIconClicked: () -> Unit
|
||||
onLeadingIconClicked: () -> Unit = {},
|
||||
onTrailingIconClicked: () -> Unit = {}
|
||||
) {
|
||||
SearchBarDefaults.InputField(
|
||||
query = query,
|
||||
@@ -186,27 +106,31 @@ fun GeneralSearchInputField(
|
||||
expanded = false,
|
||||
onExpandedChange = { },
|
||||
placeholder = { Text(stringResource(R.string.Search_Here)) },
|
||||
leadingIcon = {
|
||||
IconButton(onClick = onLeadingIconClicked) {
|
||||
Icon(
|
||||
imageVector = leadingIcon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
Row {
|
||||
if (query.isNotBlank()) CloseButton(onSearchClosed)
|
||||
IconButton(onClick = onTrailingIconClicked) {
|
||||
leadingIcon = leadingIcon?.let {
|
||||
{
|
||||
IconButton(onClick = onLeadingIconClicked) {
|
||||
Icon(
|
||||
imageVector = trailingIcon,
|
||||
imageVector = it,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
trailingIcon = {
|
||||
Row {
|
||||
if (query.isNotBlank()) CloseButton(onSearchClosed)
|
||||
trailingIcon?.let {
|
||||
IconButton(onClick = onTrailingIconClicked) {
|
||||
Icon(
|
||||
imageVector = trailingIcon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TextFieldDefaults.colors(unfocusedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), focusedContainerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp))
|
||||
)
|
||||
}
|
||||
@@ -1,824 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import android.text.format.DateUtils
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.FormatAlignLeft
|
||||
import androidx.compose.material.icons.automirrored.filled.List
|
||||
import androidx.compose.material.icons.filled.Abc
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.FormatQuote
|
||||
import androidx.compose.material.icons.filled.Numbers
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.outlined.UnfoldLess
|
||||
import androidx.compose.material.icons.outlined.UnfoldMore
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.R
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.data.model.Space
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
import com.flux.data.model.getSpacesList
|
||||
import com.flux.other.HeaderNode
|
||||
import com.flux.other.icons
|
||||
import com.flux.other.workspaceIconList
|
||||
import com.flux.ui.screens.events.formatCustom
|
||||
import com.flux.ui.screens.events.formatMonthly
|
||||
import com.flux.ui.screens.events.formatOnce
|
||||
import com.flux.ui.screens.events.formatYearly
|
||||
import java.util.Calendar
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NewWorkspaceBottomSheet(
|
||||
isEditing: Boolean = false,
|
||||
workspace: WorkspaceModel = WorkspaceModel(),
|
||||
isVisible: Boolean,
|
||||
sheetState: SheetState,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (WorkspaceModel) -> Unit
|
||||
) {
|
||||
var title by remember { mutableStateOf(workspace.title) }
|
||||
var description by remember { mutableStateOf(workspace.description) }
|
||||
val focusRequesterDesc = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
keyboardController?.hide()
|
||||
onDismiss()
|
||||
title = workspace.title
|
||||
description = workspace.description
|
||||
},
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(if (isEditing) Icons.Default.Edit else Icons.Default.Add, null)
|
||||
Text(
|
||||
if (isEditing) stringResource(R.string.Edit_Workspace) else stringResource(R.string.Add_Workspace),
|
||||
fontWeight = FontWeight.Bold,
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 3.dp),
|
||||
placeholder = { Text(stringResource(R.string.Title)) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = MaterialTheme.colorScheme.primary,
|
||||
focusedPlaceholderColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() })
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequesterDesc),
|
||||
placeholder = { Text(stringResource(R.string.Description)) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedTextColor = MaterialTheme.colorScheme.primary,
|
||||
focusedPlaceholderColor = MaterialTheme.colorScheme.primary
|
||||
),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus(force = true)
|
||||
onConfirm(workspace.copy(title = title, description = description))
|
||||
onDismiss()
|
||||
title = workspace.title
|
||||
description = workspace.description
|
||||
}
|
||||
)
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(onClick = {
|
||||
keyboardController?.hide()
|
||||
onDismiss()
|
||||
title = workspace.title
|
||||
description = workspace.description
|
||||
}) {
|
||||
Text(stringResource(R.string.Dismiss))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FilledTonalButton(
|
||||
enabled = title.isNotBlank(),
|
||||
onClick = {
|
||||
keyboardController?.hide()
|
||||
onConfirm(workspace.copy(title = title, description = description))
|
||||
onDismiss()
|
||||
title = workspace.title
|
||||
description = workspace.description
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.Confirm))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChangeIconBottomSheet(
|
||||
isVisible: Boolean,
|
||||
sheetState: SheetState,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (Int) -> Unit
|
||||
) {
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { onDismiss() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 500.dp)
|
||||
) {
|
||||
items(workspaceIconList) { item ->
|
||||
Text(
|
||||
item.title,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
item.icons.forEach { index ->
|
||||
IconButton({ onConfirm(index) }) { Icon(icons[index], null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AddNewSpacesBottomSheet(
|
||||
isVisible: Boolean,
|
||||
sheetState: SheetState,
|
||||
selectedSpaces: List<Space>,
|
||||
onDismiss: () -> Unit,
|
||||
onRemove: (Int) -> Unit,
|
||||
onSelect: (Int) -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var removeSpace by remember { mutableIntStateOf(-1) }
|
||||
val spacesList = getSpacesList()
|
||||
if (showDeleteDialog) {
|
||||
DeleteAlert(onConfirmation = {
|
||||
onRemove(removeSpace)
|
||||
removeSpace = -1
|
||||
showDeleteDialog = false
|
||||
}, onDismissRequest = {
|
||||
showDeleteDialog = false
|
||||
})
|
||||
}
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { onDismiss() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
LazyColumn(Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)) {
|
||||
if (selectedSpaces.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.Current),
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
selectedSpaces.forEach { space ->
|
||||
SpaceCard(space, true, { onSelect(space.id) }, {
|
||||
removeSpace = space.id
|
||||
showDeleteDialog = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectedSpaces.size != spacesList.size) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.Available_Spaces),
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
spacesList.filterNot { id -> selectedSpaces.contains(id) }
|
||||
.forEach { space ->
|
||||
SpaceCard(space, false, { onSelect(space.id) }, { })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SpaceCard(space: Space, isSelected: Boolean, onSelect: () -> Unit, onRemove: () -> Unit) {
|
||||
val cardContainerColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp) else MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
val cardContentColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
|
||||
val iconContainerColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface
|
||||
val iconContentColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Card(
|
||||
modifier = Modifier.clip(RoundedCornerShape(50)),
|
||||
shape = RoundedCornerShape(50),
|
||||
onClick = onSelect,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = cardContainerColor,
|
||||
contentColor = cardContentColor
|
||||
)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(
|
||||
onClick = onSelect,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = iconContainerColor,
|
||||
contentColor = iconContentColor
|
||||
)
|
||||
) { Icon(space.icon, null) }
|
||||
Text(space.title, modifier = Modifier.padding(end = if (isSelected) 0.dp else 16.dp))
|
||||
if (isSelected) {
|
||||
IconButton(onRemove) { Icon(Icons.Default.Remove, null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RecurrenceRule.label(): String = when (this) {
|
||||
is RecurrenceRule.Once -> stringResource(R.string.Once)
|
||||
is RecurrenceRule.Custom -> stringResource(R.string.Custom)
|
||||
is RecurrenceRule.Weekly -> stringResource(R.string.Weekly)
|
||||
is RecurrenceRule.Monthly -> stringResource(R.string.Monthly)
|
||||
is RecurrenceRule.Yearly -> stringResource(R.string.Yearly)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RecurrenceBottomSheet(
|
||||
isVisible: Boolean,
|
||||
sheetState: SheetState,
|
||||
startDateTime: Long,
|
||||
onDismiss: () -> Unit,
|
||||
currentRule: RecurrenceRule,
|
||||
onRuleChange: (RecurrenceRule, Long) -> Unit
|
||||
) {
|
||||
if (!isVisible) return
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var selectedDateTime by remember { mutableLongStateOf(startDateTime) }
|
||||
var tempRule by remember(currentRule) { mutableStateOf(currentRule) }
|
||||
|
||||
val options = listOf(
|
||||
RecurrenceRule.Once,
|
||||
RecurrenceRule.Weekly(),
|
||||
RecurrenceRule.Monthly,
|
||||
RecurrenceRule.Yearly,
|
||||
RecurrenceRule.Custom()
|
||||
)
|
||||
|
||||
if (showDatePicker) {
|
||||
DatePickerModal(onDateSelected = { newDateMillis ->
|
||||
if (newDateMillis != null) {
|
||||
val timeOfDay = selectedDateTime % DateUtils.DAY_IN_MILLIS
|
||||
selectedDateTime = newDateMillis + timeOfDay
|
||||
}
|
||||
}, onDismiss = { showDatePicker = false })
|
||||
}
|
||||
|
||||
ModalBottomSheet(
|
||||
modifier = Modifier.heightIn(min = 300.dp),
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
LazyColumn(modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.padding(start = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Repeat, null)
|
||||
Text(
|
||||
stringResource(R.string.repeat),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
) {
|
||||
items(options.size) { index ->
|
||||
val option = options[index]
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = option::class == tempRule::class,
|
||||
onClick = { tempRule = option }
|
||||
)
|
||||
Text(option.label(), modifier = Modifier.padding(start = 4.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
when (val rule = tempRule) {
|
||||
is RecurrenceRule.Once -> {
|
||||
Row(Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatOnce(selectedDateTime))
|
||||
IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
)) {
|
||||
Icon(Icons.Default.Edit, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Custom -> {
|
||||
OutlinedTextField(
|
||||
value = rule.everyXDays.toString(),
|
||||
onValueChange = { new -> new.toIntOrNull()?.let { tempRule = rule.copy(everyXDays = it) } },
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
label = { Text(formatCustom(rule)) },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
|
||||
is RecurrenceRule.Weekly -> {
|
||||
val weekdays = listOf(
|
||||
stringResource(R.string.monday_short),
|
||||
stringResource(R.string.tuesday_short),
|
||||
stringResource(R.string.wednesday_short),
|
||||
stringResource(R.string.thursday_short),
|
||||
stringResource(R.string.friday_short),
|
||||
stringResource(R.string.saturday_short),
|
||||
stringResource(R.string.sunday_short)
|
||||
)
|
||||
Row (
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
weekdays.forEachIndexed { index, day ->
|
||||
val isSelected = index in rule.daysOfWeek
|
||||
Card(
|
||||
onClick = {
|
||||
val newDays = rule.daysOfWeek.toMutableList()
|
||||
if (index in newDays) newDays.remove(index) else newDays.add(index)
|
||||
tempRule = rule.copy(daysOfWeek = newDays)
|
||||
},
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 2.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp),
|
||||
contentColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = day,
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Monthly -> {
|
||||
Row(Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatMonthly(selectedDateTime))
|
||||
IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp)
|
||||
)) {
|
||||
Icon(Icons.Default.Edit, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Yearly -> {
|
||||
Row(Modifier
|
||||
.padding(horizontal = 12.dp)
|
||||
.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(formatYearly(selectedDateTime))
|
||||
IconButton({ showDatePicker=true }, colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp)
|
||||
)) {
|
||||
Icon(Icons.Default.Edit, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Buttons
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
OutlinedButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.Dismiss))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
FilledTonalButton(
|
||||
onClick = {
|
||||
onRuleChange(tempRule, adjustStartDate(tempRule, selectedDateTime))
|
||||
onDismiss()
|
||||
}
|
||||
) {
|
||||
Text(stringResource(R.string.Confirm))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun adjustStartDate(rule: RecurrenceRule, startDateTime: Long): Long {
|
||||
return when (rule) {
|
||||
is RecurrenceRule.Once -> startDateTime
|
||||
is RecurrenceRule.Monthly -> startDateTime
|
||||
is RecurrenceRule.Yearly -> startDateTime
|
||||
is RecurrenceRule.Custom -> startDateTime
|
||||
is RecurrenceRule.Weekly -> {
|
||||
val cal = Calendar.getInstance().apply { timeInMillis = startDateTime }
|
||||
|
||||
// Calendar days: Sunday = 1, Monday = 2, ... Saturday = 7
|
||||
val today = (cal.get(Calendar.DAY_OF_WEEK) + 5) % 7 // shift so Monday=0, Sunday=6
|
||||
|
||||
if (today in rule.daysOfWeek) {
|
||||
startDateTime
|
||||
} else {
|
||||
// find next closest match
|
||||
var offset = 1
|
||||
while (true) {
|
||||
val nextDay = (today + offset) % 7
|
||||
if (nextDay in rule.daysOfWeek) {
|
||||
cal.add(Calendar.DAY_OF_YEAR, offset)
|
||||
return cal.timeInMillis
|
||||
}
|
||||
offset++
|
||||
}
|
||||
}
|
||||
}
|
||||
} as Long
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OutlineBottomSheet(
|
||||
isVisible: Boolean,
|
||||
outline: HeaderNode,
|
||||
sheetState: SheetState,
|
||||
onHeaderClick: (IntRange) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
|
||||
var isAllExpanded by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { onDismiss() },
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
LazyColumn(Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(300.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.Outline),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = { isAllExpanded = !isAllExpanded }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isAllExpanded) Icons.Outlined.UnfoldLess
|
||||
else Icons.Outlined.UnfoldMore,
|
||||
contentDescription = "fold",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
}
|
||||
items(outline.children) { header ->
|
||||
HeaderItem(
|
||||
header = header,
|
||||
depth = 0,
|
||||
onHeaderClick = onHeaderClick,
|
||||
parentExpanded = isAllExpanded
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HeaderItem(
|
||||
header: HeaderNode,
|
||||
depth: Int,
|
||||
parentExpanded: Boolean,
|
||||
onHeaderClick: (IntRange) -> Unit
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(true) }
|
||||
|
||||
LaunchedEffect(parentExpanded) { expanded = parentExpanded }
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(start = (depth * 8).dp)
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 32.dp)
|
||||
.clickable {
|
||||
onHeaderClick(header.range)
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (header.children.isNotEmpty()) {
|
||||
IconButton(
|
||||
modifier = Modifier.size(32.dp),
|
||||
onClick = {
|
||||
if (header.children.isNotEmpty()) {
|
||||
expanded = !expanded
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.ArrowDropDown
|
||||
else Icons.AutoMirrored.Filled.ArrowRight,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(32.dp))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = header.title,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
}
|
||||
|
||||
if (expanded) {
|
||||
header.children.forEach { child ->
|
||||
HeaderItem(
|
||||
header = child,
|
||||
depth = depth + 1,
|
||||
onHeaderClick = onHeaderClick,
|
||||
parentExpanded = parentExpanded
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotesInfoBottomSheet(
|
||||
words: Int,
|
||||
lines: Int,
|
||||
wordsWithoutPunctuations: Int,
|
||||
paragraph: Int,
|
||||
characters: Int,
|
||||
lastEdited: String,
|
||||
isVisible: Boolean,
|
||||
sheetState: SheetState,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
if (isVisible) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer
|
||||
) {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
item {
|
||||
SettingOption(
|
||||
radius = shapeManager(isFirst = true, radius = 32),
|
||||
icon = Icons.Default.Edit,
|
||||
title = stringResource(R.string.Last_Edited),
|
||||
description = lastEdited,
|
||||
actionType = ActionType.None
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SettingOption(
|
||||
radius = shapeManager(radius = 32),
|
||||
icon = Icons.Default.Numbers,
|
||||
title = stringResource(R.string.Word_Count),
|
||||
description = words.toString(),
|
||||
actionType = ActionType.None
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SettingOption(
|
||||
radius = shapeManager(radius = 32),
|
||||
icon = Icons.Default.FormatQuote,
|
||||
title = stringResource(R.string.words_excluding_punctuations),
|
||||
description = wordsWithoutPunctuations.toString(),
|
||||
actionType = ActionType.None
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SettingOption(
|
||||
radius = shapeManager(radius = 32),
|
||||
icon = Icons.Default.Abc,
|
||||
title = stringResource(R.string.Character_Count),
|
||||
description = characters.toString(),
|
||||
actionType = ActionType.None
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SettingOption(
|
||||
radius = shapeManager(radius = 32),
|
||||
icon = Icons.AutoMirrored.Filled.List,
|
||||
title = stringResource(R.string.lines),
|
||||
description = lines.toString(),
|
||||
actionType = ActionType.None
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
SettingOption(
|
||||
radius = shapeManager(radius = 32, isLast = true),
|
||||
icon = Icons.AutoMirrored.Filled.FormatAlignLeft,
|
||||
title = stringResource(R.string.paragraph),
|
||||
description = paragraph.toString(),
|
||||
actionType = ActionType.None
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.R
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
|
||||
@Composable
|
||||
fun DailyViewDateCard(date: Long, day: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val localDate = LocalDate.ofEpochDay(date)
|
||||
|
||||
val containerColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
val contentColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Card(
|
||||
modifier = Modifier.width(60.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor.copy(alpha = 0.6f),
|
||||
contentColor = contentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
day,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraLight),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
ElevatedCard(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
localDate.dayOfMonth.toString(),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthlyViewDateCard(date: Long, count: Int, maxCount: Int = 0, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val localDate = LocalDate.ofEpochDay(date)
|
||||
val fraction = if (maxCount > 0 && count > 0) count.toFloat() / maxCount.toFloat() else 0f
|
||||
val containerColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceContainerLow
|
||||
val contentColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.drawBehind {
|
||||
if (fraction > 0f && !isSelected) {
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
primaryColor.copy(alpha = 0.35f * fraction),
|
||||
Color.Transparent
|
||||
),
|
||||
center = center,
|
||||
radius = size.minDimension / 2f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = localDate.dayOfMonth.toString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(containerColor)
|
||||
.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DailyViewCalendar(
|
||||
selectedMonth: YearMonth,
|
||||
selectedDate: Long,
|
||||
onDateChange: (Long) -> Unit
|
||||
) {
|
||||
val daysInMonth = selectedMonth.lengthOfMonth()
|
||||
val dateList = (1..daysInMonth).map { day -> selectedMonth.atDay(day) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(selectedMonth, selectedDate) {
|
||||
val todayIndex = dateList.indexOfFirst { it.toEpochDay() == selectedDate }
|
||||
if (todayIndex >= 0) {
|
||||
listState.animateScrollToItem(
|
||||
index = maxOf(0, todayIndex - 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
state = listState
|
||||
) {
|
||||
items(dateList) { date ->
|
||||
val dayName = date.dayOfWeek.name
|
||||
.take(3)
|
||||
.lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
|
||||
DailyViewDateCard(
|
||||
date = date.toEpochDay(),
|
||||
day = dayName,
|
||||
isSelected = date.toEpochDay() == selectedDate,
|
||||
onClick = { onDateChange(date.toEpochDay()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MonthlyViewCalendar(
|
||||
currentMonth: YearMonth,
|
||||
selectedDate: Long,
|
||||
monthlyJournalCount: Map<LocalDate, Int> = emptyMap(),
|
||||
onMonthChange: (YearMonth) -> Unit,
|
||||
onDateChange: (Long) -> Unit
|
||||
) {
|
||||
val daysOfWeek = listOf(
|
||||
stringResource(R.string.monday_short),
|
||||
stringResource(R.string.tuesday_short),
|
||||
stringResource(R.string.wednesday_short),
|
||||
stringResource(R.string.thursday_short),
|
||||
stringResource(R.string.friday_short),
|
||||
stringResource(R.string.saturday_short),
|
||||
stringResource(R.string.sunday_short)
|
||||
)
|
||||
val firstDayOfMonth = currentMonth.atDay(1)
|
||||
val firstDayOffset = (firstDayOfMonth.dayOfWeek.value - 1) % 7
|
||||
val daysInMonth = currentMonth.lengthOfMonth()
|
||||
|
||||
val allDates = buildList {
|
||||
repeat(firstDayOffset) { add(null) }
|
||||
for (day in 1..daysInMonth) {
|
||||
add(currentMonth.atDay(day).toEpochDay())
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = currentMonth.month.name.lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() } + ", ${currentMonth.year}",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
val prevMonth = currentMonth.minusMonths(1)
|
||||
onMonthChange(prevMonth)
|
||||
onDateChange(prevMonth.atDay(1).toEpochDay())
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowBackIos,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = "Previous month",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.alpha(0.5f)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
val nextMonth = currentMonth.plusMonths(1)
|
||||
onMonthChange(nextMonth)
|
||||
onDateChange(nextMonth.atDay(1).toEpochDay())
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowForwardIos,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = "Next month",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.alpha(0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Weekday Row
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
daysOfWeek.forEach {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Calendar Grid
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(7),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 100.dp, max = 300.dp),
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
items(allDates) { date ->
|
||||
if (date == null) {
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
} else {
|
||||
MonthlyViewDateCard(
|
||||
date = date,
|
||||
isSelected = selectedDate == date,
|
||||
count = monthlyJournalCount[LocalDate.ofEpochDay(date)] ?: 0,
|
||||
maxCount = monthlyJournalCount.values.maxOrNull() ?: 0,
|
||||
onClick = { onDateChange(date) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.foundation.shape.CornerBasedShape
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.ZeroCornerSize
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Outline
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* A custom shape with curly edges, resembling a circle with sinusoidal indentations.
|
||||
*
|
||||
* This shape creates an outline that appears as a circle with "curly" or "wavy"
|
||||
* edges, achieved by applying a sine wave function to the circle's radius. The
|
||||
* amplitude and the number of curls (waves) can be adjusted.
|
||||
*
|
||||
* @property curlAmplitude The amplitude of the sine wave, controlling the depth of the curls.
|
||||
* A higher value results in deeper curls. Defaults to 16.0.
|
||||
* @property curlCount The number of curls (waves) around the circle's circumference.
|
||||
* A higher count results in more frequent curls. Defaults to 12.
|
||||
*/
|
||||
class CurlyCornerShape(
|
||||
private val curlAmplitude: Double = 16.0,
|
||||
private val curlCount: Int = 12,
|
||||
) : CornerBasedShape(
|
||||
topStart = ZeroCornerSize,
|
||||
topEnd = ZeroCornerSize,
|
||||
bottomEnd = ZeroCornerSize,
|
||||
bottomStart = ZeroCornerSize
|
||||
) {
|
||||
|
||||
/**
|
||||
* Calculates the x and y coordinates of a point on the curly circle at a given angle.
|
||||
*
|
||||
* @param centerX The x-coordinate of the center of the circle.
|
||||
* @param centerY The y-coordinate of the center of the circle.
|
||||
* @param baseRadius The base radius of the circle.
|
||||
* @param amplitude The amplitude of the sine wave.
|
||||
* @param angle The angle in radians.
|
||||
* @return A Pair containing the x and y coordinates of the point.
|
||||
*/
|
||||
private fun calculateCurlyCirclePoint(
|
||||
centerX: Double,
|
||||
centerY: Double,
|
||||
baseRadius: Double,
|
||||
amplitude: Double,
|
||||
angle: Double,
|
||||
): Pair<Double, Double> {
|
||||
// Calculate the radius with the sine wave applied.
|
||||
val radius = baseRadius + amplitude * sin(curlCount * angle)
|
||||
// Calculate x and y coordinates using polar coordinates.
|
||||
val x = centerX + radius * cos(angle)
|
||||
val y = centerY + radius * sin(angle)
|
||||
return Pair(x, y)
|
||||
}
|
||||
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
topStart: Float,
|
||||
topEnd: Float,
|
||||
bottomEnd: Float,
|
||||
bottomStart: Float,
|
||||
layoutDirection: LayoutDirection
|
||||
): Outline {
|
||||
val centerX = size.width / 2.0
|
||||
val centerY = size.height / 2.0
|
||||
val baseRadius = centerX - curlAmplitude
|
||||
val path = Path()
|
||||
|
||||
// Start at the rightmost point
|
||||
val startPoint = calculateCurlyCirclePoint(centerX, centerY, baseRadius, curlAmplitude, 0.0)
|
||||
path.moveTo(startPoint.first.toFloat(), startPoint.second.toFloat())
|
||||
|
||||
// Iterate through 360 degrees to draw the curly circle
|
||||
for (angleDegrees in 1..360) {
|
||||
// Convert the angle to radians.
|
||||
val angleRadians = Math.toRadians(angleDegrees.toDouble())
|
||||
|
||||
// calculate the current point
|
||||
val currentPoint =
|
||||
calculateCurlyCirclePoint(centerX, centerY, baseRadius, curlAmplitude, angleRadians)
|
||||
|
||||
path.lineTo(currentPoint.first.toFloat(), currentPoint.second.toFloat())
|
||||
}
|
||||
|
||||
path.close()
|
||||
return Outline.Generic(path)
|
||||
}
|
||||
|
||||
override fun copy(
|
||||
topStart: CornerSize,
|
||||
topEnd: CornerSize,
|
||||
bottomEnd: CornerSize,
|
||||
bottomStart: CornerSize,
|
||||
): CurlyCornerShape = CurlyCornerShape(
|
||||
curlAmplitude = this.curlAmplitude,
|
||||
curlCount = this.curlCount
|
||||
)
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.FormatIndentDecrease
|
||||
import androidx.compose.material.icons.automirrored.outlined.FormatIndentIncrease
|
||||
import androidx.compose.material.icons.automirrored.outlined.Label
|
||||
import androidx.compose.material.icons.automirrored.outlined.List
|
||||
import androidx.compose.material.icons.outlined.AddChart
|
||||
import androidx.compose.material.icons.outlined.AudioFile
|
||||
import androidx.compose.material.icons.outlined.CheckBox
|
||||
import androidx.compose.material.icons.outlined.Code
|
||||
import androidx.compose.material.icons.outlined.DataArray
|
||||
import androidx.compose.material.icons.outlined.DataObject
|
||||
import androidx.compose.material.icons.outlined.Feedback
|
||||
import androidx.compose.material.icons.outlined.FormatBold
|
||||
import androidx.compose.material.icons.outlined.FormatItalic
|
||||
import androidx.compose.material.icons.outlined.FormatPaint
|
||||
import androidx.compose.material.icons.outlined.FormatQuote
|
||||
import androidx.compose.material.icons.outlined.FormatUnderlined
|
||||
import androidx.compose.material.icons.outlined.HorizontalRule
|
||||
import androidx.compose.material.icons.outlined.Image
|
||||
import androidx.compose.material.icons.outlined.Info
|
||||
import androidx.compose.material.icons.outlined.Lightbulb
|
||||
import androidx.compose.material.icons.outlined.Link
|
||||
import androidx.compose.material.icons.outlined.Mic
|
||||
import androidx.compose.material.icons.outlined.ReportGmailerrorred
|
||||
import androidx.compose.material.icons.outlined.StrikethroughS
|
||||
import androidx.compose.material.icons.outlined.TableChart
|
||||
import androidx.compose.material.icons.outlined.Title
|
||||
import androidx.compose.material.icons.outlined.VideoFile
|
||||
import androidx.compose.material.icons.outlined.Warning
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.R
|
||||
import com.flux.other.Constants
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CustomIconButton(
|
||||
enabled: Boolean = true,
|
||||
imageVector: ImageVector? = null,
|
||||
painter: Int? = null,
|
||||
contentDescription: String?=null,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
) {
|
||||
if (imageVector != null) {
|
||||
Icon(
|
||||
imageVector = imageVector,
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(id = painter!!),
|
||||
contentDescription = contentDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MarkdownEditorRow(
|
||||
canUndo: Boolean,
|
||||
canRedo: Boolean,
|
||||
onEdit: (String) -> Unit,
|
||||
onTableButtonClick: () -> Unit,
|
||||
onListButtonClick: () -> Unit,
|
||||
onTaskButtonClick: () -> Unit,
|
||||
onLinkButtonClick: () -> Unit,
|
||||
onImageButtonClick: () -> Unit,
|
||||
onAudioButtonClick: () -> Unit,
|
||||
onRecordAudioClick: () -> Unit,
|
||||
onVideoButtonClick: () -> Unit
|
||||
) {
|
||||
|
||||
var isExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
var isAlertExpanded by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
.navigationBarsPadding()
|
||||
.height(48.dp)
|
||||
.border(BorderStroke(0.5.dp, MaterialTheme.colorScheme.primary), shape = RoundedCornerShape(50))
|
||||
.clip(RoundedCornerShape(50))
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
CustomIconButton(
|
||||
enabled = canUndo,
|
||||
painter = R.drawable.undo,
|
||||
contentDescription = "Undo"
|
||||
) {
|
||||
onEdit(Constants.Editor.UNDO)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
enabled = canRedo,
|
||||
painter = R.drawable.redo,
|
||||
contentDescription = "Redo"
|
||||
) {
|
||||
onEdit(Constants.Editor.REDO)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Title,
|
||||
contentDescription = "Heading Level"
|
||||
) {
|
||||
isExpanded = !isExpanded
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isExpanded) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
|
||||
CustomIconButton(
|
||||
painter = R.drawable.format_h1,
|
||||
contentDescription = "H1"
|
||||
) {
|
||||
onEdit(Constants.Editor.H1)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
painter = R.drawable.format_h2,
|
||||
contentDescription = "H2"
|
||||
) {
|
||||
onEdit(Constants.Editor.H2)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
painter = R.drawable.format_h3,
|
||||
contentDescription = "H3"
|
||||
) {
|
||||
onEdit(Constants.Editor.H3)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
painter = R.drawable.format_h4,
|
||||
contentDescription = "H4"
|
||||
) {
|
||||
onEdit(Constants.Editor.H4)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
painter = R.drawable.format_h5,
|
||||
contentDescription = "H5"
|
||||
) {
|
||||
onEdit(Constants.Editor.H5)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
painter = R.drawable.format_h6,
|
||||
contentDescription = "H6"
|
||||
) {
|
||||
onEdit(Constants.Editor.H6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.FormatBold,
|
||||
contentDescription = "Bold"
|
||||
) {
|
||||
onEdit(Constants.Editor.BOLD)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.FormatItalic,
|
||||
contentDescription = "Italic"
|
||||
) {
|
||||
onEdit(Constants.Editor.ITALIC)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.FormatUnderlined,
|
||||
contentDescription = "Underline"
|
||||
) {
|
||||
onEdit(Constants.Editor.UNDERLINE)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.StrikethroughS,
|
||||
contentDescription = "Strike Through"
|
||||
) {
|
||||
onEdit(Constants.Editor.STRIKETHROUGH)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.FormatPaint,
|
||||
contentDescription = "Mark"
|
||||
) {
|
||||
onEdit(Constants.Editor.MARK)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Code,
|
||||
contentDescription = "Code"
|
||||
) {
|
||||
onEdit(Constants.Editor.INLINE_CODE)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.DataArray,
|
||||
contentDescription = "Brackets"
|
||||
) {
|
||||
onEdit(Constants.Editor.INLINE_BRACKETS)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.DataObject,
|
||||
contentDescription = "Braces"
|
||||
) {
|
||||
onEdit(Constants.Editor.INLINE_BRACES)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.AutoMirrored.Outlined.FormatIndentIncrease,
|
||||
contentDescription = "Tab"
|
||||
) {
|
||||
onEdit(Constants.Editor.TAB)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.AutoMirrored.Outlined.FormatIndentDecrease,
|
||||
contentDescription = "unTab"
|
||||
) {
|
||||
onEdit(Constants.Editor.UN_TAB)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
painter = R.drawable.function,
|
||||
contentDescription = "Math"
|
||||
) {
|
||||
onEdit(Constants.Editor.INLINE_MATH)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.FormatQuote,
|
||||
contentDescription = "Quote"
|
||||
) {
|
||||
onEdit(Constants.Editor.QUOTE)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.AutoMirrored.Outlined.Label,
|
||||
contentDescription = "Alert",
|
||||
) {
|
||||
isAlertExpanded = !isAlertExpanded
|
||||
}
|
||||
|
||||
AnimatedVisibility(visible = isAlertExpanded) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start
|
||||
) {
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Info,
|
||||
contentDescription = "Note Alert",
|
||||
) {
|
||||
onEdit(Constants.Editor.NOTE)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Lightbulb,
|
||||
contentDescription = "Tip Alert",
|
||||
) {
|
||||
onEdit(Constants.Editor.TIP)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Feedback,
|
||||
contentDescription = "Important Alert",
|
||||
) {
|
||||
onEdit(Constants.Editor.IMPORTANT)
|
||||
|
||||
}
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Warning,
|
||||
contentDescription = "Warning Alert",
|
||||
) {
|
||||
onEdit(Constants.Editor.WARNING)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.ReportGmailerrorred,
|
||||
contentDescription = "Caution Alert",
|
||||
) {
|
||||
onEdit(Constants.Editor.CAUTION)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.HorizontalRule,
|
||||
contentDescription = "Horizontal Rule",
|
||||
) {
|
||||
onEdit(Constants.Editor.RULE)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.TableChart,
|
||||
contentDescription = "Table",
|
||||
onClick = onTableButtonClick
|
||||
)
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.AddChart,
|
||||
contentDescription = "Mermaid Diagram",
|
||||
) {
|
||||
onEdit(Constants.Editor.DIAGRAM)
|
||||
}
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.AutoMirrored.Outlined.List,
|
||||
contentDescription = "List",
|
||||
onClick = onListButtonClick
|
||||
)
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.CheckBox,
|
||||
contentDescription = "Task List",
|
||||
onClick = onTaskButtonClick
|
||||
)
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Link,
|
||||
contentDescription = "Link",
|
||||
onClick = onLinkButtonClick
|
||||
)
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Mic,
|
||||
contentDescription = "Audio",
|
||||
onClick = onRecordAudioClick
|
||||
)
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.AudioFile,
|
||||
contentDescription = "Audio",
|
||||
onClick = onAudioButtonClick
|
||||
)
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.VideoFile,
|
||||
contentDescription = "Video",
|
||||
onClick = onVideoButtonClick
|
||||
)
|
||||
|
||||
CustomIconButton(
|
||||
imageVector = Icons.Outlined.Image,
|
||||
contentDescription = "Image",
|
||||
onClick = onImageButtonClick
|
||||
)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,182 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Label
|
||||
import androidx.compose.material.icons.automirrored.filled.LabelImportant
|
||||
import androidx.compose.material.icons.automirrored.filled.Notes
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flux.R
|
||||
import com.flux.data.model.NotesModel
|
||||
import com.flux.other.parseMarkdownContent
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NotesPreviewCard(
|
||||
modifier: Modifier = Modifier,
|
||||
radius: Int,
|
||||
isSelected: Boolean,
|
||||
note: NotesModel,
|
||||
labels: List<String>,
|
||||
onClick: (String) -> Unit,
|
||||
onLongPressed: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)),
|
||||
modifier = modifier
|
||||
.clip(shapeManager(isBoth = true, radius = radius / 2))
|
||||
.combinedClickable(
|
||||
onClick = { onClick(note.notesId) },
|
||||
onLongClick = onLongPressed
|
||||
),
|
||||
shape = shapeManager(isBoth = true, radius = radius / 2),
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp)
|
||||
) {
|
||||
Text(
|
||||
text = note.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.alpha(0.75f)
|
||||
.padding(horizontal = 12.dp)
|
||||
.padding(top = 12.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = parseMarkdownContent(note.description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 12,
|
||||
modifier = Modifier
|
||||
.alpha(0.9f)
|
||||
.padding(horizontal = 12.dp)
|
||||
)
|
||||
|
||||
val maxVisibleLabels = 2
|
||||
val visibleLabels = labels.take(maxVisibleLabels)
|
||||
val extraCount = labels.size - maxVisibleLabels
|
||||
|
||||
if (visibleLabels.isNotEmpty()) {
|
||||
FlowRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
visibleLabels.forEach { label ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.LabelImportant,
|
||||
null,
|
||||
modifier = Modifier.size(15.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (extraCount > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||
) {
|
||||
Text(
|
||||
text = "+$extraCount",
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyNotes() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Notes,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Notes))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyLabels() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Label,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Labels))
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
|
||||
@Composable
|
||||
fun RenderRadio(
|
||||
enabled: Boolean,
|
||||
onRadioEnabled: () -> Unit
|
||||
) {
|
||||
RadioButton(
|
||||
selected = enabled,
|
||||
onClick = {
|
||||
onRadioEnabled()
|
||||
},
|
||||
modifier = Modifier
|
||||
.scale(0.9f)
|
||||
.padding(0.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderCategoryTitle(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderCategoryDescription(subTitle: String) {
|
||||
if (subTitle.isNotBlank()) {
|
||||
Text(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
text = subTitle,
|
||||
fontSize = 10.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderCategoryIcon(icon: ImageVector) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
shape = RoundedCornerShape(50)
|
||||
),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
modifier = Modifier
|
||||
.scale(1f)
|
||||
.padding(9.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandIn
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun SelectableColorPlatte(
|
||||
modifier: Modifier = Modifier,
|
||||
selected: Boolean,
|
||||
colorScheme: ColorScheme,
|
||||
onClick: () -> Unit
|
||||
) = Box(modifier = modifier.clip(MaterialTheme.shapes.large)) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.clickable { onClick() }
|
||||
.padding(6.dp)
|
||||
.size(48.dp),
|
||||
shape = CircleShape,
|
||||
color = colorScheme.primary,
|
||||
) {
|
||||
Box {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.offset((-24).dp, 24.dp),
|
||||
color = colorScheme.tertiary,
|
||||
) {}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.offset(24.dp, 24.dp),
|
||||
color = colorScheme.secondaryContainer,
|
||||
) {}
|
||||
AnimatedVisibility(
|
||||
visible = selected,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.clip(CircleShape)
|
||||
.background(colorScheme.tertiaryContainer),
|
||||
enter = fadeIn() + expandIn(expandFrom = Alignment.Center),
|
||||
exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut()
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Check,
|
||||
contentDescription = "Checked",
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.size(16.dp),
|
||||
tint = MaterialTheme.colorScheme.onTertiary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun SettingCategory(
|
||||
title: String,
|
||||
subTitle: String = "",
|
||||
icon: ImageVector,
|
||||
shape: RoundedCornerShape,
|
||||
isLast: Boolean = false,
|
||||
action: () -> Unit = {},
|
||||
composableAction: @Composable (() -> Unit) -> Unit = {},
|
||||
) {
|
||||
var showCustomAction by remember { mutableStateOf(false) }
|
||||
if (showCustomAction) composableAction { showCustomAction = !showCustomAction }
|
||||
|
||||
ElevatedCard(
|
||||
shape = shape,
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.clickable {
|
||||
showCustomAction = showCustomAction.not()
|
||||
action()
|
||||
},
|
||||
colors = CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RenderCategoryIcon(icon = icon)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Column {
|
||||
RenderCategoryTitle(title = title)
|
||||
RenderCategoryDescription(subTitle = subTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(if (isLast) 12.dp else 2.dp))
|
||||
}
|
||||
|
||||
fun shapeManager(
|
||||
isBoth: Boolean = false,
|
||||
isLast: Boolean = false,
|
||||
isFirst: Boolean = false,
|
||||
radius: Int
|
||||
): RoundedCornerShape {
|
||||
val smallerRadius: Dp = (radius / 5).dp
|
||||
val defaultRadius: Dp = radius.dp
|
||||
|
||||
return when {
|
||||
isBoth -> RoundedCornerShape(defaultRadius)
|
||||
isLast -> RoundedCornerShape(smallerRadius, smallerRadius, defaultRadius, defaultRadius)
|
||||
isFirst -> RoundedCornerShape(defaultRadius, defaultRadius, smallerRadius, smallerRadius)
|
||||
else -> RoundedCornerShape(smallerRadius)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CircleWrapper(
|
||||
color: Color = MaterialTheme.colorScheme.background,
|
||||
size: Dp = 8.dp,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = color,
|
||||
shape = RoundedCornerShape(50)
|
||||
)
|
||||
.padding(size),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MaterialText(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
description: String? = null,
|
||||
titleSize: TextUnit = 14.sp,
|
||||
descriptionSize: TextUnit = 11.sp,
|
||||
center: Boolean = false,
|
||||
titleColor: Color = MaterialTheme.colorScheme.onSurface,
|
||||
descriptionColor: Color = MaterialTheme.colorScheme.primary
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = if (center) Alignment.CenterHorizontally else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontSize = titleSize),
|
||||
color = titleColor,
|
||||
textAlign = if (center) TextAlign.Center else TextAlign.Start
|
||||
)
|
||||
if (description != null) {
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall.copy(fontSize = descriptionSize),
|
||||
color = descriptionColor,
|
||||
maxLines = 6,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun TimelineBody(
|
||||
isLast: Boolean,
|
||||
color: Color = MaterialTheme.colorScheme.onSurface.copy(0.3f),
|
||||
thickness: Dp = 2.dp
|
||||
) {
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.width(16.dp)
|
||||
.fillMaxHeight()
|
||||
) {
|
||||
if (!isLast) {
|
||||
val x = size.width / 2f
|
||||
drawLine(
|
||||
color = color,
|
||||
start = Offset(x, 0f),
|
||||
end = Offset(x, size.height),
|
||||
strokeWidth = thickness.toPx(),
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,328 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowDropDown
|
||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.GridView
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.ViewStream
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.VerticalDivider
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.other.canScheduleReminder
|
||||
import com.flux.other.isNotificationPermissionGranted
|
||||
import com.flux.other.openAppNotificationSettings
|
||||
import com.flux.other.requestExactAlarmPermission
|
||||
import java.time.LocalDate
|
||||
import java.time.LocalTime
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@Composable
|
||||
fun SpacesToolBar(
|
||||
title: String,
|
||||
icon: ImageVector,
|
||||
isEmptyWorkspace: Boolean,
|
||||
onMainClick: () -> Unit,
|
||||
onEditClick: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(50))
|
||||
.background(MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp))
|
||||
) {
|
||||
if (isEmptyWorkspace) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { onEditClick() }
|
||||
.padding(vertical = 6.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = stringResource(R.string.Add_Spaces_Content_Desc),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.Add_Space),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.height(IntrinsicSize.Min),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.clickable { onMainClick() }
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = stringResource(R.string.Space_Content_Desc),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.ArrowDropDown,
|
||||
contentDescription = stringResource(R.string.Space_Content_Desc),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
VerticalDivider(Modifier.fillMaxHeight())
|
||||
|
||||
// Right section (Edit icon)
|
||||
Row(
|
||||
modifier = Modifier.clickable { onEditClick() },
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
modifier = Modifier.padding(6.dp),
|
||||
contentDescription = stringResource(R.string.Edit_Space_Content_Desc),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TodoToolBar(navController: NavController, workspaceId: String) {
|
||||
IconButton({ navController.navigate(NavRoutes.TodoDetail.withArgs(workspaceId, "")) }) {
|
||||
Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun JournalToolBar(
|
||||
navController: NavController,
|
||||
workspaceId: String,
|
||||
selectedEpochDay: Long,
|
||||
isMonthlyView: Boolean,
|
||||
onChangeView: (Boolean) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val zoneId = ZoneId.systemDefault()
|
||||
|
||||
val selectedDate = remember(selectedEpochDay) {
|
||||
LocalDate.ofEpochDay(selectedEpochDay)
|
||||
}
|
||||
|
||||
val today = LocalDate.now()
|
||||
val isToday = selectedDate.isEqual(today)
|
||||
|
||||
Row {
|
||||
IconButton(onClick = { onChangeView(!isMonthlyView) }) {
|
||||
Icon(
|
||||
imageVector = if (isMonthlyView)
|
||||
Icons.Default.CalendarViewDay
|
||||
else
|
||||
Icons.Default.CalendarViewMonth,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (selectedDate.isAfter(today)) {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
"You can't write journals for future dates",
|
||||
Toast.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
return@IconButton
|
||||
}
|
||||
|
||||
val navigationEpochMillis =
|
||||
if (isToday) {
|
||||
System.currentTimeMillis()
|
||||
} else {
|
||||
selectedDate
|
||||
.atStartOfDay(zoneId)
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}
|
||||
|
||||
navController.navigate(
|
||||
NavRoutes.EditJournal.withArgs(
|
||||
workspaceId,
|
||||
"",
|
||||
navigationEpochMillis
|
||||
)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
fun EventToolBar(navController: NavController, workspaceId: String, context: Context, selectedDate: Long, isMonthlyView: Boolean, onClick: (Boolean) -> Unit) {
|
||||
Row {
|
||||
IconButton({ onClick(!isMonthlyView) }) {
|
||||
Icon(
|
||||
if (isMonthlyView) Icons.Default.CalendarViewDay else Icons.Default.CalendarViewMonth,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
IconButton({
|
||||
if (!canScheduleReminder(context)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getText(R.string.Reminder_Permission),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
requestExactAlarmPermission(context)
|
||||
}
|
||||
if (!isNotificationPermissionGranted(context)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getText(R.string.Notification_Permission),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
openAppNotificationSettings(context)
|
||||
}
|
||||
if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) {
|
||||
val localDate = LocalDate.ofEpochDay(selectedDate)
|
||||
val currentTime = LocalTime.now()
|
||||
val zonedDateTime = ZonedDateTime.of(localDate, currentTime, ZoneId.systemDefault())
|
||||
val selectedDateMillis = zonedDateTime.toInstant().toEpochMilli()
|
||||
|
||||
navController.navigate(NavRoutes.NewEvent.withArgs(workspaceId, "", selectedDateMillis))
|
||||
}
|
||||
}) { Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.S)
|
||||
@Composable
|
||||
fun HabitToolBar(context: Context, onAddClick: () -> Unit) {
|
||||
IconButton({
|
||||
if (!canScheduleReminder(context)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getText(R.string.Reminder_Permission),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
requestExactAlarmPermission(context)
|
||||
}
|
||||
if (!isNotificationPermissionGranted(context)) {
|
||||
Toast.makeText(
|
||||
context,
|
||||
context.getText(R.string.Notification_Permission),
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
openAppNotificationSettings(context)
|
||||
}
|
||||
if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) {
|
||||
onAddClick()
|
||||
}
|
||||
}) { Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NotesToolBar(
|
||||
navController: NavController,
|
||||
workspaceId: String,
|
||||
query: String,
|
||||
isGridView: Boolean,
|
||||
onImportNote: () -> Unit,
|
||||
onChangeView: () -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
var onSearchClicked by remember { mutableStateOf(false) }
|
||||
|
||||
if (!onSearchClicked) {
|
||||
Row {
|
||||
IconButton({ onSearchClicked = true }) {
|
||||
Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onChangeView) {
|
||||
val icon = when {
|
||||
isGridView -> Icons.Default.ViewStream
|
||||
else -> Icons.Default.GridView
|
||||
}
|
||||
Icon(icon, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton(onImportNote) {
|
||||
Icon(Icons.Default.Download, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
IconButton({
|
||||
navController.navigate(
|
||||
NavRoutes.NoteDetails.withArgs(
|
||||
workspaceId,
|
||||
""
|
||||
)
|
||||
)
|
||||
}) {
|
||||
Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NotesSearchBar(
|
||||
query = query,
|
||||
onQueryChange = { onSearch(it) },
|
||||
onCloseClicked = { onSearchClicked = false },
|
||||
modifier = Modifier.width(200.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Deselect
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.PushPin
|
||||
import androidx.compose.material.icons.filled.RemoveRedEye
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.SearchOff
|
||||
import androidx.compose.material.icons.filled.SelectAll
|
||||
import androidx.compose.material.icons.filled.Summarize
|
||||
import androidx.compose.material.icons.outlined.PushPin
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NoteDetailsTopBar(
|
||||
isPinned: Boolean,
|
||||
isSearching: Boolean,
|
||||
isReadView: Boolean,
|
||||
onBackPressed: () -> Unit,
|
||||
onOutlineClicked: () -> Unit,
|
||||
onReadClick: () -> Unit,
|
||||
onEditClick: ()->Unit,
|
||||
onDelete: () -> Unit,
|
||||
onAddLabel: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onAboutClicked: () -> Unit,
|
||||
onShareNote: () -> Unit,
|
||||
onSaveNote: () -> Unit,
|
||||
onPrintNote: () -> Unit,
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if(!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp))
|
||||
.clickable { onEditClick() }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(1.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if(isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp))
|
||||
.clickable { onReadClick() }
|
||||
.padding(8.dp)
|
||||
) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } },
|
||||
actions = {
|
||||
if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } }
|
||||
IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) }
|
||||
DropdownMenuWithDetails(isPinned, onTogglePinned, onAddLabel, onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete) }
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun JournalDetailsTopBar(
|
||||
isSearching: Boolean,
|
||||
isReadView: Boolean,
|
||||
onBackPressed: () -> Unit,
|
||||
onOutlineClicked: () -> Unit,
|
||||
onReadClick: () -> Unit,
|
||||
onEditClick: ()->Unit,
|
||||
onDelete: () -> Unit,
|
||||
onSearchClick: () -> Unit,
|
||||
onAboutClicked: () -> Unit,
|
||||
onShareNote: () -> Unit,
|
||||
onSaveNote: () -> Unit,
|
||||
onPrintNote: () -> Unit,
|
||||
) {
|
||||
CenterAlignedTopAppBar(
|
||||
title = {
|
||||
Row {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if(!isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomStart = 32.dp, topStart = 32.dp))
|
||||
.clickable { onEditClick() }
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Edit, null, tint= if(!isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Spacer(Modifier.width(1.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
if(isReadView) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(bottomEnd = 32.dp, topEnd = 32.dp))
|
||||
.clickable { onReadClick() }
|
||||
.padding(8.dp)
|
||||
) { Icon(Icons.Default.RemoveRedEye, null, tint=if(isReadView) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
navigationIcon = { IconButton(onClick = onBackPressed) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) } },
|
||||
actions = {
|
||||
if(!isReadView){ IconButton({onSearchClick()}) { Icon(if(isSearching) Icons.Default.SearchOff else Icons.Default.Search, null) } }
|
||||
IconButton({onOutlineClicked()}) { Icon(Icons.Default.Summarize, null) }
|
||||
JournalDropdownMenu(onAboutClicked, onShareNote, onSaveNote, onPrintNote, onDelete) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun WorkspaceTopBar(
|
||||
workspace: WorkspaceModel,
|
||||
onBackPressed: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleLock: () -> Unit,
|
||||
onAddCover: () -> Unit,
|
||||
onEditDetails: () -> Unit,
|
||||
onEditLabel: () -> Unit,
|
||||
onRemoveCover: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(if (workspace.cover.isNotBlank()) 160.dp else 80.dp)
|
||||
) {
|
||||
// Background image
|
||||
AsyncImage(
|
||||
model = workspace.cover,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
|
||||
// Overlay TopAppBar
|
||||
TopAppBar(
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = onBackPressed,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
contentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) { Icon(Icons.AutoMirrored.Default.ArrowBack, null) }
|
||||
},
|
||||
actions = {
|
||||
WorkspaceMore(
|
||||
isCoverAdded = workspace.cover.isNotBlank(),
|
||||
isLocked = workspace.passKey.isNotBlank(),
|
||||
isPinned = workspace.isPinned,
|
||||
showEditLabel = workspace.selectedSpaces.contains(1),
|
||||
onDelete = onDelete,
|
||||
onEditDetails = onEditDetails,
|
||||
onEditLabel = onEditLabel,
|
||||
onRemoveCover = onRemoveCover,
|
||||
onAddCover = onAddCover,
|
||||
onTogglePinned = onTogglePinned,
|
||||
onToggleLock = onToggleLock
|
||||
)
|
||||
},
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun SelectedBar(
|
||||
showDeleteOption: Boolean=true,
|
||||
isAllSelected: Boolean,
|
||||
isAllSelectedPinned: Boolean,
|
||||
selectedItemsSize: Int,
|
||||
onPinClick: () -> Unit,
|
||||
onDeleteClick: () -> Unit,
|
||||
onSelectAllClick: () -> Unit,
|
||||
onCloseClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
IconButton(onCloseClick) {
|
||||
Icon(
|
||||
Icons.Default.Close,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
Text("$selectedItemsSize", color = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
IconButton(onClick = {
|
||||
onPinClick()
|
||||
onCloseClick()
|
||||
}) { Icon(if(isAllSelectedPinned) Icons.Filled.PushPin else Icons.Outlined.PushPin, null, tint = MaterialTheme.colorScheme.primary) }
|
||||
IconButton(onSelectAllClick) { Icon(if(isAllSelected) Icons.Default.Deselect else Icons.Default.SelectAll, null, tint = MaterialTheme.colorScheme.primary) }
|
||||
if(showDeleteOption) IconButton({
|
||||
onDeleteClick()
|
||||
} ) { Icon(Icons.Default.Delete, null, tint = MaterialTheme.colorScheme.primary) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package com.flux.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Workspaces
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.net.toUri
|
||||
import coil.compose.AsyncImage
|
||||
import com.flux.R
|
||||
import com.flux.other.icons
|
||||
|
||||
@Composable
|
||||
fun WorkspaceCard(
|
||||
gridColumns: Int,
|
||||
radius: Int,
|
||||
isLocked: Boolean = false,
|
||||
cover: String,
|
||||
title: String,
|
||||
description: String,
|
||||
iconIndex: Int,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onLongPressed: ()->Unit
|
||||
){
|
||||
val coverHeight = when (gridColumns) {
|
||||
1 -> 120.dp
|
||||
2 -> 100.dp
|
||||
else -> 80.dp
|
||||
}
|
||||
|
||||
val maxTitleLines = when (gridColumns) {
|
||||
1 -> 2
|
||||
else -> 1
|
||||
}
|
||||
|
||||
val maxDescriptionLines = when (gridColumns) {
|
||||
1 -> 3
|
||||
else -> 2
|
||||
}
|
||||
|
||||
val paddingValues = when (gridColumns) {
|
||||
1 -> 8.dp
|
||||
2 -> 6.dp
|
||||
else -> 4.dp
|
||||
}
|
||||
|
||||
val iconSize = when (gridColumns) {
|
||||
1 -> 28.dp
|
||||
2 -> 24.dp
|
||||
else -> 18.dp
|
||||
}
|
||||
|
||||
val titleStyle = when (gridColumns) {
|
||||
1 -> MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
2 -> MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold)
|
||||
else -> MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
val descriptionStyle = when (gridColumns) {
|
||||
1 -> MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Normal)
|
||||
2 -> MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Normal)
|
||||
else -> MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.ExtraLight)
|
||||
}
|
||||
|
||||
Card(
|
||||
shape = shapeManager(radius = radius*2),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = paddingValues)
|
||||
.clip(shapeManager(radius = radius*2))
|
||||
.combinedClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongPressed
|
||||
),
|
||||
colors = CardDefaults.elevatedCardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)),
|
||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = 4.dp)
|
||||
) {
|
||||
if (cover.isBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(coverHeight)
|
||||
.alpha(0.125f)
|
||||
.background(MaterialTheme.colorScheme.onSurface)
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = cover.toUri(),
|
||||
modifier = Modifier
|
||||
.height(coverHeight)
|
||||
.alpha(0.8f),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = paddingValues),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
icons[iconIndex],
|
||||
null,
|
||||
Modifier.size(iconSize),
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
if (isLocked) Icon(
|
||||
Icons.Default.Lock,
|
||||
null,
|
||||
Modifier.size(iconSize),
|
||||
MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(
|
||||
title,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
maxLines = maxTitleLines,
|
||||
style = titleStyle,
|
||||
overflow = TextOverflow.Clip,
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
description,
|
||||
style = descriptionStyle,
|
||||
modifier = Modifier
|
||||
.padding(bottom = 6.dp)
|
||||
.padding(horizontal = paddingValues),
|
||||
maxLines = maxDescriptionLines,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptySpaces() {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Workspaces,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Workspace))
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
package com.flux.ui.events
|
||||
|
||||
import android.content.Context
|
||||
import com.flux.data.model.HabitConfig
|
||||
import com.flux.data.model.HabitInstanceModel
|
||||
import com.flux.data.model.HabitModel
|
||||
|
||||
sealed class HabitEvents {
|
||||
data class DeleteAllWorkspaceHabits(val workspaceId: String, val context: Context) : HabitEvents()
|
||||
data class EnterWorkspace(val workspaceId: String) : HabitEvents()
|
||||
data class DeleteHabit(val habit: HabitModel, val context: Context) : HabitEvents()
|
||||
data class UpsertHabit(val context: Context, val habit: HabitModel) : HabitEvents()
|
||||
data class MarkDone(val habitInstance: HabitInstanceModel) : HabitEvents()
|
||||
data class MarkUndone(val habitInstance: HabitInstanceModel) : HabitEvents()
|
||||
data class UpdateInstance(val habitInstance: HabitInstanceModel, val config: HabitConfig) : HabitEvents()
|
||||
}
|
||||
@@ -6,19 +6,15 @@ import android.webkit.WebView
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import com.flux.data.model.JournalModel
|
||||
import com.flux.other.ExportType
|
||||
import java.time.YearMonth
|
||||
|
||||
sealed class JournalEvents {
|
||||
data class UpsertEntry(val entry: JournalModel) : JournalEvents()
|
||||
data class DeleteEntry(val entry: JournalModel) : JournalEvents()
|
||||
data class DeleteWorkspaceEntries(val workspaceId: String) : JournalEvents()
|
||||
data class EnterWorkspace(val workspaceId: String) : JournalEvents()
|
||||
data class ImportAudio(val context: Context, val sourceUri: Uri, val contentState: TextFieldState): JournalEvents()
|
||||
data class ImportImages(val context: Context, val uriList: List<Uri>, val contentState: TextFieldState): JournalEvents()
|
||||
data class ImportVideo(val context: Context, val uri: Uri, val contentState: TextFieldState): JournalEvents()
|
||||
data class ExportJournal(val context: Context, val type: ExportType, val title: String, val content: String, val webView: WebView?): JournalEvents()
|
||||
data class CalculateOutline(val content: CharSequence): JournalEvents()
|
||||
data class CalculateTextState(val content: CharSequence): JournalEvents()
|
||||
data class ChangeMonth(val newYearMonth: YearMonth) : JournalEvents()
|
||||
data class ChangeDate(val newLocalDate: Long) : JournalEvents()
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.flux.ui.events
|
||||
|
||||
import com.flux.data.model.LabelModel
|
||||
|
||||
sealed class LabelEvents {
|
||||
data class DeleteLabel(val data: LabelModel) : LabelEvents()
|
||||
data class UpsertLabel(val data: LabelModel) : LabelEvents()
|
||||
data class DeleteAllWorkspaceLabels(val workspaceId: String) : LabelEvents()
|
||||
}
|
||||
@@ -4,19 +4,15 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import com.flux.data.model.LabelModel
|
||||
import com.flux.data.model.NotesModel
|
||||
import com.flux.other.ExportType
|
||||
|
||||
sealed class NotesEvents {
|
||||
data class DeleteAllWorkspaceNotes(val workspaceId: String) : NotesEvents()
|
||||
data class EnterWorkspace(val workspaceId: String) : NotesEvents()
|
||||
data class DeleteNote(val data: NotesModel) : NotesEvents()
|
||||
data class DeleteNotes(val data: List<NotesModel>) : NotesEvents()
|
||||
data class TogglePinMultiple(val data: List<NotesModel>) : NotesEvents()
|
||||
data class UpsertNote(val data: NotesModel) : NotesEvents()
|
||||
data class DeleteLabel(val data: LabelModel) : NotesEvents()
|
||||
data class UpsertLabel(val data: LabelModel) : NotesEvents()
|
||||
data class SelectNotes(val noteId: String) : NotesEvents()
|
||||
data class UnSelectNotes(val noteId: String) : NotesEvents()
|
||||
data class ImportAudio(val context: Context, val sourceUri: Uri, val contentState: TextFieldState): NotesEvents()
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.flux.ui.events
|
||||
|
||||
import com.flux.data.model.ProgressBoardModel
|
||||
|
||||
sealed class ProgressBoardEvents {
|
||||
data class DeleteBoardItemsByWorkspace(val workspaceId: String) : ProgressBoardEvents()
|
||||
data class DeleteProgressItem(val data: ProgressBoardModel) : ProgressBoardEvents()
|
||||
data class UpsertProgressItem(val data: ProgressBoardModel) : ProgressBoardEvents()
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import java.time.YearMonth
|
||||
|
||||
sealed class TaskEvents {
|
||||
data class DeleteAllWorkspaceEvents(val workspaceId: String, val context: Context) : TaskEvents()
|
||||
data class EnterWorkspace(val workspaceId: String) : TaskEvents()
|
||||
data class UpsertTask(val context: Context, val taskEvent: EventModel) : TaskEvents()
|
||||
data class DeleteTask(val taskEvent: EventModel, val context: Context) : TaskEvents()
|
||||
data class ToggleStatus(val markDone: Boolean, val eventId: String, val workspaceId: String, val date: Long) : TaskEvents()
|
||||
|
||||
@@ -1,10 +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 EnterWorkspace(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()
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import com.flux.data.model.WorkspaceModel
|
||||
|
||||
sealed class WorkspaceEvents {
|
||||
data class ChangeCover(val context: Context, val uri: Uri, val workspace: WorkspaceModel): WorkspaceEvents()
|
||||
data class DeleteSpace(val space: WorkspaceModel) : WorkspaceEvents()
|
||||
data class UpsertSpace(val space: WorkspaceModel) : WorkspaceEvents()
|
||||
data class UpsertSpaces(val spaces: List<WorkspaceModel>) : WorkspaceEvents()
|
||||
data class DeleteSpace(val workspace: WorkspaceModel) : WorkspaceEvents()
|
||||
data class UpsertSpace(val workspace: WorkspaceModel) : WorkspaceEvents()
|
||||
data class UpsertSpaces(val workspaces: List<WorkspaceModel>) : WorkspaceEvents()
|
||||
data class ChangeWorkspace(val workspace: WorkspaceModel) : WorkspaceEvents()
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -27,25 +28,33 @@ import androidx.compose.material.icons.filled.LocalFireDepartment
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.lerp
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.data.model.EventInstanceModel
|
||||
import com.flux.data.model.EventModel
|
||||
@@ -53,11 +62,17 @@ import com.flux.data.model.HabitInstanceModel
|
||||
import com.flux.data.model.HabitModel
|
||||
import com.flux.data.model.JournalModel
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
import com.flux.data.model.isCompleted
|
||||
import com.flux.data.model.occursOn
|
||||
import com.flux.ui.components.ActionType
|
||||
import com.flux.ui.components.SettingOption
|
||||
import com.flux.ui.components.WeeklyHabitProgressChart
|
||||
import com.flux.ui.components.shapeManager
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.ui.common.SpaceTopBar
|
||||
import com.flux.ui.common.SpacesMenu
|
||||
import com.flux.ui.screens.habits.HabitsWeeklyProgressAnalysis
|
||||
import com.flux.ui.screens.settings.ActionType
|
||||
import com.flux.ui.screens.settings.SettingOption
|
||||
import com.flux.ui.screens.settings.shapeManager
|
||||
import com.flux.ui.screens.workspaces.SpacesToolBar
|
||||
import com.flux.ui.state.States
|
||||
import com.flux.ui.theme.completed
|
||||
import com.flux.ui.theme.failed
|
||||
import com.flux.ui.theme.pending
|
||||
@@ -69,62 +84,125 @@ import java.time.ZoneId
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.time.temporal.TemporalAdjusters
|
||||
|
||||
fun LazyListScope.analyticsItems(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AnalyticScreen(
|
||||
navController: NavController,
|
||||
states: States,
|
||||
workspace: WorkspaceModel,
|
||||
radius: Int,
|
||||
allHabitInstances: List<HabitInstanceModel>,
|
||||
totalHabits: Int,
|
||||
totalNotes: Int,
|
||||
journalEntries: List<JournalModel>,
|
||||
allHabits: List<HabitModel>,
|
||||
allEvents: List<EventModel>,
|
||||
allEventInstances: List<EventInstanceModel>
|
||||
) {
|
||||
when {
|
||||
workspace.selectedSpaces.isEmpty() -> item { EmptyAnalytics() }
|
||||
else -> {
|
||||
if (workspace.selectedSpaces.contains(1)){
|
||||
item {
|
||||
SettingOption(
|
||||
title = stringResource(R.string.Notes),
|
||||
description = totalNotes.toString(),
|
||||
icon = Icons.AutoMirrored.Default.Notes,
|
||||
radius = shapeManager(radius = radius, isBoth = true),
|
||||
actionType = ActionType.None
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (workspace.selectedSpaces.contains(4)) {
|
||||
item {
|
||||
JournalAnalytics(radius, journalEntries)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (workspace.selectedSpaces.contains(3)) {
|
||||
item {
|
||||
ChartCirclePie(
|
||||
radius = radius,
|
||||
weeklyEventStats = calculateWeeklyStats(allEvents, allEventInstances)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (workspace.selectedSpaces.contains(5)){
|
||||
item {
|
||||
HabitHeatMap(radius, allHabitInstances, totalHabits)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if(workspace.selectedSpaces.contains(5)){
|
||||
item {
|
||||
WeeklyHabitProgressChart(
|
||||
radius,
|
||||
habits = allHabits,
|
||||
habitInstances = allHabitInstances,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
selectedSpaces: List<Int>,
|
||||
onShowSpaceBottomSheet: () -> Unit,
|
||||
onSpaceChange: (Int) -> Unit,
|
||||
onAddCover: () -> Unit,
|
||||
onRemoveCover: () -> Unit,
|
||||
onDeleteWorkspace: () -> Unit,
|
||||
onToggleLock: () -> Unit
|
||||
){
|
||||
val workspaceId = workspace.workspaceId
|
||||
val totalNotes = states.notesState.allNotes.filter { it.workspaceId == workspaceId }.size
|
||||
val radius = states.settings.data.cornerRadius
|
||||
val allEvents = states.eventState.allEvent.filter { it.workspaceId == workspaceId }
|
||||
val allEventInstances = states.eventState.allEventInstances.filter { it.workspaceId == workspaceId }
|
||||
val journalEntries = states.journalState.data.filter { it.workspaceId == workspaceId }
|
||||
val allHabits = states.habitState.allHabits.filter { it.workspaceId == workspaceId }
|
||||
val allHabitInstances = states.habitState.allInstances.filter { it.workspaceId == workspaceId }
|
||||
val totalHabits = states.habitState.allHabits.filter { it.workspaceId == workspaceId }.size
|
||||
var showSpacesMenu by remember { mutableStateOf(false) }
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
topBar = {
|
||||
SpaceTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
title = workspace.title,
|
||||
description = workspace.description,
|
||||
cover = workspace.cover,
|
||||
icon = workspace.icon,
|
||||
isLocked = workspace.passKey!=null,
|
||||
onBackPressed = { navController.popBackStack() },
|
||||
onAddCover = onAddCover,
|
||||
onRemoveCover = onRemoveCover,
|
||||
onToggleLock = onToggleLock,
|
||||
onDeleteWorkspace = onDeleteWorkspace,
|
||||
onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) }
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
when {
|
||||
selectedSpaces.isEmpty() -> EmptyAnalytics()
|
||||
else -> {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(12.dp)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom=8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
SpacesToolBar(
|
||||
stringResource(R.string.Analytics),
|
||||
Icons.Default.Analytics,
|
||||
false,
|
||||
onMainClick = { showSpacesMenu = true },
|
||||
onEditClick = onShowSpaceBottomSheet
|
||||
)
|
||||
SpacesMenu(showSpacesMenu, workspace, onSpaceChange) { showSpacesMenu = false }
|
||||
}
|
||||
}
|
||||
if (selectedSpaces.contains(1)){
|
||||
item {
|
||||
SettingOption(
|
||||
title = stringResource(R.string.Notes),
|
||||
description = totalNotes.toString(),
|
||||
icon = Icons.AutoMirrored.Default.Notes,
|
||||
radius = shapeManager(radius = radius, isBoth = true),
|
||||
actionType = ActionType.None
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (selectedSpaces.contains(3)) {
|
||||
item {
|
||||
ChartCirclePie(
|
||||
radius = radius,
|
||||
weeklyEventStats = calculateWeeklyStats(allEvents, allEventInstances)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (selectedSpaces.contains(4)) {
|
||||
item {
|
||||
JournalAnalytics(radius, journalEntries)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
item {
|
||||
JournalHeatMap(radius, journalEntries)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (selectedSpaces.contains(5)){
|
||||
item {
|
||||
HabitHeatMap(radius, allHabits, allHabitInstances, totalHabits)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if(selectedSpaces.contains(5)){
|
||||
item {
|
||||
HabitsWeeklyProgressAnalysis(
|
||||
radius,
|
||||
habits = allHabits,
|
||||
habitInstances = allHabitInstances,
|
||||
modifier = Modifier.padding(horizontal = 16.dp)
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,7 +274,10 @@ fun calculateWeeklyStats(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HabitHeatMap(radius: Int, allHabitInstances: List<HabitInstanceModel>, totalHabits: Int) {
|
||||
fun JournalHeatMap(
|
||||
radius: Int,
|
||||
allEntries: List<JournalModel>
|
||||
){
|
||||
val today = LocalDate.now()
|
||||
val yearStart = LocalDate.of(today.year, 1, 1)
|
||||
|
||||
@@ -207,8 +288,92 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List<HabitInstanceModel>, total
|
||||
val totalDays = ChronoUnit.DAYS.between(yearStart, today).toInt() + 1
|
||||
val allDates = (0 until totalDays).map { yearStart.plusDays(it.toLong()) }
|
||||
|
||||
val habitMap = remember(allHabitInstances) {
|
||||
allHabitInstances.groupBy { LocalDate.ofEpochDay(it.instanceDate) }
|
||||
val heatMap = remember(allEntries) {
|
||||
allEntries
|
||||
.groupingBy {
|
||||
Instant.ofEpochMilli(it.dateTime)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
}
|
||||
.eachCount()
|
||||
}
|
||||
|
||||
// Create week columns with proper day alignment
|
||||
val weekColumns = mutableListOf<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.journal_heat_map),
|
||||
"",
|
||||
boxSize,
|
||||
5,
|
||||
lazyListState,
|
||||
weekColumns,
|
||||
heatMap.toMap()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HabitHeatMap(radius: Int, habits: List<HabitModel>, allHabitInstances: List<HabitInstanceModel>, totalHabits: Int) {
|
||||
val today = LocalDate.now()
|
||||
val yearStart = LocalDate.of(today.year, 1, 1)
|
||||
|
||||
// Calculate the offset from Monday for January 1st
|
||||
val jan1DayOfWeek = yearStart.dayOfWeek.value // Monday = 1, Sunday = 7
|
||||
val offsetFromMonday = jan1DayOfWeek - 1 // 0 for Monday, 6 for Sunday
|
||||
|
||||
val totalDays = ChronoUnit.DAYS.between(yearStart, today).toInt() + 1
|
||||
val allDates = (0 until totalDays).map { yearStart.plusDays(it.toLong()) }
|
||||
|
||||
val habitMapById = remember(habits) {
|
||||
habits.associateBy { it.id }
|
||||
}
|
||||
|
||||
val heatMap = remember(allHabitInstances, habits) {
|
||||
allHabitInstances
|
||||
.groupBy { LocalDate.ofEpochDay(it.instanceDate) }
|
||||
.mapValues { (_, instances) ->
|
||||
instances.count { instance ->
|
||||
val habit = habitMapById[instance.habitId] ?: return@count false
|
||||
instance.isCompleted(habit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create week columns with proper day alignment
|
||||
@@ -253,6 +418,29 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List<HabitInstanceModel>, total
|
||||
}
|
||||
}
|
||||
|
||||
HeatMapCard(
|
||||
radius,
|
||||
stringResource(R.string.HeatMap),
|
||||
"${stringResource(R.string.Completed_Today)}: $todayHabit/$totalHabits",
|
||||
boxSize,
|
||||
totalHabits,
|
||||
lazyListState,
|
||||
weekColumns,
|
||||
heatMap.toMap()
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HeatMapCard(
|
||||
radius: Int,
|
||||
title: String,
|
||||
description: String,
|
||||
boxSize: Dp,
|
||||
intensityParam: Int,
|
||||
lazyListState: LazyListState,
|
||||
weekColumns: List<List<LocalDate?>>,
|
||||
heatMap: Map<LocalDate, Int>
|
||||
){
|
||||
Card(
|
||||
onClick = {},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -268,16 +456,18 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List<HabitInstanceModel>, total
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.HeatMap),
|
||||
title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
"${stringResource(R.string.Completed_Today)}: $todayHabit/$totalHabits",
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
if(description.isNotBlank()){
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.labelMedium
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -336,9 +526,9 @@ fun HabitHeatMap(radius: Int, allHabitInstances: List<HabitInstanceModel>, total
|
||||
// Heatmap boxes
|
||||
columnDates.forEach { date ->
|
||||
if (date != null) {
|
||||
val count = habitMap[date]?.size ?: 0
|
||||
val count = heatMap[date] ?: 0
|
||||
val intensity =
|
||||
(count / if (totalHabits > 0) totalHabits.toFloat() else 2f)
|
||||
(count / if (intensityParam > 0) intensityParam.toFloat() else 2f)
|
||||
.coerceIn(0f, 1f)
|
||||
val color = lerp(
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
|
||||
|
||||
@@ -42,7 +42,7 @@ import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.other.BiometricAuthenticator
|
||||
import com.flux.ui.components.CircleWrapper
|
||||
import com.flux.ui.screens.settings.CircleWrapper
|
||||
|
||||
@Composable
|
||||
fun AuthScreen(
|
||||
|
||||
@@ -0,0 +1,651 @@
|
||||
package com.flux.ui.screens.events
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.outlined.NotificationsActive
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import com.flux.R
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.ui.screens.settings.CircleWrapper
|
||||
import com.flux.ui.screens.settings.shapeManager
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBackIos
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
|
||||
// ------------- Dialogs -------------
|
||||
@Composable
|
||||
fun EventNotificationDialog(
|
||||
currentOffset: Long,
|
||||
onChange: (Long) -> Unit,
|
||||
onCustomClick: () -> Unit,
|
||||
onDismissRequest: () -> Unit
|
||||
) {
|
||||
val options = listOf(
|
||||
0L to stringResource(R.string.On_Time),
|
||||
5L to stringResource(R.string.five_minutes_before),
|
||||
30L to stringResource(R.string.thirty_minutes_before)
|
||||
)
|
||||
|
||||
// Convert to minutes for comparison
|
||||
val currentMinutes = currentOffset / 1000 / 60
|
||||
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CircleWrapper(MaterialTheme.colorScheme.primary) {
|
||||
Icon(
|
||||
Icons.Outlined.NotificationsActive,
|
||||
contentDescription = "Notification Icon",
|
||||
tint = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
stringResource(R.string.Add_Notification),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
options.forEach { (minutesBefore, label) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
onChange(minutesBefore * 60 * 1000)
|
||||
onDismissRequest()
|
||||
}
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(label)
|
||||
RadioButton(
|
||||
selected = currentMinutes == minutesBefore,
|
||||
onClick = {
|
||||
onChange(minutesBefore * 60 * 1000)
|
||||
onDismissRequest()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable {
|
||||
onCustomClick()
|
||||
onDismissRequest()
|
||||
}
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(stringResource(R.string.Custom))
|
||||
RadioButton(
|
||||
selected = options.none { it.first == currentMinutes },
|
||||
onClick = {
|
||||
onCustomClick()
|
||||
onDismissRequest()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomNotificationDialog(
|
||||
onDismissRequest: () -> Unit,
|
||||
onConfirm: (offsetMillis: Long) -> Unit
|
||||
) {
|
||||
val timeUnits = listOf(
|
||||
stringResource(R.string.minutes),
|
||||
stringResource(R.string.hours),
|
||||
stringResource(R.string.days)
|
||||
)
|
||||
var selectedUnit by remember { mutableStateOf(timeUnits[0]) }
|
||||
var amountText by remember { mutableStateOf("1") }
|
||||
val amount = amountText.toIntOrNull()?.coerceAtLeast(1) ?: 1
|
||||
|
||||
val offsetMillis = when (selectedUnit) {
|
||||
stringResource(R.string.minutes) -> amount * 60_000L
|
||||
stringResource(R.string.hours) -> amount * 60 * 60_000L
|
||||
stringResource(R.string.days) -> amount * 24 * 60 * 60_000L
|
||||
else -> 0L
|
||||
}
|
||||
|
||||
Dialog(onDismissRequest = onDismissRequest) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.Custom_Notification),
|
||||
style = MaterialTheme.typography.titleLarge
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
timeUnits.forEach { unit ->
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.selectable(
|
||||
selected = selectedUnit == unit,
|
||||
onClick = { selectedUnit = unit }
|
||||
)
|
||||
.padding(2.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedUnit == unit,
|
||||
onClick = { selectedUnit = unit }
|
||||
)
|
||||
Text(unit, modifier = Modifier.padding(start = 8.dp))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
OutlinedTextField(
|
||||
value = amountText,
|
||||
onValueChange = { if (it.all { c -> c.isDigit() }) amountText = it },
|
||||
label = { Text(stringResource(R.string.Amount)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
keyboardType = KeyboardType.Number
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = onDismissRequest) { Text(stringResource(R.string.Cancel)) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(onClick = {
|
||||
onConfirm(offsetMillis)
|
||||
onDismissRequest()
|
||||
}) {
|
||||
Text(stringResource(R.string.Set))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconRadioButton(
|
||||
modifier: Modifier = Modifier,
|
||||
uncheckedTint: Color = MaterialTheme.colorScheme.onSurface,
|
||||
checkedTint: Color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(modifier = modifier, onClick = onClick) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Selected",
|
||||
tint = checkedTint
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = "Unselected",
|
||||
tint = uncheckedTint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EventCard(
|
||||
radius: Int,
|
||||
is24HourFormat: Boolean,
|
||||
isPending: Boolean,
|
||||
title: String,
|
||||
repeat: RecurrenceRule,
|
||||
startDateTime: Long,
|
||||
onChangeStatus: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val containerColor =
|
||||
if (isPending) MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
|
||||
else MaterialTheme.colorScheme.primaryContainer
|
||||
val contentColor =
|
||||
if (isPending) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
val context = LocalContext.current
|
||||
val time = startDateTime.toFormattedTime(is24HourFormat)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
),
|
||||
shape = shapeManager(radius = radius * 2),
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
IconRadioButton(
|
||||
selected = !isPending,
|
||||
onClick = onChangeStatus
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"${getRecurrenceText(context, repeat, startDateTime)} at $time",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecurrenceText(context: Context, repeat: RecurrenceRule, startDateTime: Long): String {
|
||||
val localDate = Instant.ofEpochMilli(startDateTime)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
|
||||
return when (repeat) {
|
||||
is RecurrenceRule.Once -> {
|
||||
context.getString(
|
||||
R.string.recurrence_once,
|
||||
localDate.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
||||
)
|
||||
}
|
||||
|
||||
is RecurrenceRule.Custom -> {
|
||||
if (repeat.everyXDays == 1) {
|
||||
context.getString(R.string.recurrence_daily)
|
||||
} else {
|
||||
context.getString(R.string.recurrence_every_x_days, repeat.everyXDays)
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Weekly -> {
|
||||
val days = listOf("M", "T", "W", "T", "F", "S", "S")
|
||||
if (repeat.daysOfWeek.size == 7) {
|
||||
context.getString(R.string.recurrence_daily)
|
||||
} else {
|
||||
val daysText = repeat.daysOfWeek.sorted().joinToString(", ") { days[it] }
|
||||
context.getString(R.string.recurrence_weekly_on, daysText)
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Monthly -> {
|
||||
context.getString(R.string.recurrence_monthly_on, localDate.dayOfMonth)
|
||||
}
|
||||
|
||||
is RecurrenceRule.Yearly -> {
|
||||
context.getString(
|
||||
R.string.recurrence_yearly_on,
|
||||
localDate.format(DateTimeFormatter.ofPattern("MMM dd"))
|
||||
)
|
||||
}
|
||||
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
fun Long.toFormattedTime(is24Hour: Boolean = false): String {
|
||||
val pattern = if (is24Hour) "HH:mm" else "hh:mm a"
|
||||
val formatter = SimpleDateFormat(pattern, Locale.getDefault())
|
||||
return formatter.format(Date(this))
|
||||
}
|
||||
|
||||
fun Long.toFormattedDate(): String {
|
||||
val date = Date(this)
|
||||
val format = SimpleDateFormat("dd MMMM, yyyy", Locale.getDefault())
|
||||
return format.format(date)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DailyViewDateCard(date: Long, day: String, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val localDate = LocalDate.ofEpochDay(date)
|
||||
|
||||
val containerColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceContainerHighest
|
||||
val contentColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.onPrimary
|
||||
else MaterialTheme.colorScheme.onSurface
|
||||
|
||||
Card(
|
||||
modifier = Modifier.width(60.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor.copy(alpha = 0.6f),
|
||||
contentColor = contentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
onClick = onClick
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
day,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.ExtraLight),
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
)
|
||||
ElevatedCard(
|
||||
Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Text(
|
||||
localDate.dayOfMonth.toString(),
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MonthlyViewDateCard(date: Long, count: Int, maxCount: Int = 0, isSelected: Boolean, onClick: () -> Unit) {
|
||||
val localDate = LocalDate.ofEpochDay(date)
|
||||
val fraction = if (maxCount > 0 && count > 0) count.toFloat() / maxCount.toFloat() else 0f
|
||||
val containerColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.surfaceContainerLow
|
||||
val contentColor =
|
||||
if (isSelected) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
val primaryColor = MaterialTheme.colorScheme.primary
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(RoundedCornerShape(50))
|
||||
.drawBehind {
|
||||
if (fraction > 0f && !isSelected) {
|
||||
drawCircle(
|
||||
brush = Brush.radialGradient(
|
||||
colors = listOf(
|
||||
primaryColor.copy(alpha = 0.35f * fraction),
|
||||
Color.Transparent
|
||||
),
|
||||
center = center,
|
||||
radius = size.minDimension / 2f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
.clickable { onClick() },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = localDate.dayOfMonth.toString(),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor
|
||||
)
|
||||
|
||||
if (isSelected) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(containerColor)
|
||||
.padding(top = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DailyViewCalendar(
|
||||
selectedMonth: YearMonth,
|
||||
selectedDate: Long,
|
||||
onDateChange: (Long) -> Unit
|
||||
) {
|
||||
val daysInMonth = selectedMonth.lengthOfMonth()
|
||||
val dateList = (1..daysInMonth).map { day -> selectedMonth.atDay(day) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(selectedMonth, selectedDate) {
|
||||
val todayIndex = dateList.indexOfFirst { it.toEpochDay() == selectedDate }
|
||||
if (todayIndex >= 0) {
|
||||
listState.animateScrollToItem(
|
||||
index = maxOf(0, todayIndex - 2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
state = listState
|
||||
) {
|
||||
items(dateList) { date ->
|
||||
val dayName = date.dayOfWeek.name
|
||||
.take(3)
|
||||
.lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
|
||||
DailyViewDateCard(
|
||||
date = date.toEpochDay(),
|
||||
day = dayName,
|
||||
isSelected = date.toEpochDay() == selectedDate,
|
||||
onClick = { onDateChange(date.toEpochDay()) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MonthlyViewCalendar(
|
||||
currentMonth: YearMonth,
|
||||
selectedDate: Long,
|
||||
monthlyJournalCount: Map<LocalDate, Int> = emptyMap(),
|
||||
onMonthChange: (YearMonth) -> Unit,
|
||||
onDateChange: (Long) -> Unit
|
||||
) {
|
||||
val daysOfWeek = listOf(
|
||||
stringResource(R.string.monday_short),
|
||||
stringResource(R.string.tuesday_short),
|
||||
stringResource(R.string.wednesday_short),
|
||||
stringResource(R.string.thursday_short),
|
||||
stringResource(R.string.friday_short),
|
||||
stringResource(R.string.saturday_short),
|
||||
stringResource(R.string.sunday_short)
|
||||
)
|
||||
val firstDayOfMonth = currentMonth.atDay(1)
|
||||
val firstDayOffset = (firstDayOfMonth.dayOfWeek.value - 1) % 7
|
||||
val daysInMonth = currentMonth.lengthOfMonth()
|
||||
|
||||
val allDates = buildList {
|
||||
repeat(firstDayOffset) { add(null) }
|
||||
for (day in 1..daysInMonth) {
|
||||
add(currentMonth.atDay(day).toEpochDay())
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = currentMonth.month.name.lowercase()
|
||||
.replaceFirstChar { it.uppercaseChar() } + ", ${currentMonth.year}",
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
val prevMonth = currentMonth.minusMonths(1)
|
||||
onMonthChange(prevMonth)
|
||||
onDateChange(prevMonth.atDay(1).toEpochDay())
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowBackIos,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = "Previous month",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.alpha(0.5f)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = {
|
||||
val nextMonth = currentMonth.plusMonths(1)
|
||||
onMonthChange(nextMonth)
|
||||
onDateChange(nextMonth.atDay(1).toEpochDay())
|
||||
}) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Default.ArrowForwardIos,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
contentDescription = "Next month",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.alpha(0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Weekday Row
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
daysOfWeek.forEach {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier.weight(1f),
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold)
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
// Calendar Grid
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(7),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 100.dp, max = 300.dp),
|
||||
userScrollEnabled = false
|
||||
) {
|
||||
items(allDates) { date ->
|
||||
if (date == null) {
|
||||
Box(modifier = Modifier.size(48.dp))
|
||||
} else {
|
||||
MonthlyViewDateCard(
|
||||
date = date,
|
||||
isSelected = selectedDate == date,
|
||||
count = monthlyJournalCount[LocalDate.ofEpochDay(date)] ?: 0,
|
||||
maxCount = monthlyJournalCount.values.maxOrNull() ?: 0,
|
||||
onClick = { onDateChange(date) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.ui.components.DeleteAlert
|
||||
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
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package com.flux.ui.screens.events
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.TaskAlt
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.data.model.EventInstanceModel
|
||||
import com.flux.data.model.EventModel
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.navigation.Loader
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.ui.components.DailyViewCalendar
|
||||
import com.flux.ui.components.MonthlyViewCalendar
|
||||
import com.flux.ui.components.shapeManager
|
||||
import com.flux.ui.events.TaskEvents
|
||||
import com.flux.ui.state.Settings
|
||||
import java.text.SimpleDateFormat
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.YearMonth
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
fun LazyListScope.eventHomeItems(
|
||||
navController: NavController,
|
||||
radius: Int,
|
||||
is24HourFormat: Boolean,
|
||||
isLoading: Boolean,
|
||||
workspaceId: String,
|
||||
selectedMonth: YearMonth,
|
||||
selectedDate: Long,
|
||||
monthlyEventCount: Map<LocalDate, Int>,
|
||||
settings: Settings,
|
||||
datedEvents: List<EventModel>,
|
||||
allEventInstances: List<EventInstanceModel>,
|
||||
onTaskEvents: (TaskEvents) -> Unit
|
||||
) {
|
||||
val isMonthlyView = settings.data.isCalendarMonthlyView
|
||||
|
||||
if (isMonthlyView) {
|
||||
item {
|
||||
MonthlyViewCalendar(
|
||||
selectedMonth, selectedDate, monthlyEventCount,
|
||||
onMonthChange = { onTaskEvents(TaskEvents.ChangeMonth(it)) },
|
||||
onDateChange = { onTaskEvents(TaskEvents.ChangeDate(it)) }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
item {
|
||||
DailyViewCalendar(selectedMonth, selectedDate){
|
||||
onTaskEvents(TaskEvents.ChangeDate(it))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) { item { Loader() }
|
||||
} else if (datedEvents.isEmpty()) { item { EmptyEvents() } } else {
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
|
||||
val pendingTasks = datedEvents.filter { task ->
|
||||
val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate }
|
||||
instance == null
|
||||
}
|
||||
|
||||
val completedTasks = datedEvents.filter { task ->
|
||||
val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate }
|
||||
instance != null
|
||||
}
|
||||
|
||||
if (pendingTasks.isNotEmpty()) {
|
||||
items(pendingTasks) { task ->
|
||||
EventCard(
|
||||
radius = radius,
|
||||
is24HourFormat = is24HourFormat,
|
||||
isPending = true,
|
||||
title = task.title,
|
||||
repeat = task.recurrence,
|
||||
startDateTime = task.startDateTime,
|
||||
onChangeStatus = { onTaskEvents(TaskEvents.ToggleStatus(true, task.id, workspaceId, selectedDate)) },
|
||||
onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (completedTasks.isNotEmpty()) {
|
||||
items(completedTasks) { task ->
|
||||
EventCard(
|
||||
radius = radius,
|
||||
is24HourFormat = is24HourFormat,
|
||||
isPending = false,
|
||||
title = task.title,
|
||||
repeat = task.recurrence,
|
||||
startDateTime = task.startDateTime,
|
||||
onChangeStatus = { onTaskEvents(TaskEvents.ToggleStatus(false, task.id, workspaceId, selectedDate)) },
|
||||
onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyEvents() {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.TaskAlt,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Text(stringResource(R.string.Empty_Events))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IconRadioButton(
|
||||
modifier: Modifier = Modifier,
|
||||
uncheckedTint: Color = MaterialTheme.colorScheme.onSurface,
|
||||
checkedTint: Color = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
IconButton(modifier = modifier, onClick = onClick) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = "Selected",
|
||||
tint = checkedTint
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = "Unselected",
|
||||
tint = uncheckedTint
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EventCard(
|
||||
radius: Int,
|
||||
is24HourFormat: Boolean,
|
||||
isPending: Boolean,
|
||||
title: String,
|
||||
repeat: RecurrenceRule,
|
||||
startDateTime: Long,
|
||||
onChangeStatus: () -> Unit,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val containerColor =
|
||||
if (isPending) MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
|
||||
else MaterialTheme.colorScheme.primaryContainer
|
||||
val contentColor =
|
||||
if (isPending) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onPrimaryContainer
|
||||
|
||||
val context = LocalContext.current
|
||||
val time = startDateTime.toFormattedTime(is24HourFormat)
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor
|
||||
),
|
||||
shape = shapeManager(radius = radius * 2),
|
||||
onClick = onClick
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp)
|
||||
) {
|
||||
IconRadioButton(
|
||||
selected = !isPending,
|
||||
onClick = onChangeStatus
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"${getRecurrenceText(context, repeat, startDateTime)} at $time",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getRecurrenceText(context: Context, repeat: RecurrenceRule, startDateTime: Long): String {
|
||||
val localDate = Instant.ofEpochMilli(startDateTime)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
|
||||
return when (repeat) {
|
||||
is RecurrenceRule.Once -> {
|
||||
context.getString(
|
||||
R.string.recurrence_once,
|
||||
localDate.format(DateTimeFormatter.ofPattern("MMM dd, yyyy"))
|
||||
)
|
||||
}
|
||||
|
||||
is RecurrenceRule.Custom -> {
|
||||
if (repeat.everyXDays == 1) {
|
||||
context.getString(R.string.recurrence_daily)
|
||||
} else {
|
||||
context.getString(R.string.recurrence_every_x_days, repeat.everyXDays)
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Weekly -> {
|
||||
val days = listOf("M", "T", "W", "T", "F", "S", "S")
|
||||
if (repeat.daysOfWeek.size == 7) {
|
||||
context.getString(R.string.recurrence_daily)
|
||||
} else {
|
||||
val daysText = repeat.daysOfWeek.sorted().joinToString(", ") { days[it] }
|
||||
context.getString(R.string.recurrence_weekly_on, daysText)
|
||||
}
|
||||
}
|
||||
|
||||
is RecurrenceRule.Monthly -> {
|
||||
context.getString(R.string.recurrence_monthly_on, localDate.dayOfMonth)
|
||||
}
|
||||
|
||||
is RecurrenceRule.Yearly -> {
|
||||
context.getString(
|
||||
R.string.recurrence_yearly_on,
|
||||
localDate.format(DateTimeFormatter.ofPattern("MMM dd"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Long.toFormattedTime(is24Hour: Boolean = false): String {
|
||||
val pattern = if (is24Hour) "HH:mm" else "hh:mm a"
|
||||
val formatter = SimpleDateFormat(pattern, Locale.getDefault())
|
||||
return formatter.format(Date(this))
|
||||
}
|
||||
|
||||
fun Long.toFormattedDate(): String {
|
||||
val date = Date(this)
|
||||
val format = SimpleDateFormat("dd MMMM, yyyy", Locale.getDefault())
|
||||
return format.format(date)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package com.flux.ui.screens.events
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.CalendarViewDay
|
||||
import androidx.compose.material.icons.filled.CalendarViewMonth
|
||||
import androidx.compose.material.icons.filled.Event
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
import com.flux.navigation.Loader
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.other.canScheduleReminder
|
||||
import com.flux.other.computeMonthlyEventDates
|
||||
import com.flux.other.isNotificationPermissionGranted
|
||||
import com.flux.other.openAppNotificationSettings
|
||||
import com.flux.other.requestExactAlarmPermission
|
||||
import com.flux.ui.common.EmptyEvents
|
||||
import com.flux.ui.common.SpaceSearchBar
|
||||
import com.flux.ui.common.SpaceTopBar
|
||||
import com.flux.ui.common.SpacesMenu
|
||||
import com.flux.ui.events.SettingEvents
|
||||
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
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.S)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EventScreen(
|
||||
navController: NavController,
|
||||
state: EventState,
|
||||
settings: Settings,
|
||||
workspace: WorkspaceModel,
|
||||
onShowSpaceBottomSheet: () -> Unit,
|
||||
onSpaceChange: (Int) -> Unit,
|
||||
onAddCover: () -> Unit,
|
||||
onRemoveCover: () -> Unit,
|
||||
onDeleteWorkspace: () -> Unit,
|
||||
onToggleLock: () -> Unit,
|
||||
onSettingEvents: (SettingEvents) -> Unit,
|
||||
onEvent: (TaskEvents) -> Unit
|
||||
){
|
||||
val context = LocalContext.current
|
||||
val workspaceId = workspace.workspaceId
|
||||
val selectedDate = state.selectedDate
|
||||
val isLoading = state.isDatedEventLoading
|
||||
val is24HourFormat = settings.data.is24HourFormat
|
||||
val radius = settings.data.cornerRadius
|
||||
val selectedMonth = state.selectedYearMonth
|
||||
val isMonthlyView = settings.data.isCalendarMonthlyView
|
||||
var query by remember { mutableStateOf("") }
|
||||
val datedEvents = state.datedEvents
|
||||
.filter { it.workspaceId==workspaceId }
|
||||
.filter { it.title.contains(query, ignoreCase = true) }
|
||||
.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) }
|
||||
var showSpacesMenu by remember { mutableStateOf(false) }
|
||||
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
|
||||
val notificationPermissionLabel = stringResource(R.string.Notification_Permission)
|
||||
val reminderPermissionLabel = stringResource(R.string.Reminder_Permission)
|
||||
|
||||
val pendingTasks = datedEvents.filter { task ->
|
||||
val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate }
|
||||
instance == null
|
||||
}
|
||||
|
||||
val completedTasks = datedEvents.filter { task ->
|
||||
val instance = allEventInstances.find { it.eventId == task.id && it.instanceDate == selectedDate }
|
||||
instance != null
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
topBar = {
|
||||
SpaceTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
title = workspace.title,
|
||||
description = workspace.description,
|
||||
cover = workspace.cover,
|
||||
icon = workspace.icon,
|
||||
isLocked = workspace.passKey!=null,
|
||||
onBackPressed = { navController.popBackStack() },
|
||||
onAddCover = onAddCover,
|
||||
onRemoveCover = onRemoveCover,
|
||||
onToggleLock = onToggleLock,
|
||||
onDeleteWorkspace = onDeleteWorkspace,
|
||||
onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) }
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton({
|
||||
if (!canScheduleReminder(context)) {
|
||||
Toast.makeText(context, reminderPermissionLabel, Toast.LENGTH_SHORT).show()
|
||||
requestExactAlarmPermission(context)
|
||||
}
|
||||
if (!isNotificationPermissionGranted(context)) {
|
||||
Toast.makeText(context, notificationPermissionLabel, Toast.LENGTH_SHORT).show()
|
||||
openAppNotificationSettings(context)
|
||||
}
|
||||
if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) {
|
||||
val localDate = LocalDate.ofEpochDay(selectedDate)
|
||||
val currentTime = LocalTime.now()
|
||||
val zonedDateTime = ZonedDateTime.of(localDate, currentTime, ZoneId.systemDefault())
|
||||
val selectedDateMillis = zonedDateTime.toInstant().toEpochMilli()
|
||||
|
||||
navController.navigate(NavRoutes.NewEvent.withArgs(workspaceId, "", selectedDateMillis))
|
||||
}
|
||||
}) { Icon(Icons.Default.Add, null) }
|
||||
}
|
||||
) { innerPadding ->
|
||||
when {
|
||||
isLoading -> { Loader() }
|
||||
else ->
|
||||
LazyColumn(Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(12.dp)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
item {
|
||||
if(showSearchBar){ SpaceSearchBar(query, { query=it }, { showSearchBar=false }) }
|
||||
else {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
SpacesToolBar(
|
||||
stringResource(R.string.Events),
|
||||
Icons.Default.Event,
|
||||
false,
|
||||
onMainClick = { showSpacesMenu = true },
|
||||
onEditClick = onShowSpaceBottomSheet
|
||||
)
|
||||
SpacesMenu(
|
||||
showSpacesMenu,
|
||||
workspace,
|
||||
onSpaceChange
|
||||
) { showSpacesMenu = false }
|
||||
|
||||
Row {
|
||||
IconButton({ showSearchBar = true }) {
|
||||
Icon(
|
||||
Icons.Default.Search,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
|
||||
IconButton({ onSettingEvents(SettingEvents.UpdateSettings(
|
||||
settings.data.copy(isCalendarMonthlyView = !isMonthlyView)))
|
||||
}) {
|
||||
Icon(
|
||||
if (isMonthlyView) Icons.Default.CalendarViewDay else Icons.Default.CalendarViewMonth,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isMonthlyView) {
|
||||
item {
|
||||
MonthlyViewCalendar(
|
||||
selectedMonth, selectedDate, monthlyEventCount,
|
||||
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() }
|
||||
if (pendingTasks.isNotEmpty()) {
|
||||
items(pendingTasks) { task ->
|
||||
EventCard(
|
||||
radius = radius,
|
||||
is24HourFormat = is24HourFormat,
|
||||
isPending = true,
|
||||
title = task.title,
|
||||
repeat = task.recurrence,
|
||||
startDateTime = task.startDateTime,
|
||||
onChangeStatus = { onEvent(TaskEvents.ToggleStatus(true, task.id, workspaceId, selectedDate)) },
|
||||
onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
if (completedTasks.isNotEmpty()) {
|
||||
items(completedTasks) { task ->
|
||||
EventCard(
|
||||
radius = radius,
|
||||
is24HourFormat = is24HourFormat,
|
||||
isPending = false,
|
||||
title = task.title,
|
||||
repeat = task.recurrence,
|
||||
startDateTime = task.startDateTime,
|
||||
onChangeStatus = { onEvent(TaskEvents.ToggleStatus(false, task.id, workspaceId, selectedDate)) },
|
||||
onClick = { navController.navigate(NavRoutes.EventDetails.withArgs(workspaceId, task.id, selectedDate)) }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
@@ -49,19 +51,20 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.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
|
||||
import com.flux.data.model.EventModel
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.ui.components.CustomNotificationDialog
|
||||
import com.flux.ui.components.DatePickerModal
|
||||
import com.flux.ui.components.EventNotificationDialog
|
||||
import com.flux.ui.components.RecurrenceBottomSheet
|
||||
import com.flux.ui.components.TimePicker
|
||||
import com.flux.ui.components.convertMillisToTime
|
||||
import com.flux.ui.components.label
|
||||
import com.flux.ui.common.DatePickerModal
|
||||
import com.flux.ui.common.RecurrenceBottomSheet
|
||||
import com.flux.ui.common.TimePicker
|
||||
import com.flux.ui.common.convertMillisToTime
|
||||
import com.flux.ui.common.label
|
||||
import com.flux.ui.events.TaskEvents
|
||||
import com.flux.ui.state.Settings
|
||||
import kotlin.math.max
|
||||
@@ -161,6 +164,7 @@ fun NewEvent(
|
||||
placeholder = { Text(stringResource(R.string.Title)) },
|
||||
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Words, imeAction = ImeAction.Next),
|
||||
colors = getTextFieldColors()
|
||||
)
|
||||
TextField(
|
||||
@@ -170,6 +174,7 @@ fun NewEvent(
|
||||
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraLight),
|
||||
shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp),
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp, top = 4.dp),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Done),
|
||||
colors = getTextFieldColors()
|
||||
)
|
||||
}
|
||||
@@ -247,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())
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +1,34 @@
|
||||
package com.flux.ui.screens.habits
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.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.ui.components.HabitCalendarCard
|
||||
import com.flux.ui.components.HabitEndCard
|
||||
import com.flux.ui.components.HabitScaffold
|
||||
import com.flux.ui.components.HabitStartCard
|
||||
import com.flux.ui.components.HabitStreakCard
|
||||
import com.flux.ui.components.MonthlyHabitAnalyticsCard
|
||||
import com.flux.ui.components.WeeklyHabitAnalyticsCard
|
||||
import com.flux.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)
|
||||
@Composable
|
||||
@@ -31,47 +37,98 @@ 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)
|
||||
|
||||
HabitScaffold(
|
||||
title = habit.title,
|
||||
description = habit.description,
|
||||
onBackPressed = { navController.popBackStack() },
|
||||
onDeleteClicked = {
|
||||
if(showDeleteDialog){
|
||||
DeleteAlert({
|
||||
showDeleteDialog=false
|
||||
}, {
|
||||
navController.popBackStack()
|
||||
onHabitEvents(HabitEvents.DeleteHabit(habit, context))
|
||||
},
|
||||
showDeleteDialog=false
|
||||
})
|
||||
}
|
||||
|
||||
HabitScaffold(
|
||||
onBackPressed = { navController.popBackStack() },
|
||||
onDeleteClicked = { showDeleteDialog=true },
|
||||
onEditClicked = { navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, habit.id)) },
|
||||
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 { HabitStartCard(habit.startDateTime, radius) }
|
||||
if(habit.endDateTime!=-1L){ item { HabitEndCard(habit.endDateTime, radius) } }
|
||||
item { HabitStreakCard(habit, habitInstances, radius) }
|
||||
item {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HabitCalendarCard(
|
||||
radius,
|
||||
habit.id,
|
||||
workspaceId,
|
||||
habit.startDateTime,
|
||||
habit.endDateTime,
|
||||
habit.recurrence,
|
||||
habitInstances,
|
||||
onHabitEvents
|
||||
)
|
||||
item { HabitDetailedInfo(radius, habit, habitInstances) }
|
||||
if(habit.habitConfig is HabitConfig.Counted && isAllowedByRecurrence) {
|
||||
item { CountedHabitStatus(radius, habit, todayInstance, onHabitEvents) }
|
||||
}
|
||||
item { WeeklyHabitAnalyticsCard(radius, habitInstances) }
|
||||
item { MonthlyHabitAnalyticsCard(radius, habitInstances) }
|
||||
if(habit.habitConfig is HabitConfig.Timed) {
|
||||
item { TimedHabitStatus(radius, habit, todayInstance, onHabitEvents) }
|
||||
}
|
||||
item { HabitCalendarCard(radius, habit, habitInstances, onHabitEvents) }
|
||||
item { WeeklyHabitAnalyticsCard(radius, habit, habitInstances) }
|
||||
if(habit.habitConfig is HabitConfig.Timed || habit.habitConfig is HabitConfig.Counted){
|
||||
item { SingleHabitWeeklyProgressChart(radius, habit, habitInstances) }
|
||||
}
|
||||
item { MonthlyHabitAnalyticsCard(radius, habit, habitInstances) }
|
||||
item { SingleHabitHeatMap(radius, habit, habitInstances) }
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
package com.flux.ui.screens.habits
|
||||
|
||||
import android.os.Build
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.EventAvailable
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.data.model.HabitInstanceModel
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.data.model.WorkspaceModel
|
||||
import com.flux.data.model.isCounted
|
||||
import com.flux.data.model.isLive
|
||||
import com.flux.navigation.Loader
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.other.canScheduleReminder
|
||||
import com.flux.other.isNotificationPermissionGranted
|
||||
import com.flux.other.openAppNotificationSettings
|
||||
import com.flux.other.requestExactAlarmPermission
|
||||
import com.flux.ui.common.EmptyHabits
|
||||
import com.flux.ui.common.SpaceSearchBar
|
||||
import com.flux.ui.common.SpaceTopBar
|
||||
import com.flux.ui.common.SpacesMenu
|
||||
import com.flux.ui.events.HabitEvents
|
||||
import com.flux.ui.screens.workspaces.SpacesToolBar
|
||||
import com.flux.ui.state.HabitState
|
||||
import com.flux.ui.state.Settings
|
||||
import java.time.LocalDate
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun HabitScreen(
|
||||
navController: NavController,
|
||||
state: HabitState,
|
||||
settings: Settings,
|
||||
workspace: WorkspaceModel,
|
||||
onShowSpaceBottomSheet: () -> Unit,
|
||||
onSpaceChange: (Int) -> Unit,
|
||||
onAddCover: () -> Unit,
|
||||
onRemoveCover: () -> Unit,
|
||||
onDeleteWorkspace: () -> Unit,
|
||||
onToggleLock: () -> Unit,
|
||||
onEvent: (HabitEvents) -> Unit
|
||||
){
|
||||
val context = LocalContext.current
|
||||
val workspaceId = workspace.workspaceId
|
||||
val isLoading = state.isLoading
|
||||
val allInstances = state.allInstances
|
||||
val radius = settings.data.cornerRadius
|
||||
val is24HourFormat = settings.data.is24HourFormat
|
||||
var query by remember { mutableStateOf("") }
|
||||
val allHabits = state.allHabits
|
||||
.filter { it.workspaceId == workspaceId }
|
||||
.filter { it.title.contains(query, ignoreCase = true) || it.description.contains(query, ignoreCase = true) }
|
||||
val currentHabits = allHabits.filter { it.isLive() }
|
||||
val pastHabits = allHabits.filter { !it.isLive() }
|
||||
var showSearchBar by remember { mutableStateOf(false) }
|
||||
var showSpacesMenu by remember { mutableStateOf(false) }
|
||||
val notificationPermissionLabel = stringResource(R.string.Notification_Permission)
|
||||
val reminderPermissionLabel = stringResource(R.string.Reminder_Permission)
|
||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
topBar = {
|
||||
SpaceTopBar(
|
||||
scrollBehavior = scrollBehavior,
|
||||
title = workspace.title,
|
||||
description = workspace.description,
|
||||
cover = workspace.cover,
|
||||
icon = workspace.icon,
|
||||
isLocked = workspace.passKey!=null,
|
||||
onBackPressed = { navController.popBackStack() },
|
||||
onAddCover = onAddCover,
|
||||
onRemoveCover = onRemoveCover,
|
||||
onToggleLock = onToggleLock,
|
||||
onDeleteWorkspace = onDeleteWorkspace,
|
||||
onEditWorkspace = { navController.navigate(NavRoutes.NewWorkspace.withArgs(workspaceId)) }
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton({
|
||||
if (!canScheduleReminder(context)) {
|
||||
Toast.makeText(context, reminderPermissionLabel, Toast.LENGTH_SHORT).show()
|
||||
requestExactAlarmPermission(context)
|
||||
}
|
||||
if (!isNotificationPermissionGranted(context)) {
|
||||
Toast.makeText(context, notificationPermissionLabel, Toast.LENGTH_SHORT).show()
|
||||
openAppNotificationSettings(context)
|
||||
}
|
||||
if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) {
|
||||
navController.navigate(NavRoutes.NewHabit.withArgs(workspaceId, ""))
|
||||
}
|
||||
}) { Icon(Icons.Default.Add, null) }
|
||||
}
|
||||
) { innerPadding ->
|
||||
when {
|
||||
isLoading -> Loader()
|
||||
else -> {
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(12.dp)
|
||||
.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
if(showSearchBar){ SpaceSearchBar(query, { query=it }, { showSearchBar=false }) }
|
||||
else {
|
||||
SpacesToolBar(
|
||||
stringResource(R.string.Habits),
|
||||
Icons.Default.EventAvailable,
|
||||
false,
|
||||
onMainClick = { showSpacesMenu = true },
|
||||
onEditClick = onShowSpaceBottomSheet
|
||||
)
|
||||
SpacesMenu(showSpacesMenu, workspace, onSpaceChange) { showSpacesMenu = false }
|
||||
Row{
|
||||
IconButton({ showSearchBar = true }) {
|
||||
Icon(Icons.Default.Search, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if(allHabits.isEmpty()) item { EmptyHabits() }
|
||||
items(currentHabits) { habit ->
|
||||
val habitInstances = allInstances.filter { it.habitId == habit.id }
|
||||
HabitPreviewCard(
|
||||
radius = radius,
|
||||
habit = habit,
|
||||
is24HourFormat = is24HourFormat,
|
||||
instances = habitInstances,
|
||||
onClick = { date ->
|
||||
if (isDateAllowedForHabit(habit.recurrence, date)) {
|
||||
val oldInstance = habitInstances.find { it.instanceDate == date }
|
||||
val count = if(habit.isCounted){
|
||||
if(oldInstance!=null) oldInstance.count+1
|
||||
else 1
|
||||
} else 0
|
||||
|
||||
val newInstance = HabitInstanceModel(
|
||||
instanceDate = date,
|
||||
habitId = habit.id,
|
||||
workspaceId = workspaceId,
|
||||
count = count
|
||||
)
|
||||
onEvent(HabitEvents.UpdateInstance(newInstance, habit.habitConfig))
|
||||
}
|
||||
},
|
||||
onAnalyticsClicked = { navController.navigate(NavRoutes.HabitDetails.withArgs(workspaceId, habit.id)) }
|
||||
)
|
||||
}
|
||||
if(pastHabits.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.past_habits),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(pastHabits) { habit ->
|
||||
val habitInstances = allInstances.filter { it.habitId == habit.id }
|
||||
HabitPreviewCard (
|
||||
radius = radius,
|
||||
is24HourFormat = is24HourFormat,
|
||||
habit = habit,
|
||||
instances = habitInstances,
|
||||
onClick = {},
|
||||
onAnalyticsClicked = {
|
||||
navController.navigate(
|
||||
NavRoutes.HabitDetails.withArgs(
|
||||
workspaceId,
|
||||
habit.id
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a date is allowed for the habit's recurrence
|
||||
fun isDateAllowedForHabit(recurrence: RecurrenceRule, epochDay: Long): Boolean {
|
||||
return when (recurrence) {
|
||||
is RecurrenceRule.Weekly -> {
|
||||
// Convert epoch day to LocalDate to get day of week
|
||||
val localDate = LocalDate.ofEpochDay(epochDay)
|
||||
// Convert to Monday=0, Tuesday=1, ..., Sunday=6 format
|
||||
val dayOfWeek = (localDate.dayOfWeek.value + 6) % 7
|
||||
dayOfWeek in recurrence.daysOfWeek
|
||||
}
|
||||
is RecurrenceRule.Custom -> true
|
||||
is RecurrenceRule.Once -> true
|
||||
is RecurrenceRule.Monthly -> true
|
||||
is RecurrenceRule.Yearly -> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package com.flux.ui.screens.habits
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.data.model.HabitInstanceModel
|
||||
import com.flux.data.model.HabitModel
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.data.model.isLive
|
||||
import com.flux.navigation.Loader
|
||||
import com.flux.navigation.NavRoutes
|
||||
import com.flux.ui.components.EmptyHabits
|
||||
import com.flux.ui.components.HabitPreviewCard
|
||||
import com.flux.ui.events.HabitEvents
|
||||
import com.flux.ui.state.Settings
|
||||
import java.time.LocalDate
|
||||
|
||||
fun LazyListScope.habitsHomeItems(
|
||||
navController: NavController,
|
||||
isLoading: Boolean,
|
||||
radius: Int,
|
||||
workspaceId: String,
|
||||
allHabits: List<HabitModel>,
|
||||
allInstances: List<HabitInstanceModel>,
|
||||
settings: Settings,
|
||||
onHabitEvents: (HabitEvents) -> Unit
|
||||
) {
|
||||
val currentHabits = allHabits.filter { it.isLive() }
|
||||
val pastHabits = allHabits.filter { !it.isLive() }
|
||||
|
||||
when {
|
||||
isLoading -> item { Loader() }
|
||||
allHabits.isEmpty() -> item { EmptyHabits() }
|
||||
else -> {
|
||||
items(currentHabits) { habit ->
|
||||
val habitInstances = allInstances.filter { it.habitId == habit.id }
|
||||
HabitPreviewCard(
|
||||
radius = radius,
|
||||
habit = habit,
|
||||
instances = habitInstances,
|
||||
settings = settings,
|
||||
onToggleDone = { date ->
|
||||
if (isDateAllowedForHabit(habit.recurrence, date)) {
|
||||
val existing = habitInstances.find { it.instanceDate == date }
|
||||
if (existing != null) {
|
||||
onHabitEvents(HabitEvents.MarkUndone(existing))
|
||||
} else {
|
||||
onHabitEvents(
|
||||
HabitEvents.MarkDone(
|
||||
HabitInstanceModel(
|
||||
instanceDate = date,
|
||||
habitId = habit.id,
|
||||
workspaceId = workspaceId
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onAnalyticsClicked = { navController.navigate(NavRoutes.HabitDetails.withArgs(workspaceId, habit.id)) }
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
if(pastHabits.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
stringResource(R.string.past_habits),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
modifier = Modifier.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
items(pastHabits) { habit ->
|
||||
val habitInstances = allInstances.filter { it.habitId == habit.id }
|
||||
HabitPreviewCard(
|
||||
radius = radius,
|
||||
habit = habit,
|
||||
instances = habitInstances,
|
||||
settings = settings,
|
||||
onToggleDone = {},
|
||||
onAnalyticsClicked = {
|
||||
navController.navigate(
|
||||
NavRoutes.HabitDetails.withArgs(
|
||||
workspaceId,
|
||||
habit.id
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a date is allowed for the habit's recurrence
|
||||
fun isDateAllowedForHabit(recurrence: RecurrenceRule, epochDay: Long): Boolean {
|
||||
return when (recurrence) {
|
||||
is RecurrenceRule.Weekly -> {
|
||||
// Convert epoch day to LocalDate to get day of week
|
||||
val localDate = LocalDate.ofEpochDay(epochDay)
|
||||
// Convert to Monday=0, Tuesday=1, ..., Sunday=6 format
|
||||
val dayOfWeek = (localDate.dayOfWeek.value + 6) % 7
|
||||
dayOfWeek in recurrence.daysOfWeek
|
||||
}
|
||||
is RecurrenceRule.Custom -> true
|
||||
is RecurrenceRule.Once -> true
|
||||
is RecurrenceRule.Monthly -> true
|
||||
is RecurrenceRule.Yearly -> true
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
package com.flux.ui.screens.habits
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AcUnit
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AlarmAdd
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Create
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
import androidx.compose.material.icons.filled.Repeat
|
||||
import androidx.compose.material.icons.filled.Timer
|
||||
import androidx.compose.material.icons.filled.Today
|
||||
import androidx.compose.material.icons.outlined.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.TrackChanges
|
||||
import androidx.compose.material.icons.outlined.Circle
|
||||
import androidx.compose.material.icons.outlined.Flag
|
||||
import androidx.compose.material.icons.outlined.StopCircle
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CenterAlignedTopAppBar
|
||||
@@ -26,6 +41,7 @@ import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Switch
|
||||
@@ -35,6 +51,7 @@ import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -50,15 +67,17 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavController
|
||||
import com.flux.R
|
||||
import com.flux.data.model.HabitConfig
|
||||
import com.flux.data.model.HabitModel
|
||||
import com.flux.data.model.RecurrenceRule
|
||||
import com.flux.ui.components.DatePickerModal
|
||||
import com.flux.ui.components.DeleteAlert
|
||||
import com.flux.ui.components.TimePicker
|
||||
import com.flux.ui.common.DatePickerModal
|
||||
import com.flux.ui.common.TimePicker
|
||||
import com.flux.ui.events.HabitEvents
|
||||
import com.flux.ui.screens.events.getTextFieldColors
|
||||
import com.flux.ui.screens.events.toFormattedDate
|
||||
@@ -76,15 +95,15 @@ fun NewHabit(
|
||||
habit: HabitModel,
|
||||
settings: Settings,
|
||||
onHabitEvents: (HabitEvents) -> Unit
|
||||
){
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var newHabitTitle by rememberSaveable { mutableStateOf(habit.title) }
|
||||
var newHabitDescription by rememberSaveable { mutableStateOf(habit.description) }
|
||||
var newHabitTime by rememberSaveable { mutableLongStateOf(habit.startDateTime) }
|
||||
var habitEndsOn by rememberSaveable { mutableLongStateOf(habit.endDateTime) }
|
||||
var newHabitConfig by remember { mutableStateOf(habit.habitConfig) }
|
||||
var showDatePicker by remember { mutableStateOf(false) }
|
||||
var timePickerDialog by remember { mutableStateOf(false) }
|
||||
var neverEnds by rememberSaveable { mutableStateOf(habit.endDateTime==-1L) }
|
||||
var neverEnds by rememberSaveable { mutableStateOf(habit.endDateTime == -1L) }
|
||||
val focusRequesterDesc = remember { FocusRequester() }
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
val weekdays = listOf(
|
||||
@@ -96,18 +115,6 @@ fun NewHabit(
|
||||
stringResource(R.string.saturday_short),
|
||||
stringResource(R.string.sunday_short)
|
||||
)
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
if(showDeleteDialog){
|
||||
DeleteAlert({
|
||||
showDeleteDialog=false
|
||||
}, {
|
||||
onHabitEvents(HabitEvents.DeleteHabit(habit, context))
|
||||
navController.popBackStack()
|
||||
showDeleteDialog=false
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize selectedDays from existing habit's recurrence
|
||||
val selectedDays = remember {
|
||||
mutableStateListOf<Int>().apply {
|
||||
@@ -123,6 +130,11 @@ fun NewHabit(
|
||||
stringResource(R.string.Edit_Habit)
|
||||
}
|
||||
|
||||
// Cache per-type so toggling back restores original data
|
||||
var cachedCountedConfig by remember {
|
||||
mutableStateOf(habit.habitConfig as? HabitConfig.Counted ?: HabitConfig.Counted())
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||
topBar = {
|
||||
@@ -139,185 +151,639 @@ fun NewHabit(
|
||||
enabled = newHabitTitle.isNotBlank() && selectedDays.isNotEmpty(),
|
||||
onClick = {
|
||||
navController.popBackStack()
|
||||
onHabitEvents(HabitEvents.UpsertHabit(
|
||||
context,
|
||||
habit.copy(
|
||||
title = newHabitTitle,
|
||||
description = newHabitDescription,
|
||||
startDateTime = newHabitTime,
|
||||
endDateTime = habitEndsOn,
|
||||
recurrence = RecurrenceRule.Weekly(selectedDays.toList())
|
||||
onHabitEvents(
|
||||
HabitEvents.UpsertHabit(
|
||||
context,
|
||||
habit.copy(
|
||||
title = newHabitTitle,
|
||||
description = newHabitDescription,
|
||||
startDateTime = newHabitTime,
|
||||
endDateTime = habitEndsOn,
|
||||
recurrence = RecurrenceRule.Weekly(selectedDays.toList()),
|
||||
habitConfig = newHabitConfig
|
||||
)
|
||||
)
|
||||
))
|
||||
)
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Check, null)
|
||||
}
|
||||
|
||||
if (!isNewHabit) {
|
||||
IconButton({ showDeleteDialog=true }) {
|
||||
Icon(
|
||||
Icons.Outlined.DeleteOutline,
|
||||
null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
TextField(
|
||||
value = newHabitTitle,
|
||||
onValueChange = { newHabitTitle = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(stringResource(R.string.Title)) },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||
colors = getTextFieldColors(),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Next),
|
||||
keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() })
|
||||
)
|
||||
|
||||
TextField(
|
||||
value = newHabitDescription,
|
||||
onValueChange = { newHabitDescription = it },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.focusRequester(focusRequesterDesc),
|
||||
placeholder = { Text(stringResource(R.string.Description)) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp),
|
||||
colors = getTextFieldColors(),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
|
||||
)
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(Modifier.fillMaxWidth().padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AlarmAdd,
|
||||
contentDescription = "Alarm Icon"
|
||||
LazyColumn(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = PaddingValues(
|
||||
top = 16.dp,
|
||||
bottom = 64.dp
|
||||
)
|
||||
) {
|
||||
item {
|
||||
TextField(
|
||||
value = newHabitTitle,
|
||||
onValueChange = { newHabitTitle = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = { Text(stringResource(R.string.Title)) },
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
|
||||
colors = getTextFieldColors(),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Words,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() })
|
||||
)
|
||||
Text(stringResource(R.string.time))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(newHabitTime.toFormattedTime(settings.data.is24HourFormat))
|
||||
FilledTonalIconButton({ timePickerDialog = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Create,
|
||||
contentDescription = "Pick Time"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(Modifier.padding(top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Repeat, null)
|
||||
Text(stringResource(R.string.repeat))
|
||||
}
|
||||
|
||||
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
|
||||
weekdays.forEachIndexed { index, day ->
|
||||
val isSelected = selectedDays.contains(index)
|
||||
Card(
|
||||
onClick = {
|
||||
if (isSelected) {
|
||||
// Prevent removing all days (must have at least one)
|
||||
if (selectedDays.size > 1) { selectedDays.remove(index) }
|
||||
} else { selectedDays.add(index) }
|
||||
},
|
||||
item {
|
||||
TextField(
|
||||
value = newHabitDescription,
|
||||
onValueChange = { newHabitDescription = it },
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 2.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if(isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
contentColor = if(isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = day,
|
||||
modifier = Modifier.padding(6.dp).fillMaxWidth(),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically){
|
||||
Icon(Icons.Outlined.Flag, null)
|
||||
Text(stringResource(R.string.never_ends))
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp)
|
||||
.focusRequester(focusRequesterDesc),
|
||||
placeholder = { Text(stringResource(R.string.Description)) },
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp),
|
||||
colors = getTextFieldColors(),
|
||||
keyboardOptions = KeyboardOptions.Default.copy(
|
||||
capitalization = KeyboardCapitalization.Sentences,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
|
||||
)
|
||||
}
|
||||
|
||||
Switch(neverEnds, onCheckedChange = {
|
||||
if(neverEnds){
|
||||
neverEnds=false
|
||||
habitEndsOn=max(newHabitTime, System.currentTimeMillis())
|
||||
}
|
||||
else{
|
||||
neverEnds=true
|
||||
habitEndsOn=-1L
|
||||
}
|
||||
})
|
||||
}
|
||||
item { HorizontalDivider() }
|
||||
|
||||
if(!neverEnds){
|
||||
Row(Modifier.fillMaxWidth().padding(top = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Default.Today, null, modifier = Modifier.size(24.dp))
|
||||
Text(stringResource(R.string.ends_on))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(habitEndsOn.toFormattedDate())
|
||||
FilledTonalIconButton({ showDatePicker = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Create,
|
||||
contentDescription = "Pick Time"
|
||||
)
|
||||
item {
|
||||
when (newHabitConfig) {
|
||||
is HabitConfig.Simple -> {
|
||||
SimpleConfigItems(
|
||||
newHabitTime,
|
||||
settings.data.is24HourFormat
|
||||
) { habitTime, newConfig ->
|
||||
newHabitTime = habitTime
|
||||
newHabitConfig = newConfig
|
||||
}
|
||||
}
|
||||
|
||||
is HabitConfig.Counted -> {
|
||||
CountedConfigItems(
|
||||
settings.data.is24HourFormat,
|
||||
newHabitConfig as HabitConfig.Counted
|
||||
) { newHabitConfig = it }
|
||||
}
|
||||
|
||||
else -> {
|
||||
//
|
||||
// TimedConfigItems(
|
||||
// newHabitTime,
|
||||
// newHabitConfig as HabitConfig.Timed,
|
||||
// settings.data.is24HourFormat
|
||||
// ) { habitTime, newConfig ->
|
||||
// newHabitTime = habitTime
|
||||
// newHabitConfig = newConfig
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Row(
|
||||
Modifier.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Repeat, null)
|
||||
Text(stringResource(R.string.repeat))
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
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 = selectedDays.contains(index)
|
||||
Card(
|
||||
onClick = {
|
||||
if (isSelected) {
|
||||
// Prevent removing all days (must have at least one)
|
||||
if (selectedDays.size > 1) {
|
||||
selectedDays.remove(index)
|
||||
}
|
||||
} else {
|
||||
selectedDays.add(index)
|
||||
}
|
||||
},
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor =
|
||||
if (isSelected)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(8.dp),
|
||||
|
||||
contentColor =
|
||||
if (isSelected)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = day,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(
|
||||
horizontal = 12.dp,
|
||||
vertical = 8.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { HorizontalDivider() }
|
||||
|
||||
item {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Outlined.Flag, null)
|
||||
Text(stringResource(R.string.never_ends))
|
||||
}
|
||||
|
||||
Switch(neverEnds, onCheckedChange = {
|
||||
if (neverEnds) {
|
||||
neverEnds = false
|
||||
habitEndsOn =
|
||||
max(newHabitTime, System.currentTimeMillis())
|
||||
} else {
|
||||
neverEnds = true
|
||||
habitEndsOn = -1L
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (!neverEnds) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Today,
|
||||
null,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(stringResource(R.string.ends_on))
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(habitEndsOn.toFormattedDate())
|
||||
FilledTonalIconButton({ showDatePicker = true }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Create,
|
||||
contentDescription = "Pick Time"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
|
||||
if (timePickerDialog) {
|
||||
TimePicker(
|
||||
initialTime = newHabitTime,
|
||||
is24Hour = settings.data.is24HourFormat,
|
||||
onConfirm = { newHabitTime=it }
|
||||
) { timePickerDialog = false }
|
||||
HabitConfigRow(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 16.dp),
|
||||
config = newHabitConfig,
|
||||
onChangeConfig = { incoming ->
|
||||
// Persist the current config before switching away
|
||||
when (val current = newHabitConfig) {
|
||||
is HabitConfig.Counted -> cachedCountedConfig = current
|
||||
else -> Unit
|
||||
}
|
||||
// Restore from cache when switching back to a known type
|
||||
newHabitConfig = when (incoming) {
|
||||
is HabitConfig.Counted -> cachedCountedConfig
|
||||
else -> incoming
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDatePicker) {
|
||||
DatePickerModal(onDateSelected = {
|
||||
if (it != null)
|
||||
habitEndsOn = LocalDate
|
||||
.ofEpochDay(it / 86_400_000)
|
||||
.atTime(LocalTime.MAX)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}) {
|
||||
showDatePicker = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SimpleConfigItems(startDateTime: Long, is24HourFormat: Boolean, onClick: (Long, HabitConfig.Simple) -> Unit) {
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showTimePicker) {
|
||||
TimePicker(
|
||||
initialTime = startDateTime,
|
||||
is24Hour = is24HourFormat,
|
||||
onConfirm = { onClick(it, HabitConfig.Simple) }
|
||||
) { showTimePicker = false }
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
){
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.AlarmAdd, null)
|
||||
Text(stringResource(R.string.reminder_time))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(startDateTime.toFormattedTime(is24HourFormat))
|
||||
FilledTonalIconButton({ showTimePicker = true }) {
|
||||
Icon(Icons.Default.Create, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CountedConfigItems(
|
||||
is24HourFormat: Boolean,
|
||||
config: HabitConfig.Counted,
|
||||
onChange: (HabitConfig.Counted) -> Unit
|
||||
){
|
||||
var goal by remember { mutableIntStateOf(config.goal) }
|
||||
var unit by remember { mutableStateOf(config.unit) }
|
||||
var intervalMillis by remember { mutableLongStateOf(config.intervalMillis) }
|
||||
var activeStartTime by remember { mutableLongStateOf(config.activeStartTime) }
|
||||
var activeEndTime by remember { mutableLongStateOf(config.activeEndTime) }
|
||||
var showIntervalTimer by remember { mutableStateOf(false) }
|
||||
var showActiveTimePicker by remember { mutableStateOf(false) }
|
||||
var isChangingActiveStartTime by remember { mutableStateOf(false) }
|
||||
val context = LocalContext.current
|
||||
val toastLabel = stringResource(R.string.end_time_greater_than_start)
|
||||
|
||||
val count = remember(activeStartTime, activeEndTime, intervalMillis) {
|
||||
if (intervalMillis > 0) { ((activeEndTime - activeStartTime) / intervalMillis) + 1 } else 1
|
||||
}
|
||||
|
||||
if (showIntervalTimer) {
|
||||
TimerDialog(
|
||||
durationMillis = intervalMillis,
|
||||
onDismiss = { showIntervalTimer = false }
|
||||
) { duration ->
|
||||
intervalMillis = duration
|
||||
onChange(config.copy(intervalMillis = duration))
|
||||
}
|
||||
}
|
||||
|
||||
if (showActiveTimePicker) {
|
||||
TimePicker(
|
||||
initialTime = if(isChangingActiveStartTime) activeStartTime else activeEndTime,
|
||||
is24Hour = is24HourFormat,
|
||||
onConfirm = {
|
||||
if(isChangingActiveStartTime) {
|
||||
onChange(config.copy(activeStartTime = it))
|
||||
activeStartTime = it
|
||||
if(activeEndTime<it){
|
||||
activeEndTime=it
|
||||
}
|
||||
}
|
||||
else {
|
||||
if(it<activeStartTime){
|
||||
Toast.makeText(context, toastLabel, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
else {
|
||||
onChange(config.copy(activeEndTime = it))
|
||||
activeEndTime = it
|
||||
}
|
||||
}
|
||||
}
|
||||
) { showActiveTimePicker = false }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.TrackChanges, null)
|
||||
Text(stringResource(R.string.goal))
|
||||
}
|
||||
|
||||
if(showDatePicker){
|
||||
DatePickerModal(onDateSelected = {
|
||||
if(it!=null)
|
||||
habitEndsOn = LocalDate
|
||||
.ofEpochDay(it / 86_400_000)
|
||||
.atTime(LocalTime.MAX)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}){
|
||||
showDatePicker=false
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
IconButton(
|
||||
{
|
||||
goal -=1
|
||||
onChange(config.copy(goal = goal))
|
||||
},
|
||||
enabled = goal>2,
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Remove, null)
|
||||
}
|
||||
Text(goal.toString())
|
||||
IconButton(
|
||||
{
|
||||
goal+=1
|
||||
onChange(config.copy(goal = goal))
|
||||
},
|
||||
colors = IconButtonDefaults.iconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp),
|
||||
contentColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(Icons.Default.Add, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.AcUnit, null)
|
||||
Text(stringResource(R.string.unit_optional))
|
||||
}
|
||||
|
||||
TextField(
|
||||
value = unit,
|
||||
onValueChange = {
|
||||
unit = it
|
||||
onChange(config.copy(unit=it)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.width(100.dp),
|
||||
colors = getTextFieldColors(),
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(textAlign = TextAlign.Center)
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Outlined.Circle, null)
|
||||
Text(stringResource(R.string.from))
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(activeStartTime.toFormattedTime(is24HourFormat))
|
||||
FilledTonalIconButton({
|
||||
isChangingActiveStartTime=true
|
||||
showActiveTimePicker = true
|
||||
}) {
|
||||
Icon(Icons.Default.Create, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Outlined.StopCircle, null)
|
||||
Text(stringResource(R.string.to))
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(activeEndTime.toFormattedTime(is24HourFormat))
|
||||
FilledTonalIconButton({
|
||||
isChangingActiveStartTime=false
|
||||
showActiveTimePicker = true
|
||||
}) { Icon(Icons.Default.Create, null) }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
){
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.NotificationsActive, null)
|
||||
Text(stringResource(R.string.remind_every))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(formatDuration(intervalMillis))
|
||||
FilledTonalIconButton({ showIntervalTimer = true }) {
|
||||
Icon(Icons.Default.Create, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
){
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Text(stringResource(R.string.habit_reminder_count, count))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TimedConfigItems(
|
||||
startDateTime: Long,
|
||||
config: HabitConfig.Timed,
|
||||
is24HourFormat: Boolean,
|
||||
onChange: (Long, HabitConfig.Timed)->Unit
|
||||
){
|
||||
var newStartDateTime by remember { mutableLongStateOf(startDateTime) }
|
||||
var durationMillis by remember { mutableLongStateOf(config.durationMillis) }
|
||||
var showTimePicker by remember { mutableStateOf(false) }
|
||||
var showDurationPicker by remember { mutableStateOf(false) }
|
||||
|
||||
if (showDurationPicker) {
|
||||
TimerDialog(
|
||||
durationMillis = durationMillis,
|
||||
onDismiss = { showDurationPicker = false }
|
||||
) { duration ->
|
||||
durationMillis = duration
|
||||
onChange(newStartDateTime, config.copy(durationMillis = duration))
|
||||
}
|
||||
}
|
||||
|
||||
if (showTimePicker) {
|
||||
TimePicker(
|
||||
initialTime = newStartDateTime,
|
||||
is24Hour = is24HourFormat,
|
||||
onConfirm = {
|
||||
newStartDateTime = it
|
||||
onChange(it, config.copy(durationMillis = durationMillis))
|
||||
}
|
||||
) { showTimePicker = false }
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
){
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.AlarmAdd, null)
|
||||
Text(stringResource(R.string.reminder_time))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(startDateTime.toFormattedTime(is24HourFormat))
|
||||
FilledTonalIconButton({ showTimePicker = true }) {
|
||||
Icon(Icons.Default.Create, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
HorizontalDivider()
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
){
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(Icons.Default.Timer, null)
|
||||
Text(stringResource(R.string.duration))
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(formatDuration(durationMillis))
|
||||
FilledTonalIconButton({ showDurationPicker = true }) {
|
||||
Icon(Icons.Default.Create, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDuration(durationMillis: Long): String {
|
||||
val totalMinutes = durationMillis / 60000
|
||||
val hours = totalMinutes / 60
|
||||
val minutes = totalMinutes % 60
|
||||
|
||||
return when {
|
||||
hours > 0 && minutes > 0 -> "${hours}h ${minutes}m"
|
||||
hours > 0 -> "${hours}h"
|
||||
else -> "${minutes}m"
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user