45 Commits
Author SHA1 Message Date
Ronit ChindaandGitHub d5b1706b41 Merge pull request #115 from chindaronit/dev
Merge Changes from dev to release v3.1.10
2026-07-19 14:56:19 +05:30
chindaronit c8fe9cc4a0 preparing for release, version v3.1.10
with version code 16.
2026-07-19 14:48:06 +05:30
Ronit ChindaandGitHub ab3970ea4c Merge pull request #114 from chindaronit/feature/todo-analytics
Feature/todo analytics
2026-07-19 14:34:09 +05:30
chindaronit 82738d7329 feat:
- Calendar analytics card for todo list.
- journal auto selected for old and new entries in date picker
- search screen preserving category while navigation

res:
multilingualism for added strings.
2026-07-19 14:19:24 +05:30
chindaronit 33cbcedbec feat:
Added multiple analytics items for todo including:
Consistency metrics for items
Weekly Item completion card
This month completion card
2026-07-18 18:29:22 +05:30
chindaronit 4c0aef85cb fix:
bug in instance clearance for todo, when reminder time changes.
src:
consistency maintenance for todo to save just like notes
2026-07-18 13:32:20 +05:30
chindaronit e64f85e002 feat:
Order spaces in workspaces.
fix:
consistency in font family in readView of markdown for notes and journals.
chore:
removal of unnecessary libraries to optimize application size.
2026-07-18 11:54:11 +05:30
chindaronit b0aa418c94 feat:
1. Share achievement in habits
2. Widgets in habits and todo list.

fix:
1. weekly option selection bug.

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

fix:
1. text overflow at various places
2026-06-24 22:04:50 +05:30
chindaronit 6154e09516 feat:
1. Notes Preview Mode to adjust notes height
src:
    1. Improved Markdown Render in preview mode for media, links and codeblocks
fix:
    1. Progress Tracker date bug.
    2. Automatically detect line break in editor
2026-06-07 22:07:32 +05:30
chindaronit 5493c78ec6 fix: migration update. 2026-06-07 00:34:54 +05:30
chindaronit 293a8f34ba fix: Build crash due to room migration,
chore: CONTRIBUTING.md
2026-06-07 00:02:05 +05:30
chindaronit 14a462afba fix: TodoExpandable card bug in SearchScreen.kt 2026-06-06 14:36:36 +05:30
chindaronit a2b0540403 feat:
1. Added Undo in Todo Item removal
2. Draggable Items to reorder in todo.
3. Reminder in Todo Items to remind and analyse the list.
fix:
1. Dated Journal entry bug
2. Create Button Text overflow in note/journal.
2026-06-06 14:08:52 +05:30
chindaronit 575d44485b feat: drag handle added to redorder todo items. 2026-05-25 22:55:48 +05:30
chindaronit 0b5e2fc3b2 res: addition of new screenshots, changelog 11.txt. 2026-05-24 14:46:31 +05:30
chindaronit 44090314aa src: multilingual support for new strings added.
Preparation for next release.
2026-05-24 14:10:24 +05:30
Ronit ChindaandGitHub 934f9404fd Merge pull request #96 from chindaronit/Habit-Tracker
label based on workspace in notes.
2026-05-23 14:36:41 +05:30
chindaronit a8e3c7e032 label based on workspace in notes. 2026-05-23 14:27:21 +05:30
Ronit ChindaandGitHub c56ae71d4b Merge pull request #95 from chindaronit/Habit-Tracker
feat!: global search + habit tracker.
2026-05-23 14:26:27 +05:30
chindaronit e9af514a82 resolve merge conflict. 2026-05-23 14:20:38 +05:30
chindaronit a081793540 Merge remote-tracking branch 'origin/master' into Habit-Tracker
# Conflicts:
#	app/src/main/java/com/flux/navigation/NavRoutes.kt
#	app/src/main/java/com/flux/ui/components/ToolBar.kt
#	app/src/main/java/com/flux/ui/screens/workspaces/ExtremeCompactMode.kt
#	app/src/main/java/com/flux/ui/screens/workspaces/NormalModeDetails.kt
#	app/src/main/java/com/flux/ui/screens/workspaces/SpaceToolbarList.kt
2026-05-23 13:27:40 +05:30
chindaronit f869748a92 feat!:
1. added counter habits.
2. workspace cover scrollable.
3. Improvement in workspace home page UI.
4. Global searching tab.
5. added filters in spaces.
6. timeline and label in journal.

fix:
1. habit description visibility.
2. Journal data deletion on habit space removal.
3. Correction in Streak calculation.

src:
1. created a format in directory structure to help contributors to contribute.
2. conversion of previous lazy list based approach to scaffold based on every space page.
2026-05-23 13:06:56 +05:30
Ronit ChindaandGitHub 09428dac96 Merge pull request #87 from N3kOk0/language/Chinese
i18n: Added Chinese translation
2026-04-05 20:15:48 +05:30
Give5946 8c5d3b4fcc i18n: Added Chinese translation 2026-04-05 20:03:52 +08:00
chindaronit 17cf9c4b29 fix: Search bar working in notes. 2026-03-30 21:44:46 +05:30
chindaronit 5ef44cc247 build: added changelog for version 10 2026-03-28 20:43:02 +05:30
chindaronit 6fe76ff875 feat: New UI View for ExtremeCompactMode.kt
fix: habit streak calculation bug

chore: multilingual for added values.
build: updated version code and version name for next release.
2026-03-28 20:31:42 +05:30
chindaronit 3925e90582 src: refactoring WorkSpaces.kt and WorkspaceDetails.kt
fix: visibility of editor default.
fix: export/import backup
fix: addition of lazyStaggered list instead of LazyVertical List in WorkSpaces.kt, correcting the spacing.
2026-03-24 22:45:13 +05:30
chindaronit 3e6582a0f9 fix: Delete ProgressBoard item
feat: Auto Detection of user time format
feat: Sticky notification for habits
src: addition of system font to FONTS dialog
2026-03-23 17:15:39 +05:30
chindaronit d6a9d21d0b feat: auto-capitalization in every text input taken in the application.
src: added progressTracker for backup
fix: Crashes for room migration.
chore: upgraded to version code 9, version 3.1.3
2026-03-22 16:01:00 +05:30
chindaronit efca669e88 feat: Progress Tracker is added as a new space. 2026-03-21 21:58:29 +05:30
chindaronit 5b3a39d516 update: image removal in WorkspaceDetails.kt for compact modes
src: Updated README.md
2026-03-18 21:55:19 +05:30
chindaronit f58ce45d73 build: changes in libs.versions.toml 2026-03-17 21:42:24 +05:30
chindaronit 32740d97f8 src: Multilingual, changelog for version 8
build: versioncode upgraded to 8, version : 3.1.2
2026-03-16 22:20:44 +05:30
chindaronit d7f4ddb7a2 feat!: New theme customize page with theme preview, Storage selection screen added for new user to select storage to save data, backup, indicator added to calendar monthly preview to indicate entries in journals, events.
fix: Automatic Backup manager, Calendar monthly preview bug, crash due to save notes, journals, reset state due to rotation in notes, journal.

build: upgraded libs.versions.toml versions to the latest.
2026-03-15 23:20:38 +05:30
chindaronit 2c85247dd1 fix: Room upgraded to version 5 2026-02-24 20:06:18 +05:30
chindaronit 8f02da5c35 feat: Audio Recorder added for notes and journals.
src: Added multilingual support for newly added strings
docs: changes in changelogs for version 6, new screenshots added.
2026-02-22 21:33:05 +05:30
chindaronit 3b2e433757 src: Updated Guide.md
fix: Updated correct link to privacy policy and guide in About.kt
2026-02-20 22:22:47 +05:30
chindaronit ce18b47cbd src: Added PRIVACY_POLICY.md and Guide.md
fix: NoteScreen Layout fix in pinned and unpinned notes.
2026-02-20 21:57:58 +05:30
chindaronit 9eb2a55350 Merge remote-tracking branch 'origin/master' 2026-02-18 22:48:33 +05:30
chindaronit 56d8f37ab7 feat!: Added automatic BackupManger.kt 2026-02-18 22:48:04 +05:30
Ronit ChindaandGitHub 2106ad1ddc Update README.md 2026-02-17 10:36:01 +05:30
235 changed files with 25551 additions and 9738 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+222
View File
@@ -0,0 +1,222 @@
# Contributing to Flux
Thank you for your interest in contributing to Flux! Your support means a lot.
---
## Table of Contents
- [Getting Started](#getting-started)
- [How to Contribute](#how-to-contribute)
- [Branch Naming](#branch-naming)
- [Commit Messages](#commit-messages)
- [Pull Request Guidelines](#pull-request-guidelines)
- [Architecture Overview](#architecture-overview)
- [Module Structure](#module-structure)
- [Code Style & Conventions](#code-style--conventions)
- [Tech Stack](#tech-stack)
---
## Getting Started
1. Install the latest stable version of [Android Studio](https://developer.android.com/studio).
2. Clone the repository:
```bash
git clone git@github.com:chindaronit/Flux.git
```
3. Open the project in Android Studio and let Gradle sync complete.
4. Select `Run > Run 'app'` to build and launch the app on a device or emulator.
**Requirements:**
- Android SDK target: **36**, minimum: **29** (Android 10+)
- Kotlin: **2.2.10**
- JVM Target: **11**
- Build tool: **Gradle with Kotlin DSL**
---
## How to Contribute
1. **Fork the Repository** — Fork to your own GitHub account.
2. **Create a Branch** — Branch off from `main` using the naming conventions below.
3. **Make Your Changes** — Follow the architecture and code style described in this guide.
4. **Open an Issue First** — For non-trivial changes, create an issue before opening a PR so the approach can be discussed.
5. **Submit a Pull Request** — Target the `dev` branch. Reference the related issue in the PR description.
---
## Branch Naming
Use descriptive, hyphenated branch names prefixed by type:
| Type | Pattern | Example |
|------|---------|---------|
| Feature | `feat/<short-description>` | `feat/add-calendar-widget` |
| Bug fix | `fix/<issue-or-description>` | `fix/resolve-issue-123` |
| Refactor | `refactor/<description>` | `refactor/viewmodel-cleanup` |
| Translation | `i18n/<language>` | `i18n/add-japanese` |
| Documentation | `docs/<description>` | `docs/update-contributing` |
---
## Commit Messages
Write concise, present-tense commit messages that describe *what* the change does:
```
feat: add biometric lock toggle in settings
fix: resolve crash when deleting last workspace
refactor: extract reminder logic into ReminderReceiver
i18n: add Spanish translation strings
```
Avoid vague messages like `fix stuff`, `WIP`, or `update`.
---
## Pull Request Guidelines
- Target the **`dev`** branch, not `master`/`main`.
- Reference the issue your PR addresses (e.g., `Closes #42`).
- Keep PRs focused — one feature or fix per PR.
- Ensure the app builds and runs without errors before submitting.
- Add or update string resources in `res/values/strings.xml` for any user-facing text.
- If adding a new screen, register it in the navigation graph (`navigation/`).
- If adding a new data entity, update the relevant DAO, model, repository, and DI module.
- For new languages, add a corresponding `res/values-<lang>/strings.xml` file.
I'll review your pull request as soon as possible. Thank you for your contribution!
---
## Architecture Overview
Flux follows **MVI (ModelViewIntent)** architecture with **Jetpack Compose** for the UI layer. The data flow is strictly unidirectional:
```
User Interaction (Intent / Event)
ViewModel
State Update
Composable UI (re-renders from State)
```
- **Model** — Data layer: Room entities, DAOs, repositories.
- **View** — Composable screens that observe `State` objects and emit `Events`.
- **Intent/ViewModel** — Processes events, calls repository methods, and emits new `State`.
Side effects (navigation, toasts, etc.) are handled via `Effects` (one-shot flows) separate from persistent UI state.
**Dependency injection** is provided by **Hilt**, keeping ViewModels and repositories decoupled and testable.
---
## Module Structure
All source code lives under `app/src/main/java/com/flux/`.
```
app/src/main/java/com/flux/
├── data/ # Data layer
│ ├── dao/ # Room DAO interfaces (one per entity)
│ ├── models/ # Room @Entity data classes and related data models
│ ├── repository/ # Repository implementations (single source of truth)
│ └── database/ # Room database definition and migrations
├── di/ # Hilt dependency injection modules
│ ├── DataModules.kt # Provides database and DAO instances
│ ├── RepositoryModules.kt # Binds repository interfaces to implementations
│ └── Flux.kt # @HiltAndroidApp application class
├── navigation/ # Compose Navigation graph and route definitions
├── other/ # Utility and system-integration classes
│ ├── utils/ # General-purpose helper functions and extensions
│ ├── BackupManager.kt # Handles data backup and restore logic
│ ├── ReminderReceiver.kt # BroadcastReceiver for scheduled reminders
│ ├── BootReceiver.kt # Reschedules reminders after device reboot
│ ├── workspaceIcons/ # Icon assets and mapping utilities for workspaces
│ ├── BiometricAuthentication.kt # Biometric lock integration
│ └── [other app-wide utilities]
├── ui/ # UI layer (Jetpack Compose)
│ ├── common/ # Shared, reusable composables (buttons, dialogs, cards, etc.)
│ ├── events/ # Sealed classes / data classes representing user intents per screen
│ ├── states/ # Data classes holding observable UI state per screen
│ ├── effects/ # One-shot side-effect flows (navigation, snackbars, etc.)
│ ├── screens/ # Full-screen composables, one file per screen
│ ├── viewModels/ # Hilt-injected ViewModels; process events, expose state
│ └── theme/ # Material 3 color scheme, typography, and shape definitions
└── MainActivity.kt # Single-activity entry point; hosts NavHost
```
```
app/src/main/res/
├── drawable/ # Vector drawables and icons
└── values/ # Resource files
├── strings.xml # Default (English) strings
└── values-<lang>/ # Translations (hi, fr, pt-BR, ru, de, es, nl, zh-rCN, …)
```
### Adding a New Feature — Checklist
When building a new screen or feature, create/update files in this order:
1. **`data/models/`** — Define the Room entity or data class.
2. **`data/dao/`** — Write the DAO interface with required queries.
3. **`data/database/`** — Add the entity to the database and increment the version with a migration.
4. **`data/repository/`** — Implement the repository exposing `Flow`s and suspend functions.
5. **`di/`** — Bind the new DAO/repository in the appropriate Hilt module.
6. **`ui/events/`** — Define a sealed class for all user actions on the new screen.
7. **`ui/states/`** — Define the data class representing the full UI state for the screen.
8. **`ui/viewModels/`** — Implement the ViewModel; inject the repository via Hilt.
9. **`ui/screens/`** — Build the Composable screen; collect state, dispatch events.
10. **`navigation/`** — Register the new route and composable in the nav graph.
11. **`res/values/strings.xml`** — Add all user-facing strings; keep zero hardcoded text in composables.
---
## Code Style & Conventions
- **Language**: Kotlin only. No Java files.
- **Formatting**: Follow standard Kotlin style (4-space indent, no wildcard imports).
- **Naming**:
- ViewModels: `<Feature>ViewModel` (e.g., `NoteViewModel`)
- States: `<Feature>State` (e.g., `NoteState`)
- Events: `<Feature>Event` (e.g., `NoteEvent`)
- Effects: `<Feature>Effect` (e.g., `NoteEffect`)
- Screens: `<Feature>Screen` (e.g., `NoteScreen`)
- DAOs: `<Entity>Dao` (e.g., `NoteDao`)
- **State hoisting**: Keep composables stateless where possible; hoist state to the ViewModel.
- **No business logic in composables**: Composables should only render state and forward events.
- **Strings**: All user-visible text must live in `strings.xml`. Never hardcode strings in composables.
- **Markdown content**: Flux uses CommonMark + GitHub Flavored Markdown (GFM) with LaTeX math support. Refer to `Guide.md` for supported syntax.
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Language | Kotlin 2.2.10 |
| UI | Jetpack Compose + Material 3 (Material You) |
| Architecture | MVI + ViewModel |
| DI | Hilt |
| Database | Room (SQLite abstraction) |
| Navigation | Compose Navigation |
| Markdown | CommonMark + Flexmark (HTML→MD) |
| Math | LaTeX via inline renderer |
| Async | Kotlin Coroutines + Flow |
| Build | Gradle Kotlin DSL |
---
## Questions?
Open a [Discussion](https://github.com/chindaronit/Flux/discussions) or file an [Issue](https://github.com/chindaronit/Flux/issues) — happy to help you get oriented.
+209
View File
@@ -0,0 +1,209 @@
# Index
- [What is Markdown?](#what-is-markdown)
- [What is CommonMark?](#what-is-commonmark)
- [What is GitHub Flavored Markdown?](#what-is-github-flavored-markdown)
- [What is LaTeX?](#what-is-latex)
- [How to use LaTeX in Markdown?](#how-to-use-latex-in-markdown)
- [What is Flux?](#what-is-flux-approach)
- [What has Flux offered?](#what-has-flux-offered)
- [Some Basic Markdown Syntax](#some-basic-markdown-syntax)
- [Some Extended Markdown Syntax](#some-extended-markdown-syntax)
- [LaTeX Math Syntax](#latex-math-syntax)
- [Greek Letters](#greek-letters)
- [References](#references)
# What is Markdown?
Markdown is a plain text format for writing structured documents, based on conventions for
indicating formatting in email and usenet posts.
It was developed by John Gruber (with help from Aaron Swartz) and released in 2004 in the form of a
syntax description and a Perl script (Markdown.pl) for converting Markdown to HTML.
In the next decade, dozens of implementations were developed in many languages.
Some extended the original Markdown syntax with conventions for footnotes, tables, and other
document elements.
Some allowed Markdown documents to be rendered in formats other than HTML.
Websites like Reddit, StackOverflow, and GitHub had millions of people using Markdown.
And Markdown started to be used beyond the web, to author books, articles, slide shows, letters, and
lecture notes.
# What is CommonMark?
CommonMark was developed to address inconsistencies and ambiguities in Markdown.
The CommonMark specification defines rules for elements such as headings, lists, links, emphasis,
and code blocks, among others.
By adhering to the CommonMark standard, developers can ensure consistent rendering of Markdown
content across different applications and platforms.
# What is GitHub Flavored Markdown?
GitHub Flavored Markdown, often shortened as GFM, is the dialect of Markdown that is currently
supported for user content on GitHub.com and GitHub Enterprise.
This formal specification, based on the CommonMark Spec, defines the syntax and semantics of this
dialect.
GFM is a strict superset of CommonMark. All the features which are supported in GitHub user content
and that are not specified on the original CommonMark Spec are hence known as extensions, and
highlighted as such.
While GFM supports a wide range of inputs, its worth noting that GitHub.com and GitHub Enterprise
perform additional post-processing and sanitization after GFM is converted to HTML to ensure
security and consistency of the website.
# What is LaTeX?
LaTeX is a typesetting system commonly used for producing scientific and mathematical documents.
LaTeX provides a powerful way to represent mathematical notation in documents, allowing users to
create complex equations and formulas with ease.
# How to use LaTeX in Markdown?
Mathematical expressions are key to information sharing amongst engineers, scientists, data
scientists, and mathematicians.
You can use the $ and $$ delimiters in GFM to insert math expressions in TeX and LaTeX style syntax.
# What is Flux Approach
Flux is built around the idea that information should be organized based on context, intent, and workflow rather than being confined to a single linear note system.
At the core of this approach is the concept of **Workspaces**.
A workspace acts as an independent environment that users can create based on specific needs or domains of their life or work. For example, a user might create separate workspaces for Personal Life, Work Projects, Research, Study, or Fitness Tracking.
Each workspace functions as a self-contained organizational unit, allowing users to keep related data structured and isolated without interference from other contexts.
Within every workspace, Flux provides multiple **Templates** designed for different types of information and productivity workflows. These templates include, but are not limited to:
* **Notes** — Rich Markdown documents with media and LaTeX support
* **Todo** — Task tracking with completion states and prioritization
* **Events** — Scheduled items with time and reminder support
* **Calendar** — Temporal visualization of events and plans
* **Journal** — Chronological personal or professional entries
* **Analytics** — Insights and productivity metrics derived from workspace data
This modular structure allows users to mix and match templates depending on their requirements. A research workspace might emphasize notes and analytics, while a personal workspace may focus on journals, todos, and events.
By separating data into workspaces and structuring content through templates, Flux enables scalable organization, reduces cognitive clutter, and supports diverse productivity systems within a single unified platform.
# What has Flux offered?
Flux supports CommonMark and GitHub Flavored Markdown (GFM) syntax, as well as LaTeX math
syntax.
This allows users to create richly formatted notes with support for headings, lists, links,
emphasis, code blocks, tables, and mathematical expressions, and allows notes to be exported in
various formats including TXT, MD (Markdown), and HTML.
# Some Basic Markdown Syntax
| Element | Syntax |
|:--------------:|:----------------------------------:|
| Heading | `# H1` <br/> `## H2`<br/> `### H3` |
| Italic | `_italic_ or *italic*` |
| Bold | `**bold** or __bold__` |
| Blockquote | `> Blockquote` |
| Link | `[title](https://www.example.com)` |
| Code | `` `code` `` |
| Ordered List | `1. List item 1 or 2) List item 2` |
| Unordered List | `- Apple or + Banana or * Orange` |
| Image | `![alt text](image.jpeg)` |
# Some Extended Markdown Syntax
| Element | Syntax |
|:-------------:|:----------------------------------:|
| Strikethrough | `~~Strikethrough~~` |
| Underline | `++Underline++` |
| Highlight | `==Marking==` |
| Task List | `- [x] Task 1`<br/> `- [ ] Task 2` |
| Heading ID | `# Heading {#custom-id}` |
# LaTeX Math Syntax
| Element | Syntax |
|:-----------:|:------------------:|
| Inline | `$x^2$` |
| Display | `$$x^2$$` |
| newline | `\\ or \\\\` |
| Whitespace | `\quad or \` |
| Superscript | `x^2` |
| Subscript | `y_1` |
| Expression | `{x}` |
| Overline | `\overline{x}` |
| Underline | `\underline{x}` |
| Fraction | `\frac{x}{y}` |
| Left Paren | `\left(` |
| Right Paren | `\right)` |
| Parentheses | `\left(x+y\right)` |
| Brackets | `\left[x+y\right]` |
| Braces | `\left{x+y\right}` |
| Root | `\sqrt[n]{x}` |
| × | `\times` |
| ÷ | `\div` |
| ± | `\pm` |
| ≠ | `\neq` |
| ≈ | `\approx` |
| ≤ | `\leq` |
| ≥ | `\geq` |
| ∞ | `\infty` |
| ∑ | `\sum` |
| ∏ | `\prod` |
| ∫ | `\int` |
| ∑ | `\sum` |
| lim | `\lim` |
| ∀ | `\forall` |
| ∃ | `\exists` |
| ∴ | `\therefore` |
| ∵ | `\because` |
| ⊂ | `\subset` |
| ⊃ | `\supset` |
| ⊆ | `\subseteq` |
| ⊇ | `\supseteq` |
| ∈ | `\in` |
| ∉ | `\notin` |
# Greek Letters
| Uppercase | Syntax | Lowercase | Syntax |
|:---------:|:----------:|:---------:|:----------:|
| A | `A` | α | `\alpha` |
| Β | `B` | β | `\beta` |
| Γ | `\Gamma` | γ | `\gamma` |
| Δ | `\Delta` | δ | `\delta` |
| Ε | `E` | ε | `\epsilon` |
| Ζ | `Z` | ζ | `\zeta` |
| Η | `H` | η | `\eta` |
| Θ | `\Theta` | θ | `\theta` |
| Ι | `I` | ι | `\iota` |
| Κ | `K` | κ | `\kappa` |
| Λ | `\Lambda` | λ | `\lambda` |
| Μ | `M` | μ | `\mu` |
| Ν | `N` | ν | `\nu` |
| Ξ | `\Xi` | ξ | `\xi` |
| Ο | `O` | ο | `\omicron` |
| Π | `\Pi` | π | `\pi` |
| Ρ | `P` | ρ | `\rho` |
| Σ | `\Sigma` | σ | `\sigma` |
| Τ | `T` | τ | `\tau` |
| Υ | `\Upsilon` | υ | `\upsilon` |
| Φ | `\Phi` | φ | `\phi` |
| Χ | `X` | χ | `\chi` |
| Ψ | `\Psi` | ψ | `\psi` |
| Ω | `\Omega` | ω | `\omega` |
# References
You can find more information about Markdown, CommonMark, GitHub Flavored Markdown, LaTeX Math, and
Mermaid at the following links:
- [CommonMark](https://commonmark.org/)
- [GitHub Flavored Markdown](https://github.github.com/gfm/)
- [LaTeX](https://www.latex-project.org/)
- [Mermaid](https://mermaid.js.org/)
+41
View File
@@ -0,0 +1,41 @@
## Privacy Policy of Open Note
Flux is an open-source Android app developed by Ronit Chinda.
The source code is available on GitHub under the GPL-3.0 license.
### Data Collection
Flux does not collect any personal or confidential information such as addresses, names, or email addresses.
### Permissions Requested
The app requires the following permissions, as listed in the `AndroidManifest.xml` file:
https://github.com/chindaronit/Flux/blob/master/app/src/main/AndroidManifest.xml
| Permission | Purpose |
|---------------------------------|---------------------------------------------------------------------------------------------------------------|
| `android.permission.USE_BIOMETRIC` | Enables biometric authentication to provide secure access to protected notes |
| `android.permission.INTERNET` | Allows network connectivity for loading web content (images, videos, and other media) when rendering markdown |
| `android.permission.RECEIVE_BOOT_COMPLETED` | Allows the app to receive the system broadcast after device boot, enabling it to restart scheduled tasks such as reminders, alarms, or background sync services |
| `android.permission.POST_NOTIFICATIONS` | Grants permission to display notifications to the user (required on Android 13+), used for reminders, updates, or alerting about scheduled events |
| `android.permission.SCHEDULE_EXACT_ALARM` | Allows the app to schedule precise alarms that trigger at exact times (e.g., note reminders, task deadlines), bypassing battery optimizations that delay inexact alarms |
### Dependencies
The app uses the following dependencies:
- **Room**: For local database management.
- **Hilt**: For dependency injection.
- **Compose**: For building the UI.
- **CommonMark**: For markdown rendering and parsing.
### Data Sharing
Flux does not share any personal or sensitive user data with third parties.
### Data Deletion
Usually all data is stored locally and can be cleared by the user at any time.
---
If you have any questions about this policy or personal information protection, please send your inquiries, opinions, or suggestions to: chindaronit04@gmail.com
+60 -32
View File
@@ -1,6 +1,13 @@
> [!CAUTION]
> ## [Keep Android Open](https://keepandroidopen.org/)
> ### Your phone is about to stop being yours.
> Starting September 2026, a silent update, nonconsensually pushed by Google, will block every
> Android app whose developer hasn't registered with Google, signed their contract, paid up, and
> handed over government ID.
> **Every app and every device, worldwide, with no opt-out.**
<div align="center">
<img width="192" height="192" src="app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp" alt="Flux Logo" />
<br>
# Flux
@@ -16,66 +23,72 @@
[<img src=".github/get-github.png" alt="Get it on GitHub" height="55">](https://github.com/chindaronit/Flux/releases/)
[<img src=".github/get_f_droid.png" alt="Get it on f-droid" height="55">](https://f-droid.org/packages/com.flux/)
[<img src=".github/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="55">](https://apt.izzysoft.de/fdroid/index/apk/com.flux)
---
</div>
| ![Image 1](metadata/en-US/images/phoneScreenshots/screenshot1.png) | ![Image 2](metadata/en-US/images/phoneScreenshots/screenshot3.png) |
|:---------------------------------------:|:-----------------------------------------:|
| ![Image 3](metadata/en-US/images/phoneScreenshots/screenshot2.png) | ![Image 4](metadata/en-US/images/phoneScreenshots/screenshot4.png) |
| ![Image 5](metadata/en-US/images/phoneScreenshots/screenshot5.png) | ![Image 6](metadata/en-US/images/phoneScreenshots/screenshot6.png) |
| ![Image 1](metadata/en-US/images/phoneScreenshots/screenshot1.png) | ![Image 2](metadata/en-US/images/phoneScreenshots/screenshot3.png) |
| :----------------------------------------------------------------: | :----------------------------------------------------------------: |
| ![Image 3](metadata/en-US/images/phoneScreenshots/screenshot2.png) | ![Image 4](metadata/en-US/images/phoneScreenshots/screenshot4.png) |
| ![Image 5](metadata/en-US/images/phoneScreenshots/screenshot5.png) | ![Image 6](metadata/en-US/images/phoneScreenshots/screenshot6.png) |
---
<div align="center">
## Release numbers
[![Latest Release](https://img.shields.io/github/v/release/chindaronit/Flux?style=for-the-badge)](https://github.com/chindaronit/Flux/releases)
[![License](https://img.shields.io/github/license/chindaronit/Flux?style=for-the-badge)](https://github.com/chindaronit/Flux/blob/main/LICENSE)
[![Downloads](https://img.shields.io/github/downloads/chindaronit/Flux/total?style=for-the-badge)](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.
## 📦 Installation
To build and run this application, you need to install the latest version of Android Studio. Then,
you can clone this repository from GitHub and open it in Android Studio.
```bash
git clone https://github.com/YangDai2003/OpenNote.git
git clone git@github.com:chindaronit/Flux.git
```
In Android Studio, select `Run > Run 'app'` to start the application.
@@ -85,6 +98,16 @@ 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.
## Credits
<div align="center">
<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 +127,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>
+29 -13
View File
@@ -2,21 +2,20 @@ plugins {
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.flux"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "com.flux"
minSdk = 29
targetSdk = 36
versionCode = 5
versionName = "3.0"
targetSdk = 37
versionCode = 16
versionName = "3.1.10"
}
dependenciesInfo {
@@ -58,11 +57,6 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
buildFeatures {
compose = true
buildConfig = true
@@ -71,16 +65,30 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "assets/dexopt/baseline.prof"
merges += "META-INF/services/*"
}
jniLibs {
useLegacyPackaging = false
}
}
buildToolsVersion = "35.0.0"
}
tasks.withType<ProcessResources>().configureEach {
filesMatching("META-INF/services/*") {
filter { it.replace("\r\n", "\n") }
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11)
}
}
dependencies {
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.glance.appwidget)
implementation(libs.androidx.material3)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity.compose)
@@ -92,9 +100,15 @@ dependencies {
implementation(libs.androidx.biometric)
implementation(libs.androidx.browser)
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)
@@ -110,7 +124,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)
@@ -121,5 +135,7 @@ dependencies {
implementation(libs.commonmark.ext.image.attributes)
implementation(libs.commonmark.ext.yaml.front.matter)
implementation(libs.commonmark)
implementation(libs.flexmarkHtml2mdConverter)
// draggable list
implementation(libs.reorderable)
}
+33
View File
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<application
android:name=".di.Flux"
@@ -51,6 +52,38 @@
android:resource="@xml/file_paths" />
</provider>
<receiver android:name=".widgets.habits.HabitWidgetReceiver"
android:exported="true"
android:label="Habits Widget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/habit_widget"/>
</receiver>
<receiver
android:name=".widgets.todo.TodoWidgetReceiver"
android:exported="true"
android:label="Todo Widget">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/todo_widget" />
</receiver>
<activity
android:name=".widgets.todo.TodoWidgetConfigActivity"
android:exported="true"
android:theme="@style/Theme.Flux">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_CONFIGURE" />
</intent-filter>
</activity>
</application>
</manifest>
+35 -4
View File
@@ -8,6 +8,10 @@
<meta name="theme-color" content="{{BACKGROUND_COLOR}}">
<title>Markdown</title>
<style>
{{FONT_FACE_CSS}}
</style>
<style>
:root {
color-scheme: {{COLOR_SCHEME}};
@@ -17,7 +21,7 @@
background-color: {{BACKGROUND_COLOR}};
padding: 0 16px;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-family: {{FONT_FAMILY}};
}
img {
max-width: 100%;
@@ -146,12 +150,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>
+72 -28
View File
@@ -10,7 +10,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -20,25 +22,35 @@ 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
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private var keepSplashScreen = mutableStateOf(true)
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -60,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()
@@ -69,46 +83,50 @@ 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 }
// Snackbar effect
LaunchedEffect(Unit) {
workspaceViewModel.effect.collect { effect ->
if (effect is ScreenEffect.ShowSnackBarMessage) {
snackBarHostState.currentSnackbarData?.dismiss()
snackBarHostState.showSnackbar(
message = effect.message,
duration = SnackbarDuration.Short
)
}
}
}
HandleSideEffects(
snackBarHostState,
workspaceViewModel.effect,
settingsViewModel.effect
)
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
)
)
}
}
@@ -117,4 +135,30 @@ class MainActivity : AppCompatActivity() {
}
}
}
}
@Composable
fun HandleSideEffects(
snackbarHostState: SnackbarHostState,
vararg effectFlows: Flow<ScreenEffect>
) {
LaunchedEffect(Other.SIDE_EFFECT_KEY) {
effectFlows.forEach { effectFlow ->
effectFlow
.onEach { effect ->
if (effect is ScreenEffect.ShowSnackBarMessage) {
val result = snackbarHostState.showSnackbar(
message = effect.message,
actionLabel = "Dismiss",
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
snackbarHostState.currentSnackbarData?.dismiss()
}
}
}
.launchIn(this)
}
}
}
@@ -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,20 @@ 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?
@Query("SELECT * FROM HabitInstanceModel WHERE instanceDate = :date")
fun loadInstancesForDate(date: Long): Flow<List<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,21 @@ 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
WHERE workspaceId IN (:workspaceIds)
AND (endDateTime = -1 OR endDateTime > :todayEpoch)
AND startDateTime <= :todayEndMillis
""")
fun loadCandidateHabits(
workspaceIds: List<String>,
todayEpoch: Long,
todayEndMillis: Long,
): 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>
}
+10 -5
View File
@@ -2,9 +2,8 @@ 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 androidx.room.Upsert
import com.flux.data.model.TodoModel
import kotlinx.coroutines.flow.Flow
@@ -13,17 +12,23 @@ interface TodoDao {
@Query("SELECT EXISTS(SELECT 1 FROM TodoModel WHERE id = :id)")
suspend fun exists(id: String): Boolean
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Upsert
suspend fun upsertList(list: TodoModel)
@Delete
suspend fun deleteList(list: TodoModel)
@Query("SELECT * FROM TodoModel WHERE workspaceId IN (:workspaceIds)")
fun observePublicTodos(workspaceIds: List<String>): Flow<List<TodoModel>>
@Query("SELECT * FROM TodoModel WHERE id = (:todoId)")
fun observeTodoList(todoId: String): Flow<TodoModel?>
@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,34 @@
package com.flux.data.dao
import androidx.room.Dao
import androidx.room.Query
import androidx.room.Upsert
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("SELECT * FROM TodoInstance WHERE todoId = :todoId AND instanceDate = :date LIMIT 1")
suspend fun loadInstanceForDate(todoId: String, date: Long): TodoInstance?
@Query("DELETE FROM TodoInstance WHERE workspaceId = :workspaceId")
suspend fun deleteAllWorkspaceInstance(workspaceId: String)
@Query("SELECT * FROM TodoInstance WHERE instanceDate = :date and todoId = :todoId")
fun observeInstanceForDate(todoId: String, date: Long): Flow<List<TodoInstance>>
@Upsert
suspend fun upsertTodoInstance(instance: TodoInstance)
@Query("DELETE FROM TodoInstance WHERE todoId = :listId")
suspend fun deleteListInstances(listId: String)
}
@@ -19,6 +19,9 @@ interface WorkspaceDao {
@Insert(onConflict=OnConflictStrategy.REPLACE)
suspend fun upsertWorkspaces(spaces: List<WorkspaceModel>)
@Query("SELECT workspaceId FROM WorkspaceModel WHERE passKey IS NULL OR passKey = ''")
fun observePublicWorkspaceIds(): Flow<List<String>>
@Delete
suspend fun deleteWorkspace(workspace: WorkspaceModel)
@@ -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,18 +25,19 @@ 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
import com.google.gson.reflect.TypeToken
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter
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 = 4,
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 = 11,
exportSchema = false
)
@TypeConverters(Converter::class)
@@ -49,276 +52,357 @@ 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 (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)
============================================================ */
val converter = FlexmarkHtmlConverter.builder().build()
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)
)
}
}
// 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")
}
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)
)
}
}
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")
}
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
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")
}
}
val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.safeExec(
"CREATE INDEX IF NOT EXISTS `index_HabitModel_workspaceId` " +
"ON `HabitModel` (`workspaceId`)"
)
db.safeExec(
"CREATE INDEX IF NOT EXISTS `index_HabitModel_endDateTime` " +
"ON `HabitModel` (`endDateTime`)"
)
db.safeExec(
"CREATE INDEX IF NOT EXISTS `index_HabitInstanceModel_instanceDate` " +
"ON `HabitInstanceModel` (`instanceDate`)"
)
}
}
@@ -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()
}
@@ -1,37 +1,72 @@
package com.flux.data.model
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import java.time.LocalDate
import java.util.UUID
import kotlinx.serialization.Serializable
import java.time.Instant
import java.time.ZoneId
@Serializable
@Entity
@Entity(indices = [Index("workspaceId"), Index("endDateTime")])
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"])
@Entity(primaryKeys = ["habitId", "instanceDate"], indices = [Index("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()
}
}
fun HabitModel.startDateAsLocalDate(zoneId: ZoneId): LocalDate =
Instant.ofEpochMilli(startDateTime).atZone(zoneId).toLocalDate()
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
}
}
data class HabitAchievementStats(
val habitTitle: String,
val habitDescription: String,
val currentStreak: Int,
val longestStreak: Int,
val consistencyPercent: Int,
val totalCompletions: Int,
val heatMapData: Map<LocalDate, Int>
)
data class HabitWithStatus(val habit: HabitModel, val isCompleted: Boolean)
@@ -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
)
@@ -1,11 +1,13 @@
package com.flux.data.model
import kotlinx.serialization.Serializable
enum class ReminderType { EVENT, HABIT }
import java.time.LocalDate
@Serializable
sealed class RecurrenceRule {
@Serializable
object NONE: RecurrenceRule()
@Serializable
object Once : RecurrenceRule()
@@ -22,14 +24,36 @@ sealed class RecurrenceRule {
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
fun RecurrenceRule.isActiveOn(
date: LocalDate,
startDate: LocalDate
): Boolean {
if(startDate>date) return false
return when (this) {
RecurrenceRule.NONE -> false
RecurrenceRule.Once ->
date == startDate
is RecurrenceRule.Weekly -> {
val day = date.dayOfWeek.ordinal
day in daysOfWeek
}
RecurrenceRule.Monthly ->
date.dayOfMonth == startDate.dayOfMonth
RecurrenceRule.Yearly ->
date.dayOfMonth == startDate.dayOfMonth &&
date.month == startDate.month
is RecurrenceRule.Custom -> {
val daysBetween =
startDate.until(date).days
daysBetween >= 0 &&
daysBetween % everyXDays == 0
}
}
}
@@ -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,11 +20,14 @@ 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,
val isLintValid: Boolean = false,
val isLineNumbersVisible: Boolean = false,
val startWithReadView: Boolean = false,
val storageRootUri: String? = null
val storageRootUri: String? = null,
val backupFrequency: Int = 0,
val notesPreviewMode: Int = 1
)
@@ -1,23 +1,282 @@
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.Instant
import java.time.LocalDate
import java.time.ZoneId
@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
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()
)
data class ItemConsistency(
val itemId: String,
val label: String,
val checkedCount: Int,
val totalCount: Int,
val isRemoved: Boolean
) {
val percentage: Float
get() = if (totalCount == 0) 0f else checkedCount.toFloat() / totalCount
}
fun TodoModel.calculateItemConsistency(
instances: List<TodoInstance>
): List<ItemConsistency> {
val templateIds = items.map { it.id }.toSet()
val labelMap = items.associateBy({ it.id }, { it.value })
val counts = mutableMapOf<String, Pair<Int, Int>>() // itemId -> (checkedCount, totalCount)
instances.forEach { instance ->
instance.items.forEach { item ->
val (checked, total) = counts.getOrDefault(item.id, 0 to 0)
counts[item.id] = (checked + if (item.isChecked) 1 else 0) to (total + 1)
}
}
return counts.map { (id, pair) ->
ItemConsistency(
itemId = id,
label = labelMap[id] ?: item(id, instances) ?: "Unknown item",
checkedCount = pair.first,
totalCount = pair.second,
isRemoved = id !in templateIds
)
}.sortedByDescending { it.percentage }
}
private fun item(itemId: String, instances: List<TodoInstance>): String? {
return instances
.sortedByDescending { it.instanceDate }
.firstNotNullOfOrNull { instance ->
instance.items.firstOrNull { it.id == itemId }?.value
}
}
fun TodoInstance.isCompleted(): Boolean {
return items.all { it.isChecked }
}
fun TodoModel.startDateAsLocalDate(zoneId: ZoneId): LocalDate =
Instant.ofEpochMilli(startDateTime).atZone(zoneId).toLocalDate()
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")
}
}
}
sealed class TodoDisplayItem {
abstract val todoId: String
abstract val title: String
abstract val items: List<TodoItem>
/** No recurrence — the TodoModel itself is the source of truth. */
data class Static(val todo: TodoModel) : TodoDisplayItem() {
override val todoId get() = todo.id
override val title get() = todo.title
override val items get() = todo.items
}
/** Recurring and active today — items come from today's instance. */
data class Recurring(val todo: TodoModel, val instance: TodoInstance) : TodoDisplayItem() {
override val todoId get() = todo.id
override val title get() = todo.title
override val items get() = instance.items
}
}
@@ -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>>
}
@@ -4,50 +4,48 @@ import com.flux.data.dao.EventDao
import com.flux.data.dao.EventInstanceDao
import com.flux.data.model.EventInstanceModel
import com.flux.data.model.EventModel
import com.flux.di.IODispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
class EventRepositoryImpl @Inject constructor(
private val eventDao: EventDao,
private val eventInstanceDao: EventInstanceDao,
@IODispatcher private val ioDispatcher: CoroutineDispatcher
private val eventInstanceDao: EventInstanceDao
) : EventRepository {
override suspend fun upsertEvent(event: EventModel) {
return withContext(ioDispatcher) { eventDao.upsertEvent(event) }
return withContext(Dispatchers.IO) { eventDao.upsertEvent(event) }
}
override suspend fun upsertEventInstance(eventInstanceModel: EventInstanceModel) {
return withContext(ioDispatcher) { eventInstanceDao.upsertEventInstance(eventInstanceModel) }
return withContext(Dispatchers.IO) { eventInstanceDao.upsertEventInstance(eventInstanceModel) }
}
override suspend fun deleteEventInstance(eventInstanceModel: EventInstanceModel) {
return withContext(ioDispatcher) { eventInstanceDao.deleteEventInstance(eventInstanceModel) }
return withContext(Dispatchers.IO) { eventInstanceDao.deleteEventInstance(eventInstanceModel) }
}
override suspend fun loadAllEvents(): List<EventModel> {
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) {
return withContext(ioDispatcher) {
return withContext(Dispatchers.IO) {
eventInstanceDao.deleteAllEventInstance(event.id)
eventDao.deleteEvent(event)
}
}
override suspend fun deleteAllWorkspaceEvent(workspaceId: String) {
return withContext(ioDispatcher) {
return withContext(Dispatchers.IO) {
eventDao.deleteAllWorkspaceEvents(workspaceId)
eventInstanceDao.deleteAllWorkspaceInstance(workspaceId)
}
@@ -2,15 +2,20 @@ package com.flux.data.repository
import com.flux.data.model.HabitInstanceModel
import com.flux.data.model.HabitModel
import com.flux.data.model.HabitWithStatus
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>>
suspend fun loadAllHabitsInstances(): List<HabitInstanceModel>
suspend fun toggleHabit(habit: HabitModel, currentlyCompleted: Boolean)
fun observeTodayHabitStatuses(): Flow<List<HabitWithStatus>>
fun loadHabitData(): Flow<List<HabitModel>>
fun loadHabitInstanceData(): Flow<List<HabitInstanceModel>>
}
@@ -2,52 +2,150 @@ package com.flux.data.repository
import com.flux.data.dao.HabitInstanceDao
import com.flux.data.dao.HabitsDao
import com.flux.data.dao.WorkspaceDao
import com.flux.data.model.HabitConfig
import com.flux.data.model.HabitInstanceModel
import com.flux.data.model.HabitModel
import com.flux.di.IODispatcher
import kotlinx.coroutines.CoroutineDispatcher
import com.flux.data.model.HabitWithStatus
import com.flux.data.model.isActiveOn
import com.flux.data.model.isCompleted
import com.flux.data.model.startDateAsLocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.ZoneId
import javax.inject.Inject
class HabitRepositoryImpl @Inject constructor(
private val dao: HabitsDao,
private val instanceDao: HabitInstanceDao,
@IODispatcher private val ioDispatcher: CoroutineDispatcher
private val workspaceDao: WorkspaceDao
) : 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(ioDispatcher) { dao.upsertHabit(habit) }
return withContext(Dispatchers.IO) { dao.upsertHabit(habit) }
}
override suspend fun deleteInstance(habitInstance: HabitInstanceModel) {
return withContext(ioDispatcher) { instanceDao.deleteInstance(habitInstance) }
return withContext(Dispatchers.IO) { instanceDao.deleteInstance(habitInstance) }
}
override suspend fun upsertHabitInstance(habitInstance: HabitInstanceModel) {
return withContext(ioDispatcher) { instanceDao.upsertInstance(habitInstance) }
return withContext(Dispatchers.IO) { instanceDao.upsertInstance(habitInstance) }
}
override suspend fun loadAllHabits(): List<HabitModel> {
return withContext(ioDispatcher) { dao.loadAllHabits() }
return withContext(Dispatchers.IO) { dao.loadAllHabits() }
}
override fun loadAllHabitInstance(workspaceId: String): Flow<List<HabitInstanceModel>> {
return instanceDao.loadAllInstances(workspaceId)
override suspend fun loadAllHabitsInstances(): List<HabitInstanceModel> {
return withContext(Dispatchers.IO) { instanceDao.loadAllInstances() }
}
override fun loadAllHabitsOfWorkspace(workspaceId: String): Flow<List<HabitModel>> {
return dao.loadAllHabitsOfWorkspace(workspaceId)
override suspend fun toggleHabit(habit: HabitModel, currentlyCompleted: Boolean) {
val todayEpoch = LocalDate.now().toEpochDay()
val oldInstance = instanceDao.getHabitInstance(habit.id, todayEpoch)
when (habit.habitConfig) {
is HabitConfig.Simple -> toggleSimpleHabit(habit, todayEpoch, currentlyCompleted, oldInstance)
is HabitConfig.Counted -> incrementCountedHabit(habit, todayEpoch, oldInstance)
is HabitConfig.Timed -> Unit
}
}
private suspend fun toggleSimpleHabit(
habit: HabitModel,
todayEpoch: Long,
currentlyCompleted: Boolean,
oldInstance: HabitInstanceModel?,
) {
if (currentlyCompleted) {
oldInstance?.let { instanceDao.deleteInstance(it) }
} else if (oldInstance == null) {
instanceDao.upsertInstance(
HabitInstanceModel(
habitId = habit.id,
workspaceId = habit.workspaceId,
instanceDate = todayEpoch,
)
)
}
}
private suspend fun incrementCountedHabit(
habit: HabitModel,
todayEpoch: Long,
oldInstance: HabitInstanceModel?,
) {
instanceDao.upsertInstance(
HabitInstanceModel(
habitId = habit.id,
workspaceId = habit.workspaceId,
instanceDate = todayEpoch,
count = (oldInstance?.count ?: 0) + 1,
)
)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun observeTodayHabitStatuses(): Flow<List<HabitWithStatus>> {
val today = LocalDate.now()
val todayEpoch = today.toEpochDay()
val zoneId = ZoneId.systemDefault()
val todayEndMillis = today.plusDays(1).atStartOfDay(zoneId).toInstant().toEpochMilli()
return workspaceDao.observePublicWorkspaceIds()
.flatMapLatest { publicWorkspaceIds ->
if (publicWorkspaceIds.isEmpty()) {
flowOf(emptyList())
} else {
combine(
dao.loadCandidateHabits(publicWorkspaceIds, todayEpoch, todayEndMillis),
instanceDao.loadInstancesForDate(todayEpoch),
) { candidateHabits, instances ->
val instanceByHabitId = instances.associateBy { it.habitId }
candidateHabits.asSequence()
.filter { it.recurrence.isActiveOn(today, it.startDateAsLocalDate(zoneId)) }
.map { habit ->
val instance = instanceByHabitId[habit.id]
HabitWithStatus(habit, instance?.isCompleted(habit) ?: false)
}
.toList()
}
}
}
.flowOn(Dispatchers.Default)
}
override fun loadHabitInstanceData(): Flow<List<HabitInstanceModel>> {
return instanceDao.loadHabitInstanceData()
}
override fun loadHabitData(): Flow<List<HabitModel>> {
return dao.loadHabitData()
}
override suspend fun deleteHabit(habit: HabitModel) {
return withContext(ioDispatcher) {
return withContext(Dispatchers.IO) {
instanceDao.deleteAllInstances(habit.id)
dao.deleteHabit(habit)
}
}
override suspend fun deleteAllWorkspaceHabit(workspaceId: String) {
return withContext(ioDispatcher) {
return withContext(Dispatchers.IO) {
dao.deleteAllWorkspaceHabit(workspaceId)
instanceDao.deleteAllWorkspaceInstance(workspaceId)
}
@@ -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>>
}
@@ -2,29 +2,27 @@ package com.flux.data.repository
import com.flux.data.dao.JournalDao
import com.flux.data.model.JournalModel
import com.flux.di.IODispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
class JournalRepositoryImpl @Inject constructor(
private val dao: JournalDao,
@IODispatcher private val ioDispatcher: CoroutineDispatcher
private val dao: JournalDao
) : JournalRepository {
override suspend fun upsertEntry(entry: JournalModel) {
return withContext(ioDispatcher) { dao.upsertEntry(entry) }
return withContext(Dispatchers.IO) { dao.upsertEntry(entry) }
}
override suspend fun deleteEntry(entry: JournalModel) {
return withContext(ioDispatcher) { dao.deleteEntry(entry) }
return withContext(Dispatchers.IO) { dao.deleteEntry(entry) }
}
override suspend fun deleteAllWorkspaceEntry(workspaceId: String) {
return withContext(ioDispatcher) { dao.deleteAllWorkspaceEntries(workspaceId) }
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,53 +2,38 @@ 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 com.flux.di.IODispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
class NoteRepositoryImpl @Inject constructor(
private val notesDao: NotesDao,
private val labelDao: LabelDao,
@IODispatcher private val ioDispatcher: CoroutineDispatcher
private val labelDao: LabelDao
) : NoteRepository {
override suspend fun upsertNote(note: NotesModel) {
return withContext(ioDispatcher) { notesDao.upsertNote(note) }
}
override suspend fun upsertLabel(label: LabelModel) {
return withContext(ioDispatcher) { labelDao.upsertLabel(label) }
return withContext(Dispatchers.IO) { notesDao.upsertNote(note) }
}
override suspend fun upsertNotes(notes: List<NotesModel>) {
return withContext(ioDispatcher) { notesDao.upsertNotes(notes) }
return withContext(Dispatchers.IO) { notesDao.upsertNotes(notes) }
}
override suspend fun deleteNote(note: NotesModel) {
return withContext(ioDispatcher) { notesDao.deleteNote(note) }
}
override suspend fun deleteLabel(label: LabelModel) {
return withContext(ioDispatcher) { labelDao.deleteLabel(label) }
return withContext(Dispatchers.IO) { notesDao.deleteNote(note) }
}
override suspend fun deleteNotes(notes: List<String>) {
return withContext(ioDispatcher) { notesDao.deleteNotes(notes) }
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) {
return (withContext(ioDispatcher) {
return (withContext(Dispatchers.IO) {
labelDao.deleteAllWorkspaceLabels(workspaceId)
notesDao.deleteAllWorkspaceNotes(workspaceId)
})
@@ -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()
}
}
@@ -6,9 +6,8 @@ import android.net.Uri
import androidx.core.net.toUri
import com.flux.data.dao.SettingsDao
import com.flux.data.model.SettingsModel
import com.flux.di.IODispatcher
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.withContext
@@ -16,18 +15,17 @@ import javax.inject.Inject
class SettingsRepositoryImpl @Inject constructor(
private val dao: SettingsDao,
@param:ApplicationContext private val context: Context,
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher
@param:ApplicationContext private val context: Context
) : SettingsRepository {
override suspend fun upsertSettings(settings: SettingsModel) {
return withContext(ioDispatcher) { dao.upsertSettings(settings) }
return withContext(Dispatchers.IO) { dao.upsertSettings(settings) }
}
override fun loadSettings(): Flow<SettingsModel?> { return dao.loadSettings() }
override suspend fun hasValidStorageRoot(): Boolean =
withContext(ioDispatcher) {
withContext(Dispatchers.IO) {
val settings = dao.loadSettings().firstOrNull() ?: return@withContext false
val uriString = settings.storageRootUri ?: return@withContext false
val uri = uriString.toUri()
@@ -36,14 +34,14 @@ class SettingsRepositoryImpl @Inject constructor(
}
override suspend fun saveStorageRoot(uri: Uri) =
withContext(ioDispatcher) {
withContext(Dispatchers.IO) {
context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
val current = dao.loadSettings().firstOrNull() ?: SettingsModel()
dao.upsertSettings(current.copy(storageRootUri = uri.toString()))
}
override suspend fun getStorageRoot(): Uri =
withContext(ioDispatcher) {
withContext(Dispatchers.IO) {
val uriString = dao.loadSettings()
.firstOrNull()
?.storageRootUri
@@ -1,5 +1,7 @@
package com.flux.data.repository
import com.flux.data.model.TodoDisplayItem
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import kotlinx.coroutines.flow.Flow
@@ -7,5 +9,13 @@ 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>
suspend fun toggleTodoItem(item: TodoDisplayItem, itemId: String)
fun observePublicTodos(): Flow<List<TodoModel>>
fun observeTodoList(todoId: String): Flow<TodoDisplayItem?>
fun loadAllTodoInstance(): Flow<List<TodoInstance>>
fun loadTodoData(): Flow<List<TodoModel>>
}
@@ -1,30 +1,165 @@
package com.flux.data.repository
import com.flux.data.dao.TodoDao
import com.flux.data.dao.TodoInstanceDao
import com.flux.data.dao.WorkspaceDao
import com.flux.data.model.RecurrenceRule
import com.flux.data.model.TodoDisplayItem
import com.flux.data.model.TodoInstance
import com.flux.data.model.TodoModel
import com.flux.di.IODispatcher
import kotlinx.coroutines.CoroutineDispatcher
import com.flux.data.model.isActiveOn
import com.flux.data.model.startDateAsLocalDate
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
import java.time.LocalDate
import java.time.ZoneId
import javax.inject.Inject
class TodoRepositoryImpl @Inject constructor(
val dao: TodoDao,
@IODispatcher private val ioDispatcher: CoroutineDispatcher
val instanceDao: TodoInstanceDao,
val workspaceDao: WorkspaceDao
) : 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) {
return withContext(ioDispatcher) { dao.upsertList(list) }
return withContext(Dispatchers.IO) {
dao.upsertList(list)
val todayEpoch = LocalDate.now().toEpochDay()
val todayInstance = instanceDao.loadInstanceForDate(list.id, todayEpoch) ?: return@withContext
val existingById = todayInstance.items.associateBy { it.id }
val syncedItems = list.items.map { templateItem ->
existingById[templateItem.id]
?.copy(value = templateItem.value) // keep isChecked, pick up rename
?: templateItem.copy(isChecked = false) // newly added item
}
if (syncedItems != todayInstance.items) {
instanceDao.upsertTodoInstance(todayInstance.copy(items = syncedItems))
}
}
}
override suspend fun deleteList(list: TodoModel) {
return withContext(ioDispatcher) { dao.deleteList(list) }
return withContext(Dispatchers.IO) {
instanceDao.deleteListInstances(list.id)
dao.deleteList(list)
}
}
override suspend fun deleteAllWorkspaceLists(workspaceId: String) {
return withContext(ioDispatcher) { dao.deleteAllWorkspaceLists(workspaceId) }
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()
}
override fun observeTodoList(todoId: String): Flow<TodoDisplayItem?> {
val today = LocalDate.now()
val todayEpoch = today.toEpochDay()
val zone = ZoneId.systemDefault()
return combine(
dao.observeTodoList(todoId),
instanceDao.observeInstanceForDate(todoId, todayEpoch)
) { todo, instances ->
if (todo == null)
return@combine null
when {
todo.recurrence == RecurrenceRule.NONE -> TodoDisplayItem.Static(todo)
todo.recurrence.isActiveOn(
today,
todo.startDateAsLocalDate(zone)
) -> {
val instance =
instances.firstOrNull()
?: TodoInstance(
todoId = todo.id,
workspaceId = todo.workspaceId,
instanceDate = todayEpoch,
items = todo.items
)
TodoDisplayItem.Recurring(
todo = todo,
instance = instance
)
}
else -> null
}
}.flowOn(Dispatchers.Default)
}
override suspend fun toggleTodoItem(
item: TodoDisplayItem,
itemId: String
) = withContext(Dispatchers.IO) {
when (item) {
is TodoDisplayItem.Static -> {
val updated =
item.todo.items.map {
if (it.id == itemId) it.copy(isChecked = !it.isChecked)
else it
}
dao.upsertList(item.todo.copy(items = updated))
}
is TodoDisplayItem.Recurring -> {
val instance = item.instance
val updated =
instance.items.map {
if (it.id == itemId) it.copy(isChecked = !it.isChecked)
else it
}
instanceDao.upsertTodoInstance(instance.copy(items = updated))
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun observePublicTodos(): Flow<List<TodoModel>> =
workspaceDao.observePublicWorkspaceIds()
.flatMapLatest { workspaceIds ->
if (workspaceIds.isEmpty()) {
flowOf(emptyList())
} else {
dao.observePublicTodos(workspaceIds)
}
}
}
@@ -7,5 +7,7 @@ interface WorkspaceRepository {
suspend fun upsertWorkspace(workspace: WorkspaceModel)
suspend fun deleteWorkspace(workspace: WorkspaceModel)
suspend fun upsertWorkspaces(spaces: List<WorkspaceModel>)
suspend fun loadData(): List<WorkspaceModel>
fun observePublicWorkspaceIds(): Flow<List<String>>
fun loadAllWorkspaces(): Flow<List<WorkspaceModel>>
}
@@ -2,18 +2,18 @@ package com.flux.data.repository
import com.flux.data.dao.WorkspaceDao
import com.flux.data.model.WorkspaceModel
import com.flux.di.IODispatcher
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import javax.inject.Inject
class WorkspaceRepositoryImpl @Inject constructor(
private val dao: WorkspaceDao,
@IODispatcher private val ioDispatcher: CoroutineDispatcher
private val dao: WorkspaceDao
): WorkspaceRepository {
override suspend fun upsertWorkspace(workspace: WorkspaceModel) { return withContext(ioDispatcher) { dao.upsertWorkspace(workspace) } }
override suspend fun upsertWorkspaces(spaces: List<WorkspaceModel>) { return withContext(ioDispatcher) { dao.upsertWorkspaces(spaces) } }
override suspend fun deleteWorkspace(workspace: WorkspaceModel) { return withContext(ioDispatcher) { dao.deleteWorkspace(workspace) } }
override suspend fun upsertWorkspace(workspace: WorkspaceModel) { return withContext(Dispatchers.IO) { dao.upsertWorkspace(workspace) } }
override suspend fun upsertWorkspaces(spaces: List<WorkspaceModel>) { return withContext(Dispatchers.IO) { dao.upsertWorkspaces(spaces) } }
override suspend fun deleteWorkspace(workspace: WorkspaceModel) { return withContext(Dispatchers.IO) { dao.deleteWorkspace(workspace) } }
override suspend fun loadData(): List<WorkspaceModel> { return withContext(Dispatchers.IO) { dao.getAll() } }
override fun observePublicWorkspaceIds(): Flow<List<String>> { return dao.observePublicWorkspaceIds() }
override fun loadAllWorkspaces(): Flow<List<WorkspaceModel>> { return dao.loadAllWorkspaces() }
}
+34 -1
View File
@@ -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,13 +10,23 @@ 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_10_11
import com.flux.data.database.MIGRATION_1_2
import com.flux.data.database.MIGRATION_2_3
import com.flux.data.database.MIGRATION_3_4
import com.flux.data.database.MIGRATION_4_5
import com.flux.data.database.MIGRATION_5_6
import com.flux.data.database.MIGRATION_6_7
import com.flux.data.database.MIGRATION_7_8
import com.flux.data.database.MIGRATION_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
@@ -35,7 +46,17 @@ object DataModule {
FluxDatabase::class.java,
"FluxDatabase"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
MIGRATION_4_5,
MIGRATION_5_6,
MIGRATION_6_7,
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11
)
.build()
@Singleton
@@ -74,7 +95,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))
}
@@ -1,21 +0,0 @@
package com.flux.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
@IODispatcher
@Provides
fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO
}
@Retention
@Qualifier
annotation class IODispatcher
@@ -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(
+48 -211
View File
@@ -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,17 @@ fun AppNavHost(
animatedComposable(route) {
screen(
navController,
States(
notesState,
eventState,
habitState,
todoState,
workspaceState,
journalState,
settings
)
states
)
}
}
StorageSelectionScreen.forEach { (route, screen) ->
animatedComposable(route) {
screen(
navController,
states,
viewModels
)
}
}
@@ -149,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
)
}
}
@@ -197,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
)
}
}
@@ -245,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
)
}
}
@@ -361,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)
}
}
@@ -380,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
)
}
@@ -419,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,31 +10,41 @@ 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.Backup
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
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
@@ -42,18 +52,24 @@ 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")
data object Privacy : NavRoutes("settings/privacy")
data object Customize : NavRoutes("settings/customize")
data object Theme : NavRoutes("settings/customize/theme")
data object Languages : NavRoutes("settings/language")
data object About : NavRoutes("settings/about")
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 {
@@ -72,25 +88,41 @@ val AuthScreen =
}
)
val StorageSelectionScreen = mapOf<String, @Composable (navController: NavController, states: States, viewModels: ViewModels) -> Unit>(
NavRoutes.StorageSelection.route to { navController, states, viewModels ->
StorageSelectionScreen(navController, viewModels.settingsViewModel, states.settings.data.storageRootUri!=null)
}
)
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,
states.settings.data.fontNumber,
states.settings.data.isDarkMode,
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
)
}
)
@@ -103,8 +135,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 ->
@@ -122,8 +156,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
)
@@ -135,17 +183,24 @@ 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.fontNumber,
states.settings.data.isDarkMode,
states.settings.data.isLintValid,
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
)
}
)
@@ -171,11 +226,20 @@ val SettingsScreens =
NavRoutes.Contact.route to { navController, _, states, _ ->
Contact(navController, states.settings.data.cornerRadius)
},
NavRoutes.Backup.route to { navController, _, states, viewModels ->
Backup(navController, states.settings.data.cornerRadius, viewModels.backupViewModel)
NavRoutes.Backup.route to { navController, snackbarHostState, states, viewModels ->
Data(navController, states.settings.data.cornerRadius, states.settings, snackbarHostState, viewModels.backupViewModel, viewModels.settingsViewModel::onEvent)
},
NavRoutes.Editor.route to { navController, _, states, viewModels ->
Editor(navController, states.settings, viewModels.settingsViewModel::onEvent)
},
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)
}
)
@@ -184,12 +248,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 ->
@@ -206,48 +272,29 @@ 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,
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.journalState.selectedYearMonth,
states.journalState.selectedDate,
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
)
}
)
@@ -257,10 +304,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
)
}
)
@@ -0,0 +1,123 @@
package com.flux.other
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.media.MediaRecorder
import android.os.Build
import androidx.core.content.ContextCompat
import java.io.File
class AudioRecorder(private val context: Context) {
private var recorder: MediaRecorder? = null
private var tempFile: File? = null
private var isRecording = false
fun hasMicPermission(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
}
// Start recording into a temp cache file
fun startRecording() {
if (isRecording) return
tempFile = File(
context.cacheDir,
"temp_audio_${System.currentTimeMillis()}.m4a"
)
val mediaRecorder =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(context)
} else {
MediaRecorder()
}
recorder = mediaRecorder.apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioEncodingBitRate(128_000)
setAudioSamplingRate(44_100)
setOutputFile(tempFile!!.absolutePath)
prepare()
start()
}
isRecording = true
}
// Stop recording and return temp file
fun stopRecording(): File? {
if (!isRecording) return null
return try {
recorder?.apply {
stop()
release()
}
val file = tempFile
recorder = null
tempFile = null
isRecording = false
file
} catch (_: Exception) {
// Cleanup corrupted file
tempFile?.delete()
recorder = null
tempFile = null
isRecording = false
null
}
}
// Delete temp recording without saving
fun deleteRecording() {
try {
recorder?.apply {
stop()
release()
}
} catch (_: Exception) {
// Ignore stop failure if not started
}
tempFile?.delete()
recorder = null
tempFile = null
isRecording = false
}
// Release resources (lifecycle safety)
fun release() {
try {
recorder?.release()
} catch (_: Exception) {}
recorder = null
tempFile = null
isRecording = false
}
// For waveform UI
fun getAmplitude(): Int {
return recorder?.maxAmplitude ?: 0
}
}
@@ -0,0 +1,39 @@
package com.flux.other
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
class BackupManager(
private val workManager: WorkManager
) {
fun scheduleBackup(frequency: BackupFrequency) {
if (frequency == BackupFrequency.NEVER) {
workManager.cancelUniqueWork(BACKUP_WORK_NAME)
return
}
val constraints = Constraints.Builder()
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build()
val backupRequest = PeriodicWorkRequestBuilder<BackupWorker>(
frequency.days.toLong(), TimeUnit.DAYS
)
.setConstraints(constraints)
.build()
workManager.enqueueUniquePeriodicWork(
BACKUP_WORK_NAME,
ExistingPeriodicWorkPolicy.UPDATE,
backupRequest
)
}
companion object {
const val BACKUP_WORK_NAME = "database_backup_work"
}
}
@@ -0,0 +1,86 @@
package com.flux.other
import android.content.Context
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.flux.R
import com.flux.data.database.FluxBackup
import com.flux.data.model.SettingsModel
import com.flux.di.DataModule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.io.OutputStreamWriter
enum class BackupFrequency(val days: Int, val textRes: Int) {
NEVER(0, R.string.never),
DAILY(1, R.string.daily),
WEEKLY(7, R.string.weekly),
MONTHLY(30, R.string.monthly);
}
class BackupWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
val context = applicationContext
val fluxDatabase = DataModule.provideFluxDatabase(context)
val settingsDao = DataModule.provideSettingsDao(fluxDatabase)
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 baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX)
val backupDir = baseDir?.let { dir ->
getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_BACKUP)
}
backupDir?.let { dir ->
val backup = FluxBackup(
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(),
progressBoardItems = progressBoardDao.getAllBoardItems()
)
val json = Json.encodeToString(FluxBackup.serializer(), backup)
val fileName = "${System.currentTimeMillis()}.json"
val file = dir.createFile("application/json", fileName)
file?.let { docFile ->
context.contentResolver.openOutputStream(docFile.uri)
?.use { outputStream ->
OutputStreamWriter(outputStream).use { writer ->
writer.write(json)
}
}
}
}
Result.success()
} catch (_: Exception) {
Result.failure()
}
}
}
@@ -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
@@ -15,41 +18,50 @@ import kotlinx.coroutines.CoroutineScope
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
}
}
@@ -58,4 +70,5 @@ class BootReceiver : BroadcastReceiver() {
interface ReceiverEntryPoint {
fun habitRepository(): HabitRepository
fun eventRepository(): EventRepository
fun todoRepository(): TodoRepository
}
+28 -8
View File
@@ -19,6 +19,12 @@ object Constants {
const val FLUX_IMAGES = "Images"
const val FLUX_AUDIO = "Audio"
const val FLUX_VIDEOS = "Videos"
const val FLUX_BACKUP = "Backup"
}
object Other {
const val SIDE_EFFECT_KEY = "side_effect"
const val ACTION_MARK_DONE = "flux.action.MARK_DONE"
}
object Editor {
@@ -61,7 +67,7 @@ object Constants {
}
}
enum class ExportType {
enum class ExportType {
TXT,
MARKDOWN,
HTML,
@@ -69,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 =
@@ -195,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(
@@ -211,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
)
@@ -233,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: ![alt](url)
private val MD_IMAGE_REGEX = Regex("""!\[[^\]]*]\(([^)]+)\)""")
// Markdown link: [text](url) — classified by URL extension
private val MD_LINK_REGEX = Regex("""\[[^\]]*]\(([^)]+)\)""")
// HTML video tag: <video src="..." or <video src='...'
private val HTML_VIDEO_REGEX = Regex("""<video[^>]+src=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
// HTML audio tag: <audio src="..." or <audio src='...'
private val HTML_AUDIO_REGEX = Regex("""<audio[^>]+src=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
// HTML img tag: <img src="..." or <img src='...'
private val HTML_IMG_REGEX = Regex("""<img[^>]+src=["']([^"']+)["'][^>]*>""", RegexOption.IGNORE_CASE)
// Closing tags to strip alongside the opening tags
private val HTML_VIDEO_CLOSE = Regex("""</video\s*>""", RegexOption.IGNORE_CASE)
private val HTML_AUDIO_CLOSE = Regex("""</audio\s*>""", RegexOption.IGNORE_CASE)
// Bare https?:// URL not inside () — classified by extension
private val BARE_URL_REGEX = Regex("""(?<!\()https?://\S+""")
private val FENCE_REGEX = Regex(
"""^(`{3,}|~{3,})([\w\-+#.]*)[ \t]*\r?\n(.*?)\r?\n?\1[ \t]*$""",
setOf(RegexOption.MULTILINE, RegexOption.DOT_MATCHES_ALL)
)
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
private fun String.urlExtension() =
substringAfterLast('.', "").lowercase().substringBefore('?').substringBefore('#').trim()
private enum class MediaType { IMAGE, VIDEO, AUDIO }
private fun classifyUrl(url: String): MediaType? {
val ext = url.trim().urlExtension()
return when (ext) {
in IMAGE_EXTENSIONS -> MediaType.IMAGE
in VIDEO_EXTENSIONS -> MediaType.VIDEO
in AUDIO_EXTENSIONS -> MediaType.AUDIO
else -> null
}
}
// ---------------------------------------------------------------------------
// Global media extraction (call this separately from segmentMarkdown)
// ---------------------------------------------------------------------------
/**
* Scans the entire raw [text] for every media reference — markdown, HTML tags,
* and bare URLs — and returns a [MarkdownSegment.MediaGroup] plus a cleaned
* copy of the text with all media syntax removed.
*
* This is intentionally separate from [segmentMarkdown] so the caller
* (e.g. NotesPreviewCard) can render chips wherever it wants (e.g. card bottom)
* rather than inline inside the text column.
*/
data class MediaExtractionResult(
val cleanedText: String,
val media: MarkdownSegment.MediaGroup
)
fun extractMedia(text: String): MediaExtractionResult {
val images = mutableListOf<String>()
val videos = mutableListOf<String>()
val audio = mutableListOf<String>()
val stripRanges = mutableListOf<IntRange>()
fun addImage(url: String, range: IntRange) { images += url; stripRanges += range }
fun addVideo(url: String, range: IntRange) { videos += url; stripRanges += range }
fun addAudio(url: String, range: IntRange) { audio += url; stripRanges += range }
// 1. HTML <video src="..."></video>
HTML_VIDEO_REGEX.findAll(text).forEach { m ->
addVideo(m.groupValues[1].trim(), m.range)
}
// strip </video> closing tags
HTML_VIDEO_CLOSE.findAll(text).forEach { m -> stripRanges += m.range }
// 2. HTML <audio src="..."></audio>
HTML_AUDIO_REGEX.findAll(text).forEach { m ->
addAudio(m.groupValues[1].trim(), m.range)
}
HTML_AUDIO_CLOSE.findAll(text).forEach { m -> stripRanges += m.range }
// 3. HTML <img src="...">
HTML_IMG_REGEX.findAll(text).forEach { m ->
addImage(m.groupValues[1].trim(), m.range)
}
// 4. Markdown images ![alt](url) — always image
MD_IMAGE_REGEX.findAll(text).forEach { m ->
if (stripRanges.any { it.contains(m.range.first) }) return@forEach
addImage(m.groupValues[1].trim(), m.range)
}
// 5. Markdown links [text](url) — classify by extension
MD_LINK_REGEX.findAll(text).forEach { m ->
if (stripRanges.any { it.contains(m.range.first) }) return@forEach
val url = m.groupValues[1].trim()
when (classifyUrl(url)) {
MediaType.IMAGE -> addImage(url, m.range)
MediaType.VIDEO -> addVideo(url, m.range)
MediaType.AUDIO -> addAudio(url, m.range)
null -> { /* plain link — leave it */ }
}
}
// 6. Bare URLs — classify by extension
BARE_URL_REGEX.findAll(text).forEach { m ->
if (stripRanges.any { it.contains(m.range.first) }) return@forEach
val url = m.value.trim()
when (classifyUrl(url)) {
MediaType.IMAGE -> addImage(url, m.range)
MediaType.VIDEO -> addVideo(url, m.range)
MediaType.AUDIO -> addAudio(url, m.range)
null -> { /* plain URL — leave it */ }
}
}
// Build cleaned text by merging + removing strip ranges
val merged = stripRanges.sortedBy { it.first }.fold(mutableListOf<IntRange>()) { acc, r ->
if (acc.isEmpty() || acc.last().last < r.first - 1) acc.add(r)
else acc[acc.lastIndex] = acc.last().first..maxOf(acc.last().last, r.last)
acc
}
var cleaned = text
var offset = 0
for (r in merged) {
val start = (r.first - offset).coerceAtLeast(0)
val end = (r.last - offset + 1).coerceAtMost(cleaned.length)
if (start < end) {
cleaned = cleaned.removeRange(start, end)
offset += end - start
}
}
return MediaExtractionResult(
cleanedText = cleaned.trim(),
media = MarkdownSegment.MediaGroup(images, videos, audio)
)
}
// ---------------------------------------------------------------------------
// Segment splitter (fenced code blocks only — media handled separately above)
// ---------------------------------------------------------------------------
fun segmentMarkdown(text: String): List<MarkdownSegment> {
val segments = mutableListOf<MarkdownSegment>()
var lastEnd = 0
for (match in FENCE_REGEX.findAll(text)) {
if (match.range.first > lastEnd) {
val before = text.substring(lastEnd, match.range.first)
if (before.isNotBlank()) segments.add(MarkdownSegment.Text(before))
}
segments.add(MarkdownSegment.FencedCode(match.groupValues[2].trim(), match.groupValues[3]))
lastEnd = match.range.last + 1
}
if (lastEnd < text.length) {
val remaining = text.substring(lastEnd)
if (remaining.isNotBlank()) segments.add(MarkdownSegment.Text(remaining))
}
if (segments.isEmpty()) segments.add(MarkdownSegment.Text(text))
return segments
}
fun openUrl(context: android.content.Context, url: String) {
runCatching {
val uri = url.toUri().let { parsed ->
// Ensure the URL has a scheme so the browser can handle it
if (parsed.scheme.isNullOrBlank()) "https://$url".toUri() else parsed
}
context.startActivity(Intent(Intent.ACTION_VIEW, uri).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
})
}
}
@Composable
fun MarkdownBlock(
text: String,
modifier: Modifier = Modifier,
linkColor: Color = Color(0xFF4A90D9),
codeBlockBackground: Color = MaterialTheme.colorScheme.surfaceVariant,
codeBlockTextColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
onLinkClick: ((String) -> Unit)? = null,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
val context = LocalContext.current
val handleLink: (String) -> Unit = onLinkClick ?: { url -> openUrl(context, url) }
// Strip media from text before segmenting
val extracted = remember(text) { extractMedia(text) }
val segments = remember(extracted.cleanedText) { segmentMarkdown(extracted.cleanedText) }
Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
segments.forEach { segment ->
when (segment) {
is MarkdownSegment.Text -> {
val annotated = parseMarkdownContent(segment.content, linkColor)
LinkAwareText(
annotated = annotated,
onLinkClick = handleLink,
onClick = onClick,
onLongClick = onLongClick
)
}
is MarkdownSegment.FencedCode -> {
FencedCodeBlock(
info = segment.info,
content = segment.content,
background = codeBlockBackground,
textColor = codeBlockTextColor,
onClick = onClick,
onLongClick = onLongClick
)
}
}
}
}
}
@Composable
fun MediaChipsRow(
media: MarkdownSegment.MediaGroup,
modifier: Modifier = Modifier,
onClick: () -> Unit = {},
onLongClick: () -> Unit = {}
) {
if (media.isEmpty) return
FlowRow (
modifier = modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (media.images.isNotEmpty()) {
MediaChip(icon = Icons.Default.Image, label = stringResource(R.string.image), count = media.images.size)
}
if (media.videos.isNotEmpty()) {
MediaChip(icon = Icons.Default.VideoFile, label = stringResource(R.string.video), count = media.videos.size)
}
if (media.audio.isNotEmpty()) {
MediaChip(icon = Icons.Default.Audiotrack, label = stringResource(R.string.audio), count = media.audio.size)
}
}
}
@Composable
private fun MediaChip(icon: ImageVector, label: String, count: Int) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.secondaryContainer,
tonalElevation = 0.dp
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = icon,
contentDescription = label,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
text = if (count > 1) "$label × $count" else label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
@Composable
private fun LinkAwareText(
annotated: AnnotatedString,
onLinkClick: (String) -> Unit,
onClick: () -> Unit,
onLongClick: () -> Unit
) {
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotated,
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onBackground
),
onTextLayout = { textLayoutResult = it },
modifier = Modifier.pointerInput(annotated) {
detectTapGestures(
onTap = { offset ->
val layout = textLayoutResult ?: run { onClick(); return@detectTapGestures }
val charOffset = layout.getOffsetForPosition(offset)
val link = annotated
.getStringAnnotations(URL_ANNOTATION_TAG, charOffset, charOffset)
.firstOrNull()
if (link != null) onLinkClick(link.item) else onClick()
},
onLongPress = { onLongClick() }
)
}
)
}
@Composable
private fun FencedCodeBlock(
info: String,
content: String,
background: Color,
textColor: Color,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier
.fillMaxWidth()
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
shape = RoundedCornerShape(8.dp),
color = background,
tonalElevation = 0.dp
) {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
if (info.isNotBlank()) {
Text(
text = info,
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = textColor.copy(alpha = 0.55f)
),
modifier = Modifier
.padding(bottom = 8.dp)
.align(Alignment.End)
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
) {
Text(
text = content,
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = textColor,
lineHeight = 20.sp
)
)
}
}
}
}
@@ -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.alarm
else if(isEvent) R.drawable.event
else R.drawable.task
)
.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()
}
+529 -62
View File
@@ -2,23 +2,32 @@ package com.flux.other
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.webkit.WebView
import androidx.activity.result.ActivityResultLauncher
import android.graphics.Canvas
import android.os.Handler
import android.os.Looper
import android.print.PrintAttributes
import android.print.PrintManager
import android.util.Log
import android.view.View
import android.view.ViewGroup
import android.webkit.WebViewClient
import android.widget.FrameLayout
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.core.content.FileProvider
import androidx.documentfile.provider.DocumentFile
import com.flux.ui.viewModel.NotesViewModel
@@ -38,7 +47,6 @@ import org.commonmark.ext.front.matter.YamlFrontMatterExtension
import org.commonmark.ext.gfm.strikethrough.Strikethrough
import org.commonmark.ext.gfm.strikethrough.StrikethroughExtension
import org.commonmark.ext.gfm.tables.TableRow
import org.commonmark.ext.gfm.tables.TablesExtension
import org.commonmark.ext.ins.Ins
import org.commonmark.ext.ins.InsExtension
import org.commonmark.node.AbstractVisitor
@@ -58,9 +66,17 @@ import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.parser.IncludeSourceSpans
import org.commonmark.parser.Parser
import com.vladsch.flexmark.html2md.converter.FlexmarkHtmlConverter
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)
@@ -258,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,
@@ -431,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>()
@@ -444,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() {
@@ -548,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)
@@ -618,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)
)
}
}
}
}
@@ -660,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,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,363 @@
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 = {
val newDays = rule.daysOfWeek.toMutableList()
if (index in newDays) {
newDays.remove(index)
} else {
newDays.add(index)
}
tempRule = rule.copy(daysOfWeek = newDays)
},
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,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,480 @@
package com.flux.ui.common
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Pending
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.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.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.flux.R
import com.flux.ui.screens.settings.shapeManager
import java.time.DayOfWeek
import java.time.LocalDate
@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(),
shape = shapeManager(radius = radius*2),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(4.dp)
)
) {
Column(
Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
title,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth()
)
if(description.isNotBlank()){
Text(
description,
style = MaterialTheme.typography.labelMedium
)
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
) {
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier
.width(boxSize)
.padding(top = 26.dp, end = 2.dp)
) {
DayOfWeek.entries.forEach { day ->
Box(
modifier = Modifier
.width(boxSize + 12.dp)
.height(boxSize),
contentAlignment = Alignment.CenterStart
) {
Text(text = day.name.take(3), fontSize = 9.sp)
}
}
}
// Combined month + heatmap
LazyRow(
state = lazyListState,
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxWidth()
) {
itemsIndexed(weekColumns) { index, columnDates ->
val firstDate = columnDates.firstOrNull()
val month = firstDate?.month
// Show month label if this is the first week of the month
val showMonth =
month != null && (index == 0 || weekColumns.getOrNull(index - 1)?.firstOrNull()?.month != month)
Column(
verticalArrangement = Arrangement.spacedBy(2.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Month label on top (only once per month)
Box(
modifier = Modifier.height(24.dp),
contentAlignment = Alignment.Center
) {
if (showMonth) {
Text(
text = firstDate.month.name.take(3),
fontSize = 9.sp,
fontWeight = FontWeight.SemiBold
)
}
}
// Heatmap boxes
columnDates.forEach { date ->
if (date != null) {
val count = heatMap[date] ?: 0
val intensity = (count / if (intensityParam > 0) intensityParam.toFloat() else 2f).coerceIn(0f, 1f)
val color = lerp(
MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
MaterialTheme.colorScheme.primary,
intensity
)
Box(
modifier = Modifier
.size(boxSize)
.background(color, RoundedCornerShape(3.dp)),
contentAlignment = Alignment.Center
) {
Text(
text = date.dayOfMonth.toString(),
fontSize = 9.sp,
color = if (intensity > 0.5f) MaterialTheme.colorScheme.onPrimary
else MaterialTheme.colorScheme.onSurface
)
}
} else {
Box(modifier = Modifier.size(boxSize))
}
}
}
}
}
}
}
}
}
@Composable
fun WeeklyProgressChart(
radius: Int,
modifier: Modifier = Modifier,
percentages: List<Float>
){
val primaryColor = MaterialTheme.colorScheme.primary
val primaryAlpha = primaryColor.copy(alpha = 0.4f)
val today = LocalDate.now()
val startOfWeek = today.with(DayOfWeek.MONDAY)
val weekDays = (0..6).map { startOfWeek.plusDays(it.toLong()) }
val dayLabels = listOf(
stringResource(R.string.monday_short),
stringResource(R.string.tuesday_short),
stringResource(R.string.wednesday_short),
stringResource(R.string.thursday_short),
stringResource(R.string.friday_short),
stringResource(R.string.saturday_short),
stringResource(R.string.sunday_short)
)
Card(
modifier = Modifier.fillMaxWidth(),
shape = shapeManager(radius = radius * 2),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)
),
onClick = {}
) {
Column(modifier = modifier.padding(12.dp)) {
Text(
stringResource(R.string.This_Week),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp)
)
Canvas(
modifier = Modifier
.fillMaxWidth()
.padding(6.dp)
.height(200.dp)
) {
val leftPadding = 32.dp.toPx()
val bottomPadding = 32.dp.toPx()
val widthPerPoint = (size.width - leftPadding) / (weekDays.size - 1)
val height = size.height - bottomPadding
// Vertical percentage labels
for (i in 0..4) {
val y = height - i * height / 4
drawContext.canvas.nativeCanvas.apply {
drawText(
"${i * 25}%",
leftPadding - 14.dp.toPx(),
y,
Paint().apply {
color = primaryColor.toArgb()
textSize = 12.sp.toPx()
textAlign = Paint.Align.RIGHT
}
)
}
}
// Line path
val linePath = Path().apply {
percentages.forEachIndexed { index, percentage ->
val x = leftPadding + index * widthPerPoint
val y = height - (percentage / 100f * height)
if (index == 0) moveTo(x, y) else lineTo(x, y)
}
}
// Shadow under-line
val shadowPath = Path().apply {
addPath(linePath)
lineTo(leftPadding + (weekDays.size - 1) * widthPerPoint, height)
lineTo(leftPadding, height)
close()
}
drawPath(
path = shadowPath,
brush = Brush.verticalGradient(
colors = listOf(primaryAlpha, primaryColor.copy(alpha = 0f)),
startY = 0f,
endY = height
)
)
// Draw line
drawPath(
path = linePath,
color = primaryColor,
style = Stroke(width = 4.dp.toPx(), cap = StrokeCap.Round)
)
// Draw dots
percentages.forEachIndexed { index, percentage ->
val x = leftPadding + index * widthPerPoint
val y = height - (percentage / 100f * height)
drawCircle(
color = primaryColor,
radius = 6.dp.toPx(),
center = Offset(x, y)
)
}
// Draw day labels
dayLabels.forEachIndexed { index, day ->
val x = leftPadding + index * widthPerPoint
val y = height + 24.dp.toPx()
drawContext.canvas.nativeCanvas.apply {
drawText(
day,
x,
y,
Paint().apply {
color = primaryColor.toArgb()
textSize = 12.sp.toPx()
textAlign = Paint.Align.CENTER
}
)
}
}
}
}
}
}
@Composable
fun WeeklyAnalytics(
radius: Int,
dayStatus: List<Boolean>
) {
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 completedCount = dayStatus.count { it }
Card(
modifier = Modifier.fillMaxWidth(),
shape = shapeManager(radius = radius * 2),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)),
onClick = {}
) {
Column(Modifier.fillMaxSize().padding(12.dp)) {
Text(
text = stringResource(R.string.This_Week),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(R.string.completed_habits, completedCount),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 24.dp)
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
daysOfWeek.forEachIndexed { index, day ->
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f)
) {
Card(
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = if(dayStatus[index]) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.8f)
)
) {
Icon(
imageVector = if (dayStatus[index]) Icons.Default.Verified else Icons.Default.Pending,
contentDescription = null,
modifier = Modifier.padding(4.dp)
)
}
Text(
text = day,
style = MaterialTheme.typography.labelLarge,
)
}
}
}
}
}
}
@Composable
fun BarChart(
weekCounts: List<Int>,
weekLabels: List<String>,
modifier: Modifier = Modifier
) {
val maxDaysPerWeek = 7
val yLabels = (maxDaysPerWeek downTo 1).toList()
val primaryColor = MaterialTheme.colorScheme.primary
Row(
modifier = modifier
.fillMaxWidth()
.height(220.dp)
.padding(bottom = 16.dp)
) {
// Y-axis labels
Box(
modifier = Modifier
.width(20.dp)
.padding(end = 8.dp)
.fillMaxHeight()
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val stepHeight = size.height / maxDaysPerWeek
val textPaint = Paint().apply {
isAntiAlias = true
color = primaryColor.toArgb()
textSize = 30f
textAlign = Paint.Align.CENTER
}
yLabels.forEach { label ->
val y = size.height - (stepHeight * label)
drawContext.canvas.nativeCanvas.drawText(
label.toString(),
size.width - 10f,
y + 5f,
textPaint
)
}
}
}
// Bar chart canvas
Canvas(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
) {
val barWidth = size.width / (weekCounts.size * 2 + 1)
val stepHeight = size.height / maxDaysPerWeek
weekCounts.forEachIndexed { index, count ->
val barHeight = stepHeight * count
val x = barWidth + index * (barWidth + barWidth)
val y = size.height - barHeight
drawRoundRect(
color = primaryColor,
topLeft = Offset(x, y),
size = Size(barWidth, barHeight),
cornerRadius = CornerRadius(x = 16.dp.toPx(), y = 16.dp.toPx())
)
drawIntoCanvas { canvas ->
val paint = Paint().apply {
isAntiAlias = true
color = primaryColor.toArgb()
textSize = 30f
textAlign = Paint.Align.CENTER
}
canvas.nativeCanvas.drawText(
weekLabels[index],
x + barWidth / 2,
size.height + 40f,
paint
)
}
}
for (i in 1..maxDaysPerWeek) {
val y = size.height - (stepHeight * i)
drawLine(
color = Color.Gray.copy(alpha = 0.5f),
start = Offset(0f, y),
end = Offset(size.width, y),
strokeWidth = 0.25.dp.toPx()
)
}
}
}
}
@@ -0,0 +1,409 @@
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(
initialSelectedDateMillis: Long = System.currentTimeMillis(),
onDateSelected: (Long?) -> Unit,
onDismiss: () -> Unit
) {
val datePickerState = rememberDatePickerState(
initialSelectedDateMillis = initialSelectedDateMillis
)
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")
}
}
)
}
@@ -1,22 +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
import androidx.compose.material.icons.outlined.Event
import androidx.compose.material.icons.outlined.EventAvailable
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.LockOpen
@@ -24,7 +23,7 @@ import androidx.compose.material.icons.outlined.PhotoSizeSelectActual
import androidx.compose.material.icons.outlined.Print
import androidx.compose.material.icons.outlined.PushPin
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.outlined.TaskAlt
import androidx.compose.material.icons.outlined.SwapHoriz
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
@@ -41,9 +40,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.flux.R
import com.flux.data.model.WorkspaceModel
import com.flux.data.model.getSpacesList
@Composable
fun DropdownMenuWithDetails(
@@ -54,6 +55,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 +99,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 +180,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 +203,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,67 +306,31 @@ fun SpacesMenu(
onDismiss: () -> Unit
) {
val selectedSpaces = workspace.selectedSpaces
val scrollState = rememberScrollState()
val menuSpaces = getSpacesList()
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)) {
selectedSpaces.forEach { spaceId ->
val space = menuSpaces.firstOrNull { it.id == spaceId } ?: return@forEach
DropdownMenuItem(
text = { Text(stringResource(R.string.Notes)) },
leadingIcon = { Icon(Icons.AutoMirrored.Default.Notes, contentDescription = null) },
text = {
Text(
text = space.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.widthIn(max = 100.dp)
)
},
leadingIcon = { Icon(space.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(spaceId)
onDismiss()
}
)
@@ -298,20 +340,15 @@ fun SpacesMenu(
@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 +368,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 +402,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 +436,205 @@ 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,
onShare: () -> 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(stringResource(R.string.clone)) },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
onCloneNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.copy)) },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
onCopyNote()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(stringResource(R.string.share)) },
leadingIcon = { Icon(Icons.Outlined.Share, contentDescription = null) },
onClick = {
expanded = false
onShare()
}
)
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,185 @@
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.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
import androidx.compose.foundation.Canvas
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.ui.text.font.FontWeight
@Composable
fun EmptyData() {
Column(
modifier = Modifier.fillMaxWidth().padding(top = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
SearchCancelIllustration(Modifier.size(240.dp, 245.dp))
Text(stringResource(R.string.empty_no_data_found), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.primary)
}
}
@Composable
fun SearchCancelIllustration(
modifier: Modifier = Modifier
) {
val scheme = MaterialTheme.colorScheme
Canvas(modifier = modifier) {
// original artwork was authored on a 240x245 viewport; scale to actual size
val scaleX = size.width / 240f
val scaleY = size.height / 245f
withTransform({ scale(scaleX, scaleY, pivot = Offset.Zero) }) {
// background pills (primary tint)
drawRoundRect(
color = scheme.primary,
topLeft = Offset(47f, 15f),
size = Size(146f, 54f),
cornerRadius = CornerRadius(27f, 27f),
alpha = 0.12f
)
drawRoundRect(
color = scheme.primary,
topLeft = Offset(37f, 88f),
size = Size(61f, 54f),
cornerRadius = CornerRadius(27f, 27f),
alpha = 0.10f
)
drawRoundRect(
color = scheme.primary,
topLeft = Offset(47f, 150f),
size = Size(146f, 54f),
cornerRadius = CornerRadius(27f, 27f),
alpha = 0.12f
)
// back document
val backDoc = Path().apply {
moveTo(108f, 40f)
lineTo(190f, 40f)
lineTo(210f, 60f)
lineTo(210f, 182f)
quadraticTo(210f, 190f, 202f, 190f)
lineTo(108f, 190f)
quadraticTo(100f, 190f, 100f, 182f)
lineTo(100f, 48f)
quadraticTo(100f, 40f, 108f, 40f)
close()
}
drawPath(backDoc, color = scheme.inverseSurface)
val backDocFold = Path().apply {
moveTo(190f, 40f)
lineTo(210f, 60f)
lineTo(190f, 60f)
close()
}
drawPath(backDocFold, color = scheme.tertiary)
// front document
val frontDoc = Path().apply {
moveTo(68f, 55f)
lineTo(150f, 55f)
lineTo(172f, 77f)
lineTo(172f, 200f)
quadraticTo(172f, 208f, 164f, 208f)
lineTo(68f, 208f)
quadraticTo(60f, 208f, 60f, 200f)
lineTo(60f, 63f)
quadraticTo(60f, 55f, 68f, 55f)
close()
}
drawPath(frontDoc, color = scheme.surface)
drawPath(frontDoc, color = scheme.outlineVariant, style = Stroke(width = 1.5f))
val frontDocFold = Path().apply {
moveTo(150f, 55f)
lineTo(172f, 77f)
lineTo(150f, 77f)
close()
}
drawPath(frontDocFold, color = scheme.tertiary)
// top text lines
drawRoundRect(
color = scheme.outlineVariant,
topLeft = Offset(78f, 93f),
size = Size(58f, 4f),
cornerRadius = CornerRadius(2f, 2f)
)
drawRoundRect(
color = scheme.outlineVariant,
topLeft = Offset(78f, 103f),
size = Size(36f, 4f),
cornerRadius = CornerRadius(2f, 2f)
)
// face: X eyes
val eyeStroke = Stroke(width = 6f, cap = StrokeCap.Round)
drawLine(scheme.primary, Offset(82f, 128f), Offset(102f, 148f), strokeWidth = 6f, cap = StrokeCap.Round)
drawLine(scheme.primary, Offset(102f, 128f), Offset(82f, 148f), strokeWidth = 6f, cap = StrokeCap.Round)
drawLine(scheme.primary, Offset(132f, 128f), Offset(152f, 148f), strokeWidth = 6f, cap = StrokeCap.Round)
drawLine(scheme.primary, Offset(152f, 128f), Offset(132f, 148f), strokeWidth = 6f, cap = StrokeCap.Round)
// face: mouth + tongue
val mouth = Path().apply {
moveTo(96f, 164f)
quadraticTo(116f, 156f, 136f, 164f)
}
drawPath(mouth, color = scheme.onSurfaceVariant, style = Stroke(width = 5f, cap = StrokeCap.Round))
drawOval(color = scheme.tertiary, topLeft = Offset(109f, 166f), size = Size(14f, 10f))
// bottom text line
drawRoundRect(
color = scheme.outlineVariant,
topLeft = Offset(78f, 188f),
size = Size(51f, 4f),
cornerRadius = CornerRadius(2f, 2f)
)
// magnifying glass lens
drawCircle(color = scheme.surface, radius = 28f, center = Offset(72f, 188f))
drawCircle(
color = scheme.primary,
radius = 28f,
center = Offset(72f, 188f),
style = Stroke(width = 9f)
)
// handle
drawLine(
color = scheme.onPrimaryContainer,
start = Offset(96f, 212f),
end = Offset(119f, 235f),
strokeWidth = 12f,
cap = StrokeCap.Round
)
// X mark: nothing found
drawLine(scheme.onPrimaryContainer, Offset(60f, 176f), Offset(84f, 200f), strokeWidth = 6f, cap = StrokeCap.Round)
drawLine(scheme.onPrimaryContainer, Offset(84f, 176f), Offset(60f, 200f), strokeWidth = 6f, cap = StrokeCap.Round)
// floating dots
drawCircle(color = scheme.tertiary, radius = 3f, center = Offset(190f, 93f), alpha = 0.6f)
drawCircle(color = scheme.tertiary, radius = 3f, center = Offset(155f, 213f), alpha = 0.6f)
}
}
}
@@ -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()
)
}
@@ -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,12 @@ fun BasicScaffold(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HabitScaffold(
title: String,
description: String,
onDeleteClicked: () -> Unit,
onBackPressed: () -> Unit,
onEditClicked: () -> Unit,
onCopyNote: () -> Unit,
onCloneNote: () -> Unit,
onShare: () -> Unit,
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
@@ -63,26 +55,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 +66,38 @@ 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,
onShare
)
}
)
},
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
)
}
@@ -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,823 +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.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(stringResource(R.string.recurrence_every_x_days)) },
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,283 +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.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, isSelected: Boolean, onClick: () -> Unit) {
val localDate = LocalDate.ofEpochDay(date)
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)
Box(
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(50))
.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,
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 % 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,
onClick = { onDateChange(date) }
)
}
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -1,384 +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.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,
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 = 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,79 +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
) = Surface(
modifier = modifier,
shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surfaceContainer
) {
Surface(
modifier = Modifier
.clickable { onClick() }
.padding(12.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.surface
)
}
}
}
}
@@ -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) 24.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()
}
@@ -3,5 +3,6 @@ package com.flux.ui.events
import com.flux.data.model.SettingsModel
sealed class SettingEvents {
data class UpdateSettings(val data: SettingsModel) : SettingEvents()
data class UpdateSettings(val data: SettingsModel): SettingEvents()
data object ResetDatabase: SettingEvents()
}
@@ -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()
}

Some files were not shown because too many files have changed in this diff Show More