8 Commits
Author SHA1 Message Date
Ronit ChindaandGitHub f8c009d44a Merge pull request #121 from chindaronit/dev
Android Build / build (push) Canceled after 0s
Release 3.2.0
2026-08-01 22:27:28 +05:30
chindaronit d61b864ad1 chore: Update version to 3.1.12
Android Build / build (push) Canceled after 0s
src: multilingual support
2026-08-01 22:20:00 +05:30
chindaronit 8f7d847f6a feat:
- added social links option in notes and journal.
- media detection support in journals
- consistent saving of habits and events
- Changelog in about.
fix:
- local date bug in events
- event stale data of EventDetails.kt after edit.
2026-08-01 16:47:55 +05:30
Ronit ChindaandGitHub c3b06fbbf7 Merge pull request #119 from chindaronit/feature/backup_encryption
Feature/backup encryption
2026-07-26 11:11:10 +05:30
chindaronit 8e281098b6 fix:
- color in todo expandable card
- fr string build issue
2026-07-26 11:04:50 +05:30
chindaronit 7f9546fa06 feat:
addition of backup encryption to all the export of backup.
2026-07-25 23:02:32 +05:30
Ronit ChindaandGitHub 1750d1d96c Merge pull request #118 from chindaronit/master
sync master and dev branch
2026-07-25 14:00:57 +05:30
chindaronit dbe6b47ab3 fix:
- Password saving mechanism changed to save a hashed string with random salt instead of plain string.
2026-07-25 13:54:00 +05:30
81 changed files with 2351 additions and 325 deletions
+4 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.flux"
minSdk = 29
targetSdk = 37
versionCode = 16
versionName = "3.1.10"
versionCode = 17
versionName = "3.2.0"
}
dependenciesInfo {
@@ -138,4 +138,6 @@ dependencies {
// draggable list
implementation(libs.reorderable)
implementation(libs.androidx.security.crypto)
}
+4 -1
View File
@@ -30,6 +30,7 @@ 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.BackupSettingsViewModel
import com.flux.ui.viewModel.BackupViewModel
import com.flux.ui.viewModel.EventViewModel
import com.flux.ui.viewModel.HabitViewModel
@@ -74,6 +75,7 @@ class MainActivity : AppCompatActivity() {
val backupViewModel: BackupViewModel = hiltViewModel()
val labelViewModel: LabelViewModel = hiltViewModel()
val progressBoardViewModel: ProgressBoardViewModel = hiltViewModel()
val backupSettingsViewModel: BackupSettingsViewModel = hiltViewModel()
// States
val settings by settingsViewModel.state.collectAsState()
@@ -114,7 +116,8 @@ class MainActivity : AppCompatActivity() {
settingsViewModel,
backupViewModel,
labelViewModel,
progressBoardViewModel
progressBoardViewModel,
backupSettingsViewModel
),
states = States(
notesState,
@@ -19,7 +19,7 @@ interface WorkspaceDao {
@Insert(onConflict=OnConflictStrategy.REPLACE)
suspend fun upsertWorkspaces(spaces: List<WorkspaceModel>)
@Query("SELECT workspaceId FROM WorkspaceModel WHERE passKey IS NULL OR passKey = ''")
@Query("SELECT workspaceId FROM WorkspaceModel WHERE passKeyHash IS NULL OR passKeyHash = ''")
fun observePublicWorkspaceIds(): Flow<List<String>>
@Delete
@@ -1,5 +1,7 @@
package com.flux.data.database
import android.database.Cursor
import android.util.Log
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
@@ -30,6 +32,7 @@ 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.flux.other.PasswordHasher
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.serialization.json.Json
@@ -37,7 +40,7 @@ import java.util.UUID
@Database(
entities = [EventModel::class, LabelModel::class, EventInstanceModel::class, SettingsModel::class, NotesModel::class, HabitModel::class, HabitInstanceModel::class, WorkspaceModel::class, TodoModel::class, JournalModel::class, ProgressBoardModel::class, TodoInstance::class],
version = 11,
version = 12,
exportSchema = false
)
@TypeConverters(Converter::class)
@@ -405,4 +408,110 @@ val MIGRATION_10_11 = object : Migration(10, 11) {
"ON `HabitInstanceModel` (`instanceDate`)"
)
}
}
val MIGRATION_11_12 = object : Migration(11, 12) {
private val OLD_TABLE = "WorkspaceModel"
private val TMP_TABLE = "WorkspaceModel_new"
override fun migrate(db: SupportSQLiteDatabase) {
db.safeExec("ALTER TABLE NotesModel ADD COLUMN socialLinks TEXT NOT NULL DEFAULT '[]'")
db.safeExec("ALTER TABLE JournalModel ADD COLUMN socialLinks TEXT NOT NULL DEFAULT '[]'")
recreateTableWithHashedColumn(db)
rehashExistingPasswords(db)
}
/** Step 1: recreate WorkspaceModel with passKeyHash instead of passKey, copying all rows as-is. */
private fun recreateTableWithHashedColumn(db: SupportSQLiteDatabase) {
db.safeExec(
"""
CREATE TABLE IF NOT EXISTS `$TMP_TABLE` (
`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,
`passKeyHash` TEXT,
`isPinned` INTEGER NOT NULL,
`selectedSpaces` TEXT NOT NULL
)
""".trimIndent()
)
db.safeExec(
"""
INSERT INTO `$TMP_TABLE`
(`workspaceId`, `title`, `description`, `colorInd`, `cover`, `icon`, `passKeyHash`, `isPinned`, `selectedSpaces`)
SELECT
`workspaceId`, `title`, `description`, `colorInd`, `cover`, `icon`, `passKey`, `isPinned`, `selectedSpaces`
FROM `$OLD_TABLE`
""".trimIndent()
)
db.safeExec("DROP TABLE IF EXISTS `$OLD_TABLE`")
db.safeExec("ALTER TABLE `$TMP_TABLE` RENAME TO `$OLD_TABLE`")
}
/**
* Step 2: hash every non-blank plaintext passKeyHash value in place.
* Row-level failures are isolated so one bad row can never crash the migration
* or the app — this is a hard requirement, not an optimization.
*/
private fun rehashExistingPasswords(db: SupportSQLiteDatabase) {
var total = 0
var migrated = 0
var alreadyHashed = 0
var failed = 0
var cursor: Cursor? = null
try {
cursor = db.query(
"SELECT `workspaceId`, `passKeyHash` FROM `$OLD_TABLE` " +
"WHERE `passKeyHash` IS NOT NULL AND TRIM(`passKeyHash`) != ''"
)
val idColumn = cursor.getColumnIndexOrThrow("workspaceId")
val passColumn = cursor.getColumnIndexOrThrow("passKeyHash")
while (cursor.moveToNext()) {
total++
val workspaceId = cursor.getString(idColumn)
val currentValue = cursor.getString(passColumn)
try {
if (PasswordHasher.isHashed(currentValue)) {
alreadyHashed++
continue
}
val hashed = PasswordHasher.hash(currentValue)
// Use safeExec-equivalent guarded update; bind values manually
// since safeExec (per your codebase) likely takes raw SQL only.
db.execSQL(
"UPDATE `$OLD_TABLE` SET `passKeyHash` = ? WHERE `workspaceId` = ?",
arrayOf(hashed, workspaceId)
)
migrated++
} catch (rowError: Exception) {
failed++
Log.e(
"Migration_11_12",
"Failed to hash passkey for workspace $workspaceId, leaving value untouched",
rowError
)
}
}
} catch (e: Exception) {
Log.e("Migration_11_12", "Critical error while iterating WorkspaceModel rows for hashing", e)
} finally {
cursor?.close()
Log.i(
"Migration_11_12",
"Passkey hashing summary — total: $total, migrated: $migrated, " +
"alreadyHashed: $alreadyHashed, failed: $failed"
)
}
}
}
@@ -97,7 +97,21 @@ class Converter {
json.decodeFromString(value)
} catch (
_: Exception) {
HabitConfig.Simple // fallback (critical)
HabitConfig.Simple
}
}
@TypeConverter
fun socialLinksToJson(value: List<SocialModel>): String {
return json.encodeToString(value)
}
@TypeConverter
fun jsonToSocialLinks(value: String): List<SocialModel> {
return if (value.isBlank()) {
emptyList()
} else {
json.decodeFromString(value)
}
}
}
@@ -16,7 +16,8 @@ data class JournalModel(
val workspaceId: String = "",
val text: String = "",
val dateTime: Long = System.currentTimeMillis(),
val labels: List<String> = emptyList()
val labels: List<String> = emptyList(),
val socialLinks: List<SocialModel> = emptyList(),
)
fun JournalModel.writtenOnDate(date: LocalDate): Boolean {
@@ -1,7 +1,9 @@
package com.flux.data.model
import androidx.compose.runtime.Composable
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.flux.R
import java.util.UUID
import kotlinx.serialization.Serializable
@@ -14,6 +16,162 @@ data class NotesModel(
val title: String = "",
val description: String = "",
val isPinned: Boolean = false,
val socialLinks: List<SocialModel> = emptyList(),
val labels: List<String> = emptyList(),
val lastEdited: Long = System.currentTimeMillis()
)
@Serializable
data class SocialModel(
val socialId: String = UUID.randomUUID().toString(),
val notesId: String = "",
val workspaceId: String = "",
val category: Int = 0,
val title: String = "",
val link: String = "",
)
data class SocialCategory(
val name: String,
val icon: Int,
val containerColor: Long,
val contentColor: Long,
)
@Composable
fun getSocialCategory(): List<SocialCategory> {
return listOf(
SocialCategory(
name = "Facebook",
icon = R.drawable.ic_facebook,
containerColor = 0xFF1877F2,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Instagram",
icon = R.drawable.ic_instagram,
containerColor = 0xFFFFFFFF,
contentColor = 0xFF181717
),
SocialCategory(
name = "Twitter",
icon = R.drawable.ic_twitter,
containerColor = 0xFF000000,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Threads",
icon = R.drawable.ic_threads,
containerColor = 0xFF000000,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "WhatsApp",
icon = R.drawable.ic_whatsapp,
containerColor = 0xFF25D366,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Telegram",
icon = R.drawable.ic_telegram,
containerColor = 0xFF0088CC,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Discord",
icon = R.drawable.ic_discord,
containerColor = 0xFF5865F2,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Snapchat",
icon = R.drawable.ic_snapchat,
containerColor = 0xFFFFFC00,
contentColor = 0xFF181717
),
SocialCategory(
name = "TikTok",
icon = R.drawable.ic_tiktok,
containerColor = 0xFF000000,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "YouTube",
icon = R.drawable.ic_youtube,
containerColor = 0xFFFF0000,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "LinkedIn",
icon = R.drawable.ic_linkedin,
containerColor = 0xFF0A66C2,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Pinterest",
icon = R.drawable.ic_pinterest,
containerColor = 0xFFBD081C,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Reddit",
icon = R.drawable.ic_reddit,
containerColor = 0xFFFF4500,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Twitch",
icon = R.drawable.ic_twitch,
containerColor = 0xFF9146FF,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "WeChat",
icon = R.drawable.ic_wechat,
containerColor = 0xFF07C160,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Skype",
icon = R.drawable.ic_skype,
containerColor = 0xFF00AFF0,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Slack",
icon = R.drawable.ic_slack,
containerColor = 0xFF4A154B,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "GitHub",
icon = R.drawable.ic_github,
containerColor = 0xFFFFFFFF,
contentColor = 0xFF181717
),
SocialCategory(
name = "ChatGPT",
icon = R.drawable.ic_chatgpt,
containerColor = 0xFF10A37F,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Gemini",
icon = R.drawable.ic_gemini,
containerColor = 0x4285F4,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "AI",
icon = R.drawable.ic_ai,
containerColor = 0xFF7C3AED,
contentColor = 0xFFFFFFFF
),
SocialCategory(
name = "Other",
icon = R.drawable.ic_other,
containerColor = 0xFFFFFFF,
contentColor = 0xFF181717
)
)
}
@@ -16,6 +16,7 @@ import androidx.room.PrimaryKey
import java.util.UUID
import com.flux.R
import kotlinx.serialization.Serializable
import com.flux.other.PasswordHasher
@Serializable
@Entity
@@ -27,10 +28,14 @@ data class WorkspaceModel(
val colorInd: Int = 0,
val cover: String = "",
val icon: Int = 48,
val passKey: String? = null,
val passKeyHash: String? = null, // renamed from passKey; now stores a PBKDF2 hash, never plaintext
val isPinned: Boolean = false,
val selectedSpaces: List<Int> = emptyList()
)
) {
/** True if this workspace currently requires a passkey to unlock. */
val isLocked: Boolean
get() = !passKeyHash.isNullOrBlank()
}
data class Space(
val id: Int,
@@ -49,4 +54,18 @@ fun getSpacesList(): List<Space> {
Space(6, stringResource(R.string.Analytics), Icons.Default.Analytics),
Space(7, stringResource(R.string.progress_tracker), Icons.Default.TrackChanges)
)
}
fun WorkspaceModel.lockWith(rawPassword: String): WorkspaceModel {
require(rawPassword.isNotBlank()) { "Passkey cannot be blank" }
return copy(passKeyHash = PasswordHasher.hash(rawPassword))
}
/** Removes the passkey, unlocking the workspace permanently until re-locked. */
fun WorkspaceModel.removePasskey(): WorkspaceModel = copy(passKeyHash = null)
/** Checks [rawPassword] against the stored hash. Returns false if workspace isn't locked. */
fun WorkspaceModel.verifyPasskey(rawPassword: String): Boolean {
val stored = passKeyHash ?: return false
return PasswordHasher.verify(rawPassword, stored)
}
@@ -0,0 +1,29 @@
package com.flux.di
import com.flux.other.crypto.AesGcmBackupEncryptor
import com.flux.other.crypto.BackupCredentialsStore
import com.flux.other.crypto.BackupEncryptor
import com.flux.other.crypto.EncryptedPrefsBackupCredentialsStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
abstract class BackupCryptoModule {
@Binds
@Singleton
abstract fun bindBackupCredentialsStore(
impl: EncryptedPrefsBackupCredentialsStore
): BackupCredentialsStore
companion object {
@Provides
@Singleton
fun provideBackupEncryptor(): BackupEncryptor = AesGcmBackupEncryptor()
}
}
+3 -1
View File
@@ -17,6 +17,7 @@ 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_11_12
import com.flux.data.database.MIGRATION_1_2
import com.flux.data.database.MIGRATION_2_3
import com.flux.data.database.MIGRATION_3_4
@@ -55,7 +56,8 @@ object DataModule {
MIGRATION_7_8,
MIGRATION_8_9,
MIGRATION_9_10,
MIGRATION_10_11
MIGRATION_10_11,
MIGRATION_11_12
)
.build()
@@ -21,6 +21,7 @@ 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.Changelog
import com.flux.ui.screens.settings.Contact
import com.flux.ui.screens.settings.Customize
import com.flux.ui.screens.settings.Data
@@ -65,6 +66,7 @@ sealed class NavRoutes(val route: String) {
data object Theme : NavRoutes("settings/customize/theme")
data object Languages : NavRoutes("settings/language")
data object About : NavRoutes("settings/about")
data object Changelog : NavRoutes("settings/about/changelog")
data object Contact : NavRoutes("settings/contact")
data object Backup : NavRoutes("setting/backup")
data object Editor : NavRoutes("setting/editor")
@@ -227,7 +229,7 @@ val SettingsScreens =
Contact(navController, states.settings.data.cornerRadius)
},
NavRoutes.Backup.route to { navController, snackbarHostState, states, viewModels ->
Data(navController, states.settings.data.cornerRadius, states.settings, snackbarHostState, viewModels.backupViewModel, viewModels.settingsViewModel::onEvent)
Data(navController, states.settings.data.cornerRadius, states.settings, snackbarHostState, viewModels.backupViewModel, viewModels.backupSettingsViewModel, viewModels.settingsViewModel::onEvent)
},
NavRoutes.Editor.route to { navController, _, states, viewModels ->
Editor(navController, states.settings, viewModels.settingsViewModel::onEvent)
@@ -240,6 +242,9 @@ val SettingsScreens =
},
NavRoutes.NotesPreview.route to { navController, _, states, viewModels ->
NotesPreviewSetting(navController, states.settings, viewModels.settingsViewModel::onEvent)
},
NavRoutes.Changelog.route to { navController, _, _, _ ->
Changelog(navController)
}
)
@@ -0,0 +1,69 @@
package com.flux.other
import android.util.Base64
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import android.util.Log
/**
* Handles one-way password hashing for workspace passkeys.
* Stored format: "<iterations>$<base64Salt>$<base64Hash>"
*/
object PasswordHasher {
private const val TAG = "PasswordHasher"
private const val ALGORITHM = "PBKDF2WithHmacSHA256"
private const val ITERATIONS = 10_000
private const val KEY_LENGTH_BITS = 256
private const val SALT_LENGTH_BYTES = 16
private const val DELIMITER = "$"
/** Hashes [rawPassword] with a freshly generated salt. */
fun hash(rawPassword: String): String {
val salt = ByteArray(SALT_LENGTH_BYTES).apply { SecureRandom().nextBytes(this) }
val hashBytes = pbkdf2(rawPassword.toCharArray(), salt, ITERATIONS, KEY_LENGTH_BITS)
val encodedSalt = Base64.encodeToString(salt, Base64.NO_WRAP)
val encodedHash = Base64.encodeToString(hashBytes, Base64.NO_WRAP)
return "$ITERATIONS$DELIMITER$encodedSalt$DELIMITER$encodedHash"
}
/** Verifies [rawPassword] against a previously [hash]ed value. */
fun verify(rawPassword: String, stored: String): Boolean {
return try {
val (iterations, salt, expectedHash) = parse(stored) ?: return false
val actualHash = pbkdf2(rawPassword.toCharArray(), salt, iterations, expectedHash.size * 8)
actualHash.contentEquals(expectedHash)
} catch (e: Exception) {
Log.e(TAG, "verify: unable to verify password", e)
false
}
}
/** True if [value] is already in our hashed format (used to make migration idempotent). */
fun isHashed(value: String?): Boolean {
if (value.isNullOrBlank()) return false
return parse(value) != null
}
private fun parse(stored: String): Triple<Int, ByteArray, ByteArray>? {
val parts = stored.split(DELIMITER)
if (parts.size != 3) return null
val iterations = parts[0].toIntOrNull() ?: return null
return try {
val salt = Base64.decode(parts[1], Base64.NO_WRAP)
val hash = Base64.decode(parts[2], Base64.NO_WRAP)
Triple(iterations, salt, hash)
} catch (_: IllegalArgumentException) {
null
}
}
private fun pbkdf2(password: CharArray, salt: ByteArray, iterations: Int, keyLengthBits: Int): ByteArray {
val spec = PBEKeySpec(password, salt, iterations, keyLengthBits)
val factory = SecretKeyFactory.getInstance(ALGORITHM)
return factory.generateSecret(spec).encoded
}
}
@@ -0,0 +1,66 @@
package com.flux.other.crypto
import android.content.Context
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
interface BackupCredentialsStore {
suspend fun getPassword(): CharArray?
suspend fun setPassword(password: CharArray?)
fun hasPasswordFlow(): StateFlow<Boolean>
}
/**
* The ONE place the backup password lives. Deliberately outside Room and outside
* FluxBackup/SettingsModel it must never be written into an exported backup file.
* Backed by Android Keystore via EncryptedSharedPreferences: protects against file
* extraction / offline attacks, not against a fully unlocked device in an attacker's hand.
* Lost on uninstall by design, there is no recovery path.
*/
@Singleton
class EncryptedPrefsBackupCredentialsStore @Inject constructor(
@param:ApplicationContext private val context: Context
) : BackupCredentialsStore {
private val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
private val prefs = EncryptedSharedPreferences.create(
context,
"flux_backup_credentials",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
private val _hasPassword = MutableStateFlow(prefs.contains(KEY_PASSWORD))
override fun hasPasswordFlow(): StateFlow<Boolean> = _hasPassword.asStateFlow()
override suspend fun getPassword(): CharArray? = withContext(Dispatchers.IO) {
prefs.getString(KEY_PASSWORD, null)?.toCharArray()
}
override suspend fun setPassword(password: CharArray?) = withContext(Dispatchers.IO) {
if (password == null) {
prefs.edit { remove(KEY_PASSWORD) }
} else {
prefs.edit { putString(KEY_PASSWORD, String(password)) }
}
_hasPassword.value = (password != null)
}
private companion object {
const val KEY_PASSWORD = "backup_password"
}
}
@@ -0,0 +1,21 @@
package com.flux.other.crypto
import com.flux.R
import androidx.annotation.StringRes
sealed class BackupCryptoException(
@param:StringRes val messageRes: Int,
vararg val formatArgs: Any,
cause: Throwable? = null
) : Exception(null, cause) {
class WrongPasswordOrCorrupted(cause: Throwable) : BackupCryptoException(
R.string.backup_wrong_password_or_corrupted,
cause = cause
)
class UnsupportedVersion(version: Int) : BackupCryptoException(
R.string.unsupported_backup_version,
version
)
}
@@ -0,0 +1,83 @@
package com.flux.other.crypto
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.nio.ByteBuffer
import java.security.SecureRandom
import javax.crypto.AEADBadTagException
import javax.crypto.Cipher
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.PBEKeySpec
import javax.crypto.spec.SecretKeySpec
interface BackupEncryptor {
suspend fun encrypt(plaintext: ByteArray, password: CharArray): ByteArray
suspend fun decrypt(encryptedFile: ByteArray, password: CharArray): ByteArray
}
/**
* AES-256-GCM with a PBKDF2-derived key. Salt + IV are random per export and stored
* in the file header, so the file is fully self-contained and portable across devices
* only the password (known to the user) is needed to open it anywhere.
*/
class AesGcmBackupEncryptor : BackupEncryptor {
private companion object {
const val SALT_SIZE = 16
const val IV_SIZE = 12
const val GCM_TAG_BITS = 128
const val PBKDF2_ITERATIONS = 210_000 // OWASP-recommended floor for PBKDF2-HMAC-SHA256
const val KEY_BITS = 256
const val TRANSFORMATION = "AES/GCM/NoPadding"
}
override suspend fun encrypt(plaintext: ByteArray, password: CharArray): ByteArray =
withContext(Dispatchers.Default) {
val salt = randomBytes(SALT_SIZE)
val iv = randomBytes(IV_SIZE)
val key = deriveKey(password, salt)
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
}
val ciphertext = cipher.doFinal(plaintext)
BackupFileFormat.MAGIC + byteArrayOf(BackupFileFormat.VERSION) + salt + iv + ciphertext
}
override suspend fun decrypt(encryptedFile: ByteArray, password: CharArray): ByteArray =
withContext(Dispatchers.Default) {
val buffer = ByteBuffer.wrap(encryptedFile).apply { position(BackupFileFormat.MAGIC.size) }
val version = buffer.get()
if (version != BackupFileFormat.VERSION) {
throw BackupCryptoException.UnsupportedVersion(version.toInt())
}
val salt = ByteArray(SALT_SIZE).also { buffer.get(it) }
val iv = ByteArray(IV_SIZE).also { buffer.get(it) }
val ciphertext = ByteArray(buffer.remaining()).also { buffer.get(it) }
val key = deriveKey(password, salt)
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
}
try {
cipher.doFinal(ciphertext)
} catch (e: AEADBadTagException) {
throw BackupCryptoException.WrongPasswordOrCorrupted(e)
}
}
private fun deriveKey(password: CharArray, salt: ByteArray): SecretKey {
val spec = PBEKeySpec(password, salt, PBKDF2_ITERATIONS, KEY_BITS)
val raw = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).encoded
spec.clearPassword()
return SecretKeySpec(raw, "AES")
}
private fun randomBytes(size: Int): ByteArray = ByteArray(size).also { SecureRandom().nextBytes(it) }
}
@@ -0,0 +1,13 @@
package com.flux.other.crypto
/**
* Self-describing header so encrypted and legacy plaintext backups can coexist
* in the same folder, and imports can tell them apart without relying on file extension.
*/
object BackupFileFormat {
val MAGIC: ByteArray = "FLUXBK1".toByteArray(Charsets.US_ASCII) // 7 bytes
const val VERSION: Byte = 1
fun isEncrypted(bytes: ByteArray): Boolean =
bytes.size >= MAGIC.size && bytes.copyOfRange(0, MAGIC.size).contentEquals(MAGIC)
}
@@ -1,6 +1,5 @@
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
@@ -51,6 +50,9 @@ 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.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
import java.util.Calendar
@OptIn(ExperimentalMaterial3Api::class)
@@ -124,6 +126,7 @@ fun RecurrenceBottomSheet(
var showDatePicker by remember { mutableStateOf(false) }
var selectedDateTime by remember { mutableLongStateOf(startDateTime) }
var tempRule by remember(currentRule) { mutableStateOf(currentRule) }
val zone = ZoneId.systemDefault()
val options = listOf(
RecurrenceRule.Once,
@@ -134,12 +137,27 @@ fun RecurrenceBottomSheet(
)
if (showDatePicker) {
DatePickerModal(onDateSelected = { newDateMillis ->
if (newDateMillis != null) {
val timeOfDay = selectedDateTime % DateUtils.DAY_IN_MILLIS
selectedDateTime = newDateMillis + timeOfDay
}
}, onDismiss = { showDatePicker = false })
DatePickerModal(
onDateSelected = { newDateMillis ->
if (newDateMillis != null) {
val localTime = Instant.ofEpochMilli(selectedDateTime)
.atZone(zone)
.toLocalTime()
val localDate = Instant.ofEpochMilli(newDateMillis)
.atZone(ZoneOffset.UTC)
.toLocalDate()
selectedDateTime = localDate
.atTime(localTime)
.atZone(zone)
.toInstant()
.toEpochMilli()
}
},
onDismiss = { showDatePicker = false }
)
}
ModalBottomSheet(
+358 -16
View File
@@ -2,6 +2,7 @@ package com.flux.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
@@ -17,6 +18,7 @@ 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.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.DeleteOutline
@@ -48,7 +50,6 @@ 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
@@ -68,6 +69,30 @@ import java.util.Calendar
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import com.flux.data.model.SocialModel
import com.flux.ui.screens.events.getTextFieldColors
import java.time.Instant
import java.time.ZoneId
import java.time.ZoneOffset
fun convertMillisToDate(millis: Long): String {
val formatter = SimpleDateFormat("dd MMM yyyy", Locale.getDefault())
@@ -75,6 +100,58 @@ fun convertMillisToDate(millis: Long): String {
return formatter.format(Date(millis))
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DateOnlyPickerModal(
initialSelectedDateMillis: Long = System.currentTimeMillis(),
onDateSelected: (Long?) -> Unit,
onDismiss: () -> Unit
) {
val pickerInitialMillis =
Instant.ofEpochMilli(initialSelectedDateMillis)
.atZone(ZoneId.systemDefault())
.toLocalDate()
.atStartOfDay(ZoneOffset.UTC)
.toInstant()
.toEpochMilli()
val state = rememberDatePickerState(
initialSelectedDateMillis = pickerInitialMillis
)
DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = {
val result = state.selectedDateMillis?.let { utcMillis ->
val localDate = Instant.ofEpochMilli(utcMillis)
.atZone(ZoneOffset.UTC)
.toLocalDate()
localDate
.atStartOfDay(ZoneId.systemDefault())
.toInstant()
.toEpochMilli()
}
onDateSelected(result)
onDismiss()
}
) {
Text(stringResource(R.string.Set))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.Cancel))
}
}
) {
DatePicker(state = state)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerModal(
@@ -90,16 +167,7 @@ fun DatePickerModal(
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)
onDateSelected(datePickerState.selectedDateMillis)
onDismiss()
}) {
Text(stringResource(R.string.Set))
@@ -311,7 +379,7 @@ fun DataCopyDialog(
selectedWorkspaces.clear()
},
selected = selectedType == DataCopyType.COPY,
label = { Text("Copy") }
label = { Text(stringResource(R.string.copy)) }
)
SegmentedButton(
@@ -324,12 +392,12 @@ fun DataCopyDialog(
selectedWorkspaces.clear()
},
selected = selectedType == DataCopyType.MOVE,
label = { Text("Move") }
label = { Text(stringResource(R.string.move)) }
)
}
},
title = {
Text("Select Workspaces")
Text(stringResource(R.string.select_workspaces))
},
text = {
LazyColumn (Modifier
@@ -397,12 +465,286 @@ fun DataCopyDialog(
onConfirm(selectedType, selectedWorkspaces.toList())
onDismiss()
}) {
Text("Confirm")
Text(stringResource(R.string.Confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Dismiss")
Text(stringResource(R.string.Dismiss))
}
}
)
}
/** Captures a password string. Used for: initial setup, per-file import prompt, and password change. */
@Composable
fun BackupPasswordEntryDialog(
title: String,
onConfirm: (CharArray) -> Unit,
onDismiss: (() -> Unit)?, // null = non-dismissible (used for the mandatory setup flow)
supportingText: String? = null
) {
var password by remember { mutableStateOf("") }
var visible by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { onDismiss?.invoke() },
title = { Text(title) },
text = {
Column {
supportingText?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
Spacer(Modifier.height(12.dp))
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text(stringResource(R.string.backup_password)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(
if (visible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility,
contentDescription = null
)
}
}
)
Spacer(Modifier.height(4.dp))
Text(
stringResource(R.string.backup_password_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
},
confirmButton = {
TextButton(
enabled = password.isNotEmpty(),
onClick = {
val pw = password.toCharArray()
password = ""
onConfirm(pw)
}
) { Text(stringResource(R.string.set_password)) }
},
dismissButton = onDismiss?.let {
{ TextButton(onClick = it) { Text(stringResource(R.string.Dismiss)) } }
}
)
}
/** Non-dismissible: fires whenever auto-backup is on but no password exists (migration, import, or live toggle). */
@Composable
fun AutoBackupNeedsPasswordDialog(
onSetPasswordClick: () -> Unit,
onTurnOffAutoBackup: () -> Unit
) {
AlertDialog(
onDismissRequest = { /* force an explicit choice */ },
title = { Text(stringResource(R.string.backup_password_setting)) },
text = { Text(stringResource(R.string.auto_backup_requires_password)) },
confirmButton = {
TextButton(onClick = onSetPasswordClick) { Text(stringResource(R.string.set_password)) }
},
dismissButton = {
TextButton(onClick = onTurnOffAutoBackup) { Text(stringResource(R.string.turn_off_auto_backup)) }
}
)
}
@Composable
fun AutoBackupNeedsPasswordInfoDialog(
onNavigate: () -> Unit,
) {
AlertDialog(
onDismissRequest = { /* force an explicit choice */ },
title = { Text(stringResource(R.string.backup_password_setting)) },
text = { Text(stringResource(R.string.auto_backup_requires_password)) },
confirmButton = {
TextButton(onClick = onNavigate) { Text(stringResource(R.string.go_to_settings)) }
},
dismissButton = { }
)
}
/** Confirms the destructive consequence of rotating the password. */
@Composable
fun ChangePasswordWarningDialog(
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.change_backup_password_title)) },
text = { Text(stringResource(R.string.change_backup_password_message)) },
confirmButton = { TextButton(onClick = onConfirm) { Text(stringResource(R.string.Confirm)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.Dismiss)) } }
)
}
@Composable
fun SocialDialog(
socialModel: SocialModel?=null,
onConfirm: (SocialModel) -> Unit,
onDismiss: () -> Unit
) {
var title by rememberSaveable { mutableStateOf(socialModel?.title?:"") }
var link by rememberSaveable { mutableStateOf(socialModel?.link?:"") }
var selectedCategory by rememberSaveable { mutableIntStateOf(socialModel?.category?:0) }
val focusRequesterDesc = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
Dialog(onDismissRequest = onDismiss){
Card(Modifier.fillMaxWidth()) {
Column(
Modifier.fillMaxWidth().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
Text(
"Add Social",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
Spacer(Modifier.height(8.dp))
SocialCategoryDropDown(selectedCategory) {
selectedCategory=it
}
TextField(
value = title,
onValueChange = { title = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text(stringResource(R.string.Title)) },
singleLine = true,
textStyle = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
shape = RoundedCornerShape(topStart = 32.dp, topEnd = 32.dp),
colors = getTextFieldColors(),
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Words,
imeAction = ImeAction.Next
),
keyboardActions = KeyboardActions(onNext = { focusRequesterDesc.requestFocus() })
)
TextField(
value = link,
onValueChange = { link = it },
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 6.dp)
.focusRequester(focusRequesterDesc),
placeholder = { Text(stringResource(R.string.link)) },
singleLine = true,
shape = RoundedCornerShape(bottomStart = 32.dp, bottomEnd = 32.dp),
colors = getTextFieldColors(),
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(onDone = { keyboardController?.hide() })
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onDismiss) {
Text(stringResource(R.string.Dismiss))
}
FilledTonalButton(
onClick = {
onConfirm(
SocialModel(
title = title,
link = link,
category = selectedCategory
)
)
onDismiss()
}
) { Text(stringResource(R.string.Confirm)) }
}
}
}
}
}
@Composable
fun SocialCategoryCard(
title: String,
cardContainerColor: Long,
cardContentColor: Long,
icon: Int,
onClick: () -> Unit,
onLongPress: () -> Unit
) {
Card(
modifier = Modifier.clip(RoundedCornerShape(50))
.combinedClickable(
onClick = onClick,
onLongClick = onLongPress
),
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(
containerColor = Color(cardContainerColor).copy(alpha = 0.75f),
contentColor = Color(cardContentColor)
)
) {
Row(
modifier = Modifier.padding(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
modifier = Modifier.size(24.dp),
onClick = onClick,
colors = IconButtonDefaults.iconButtonColors(
containerColor = Color(cardContainerColor)
)
) {
Icon(
painter = painterResource(icon),
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = Color.Unspecified
)
}
Spacer(Modifier.width(6.dp))
Text(
title,
style = MaterialTheme.typography.bodyMedium,
color = Color(cardContentColor)
)
Spacer(Modifier.width(6.dp))
}
}
}
@Composable
fun DiscardChangesDialog(
onDiscard: () -> Unit,
onDismiss: () -> Unit
){
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.discard_changes)) },
text = { Text(stringResource(R.string.discard_changes_message)) },
confirmButton = {
TextButton(onClick = onDiscard) {
Text(stringResource(R.string.discard))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.keep_editing))
}
}
)
@@ -1,13 +1,17 @@
package com.flux.ui.common
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Label
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.RemoveCircleOutline
@@ -39,11 +43,14 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
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.getSocialCategory
import com.flux.data.model.getSpacesList
@Composable
@@ -100,7 +107,7 @@ fun DropdownMenuWithDetails(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clone") },
text = { Text(stringResource(R.string.convert)) },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
@@ -109,7 +116,7 @@ fun DropdownMenuWithDetails(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.copy)) },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
@@ -118,7 +125,7 @@ fun DropdownMenuWithDetails(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Convert") },
text = { Text(stringResource(R.string.convert)) },
leadingIcon = { Icon(Icons.Outlined.SwapHoriz, contentDescription = null) },
onClick = {
expanded = false
@@ -220,7 +227,7 @@ fun JournalDropdownMenu(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Clone") },
text = { Text(stringResource(R.string.clone)) },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
@@ -229,7 +236,7 @@ fun JournalDropdownMenu(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.copy)) },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
@@ -238,7 +245,7 @@ fun JournalDropdownMenu(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Convert") },
text = { Text(stringResource(R.string.convert)) },
leadingIcon = { Icon(Icons.Outlined.SwapHoriz, contentDescription = null) },
onClick = {
expanded = false
@@ -481,7 +488,7 @@ fun TodoDropdownMenu(
HorizontalDivider()
}
DropdownMenuItem(
text = { Text("Clone") },
text = { Text(stringResource(R.string.clone)) },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
@@ -490,7 +497,7 @@ fun TodoDropdownMenu(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.copy)) },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
@@ -499,7 +506,7 @@ fun TodoDropdownMenu(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Convert") },
text = { Text(stringResource(R.string.convert)) },
leadingIcon = { Icon(Icons.Outlined.SwapHoriz, contentDescription = null) },
onClick = {
expanded = false
@@ -543,7 +550,7 @@ fun EventDropdownMenu(
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text("Clone") },
text = { Text(stringResource(R.string.clone)) },
leadingIcon = { Icon(Icons.Outlined.ControlPointDuplicate, contentDescription = null) },
onClick = {
expanded = false
@@ -552,7 +559,7 @@ fun EventDropdownMenu(
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Copy") },
text = { Text(stringResource(R.string.copy)) },
leadingIcon = { Icon(Icons.Outlined.ContentCopy, contentDescription = null) },
onClick = {
expanded = false
@@ -637,4 +644,68 @@ fun HabitDropdownMenu(
)
}
}
}
@Composable
fun SocialCategoryDropDown(
selected: Int = 0,
onSelect: (Int) -> Unit
) {
val categories = getSocialCategory()
var expanded by remember { mutableStateOf(false) }
Box {
DropdownMenuItem(
text = { Text(categories[selected].name) },
leadingIcon = {
Icon(
painter = painterResource(categories[selected].icon),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.size(24.dp)
)
},
trailingIcon = {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
},
onClick = { expanded = true }
)
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
Column(
modifier = Modifier
.heightIn(max = 300.dp)
.verticalScroll(rememberScrollState())
) {
categories.forEachIndexed { index, item ->
DropdownMenuItem(
text = { Text(item.name) },
leadingIcon = {
Icon(
painter = painterResource(item.icon),
contentDescription = null,
tint = Color.Unspecified,
modifier = Modifier.size(24.dp)
)
},
onClick = {
expanded = false
onSelect(index)
}
)
if (index != categories.lastIndex) {
HorizontalDivider()
}
}
}
}
}
}
@@ -113,7 +113,7 @@ fun AnalyticScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -31,7 +31,6 @@ import androidx.compose.material3.TopAppBarDefaults
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
@@ -74,11 +73,11 @@ fun EventDetails(
onTaskEvents: (TaskEvents) -> Unit,
onWorkspaceEvents: (WorkspaceEvents) -> Unit
) {
var title by remember { mutableStateOf(event.title) }
var description by remember { mutableStateOf(event.description) }
val title = event.title
val description = event.description
var pendingStatus by remember { mutableStateOf(isPending) }
var notificationOffset by remember { mutableLongStateOf(event.notificationOffset) }
var currentRecurrenceRule by remember { mutableStateOf(event.recurrence) }
val notificationOffset = event.notificationOffset
val currentRecurrenceRule = event.recurrence
val context = LocalContext.current
val time = event.startDateTime.toFormattedTime(settings.data.is24HourFormat)
var showDeleteDialog by remember { mutableStateOf(false) }
@@ -123,7 +123,7 @@ fun EventScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -16,7 +16,6 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AccessTime
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Create
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.Today
@@ -61,6 +60,7 @@ import com.flux.R
import com.flux.data.model.EventModel
import com.flux.data.model.RecurrenceRule
import com.flux.ui.common.DatePickerModal
import com.flux.ui.common.DiscardChangesDialog
import com.flux.ui.common.RecurrenceBottomSheet
import com.flux.ui.common.TimePicker
import com.flux.ui.common.convertMillisToTime
@@ -78,6 +78,7 @@ fun NewEvent(
onTaskEvents: (TaskEvents) -> Unit
) {
val context = LocalContext.current
val originalEvent = remember { event }
var title by rememberSaveable { mutableStateOf(event.title) }
var description by rememberSaveable { mutableStateOf(event.description) }
var showTimePicker by remember { mutableStateOf(false) }
@@ -91,6 +92,7 @@ fun NewEvent(
var eventEndsOn by rememberSaveable { mutableLongStateOf(event.endDateTime) }
var neverEnds by rememberSaveable { mutableStateOf(event.endDateTime==-1L) }
var showDatePicker by remember { mutableStateOf(false) }
var showDiscardDialog by remember { mutableStateOf(false) }
if (showCustomNotificationDialog) {
CustomNotificationDialog({
@@ -122,6 +124,28 @@ fun NewEvent(
}
}
fun saveEventIfPossible(): Boolean {
val candidate = originalEvent.copy(
title = title,
startDateTime = selectedDateTime,
description = description,
endDateTime = eventEndsOn,
recurrence = currentRecurrenceRule,
notificationOffset = notificationOffset
)
val hasContent = candidate != originalEvent
if (!hasContent) { return true }
if (title.isBlank()) {
showDiscardDialog = true
return false
}
onTaskEvents(TaskEvents.UpsertTask(context, candidate))
return true
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
@@ -129,23 +153,16 @@ fun NewEvent(
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow),
title = { Text(stringResource(R.string.Edit_Event)) },
navigationIcon = {
IconButton({ navController.popBackStack() }) {
IconButton({
if(saveEventIfPossible()){
navController.popBackStack()
}
}) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
null
)
}
},
actions = {
IconButton(
enabled = title.isNotBlank(),
onClick = {
val updatedEvent = event.copy(title = title, description = description, startDateTime = selectedDateTime, notificationOffset = notificationOffset, recurrence = currentRecurrenceRule, endDateTime = eventEndsOn)
onTaskEvents(TaskEvents.UpsertTask(context, updatedEvent))
navController.popBackStack()
}
)
{ Icon(Icons.Default.Check, null) }
}
)
}
@@ -350,6 +367,15 @@ fun NewEvent(
}
}
if (showDiscardDialog) {
DiscardChangesDialog({
showDiscardDialog = false
navController.popBackStack()
}) {
showDiscardDialog=false
}
}
// Edit Workspace Sheet
RecurrenceBottomSheet(
isVisible = showRepetitionSheet,
@@ -99,7 +99,7 @@ fun HabitScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -1,6 +1,7 @@
package com.flux.ui.screens.habits
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -33,6 +34,7 @@ import androidx.compose.material.icons.filled.TrackChanges
import androidx.compose.material.icons.outlined.Circle
import androidx.compose.material.icons.outlined.Flag
import androidx.compose.material.icons.outlined.StopCircle
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CenterAlignedTopAppBar
@@ -46,6 +48,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.surfaceColorAtElevation
@@ -77,6 +80,7 @@ import com.flux.data.model.HabitConfig
import com.flux.data.model.HabitModel
import com.flux.data.model.RecurrenceRule
import com.flux.ui.common.DatePickerModal
import com.flux.ui.common.DiscardChangesDialog
import com.flux.ui.common.TimePicker
import com.flux.ui.events.HabitEvents
import com.flux.ui.screens.events.getTextFieldColors
@@ -106,6 +110,8 @@ fun NewHabit(
var neverEnds by rememberSaveable { mutableStateOf(habit.endDateTime == -1L) }
val focusRequesterDesc = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var showDiscardDialog by remember { mutableStateOf(false) }
val originalHabit = remember { habit }
val weekdays = listOf(
stringResource(R.string.monday_short),
stringResource(R.string.tuesday_short),
@@ -135,6 +141,37 @@ fun NewHabit(
mutableStateOf(habit.habitConfig as? HabitConfig.Counted ?: HabitConfig.Counted())
}
fun saveHabitIfPossible(): Boolean {
val candidate = originalHabit.copy(
title = newHabitTitle,
description = newHabitDescription,
startDateTime = newHabitTime,
endDateTime = habitEndsOn,
recurrence = RecurrenceRule.Weekly(selectedDays.toList()),
habitConfig = newHabitConfig
)
val hasContent = candidate != originalHabit
if (!hasContent) {
return true
}
if (newHabitTitle.isBlank()) {
showDiscardDialog = true
return false
}
onHabitEvents(HabitEvents.UpsertHabit(context, candidate))
return true
}
BackHandler {
if (saveHabitIfPossible()) {
navController.popBackStack()
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
@@ -142,31 +179,12 @@ fun NewHabit(
colors = TopAppBarDefaults.topAppBarColors(MaterialTheme.colorScheme.surfaceContainerLow),
title = { Text(topBarTitle) },
navigationIcon = {
IconButton({ navController.popBackStack() }) {
Icon(Icons.AutoMirrored.Default.ArrowBack, null)
}
},
actions = {
IconButton(
enabled = newHabitTitle.isNotBlank() && selectedDays.isNotEmpty(),
onClick = {
IconButton({
if (saveHabitIfPossible()) {
navController.popBackStack()
onHabitEvents(
HabitEvents.UpsertHabit(
context,
habit.copy(
title = newHabitTitle,
description = newHabitDescription,
startDateTime = newHabitTime,
endDateTime = habitEndsOn,
recurrence = RecurrenceRule.Weekly(selectedDays.toList()),
habitConfig = newHabitConfig
)
)
)
}
) {
Icon(Icons.Default.Check, null)
}) {
Icon(Icons.AutoMirrored.Default.ArrowBack, null)
}
}
)
@@ -413,6 +431,15 @@ fun NewHabit(
}
}
if (showDiscardDialog) {
DiscardChangesDialog({
showDiscardDialog = false
navController.popBackStack()
}) {
showDiscardDialog=false
}
}
if (showDatePicker) {
DatePickerModal(onDateSelected = {
if (it != null)
@@ -1,6 +1,7 @@
package com.flux.ui.screens.journal
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import android.widget.Toast
@@ -38,6 +39,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@@ -84,20 +89,26 @@ import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.core.net.toUri
import com.flux.R
import com.flux.data.model.LabelModel
import com.flux.data.model.NotesModel
import com.flux.data.model.SocialModel
import com.flux.data.model.TodoItem
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
import com.flux.data.model.getSocialCategory
import com.flux.navigation.NavRoutes
import com.flux.other.ConvertType
import com.flux.other.DataCopyType
import com.flux.ui.common.DataCopyDialog
import com.flux.ui.common.DatePickerModal
import com.flux.ui.common.SocialCategoryCard
import com.flux.ui.common.SocialDialog
import com.flux.ui.events.NotesEvents
import com.flux.ui.events.TodoEvents
import com.flux.ui.events.WorkspaceEvents
@@ -106,12 +117,14 @@ import com.flux.ui.screens.notes.ListDialog
import com.flux.ui.screens.notes.MarkdownEditorRow
import com.flux.ui.screens.notes.NotesInfoBottomSheet
import com.flux.ui.screens.notes.OutlineBottomSheet
import com.flux.ui.screens.notes.PendingSocialDelete
import com.flux.ui.screens.notes.RecordAudioDialog
import com.flux.ui.screens.notes.SelectLabelDialog
import com.flux.ui.screens.notes.ShareDialog
import com.flux.ui.screens.notes.TableDialog
import com.flux.ui.screens.notes.TaskDialog
import com.flux.ui.screens.notes.TaskItem
import kotlinx.coroutines.channels.Channel
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -169,6 +182,7 @@ fun EditJournal(
var showAudioRecorder by rememberSaveable { mutableStateOf(false) }
var readWebView by remember { mutableStateOf<WebView?>(null) }
var showLabelDialog by rememberSaveable { mutableStateOf(false) }
var showSocialDialog by rememberSaveable { mutableStateOf(false) }
val currentLabelIds = rememberSaveable {
mutableStateListOf<String>().apply {
addAll(journal.labels)
@@ -182,6 +196,38 @@ fun EditJournal(
val contentCopiedString = stringResource(R.string.content_copied)
val contentMovedString = stringResource(R.string.content_moved)
val successString = stringResource(R.string.success)
val socialCategoryList = getSocialCategory()
val socialLinksSaver = Saver<SnapshotStateList<SocialModel>, String>(
save = { Json.encodeToString(it.toList()) },
restore = { encoded ->
mutableStateListOf<SocialModel>().apply {
addAll(Json.decodeFromString<List<SocialModel>>(encoded))
}
}
)
val socialLinks = rememberSaveable(saver = socialLinksSaver) {
mutableStateListOf<SocialModel>().apply { addAll(journal.socialLinks) }
}
var selectedSocialModel by remember { mutableStateOf<SocialModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val deleteChannel = remember { Channel<PendingSocialDelete>(Channel.UNLIMITED) }
val undoLabel = stringResource(R.string.undo)
val socialRemovedLabel = stringResource(R.string.social_link_removed)
LaunchedEffect(Unit) {
for (pending in deleteChannel) {
val result = snackbarHostState.showSnackbar(
message = socialRemovedLabel,
actionLabel = undoLabel,
withDismissAction = true,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
socialLinks.add(pending.index.coerceAtMost(socialLinks.size), pending.item)
}
}
}
val rootPicker =
rememberLauncherForActivityResult(
@@ -227,17 +273,17 @@ fun EditJournal(
fun onSaveJournal() {
val newText = contentState.text.toString()
val hasChanged = newText != journal.text
if (!hasChanged && currentLabelIds.toList()==journal.labels && journalDate==journal.dateTime) return
if (!hasChanged && currentLabelIds.toList()==journal.labels && journalDate==journal.dateTime && socialLinks==journal.socialLinks) return
onJournalEvents(
JournalEvents.UpsertEntry(
journal.copy(
text = newText,
dateTime = if (isToday) System.currentTimeMillis() else journalDate,
labels = currentLabelIds.toList()
labels = currentLabelIds.toList(),
socialLinks = socialLinks
)
)
)
@@ -250,6 +296,7 @@ fun EditJournal(
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.imePadding(),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
@@ -294,6 +341,7 @@ fun EditJournal(
onListButtonClick = { showListDialog = true },
onTaskButtonClick = { showTaskDialog = true },
onLinkButtonClick = { showLinkDialog = true },
onSocialButtonClick = { showSocialDialog = true },
onRecordAudioClick = {
ensureStorageRoot(
scope = scope,
@@ -399,6 +447,36 @@ fun EditJournal(
}
}
}
items(socialLinks) { item ->
val category = socialCategoryList[item.category]
SocialCategoryCard(
title = item.title,
cardContainerColor = category.containerColor,
cardContentColor = category.contentColor,
icon = category.icon,
onClick = {
if(isReadView){
val intent = Intent(
Intent.ACTION_VIEW,
item.link.toUri()
)
context.startActivity(intent)
}
else{
val index = socialLinks.indexOf(item)
if (index != -1) {
socialLinks.removeAt(index)
deleteChannel.trySend(PendingSocialDelete(index, item))
}
}
},
onLongPress = {
selectedSocialModel=item
showSocialDialog=true
}
)
}
}
/*-------------------------------------------------*/
@@ -446,15 +524,11 @@ fun EditJournal(
DatePickerModal(
initialSelectedDateMillis = journalDate,
onDateSelected = { selectedDate ->
if (selectedDate == null) return@DatePickerModal
val now = System.currentTimeMillis()
val selectedLocalDate = Instant.ofEpochMilli(selectedDate)
.atZone(ZoneId.systemDefault())
.toLocalDate()
val today = LocalDate.now()
when {
@@ -574,6 +648,19 @@ fun EditJournal(
)
}
if(showSocialDialog){
SocialDialog(
selectedSocialModel,
{
socialLinks.add(it.copy(notesId = journal.journalId, workspaceId = journal.workspaceId))
},
{
showSocialDialog=false
selectedSocialModel=null
}
)
}
if(showDataCopyDialog){
DataCopyDialog(
workspaces.filterNot { it.workspaceId == workspaceId },
@@ -50,7 +50,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
@@ -66,7 +65,9 @@ import androidx.compose.ui.window.Dialog
import com.flux.R
import com.flux.data.model.LabelModel
import com.flux.other.ConvertType
import com.flux.other.parseMarkdownContent
import com.flux.other.MarkdownBlock
import com.flux.other.MediaChipsRow
import com.flux.other.extractMedia
import com.flux.ui.common.CategoryRow
import com.flux.ui.common.DateOptionRow
import com.flux.ui.common.DatePickerModal
@@ -120,21 +121,36 @@ fun JournalPreview(
labels: List<LabelModel>,
onClick: () -> Unit
) {
val mediaExtraction = remember(content) { extractMedia(content) }
val maxHeight = 400.dp
Card(
colors = CardDefaults.cardColors(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)),
modifier = Modifier.clip(shapeManager(isBoth = true, radius = radius / 2)).fillMaxWidth(),
shape = shapeManager(isBoth = true, radius = radius / 2),
onClick = onClick
) {
Text(
text = parseMarkdownContent(content),
style = MaterialTheme.typography.bodyMedium,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.alpha(0.9f)
Box(
Modifier
.fillMaxWidth()
.heightIn(max = maxHeight)
.padding(12.dp)
.heightIn(min = 50.dp)
)
) {
MarkdownBlock(
text = content,
onClick = onClick,
onLongClick = onClick
)
}
// Media chips pinned here, below the text, above labels
if (!mediaExtraction.media.isEmpty) {
MediaChipsRow(
media = mediaExtraction.media,
modifier = Modifier.padding(horizontal = 12.dp),
onClick = onClick ,
onLongClick = onClick
)
}
FlowRow(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).padding(bottom = 8.dp),
@@ -203,7 +203,7 @@ fun JournalScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -73,6 +73,7 @@ import androidx.compose.material.icons.filled.SubdirectoryArrowRight
import androidx.compose.material.icons.filled.TextFields
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.outlined.AddChart
import androidx.compose.material.icons.outlined.AlternateEmail
import androidx.compose.material.icons.outlined.AudioFile
import androidx.compose.material.icons.outlined.AutoStories
import androidx.compose.material.icons.outlined.CheckBox
@@ -1914,6 +1915,7 @@ fun MarkdownEditorRow(
onListButtonClick: () -> Unit,
onTaskButtonClick: () -> Unit,
onLinkButtonClick: () -> Unit,
onSocialButtonClick: () -> Unit,
onImageButtonClick: () -> Unit,
onAudioButtonClick: () -> Unit,
onRecordAudioClick: () -> Unit,
@@ -2184,6 +2186,12 @@ fun MarkdownEditorRow(
onClick = onLinkButtonClick
)
CustomIconButton(
imageVector = Icons.Outlined.AlternateEmail,
contentDescription = "Social",
onClick = onSocialButtonClick
)
CustomIconButton(
imageVector = Icons.Outlined.Mic,
contentDescription = "Audio",
@@ -1,6 +1,7 @@
package com.flux.ui.screens.notes
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import android.widget.Toast
@@ -42,6 +43,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
@@ -56,6 +61,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -70,14 +76,17 @@ import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastJoinToString
import androidx.core.net.toUri
import androidx.navigation.NavController
import com.flux.R
import com.flux.data.model.JournalModel
import com.flux.data.model.LabelModel
import com.flux.data.model.NotesModel
import com.flux.data.model.SocialModel
import com.flux.data.model.TodoItem
import com.flux.data.model.TodoModel
import com.flux.data.model.WorkspaceModel
import com.flux.data.model.getSocialCategory
import com.flux.navigation.NavRoutes
import com.flux.other.Constants
import com.flux.other.HeaderNode
@@ -99,10 +108,18 @@ import com.flux.other.AudioRecorder
import com.flux.other.ConvertType
import com.flux.other.DataCopyType
import com.flux.ui.common.DataCopyDialog
import com.flux.ui.common.SocialCategoryCard
import com.flux.ui.common.SocialDialog
import com.flux.ui.common.convertMillisToTime
import com.flux.ui.events.JournalEvents
import com.flux.ui.events.TodoEvents
import com.flux.ui.events.WorkspaceEvents
import kotlinx.coroutines.channels.Channel
data class PendingSocialDelete(
val index: Int,
val item: SocialModel
)
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@@ -151,6 +168,7 @@ fun NoteDetails(
var isSearching by remember { mutableStateOf(false) }
var showAboutNotes by rememberSaveable { mutableStateOf(false) }
var showLinkDialog by rememberSaveable { mutableStateOf(false) }
var showSocialDialog by rememberSaveable { mutableStateOf(false) }
var showTaskDialog by rememberSaveable { mutableStateOf(false) }
var showTableDialog by rememberSaveable { mutableStateOf(false) }
var showListDialog by rememberSaveable { mutableStateOf(false) }
@@ -171,6 +189,38 @@ fun NoteDetails(
val contentCopiedString = stringResource(R.string.content_copied)
val contentMovedString = stringResource(R.string.content_moved)
val successString = stringResource(R.string.success)
val socialCategoryList = getSocialCategory()
val socialLinksSaver = Saver<SnapshotStateList<SocialModel>, String>(
save = { Json.encodeToString(it.toList()) },
restore = { encoded ->
mutableStateListOf<SocialModel>().apply {
addAll(Json.decodeFromString<List<SocialModel>>(encoded))
}
}
)
val socialLinks = rememberSaveable(saver = socialLinksSaver) {
mutableStateListOf<SocialModel>().apply { addAll(note.socialLinks) }
}
var selectedSocialModel by remember { mutableStateOf<SocialModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val deleteChannel = remember { Channel<PendingSocialDelete>(Channel.UNLIMITED) }
val undoLabel = stringResource(R.string.undo)
val socialRemovedLabel = stringResource(R.string.social_link_removed)
LaunchedEffect(Unit) {
for (pending in deleteChannel) {
val result = snackbarHostState.showSnackbar(
message = socialRemovedLabel,
actionLabel = undoLabel,
withDismissAction = true,
duration = SnackbarDuration.Short
)
if (result == SnackbarResult.ActionPerformed) {
socialLinks.add(pending.index.coerceAtMost(socialLinks.size), pending.item)
}
}
}
LaunchedEffect(searchState.searchWord, contentState.text) {
withContext(Dispatchers.Default) {
@@ -227,7 +277,7 @@ fun NoteDetails(
val newTitle = titleState.text.toString()
val newDescription = contentState.text.toString()
if (newTitle == note.title && newDescription == note.description && noteLabelIds.toList()==note.labels) return
if (newTitle == note.title && newDescription == note.description && noteLabelIds.toList()==note.labels && socialLinks==note.socialLinks) return
onNotesEvents(
NotesEvents.UpsertNote(
@@ -236,7 +286,8 @@ fun NoteDetails(
description = contentState.text.toString(),
isPinned = isPinned,
lastEdited = System.currentTimeMillis(),
labels = noteLabelIds.toList()
labels = noteLabelIds.toList(),
socialLinks = socialLinks
)
)
)
@@ -249,6 +300,7 @@ fun NoteDetails(
}
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = Modifier.imePadding(),
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
@@ -300,6 +352,7 @@ fun NoteDetails(
onListButtonClick = { showListDialog = true },
onTaskButtonClick = { showTaskDialog = true },
onLinkButtonClick = { showLinkDialog = true },
onSocialButtonClick = { showSocialDialog = true },
onRecordAudioClick = {
ensureStorageRoot(
scope = scope,
@@ -378,7 +431,7 @@ fun NoteDetails(
)
if (noteLabelIds.isNotEmpty()) {
LazyRow(modifier = Modifier.padding(start = 12.dp, top = 2.dp, bottom = 2.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
LazyRow(modifier = Modifier.padding(start = 12.dp, top = 2.dp, bottom = 2.dp, end = 6.dp), horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically) {
items(allLabels.filter { l-> noteLabelIds.contains(l.labelId) }) { label ->
Box(
modifier = Modifier
@@ -393,8 +446,8 @@ fun NoteDetails(
) {
Icon(
Icons.AutoMirrored.Default.LabelImportant,
contentDescription = "Label",
modifier = Modifier.size(18.dp),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
@@ -405,6 +458,36 @@ fun NoteDetails(
}
}
}
items(socialLinks) { item ->
val category = socialCategoryList[item.category]
SocialCategoryCard(
title = item.title,
cardContainerColor = category.containerColor,
cardContentColor = category.contentColor,
icon = category.icon,
onClick = {
if(isReadView){
val intent = Intent(
Intent.ACTION_VIEW,
item.link.toUri()
)
context.startActivity(intent)
}
else{
val index = socialLinks.indexOf(item)
if (index != -1) {
socialLinks.removeAt(index)
deleteChannel.trySend(PendingSocialDelete(index, item))
}
}
},
onLongPress = {
selectedSocialModel=item
showSocialDialog=true
}
)
}
}
}
}
@@ -469,6 +552,17 @@ fun NoteDetails(
}
}
if(showSocialDialog){
SocialDialog(
selectedSocialModel,
{ socialLinks.add(it.copy(notesId = note.notesId, workspaceId = note.workspaceId)) },
{
showSocialDialog=false
selectedSocialModel=null
}
)
}
if (showTaskDialog) {
TaskDialog(onDismissRequest = { showTaskDialog = false }) {
onMarkdownKeyPressed(Constants.Editor.TASK, contentState, Json.encodeToString<List<TaskItem>>(it))
@@ -212,7 +212,7 @@ fun NotesScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -91,7 +91,7 @@ fun ProgressTrackerScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -66,7 +66,7 @@ import com.flux.R
import com.flux.data.model.ProgressBoardModel
import com.flux.other.icons
import com.flux.ui.common.ChangeIconSheet
import com.flux.ui.common.DatePickerModal
import com.flux.ui.common.DateOnlyPickerModal
import com.flux.ui.common.convertMillisToDate
import com.flux.ui.screens.events.getTextFieldColors
import com.flux.ui.screens.settings.shapeManager
@@ -104,13 +104,18 @@ fun NewBoardItemSheet(
val startDateString = stringResource(R.string.start_date_after_target_error)
val targetDateString = stringResource(R.string.target_date_before_start_error)
if (showDateSelector) {
DatePickerModal(
onDateSelected = {
val selectedDate = it ?: -1L
if(showDateSelector){
DateOnlyPickerModal(
initialSelectedDateMillis = if (isSelectingStartDate) {
if (startDate == -1L) System.currentTimeMillis() else startDate
} else {
if (endDate == -1L) System.currentTimeMillis() else endDate
},
onDateSelected = { picked ->
val selectedDate = picked ?: -1L
if (isSelectingStartDate) {
if (
endDate != -1L &&
selectedDate != -1L &&
@@ -124,9 +129,7 @@ fun NewBoardItemSheet(
} else {
startDate = selectedDate
}
} else {
if (
startDate != -1L &&
selectedDate != -1L &&
@@ -140,12 +143,12 @@ fun NewBoardItemSheet(
} else {
endDate = selectedDate
}
}
},
onDismiss = {
showDateSelector = false
}
) {
showDateSelector = false
}
)
}
ChangeIconSheet (isChangeIcon, iconSheetState, { isChangeIcon=false }) {
@@ -120,7 +120,7 @@ fun SearchScreen(navController: NavController, states: States, viewModels: ViewM
val context = LocalContext.current
var query by rememberSaveable { mutableStateOf("") }
val allSpaces = getSpacesList().filter { it.id!=6 }
val lockedWorkspace = states.workspaceState.allWorkspaces.filter { it.passKey?.isNotBlank()==true }.map { it.workspaceId }
val lockedWorkspace = states.workspaceState.allWorkspaces.filter { it.isLocked }.map { it.workspaceId }
var filterState by remember {
mutableStateOf(
FilterState(
@@ -10,7 +10,7 @@ import androidx.compose.material.icons.automirrored.rounded.Article
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material.icons.rounded.Code
import androidx.compose.material.icons.rounded.DeveloperMode
import androidx.compose.material.icons.rounded.Commit
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.IosShare
import androidx.compose.material.icons.rounded.PrivacyTip
@@ -26,6 +26,7 @@ import androidx.core.net.toUri
import androidx.navigation.NavController
import com.flux.BuildConfig
import com.flux.R
import com.flux.navigation.NavRoutes
import com.flux.ui.common.BasicScaffold
@OptIn(ExperimentalMaterial3Api::class)
@@ -80,11 +81,12 @@ fun About(navController: NavController, radius: Int) {
item {
Spacer(Modifier.height(24.dp))
SettingOption(
title = stringResource(R.string.Developer),
description = stringResource(R.string.Developer_Name),
icon = Icons.Rounded.DeveloperMode,
title = stringResource(R.string.changelog),
description = stringResource(R.string.changelog_description),
icon = Icons.Rounded.Commit,
radius = shapeManager(radius = radius, isFirst = true),
actionType = ActionType.None
actionType = ActionType.CUSTOM,
onCustomClick = { navController.navigate(NavRoutes.Changelog.route) }
)
}
@@ -0,0 +1,284 @@
package com.flux.ui.screens.settings
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Event
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.alpha
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.flux.R
import androidx.navigation.NavController
import com.flux.ui.common.BasicScaffold
@Composable
fun Changelog(
navController: NavController,
){
BasicScaffold(
title = stringResource(R.string.changelog),
onBackClicked = { navController.popBackStack() }
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.padding(16.dp, 8.dp, 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
items(CHANGELOG_DATA.sortedByDescending { it.versionCode }){ item->
Column(verticalArrangement = Arrangement.spacedBy(6.dp)){
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Tag, null)
Text(item.version, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Event, null, modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.primary)
Text(item.date, color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelMedium)
}
}
Text(item.changes, modifier = Modifier.alpha(0.8f).padding(horizontal = 8.dp, vertical = 4.dp), fontWeight = FontWeight.ExtraLight, style = MaterialTheme.typography.labelLarge)
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp, top = 12.dp))
}
}
}
}
}
data class ChangelogEntry(
val version: String,
val versionCode: Int,
val date: String,
val changes: String
)
val CHANGELOG_DATA = listOf(
ChangelogEntry(
version = "v1.0",
versionCode = 1,
date = "Aug 10, 2025",
changes = "Initial Release of the application."
),
ChangelogEntry(
version = "v2.0",
versionCode = 2,
date = "Oct 4, 2025",
changes = """
- Database breaking changes has been done (that can't be migrated), please copy your data to a file uninstall old version and reinstall the new one then paste copied data.
option to change icons of work-spaces
- import/export data
- more languages support
- Custom repetition to habits and events
- UI is more intuitive now.
- More Customizability: multiple theme palettes
""".trimIndent()
),
ChangelogEntry(
version = "v2.1",
versionCode = 3,
date = "Oct 23, 2025",
changes = """
- More Customizability: custom font options.
- Different Notification Icon for event, habit.
- import/export and share notes in markdown/txt file.
""".trimIndent()
),
ChangelogEntry(
version = "v2.2",
versionCode = 4,
date = "Nov 22, 2025",
changes = """
- Image Support in Notes.
- Mark status of Event/Habits through Notification.
- Added end date for events/habits.
- Some UI changes in Edit Event.
- Added more analytics components for habits.
- Added support to many other languages (German, Russian, Portuguese (Brazil), Spanish).
- Bug fixes of staggered List in notes, state disappearing in rotation.
""".trimIndent()
),
ChangelogEntry(
version = "v3.0",
versionCode = 5,
date = "Feb 16, 2026",
changes = """
- Markdown Support in Notes & Journal.
- Share Notes and Journal as markdown, html, image, pdf.
- Improved To-do UI
- Merged Calendar with Events and Journal.
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.1",
versionCode = 6,
date = "Feb 24, 2026",
changes = """
- Audio recorder added to notes and journal.
- Privacy Policy and User guide added in About.
- Removal of redundant editor options in Customize Screen.
- Automatic Backup manager added in Flux.
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.2",
versionCode = 7,
date = "Mar 17, 2026",
changes = """
- Fixed Automatic Backup Manager
- Indication in Monthly Calendar view for both events and journals
- New Themes page with preview in Customize settings
- Storage Selection page is added to ensure storage root selection for data, backup
- Fixed Crash in Save Notes, Journals.
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.3",
versionCode = 8,
date = "Mar 22, 2026",
changes = """
- New Space Addition: Progress Tracker
- Database Migration query to be crash-free
- Auto-Capitalization on all the text fields
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.4",
versionCode = 9,
date = "Mar 28, 2026",
changes = """
- Fixed Progress Tracker Delete bug
- New UI view for Extreme Compact Mode
- Fixed Habit Streak calculation bug
- Fixed Compact Mode workspace spacing issue
- Fixed Default Editor Visibility Bug
- Fixed Backup import Failure Bug
- Added System Font in settings
- Sticky Notification in Habits
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.5",
versionCode = 10,
date = "May 24, 2026",
changes = """
feat!:
- Counter Habit.
- Scrollable workspace cover.
- UI Improvement.
- Global searching.
- Filters in notes, journal, global search.
- Timeline and labels in journal.
- Journal Heat map in analytics.
- Additional analytics item in habits.
fix!:
- Habit description visibility.
- Journal data deletion on habit space removal.
- Correction in Streak calculation.
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.6",
versionCode = 11,
date = "Jun 7, 2026",
changes = """
feat:
- Added Undo in Todo Item removal
- Draggable Items to reorder in todo.
- Reminder in Todo Items to remind and analyse the list.
fix:
- Dated Journal entry bug
- Create Button Text overflow in note/journal.
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.7",
versionCode = 12,
date = "Jun 7, 2026",
changes = """
feat:
1. Notes Preview Mode to adjust notes height
src:
1. Improved Markdown Render in preview mode for media, links and code-blocks
fix:
1. Progress Tracker date bug.
2. Automatically detect line break in editor
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.8",
versionCode = 13,
date = "Jun 25, 2026",
changes = """
feat:
- Content copy/move to another workspaces
- Various export options in todo
- Clone a data point
- Responsive UI for various display size.
- Day addition in journal timeline with 24-hour format support
fix:
- text overflow at various places
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.9",
versionCode = 14,
date = "July 12, 2026",
changes = """
feat:
- Share achievement in habits
- Widgets in habits and todo list.
fix:
- weekly option selection bug.
""".trimIndent()
),
ChangelogEntry(
version = "v3.1.10",
versionCode = 15,
date = "July 19, 2026",
changes = """
feat:
- Share achievement in habits
- Widgets in habits and todo list.
fix:
- weekly option selection bug.
""".trimIndent()
),
ChangelogEntry(
version = "v3.2.0",
versionCode = 16,
date = "Aug 1, 2026",
changes = """
feat:
- Addition of backup encryption to all the export of backup.
- Added social links option in notes and journal.
- Media detection support in journals
- Consistent saving of habits and events
- Changelog in about.
fix:
- Local date bug in events
- Event stale data of EventDetails.kt after edit.
""".trimIndent()
)
)
@@ -27,7 +27,8 @@ import com.flux.ui.common.BasicScaffold
@Composable
fun Contact(navController: NavController, radius: Int) {
val context = LocalContext.current
val no_email_app = stringResource(R.string.no_email_app)
val noEmailAppLabel = stringResource(R.string.no_email_app)
BasicScaffold(
title = stringResource(R.string.Contact),
onBackClicked = { navController.popBackStack() }
@@ -86,7 +87,7 @@ fun Contact(navController: NavController, radius: Int) {
try {
context.startActivity(intent)
} catch (_: ActivityNotFoundException) {
Toast.makeText(context, no_email_app, Toast.LENGTH_SHORT).show()
Toast.makeText(context, noEmailAppLabel, Toast.LENGTH_SHORT).show()
}
}
)
@@ -1,5 +1,6 @@
package com.flux.ui.screens.settings
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -13,6 +14,7 @@ import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.outlined.CleaningServices
import androidx.compose.material.icons.outlined.EditCalendar
import androidx.compose.material.icons.rounded.Backup
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.material.icons.rounded.Restore
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
@@ -52,9 +55,13 @@ import com.flux.other.canScheduleReminder
import com.flux.other.isNotificationPermissionGranted
import com.flux.other.openAppNotificationSettings
import com.flux.other.requestExactAlarmPermission
import com.flux.ui.common.AutoBackupNeedsPasswordDialog
import com.flux.ui.common.BackupPasswordEntryDialog
import com.flux.ui.common.ChangePasswordWarningDialog
import com.flux.ui.common.DeleteAlert
import com.flux.ui.events.SettingEvents
import com.flux.ui.state.Settings
import com.flux.ui.viewModel.BackupSettingsViewModel
import com.flux.ui.viewModel.BackupViewModel
import kotlinx.coroutines.launch
@@ -67,6 +74,7 @@ fun Data(
settings: Settings,
snackbarHostState: SnackbarHostState,
backupViewModel: BackupViewModel,
backupSettingsViewModel: BackupSettingsViewModel,
onSettingsEvents: (SettingEvents) -> Unit
) {
val context = LocalContext.current
@@ -74,16 +82,49 @@ fun Data(
val coroutineScope = rememberCoroutineScope()
val operationSuccessful = stringResource(R.string.success)
val operationFailed = stringResource(R.string.Failed)
var showWarningDialog by remember { mutableStateOf(false) }
// IMPORT launcher
var showWarningDialog by remember { mutableStateOf(false) }
var showAutoBackupNeedsPasswordDialog by remember { mutableStateOf(false) }
var showSetPasswordDialog by remember { mutableStateOf(false) }
var showChangePasswordWarning by remember { mutableStateOf(false) }
var showChangePasswordEntry by remember { mutableStateOf(false) }
var showImportPasswordDialog by remember { mutableStateOf(false) }
var pendingImportUri by remember { mutableStateOf<Uri?>(null) }
var pendingBackupFrequency by remember {
mutableStateOf<BackupFrequency?>(null)
}
val hasPassword by backupSettingsViewModel.hasBackupPassword.collectAsState()
// --- Migration / startup check: existing users with auto-backup already on, no password yet ---
LaunchedEffect(Unit) {
if (backupSettingsViewModel.isAutoBackupUnsafe(settings.data.backupFrequency)) {
showAutoBackupNeedsPasswordDialog = true
}
}
// --- Post-import check: imported settings may enable auto-backup on a device with no password ---
LaunchedEffect(Unit) {
backupViewModel.importCompleted.collect {
if (backupSettingsViewModel.isAutoBackupUnsafe(settings.data.backupFrequency)) {
showAutoBackupNeedsPasswordDialog = true
}
}
}
// --- Import needs a password we don't have / doesn't match the stored one ---
LaunchedEffect(Unit) {
backupViewModel.passwordRequired.collect { uri ->
pendingImportUri = uri
showImportPasswordDialog = true
}
}
val importLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { backupViewModel.importBackup(context, it) }
}
// Observe result - Collect SharedFlow properly
LaunchedEffect(Unit) {
backupViewModel.backupResult.collect { result ->
if (result.isSuccess) {
@@ -94,7 +135,6 @@ fun Data(
}
}
// ... rest of your UI
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
@@ -102,17 +142,14 @@ fun Data(
colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
title = { Text(stringResource(R.string.data_title)) },
navigationIcon = {
IconButton({navController.navigateUp()}) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
IconButton({ navController.navigateUp() }) {
Icon(imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
){ innerPadding ->
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
@@ -125,7 +162,7 @@ fun Data(
icon = Icons.Rounded.Backup,
radius = shapeManager(radius = radius, isFirst = true),
actionType = ActionType.CUSTOM,
onCustomClick = { coroutineScope.launch { backupViewModel.exportBackup(context) }}
onCustomClick = { coroutineScope.launch { backupViewModel.exportBackup(context) } }
)
}
@@ -137,23 +174,15 @@ fun Data(
title = stringResource(R.string.Restore),
description = stringResource(R.string.Restore_Description),
icon = Icons.Rounded.Restore,
radius = shapeManager(radius = radius, isLast = true),
radius = shapeManager(radius = radius),
actionType = ActionType.CUSTOM,
onCustomClick = {
if (!canScheduleReminder(context)) {
Toast.makeText(
context,
reminderPermission,
Toast.LENGTH_SHORT
).show()
Toast.makeText(context, reminderPermission, Toast.LENGTH_SHORT).show()
requestExactAlarmPermission(context)
}
if (!isNotificationPermissionGranted(context)) {
Toast.makeText(
context,
notificationPermission,
Toast.LENGTH_SHORT
).show()
Toast.makeText(context, notificationPermission, Toast.LENGTH_SHORT).show()
openAppNotificationSettings(context)
}
if (canScheduleReminder(context) && isNotificationPermissionGranted(context)) {
@@ -163,41 +192,46 @@ fun Data(
)
}
// --- Backup password setting row ---
item {
SettingOption(
title = stringResource(R.string.backup_password_setting),
description =
if (hasPassword) stringResource(R.string.backup_encrypted)
else stringResource(R.string.backup_not_encrypted)
,
icon = Icons.Rounded.Lock,
radius = shapeManager(radius = radius, isLast = true),
actionType = ActionType.CUSTOM,
onCustomClick = {
if (hasPassword) showChangePasswordWarning = true
else showSetPasswordDialog = true
}
)
}
item {
val workManager = remember { WorkManager.getInstance(context.applicationContext) }
val backupManager = remember(workManager) { BackupManager(workManager) }
fun mapDaysToSliderPosition(days: Int): Float = when (days) {
0 -> 0f
1 -> 1f
7 -> 2f
30 -> 3f
else -> 0f
0 -> 0f; 1 -> 1f; 7 -> 2f; 30 -> 3f; else -> 0f
}
fun mapSliderPositionToFrequency(position: Float): BackupFrequency = when (position) {
0f -> BackupFrequency.NEVER
1f -> BackupFrequency.DAILY
2f -> BackupFrequency.WEEKLY
3f -> BackupFrequency.MONTHLY
0f -> BackupFrequency.NEVER; 1f -> BackupFrequency.DAILY
2f -> BackupFrequency.WEEKLY; 3f -> BackupFrequency.MONTHLY
else -> BackupFrequency.NEVER
}
var currentSliderPosition by remember(settings.data.backupFrequency) {
mutableFloatStateOf(mapDaysToSliderPosition(settings.data.backupFrequency))
}
val selectedFrequency = mapSliderPositionToFrequency(currentSliderPosition)
ListItem(
modifier = Modifier.padding(top = 8.dp),
leadingContent = {
Icon(
imageVector = Icons.Outlined.EditCalendar,
contentDescription = "Auto backup"
)
},
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
leadingContent = { Icon(Icons.Outlined.EditCalendar, contentDescription = "Auto backup") },
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
headlineContent = { Text(stringResource(R.string.backup_frequency)) },
supportingContent = { Text(text = stringResource(selectedFrequency.textRes)) }
)
@@ -206,29 +240,32 @@ fun Data(
modifier = Modifier.padding(horizontal = 16.dp),
value = currentSliderPosition,
onValueChange = { newPosition ->
currentSliderPosition = newPosition // update local state immediately
currentSliderPosition = newPosition
hapticFeedback.performHapticFeedback(HapticFeedbackType.SegmentFrequentTick)
val newFrequency = mapSliderPositionToFrequency(newPosition)
onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(backupFrequency = newFrequency.days)))
},
onValueChangeFinished = {
backupManager.scheduleBackup(mapSliderPositionToFrequency(currentSliderPosition))
val newFrequency = mapSliderPositionToFrequency(currentSliderPosition)
coroutineScope.launch {
if (backupSettingsViewModel.isAutoBackupUnsafe(newFrequency.days)) {
pendingBackupFrequency = newFrequency
showAutoBackupNeedsPasswordDialog = true
} else {
backupManager.scheduleBackup(newFrequency)
}
}
},
valueRange = 0f..3f,
steps = 2
)
}
item {
ListItem(
colors = ListItemDefaults.colors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
),
leadingContent = {
Icon(
imageVector = Icons.Outlined.CleaningServices,
contentDescription = "Reset"
)
},
colors = ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
leadingContent = { Icon(Icons.Outlined.CleaningServices, contentDescription = "Reset") },
headlineContent = { Text(stringResource(R.string.reset_database)) },
trailingContent = {
TextButton(
@@ -240,27 +277,88 @@ fun Data(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Text(stringResource(R.string.reset))
}
) { Text(stringResource(R.string.reset)) }
},
supportingContent = {
Text(stringResource(R.string.clear_app_data))
}
supportingContent = { Text(stringResource(R.string.clear_app_data)) }
)
}
}
if(showWarningDialog){
if (showWarningDialog) {
DeleteAlert(
onConfirmation = {
showWarningDialog=false
onSettingsEvents(SettingEvents.ResetDatabase) },
onConfirmation = { showWarningDialog = false; onSettingsEvents(SettingEvents.ResetDatabase) },
onDismissRequest = { showWarningDialog = false },
dialogTitle = stringResource(R.string.deleteDialogTitle),
dialogText = stringResource(R.string.deleteDialogText),
icon = Icons.Default.Delete
)
}
// --- Mandatory: auto-backup on, no password (migration / post-import / live toggle) ---
if (showAutoBackupNeedsPasswordDialog) {
AutoBackupNeedsPasswordDialog(
onSetPasswordClick = {
showSetPasswordDialog = true
},
onTurnOffAutoBackup = {
showAutoBackupNeedsPasswordDialog = false
onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(backupFrequency = BackupFrequency.NEVER.days)))
WorkManager.getInstance(context.applicationContext).let { BackupManager(it).scheduleBackup(BackupFrequency.NEVER) }
}
)
}
// --- Initial password setup (first time only) ---
if (showSetPasswordDialog) {
BackupPasswordEntryDialog(
title = stringResource(R.string.set_backup_password),
onConfirm = { pw ->
showAutoBackupNeedsPasswordDialog=false
showSetPasswordDialog = false
backupSettingsViewModel.setPassword(pw) {
pendingBackupFrequency?.let { frequency ->
val workManager = WorkManager.getInstance(context.applicationContext)
onSettingsEvents(SettingEvents.UpdateSettings(settings.data.copy(backupFrequency = frequency.days)))
BackupManager(workManager).scheduleBackup(frequency)
pendingBackupFrequency = null
}
}
},
onDismiss = {
showSetPasswordDialog = false
}
)
}
// --- Rotation warning, then capture new password ---
if (showChangePasswordWarning) {
ChangePasswordWarningDialog(
onConfirm = { showChangePasswordWarning = false; showChangePasswordEntry = true },
onDismiss = { showChangePasswordWarning = false }
)
}
if (showChangePasswordEntry) {
BackupPasswordEntryDialog(
title = stringResource(R.string.set_backup_password),
onConfirm = { pw ->
showChangePasswordEntry = false
backupSettingsViewModel.changePassword(pw)
},
onDismiss = { showChangePasswordEntry = false }
)
}
// --- Per-file password prompt on import (new device, or file predates a rotation) ---
if (showImportPasswordDialog) {
BackupPasswordEntryDialog(
title = stringResource(R.string.enter_backup_password_title),
onConfirm = { pw ->
showImportPasswordDialog = false
pendingImportUri?.let { backupViewModel.importBackupWithPassword(context, it, pw) }
},
onDismiss = { showImportPasswordDialog = false }
)
}
}
}
@@ -131,7 +131,7 @@ fun TodoExpandableCard(
Card(
modifier = Modifier.padding(top = 4.dp),
shape = if(isExpanded) shapeManager(isBoth = true, radius=radius) else RoundedCornerShape(50),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer)
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f))
) {
Column {
TodoHeaderRow(
@@ -250,7 +250,7 @@ fun MaterialListItem(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(50),
colors = CardDefaults.cardColors(
containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer.copy(0.5f) else MaterialTheme.colorScheme.primaryContainer.copy(0.7f)
containerColor = if (todoItem.isChecked) MaterialTheme.colorScheme.primaryContainer.copy(0.4f) else MaterialTheme.colorScheme.primaryContainer.copy(0.6f)
),
onClick = onToggleCheck
) {
@@ -87,7 +87,7 @@ fun TodoScreen(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -50,7 +50,7 @@ fun EmptyWorkspace(
description = workspace.description,
cover = workspace.cover,
icon = workspace.icon,
isLocked = workspace.passKey!=null,
isLocked = workspace.isLocked,
onBackPressed = { navController.popBackStack() },
onAddCover = onAddCover,
onRemoveCover = onRemoveCover,
@@ -48,6 +48,8 @@ import androidx.navigation.NavController
import com.flux.R
import com.flux.data.model.WorkspaceModel
import com.flux.data.model.getSpacesList
import com.flux.data.model.lockWith
import com.flux.data.model.removePasskey
import com.flux.other.icons
import com.flux.ui.common.ChangeIconSheet
import com.flux.ui.common.CompactCard
@@ -85,7 +87,9 @@ fun NewWorkspaceScreen(
var title by remember { mutableStateOf(workspace.title) }
var description by remember { mutableStateOf(workspace.description) }
var icon by remember { mutableIntStateOf(workspace.icon) }
var passkey by remember { mutableStateOf(workspace.passKey) }
var isLocked by remember { mutableStateOf(workspace.passKeyHash != null) }
var pendingPasskey by remember { mutableStateOf<String?>(null) }
val focusRequesterDesc = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
@@ -109,17 +113,20 @@ fun NewWorkspaceScreen(
}
fun saveWorkspace() {
onEvent(
WorkspaceEvents.UpsertSpace(
workspace.copy(
title = title,
description = description,
icon = icon,
passKey = passkey,
selectedSpaces = selectedSpacesId.toList()
)
)
val baseWorkspace = workspace.copy(
title = title,
description = description,
icon = icon,
selectedSpaces = selectedSpacesId.toList()
)
val resolvedWorkspace = when {
!isLocked -> baseWorkspace.removePasskey()
!pendingPasskey.isNullOrBlank() -> baseWorkspace.lockWith(pendingPasskey!!)
else -> baseWorkspace // keep existing passKeyHash as-is (unchanged)
}
onEvent(WorkspaceEvents.UpsertSpace(resolvedWorkspace))
}
if (showSpaceDeleteWarningDialog) {
@@ -205,17 +212,26 @@ fun NewWorkspaceScreen(
.fillMaxWidth()
.padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){
Text(stringResource(R.string.Lock_Workspace), style = MaterialTheme.typography.bodyLarge)
Switch(passkey!=null, onCheckedChange = { passkey = if(it) "" else null })
Switch(
checked = isLocked,
onCheckedChange = { checked ->
isLocked = checked
pendingPasskey = if (checked) { "" } else { null }
}
)
}
passkey?.let {
if (isLocked) {
Row(Modifier
.fillMaxWidth()
.padding(8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween){
Text(stringResource(R.string.passkey), style = MaterialTheme.typography.bodyLarge)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if(it.isNotBlank()) Text("****")
// Show masked dots if either an existing hash is already stored
// OR the user has typed a new passkey in this session.
val hasSetPasskey = workspace.passKeyHash != null || !pendingPasskey.isNullOrBlank()
if (hasSetPasskey) Text("****")
IconButton(
{ isDialogVisible = true },
colors = IconButtonDefaults.iconButtonColors(
@@ -307,7 +323,13 @@ fun NewWorkspaceScreen(
onConfirm = { idx-> scope.launch { sheetState.hide() }.invokeOnCompletion { icon = idx } }
)
if (isDialogVisible) { SetPasskeyDialog(passkey,{ passkey = it }) { isDialogVisible = false } }
if (isDialogVisible) {
SetPasskeyDialog(
key = pendingPasskey,
onConfirmRequest = { newRaw -> pendingPasskey = newRaw },
onDismissRequest = { isDialogVisible = false }
)
}
}
@Composable
@@ -350,4 +372,4 @@ fun SelectedSpacesOrderEditor(
}
}
}
}
}
@@ -72,8 +72,19 @@ import com.flux.ui.screens.settings.shapeManager
// ------------- Dialog -------------
@Composable
fun SetPasskeyDialog(key: String?=null, onConfirmRequest: (String) -> Unit, onDismissRequest: () -> Unit) {
var passKey by remember { mutableStateOf(key?: "") }
fun SetPasskeyDialog(
key: String? = null,
onConfirmRequest: (String) -> Unit,
onDismissRequest: () -> Unit
) {
var passKey by remember { mutableStateOf(key ?: "") }
val isValid = passKey.isNotBlank()
fun confirmIfValid() {
if (!isValid) return
onConfirmRequest(passKey)
onDismissRequest()
}
Dialog(onDismissRequest) {
Card(shape = RoundedCornerShape(16.dp)) {
@@ -93,24 +104,20 @@ fun SetPasskeyDialog(key: String?=null, onConfirmRequest: (String) -> Unit, onDi
singleLine = true,
onValueChange = { passKey = it },
modifier = Modifier.fillMaxWidth(),
isError = passKey.isNotEmpty() && !isValid,
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Done
),
keyboardActions = KeyboardActions(
onDone = {
onConfirmRequest(passKey)
onDismissRequest()
}
onDone = { confirmIfValid() }
)
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
TextButton(onDismissRequest) { Text(stringResource(R.string.Dismiss)) }
TextButton(
onClick = {
onConfirmRequest(passKey)
onDismissRequest()
},
onClick = { confirmIfValid() },
enabled = isValid,
colors = ButtonDefaults.buttonColors()
) { Text(stringResource(R.string.Confirm)) }
}
@@ -20,6 +20,8 @@ import androidx.compose.ui.platform.LocalContext
import androidx.navigation.NavController
import com.flux.data.model.WorkspaceModel
import com.flux.data.model.getSpacesList
import com.flux.data.model.lockWith
import com.flux.data.model.removePasskey
import com.flux.other.ensureStorageRoot
import com.flux.ui.common.DeleteAlert
import com.flux.ui.events.HabitEvents
@@ -87,8 +89,8 @@ fun WorkspaceDetails(
onRemoveCover = { viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.copy(cover = ""))) },
onDeleteWorkspace = { isDeleteDialogVisible = true },
onToggleLock = {
if(workspace.passKey!=null) {
viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.copy(passKey = null)))
if(workspace.isLocked) {
viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.removePasskey()))
}
else { isPasskeyDialogVisible = true }
},
@@ -99,7 +101,7 @@ fun WorkspaceDetails(
if (isPasskeyDialogVisible) {
SetPasskeyDialog(
onConfirmRequest = {
viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.copy(passKey = it)))
viewModels.workspaceViewModel.onEvent(WorkspaceEvents.UpsertSpace(workspace.lockWith(it)))
},
onDismissRequest = { isPasskeyDialogVisible = false }
)
@@ -22,6 +22,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@@ -38,6 +39,8 @@ import com.flux.data.model.WorkspaceModel
import com.flux.navigation.NavRoutes
import com.flux.ui.events.WorkspaceEvents
import com.flux.R
import com.flux.data.model.verifyPasskey
import com.flux.ui.common.AutoBackupNeedsPasswordInfoDialog
import com.flux.ui.common.BottomBar
import com.flux.ui.common.SelectedToolBarRow
import com.flux.ui.state.States
@@ -58,10 +61,13 @@ fun WorkspaceHomeScreen(
val wrongPassKeyLabel = stringResource(R.string.Wrong_Passkey)
val selectedWorkspace = remember { mutableStateListOf<WorkspaceModel>() }
var lockedWorkspace by remember { mutableStateOf<WorkspaceModel?>(null) }
var showAutoBackupNeedsPasswordDialog by remember { mutableStateOf(false) }
val backupSettingsViewModel = viewModels.backupSettingsViewModel
val settings = states.settings.data
lockedWorkspace?.let {
SetPasskeyDialog(onConfirmRequest = { passkey ->
if (it.passKey == passkey) {
if (it.verifyPasskey(passkey)) {
navController.navigate(NavRoutes.WorkspaceHome.withArgs(it.workspaceId))
} else {
Toast.makeText(context, wrongPassKeyLabel, Toast.LENGTH_SHORT).show()
@@ -70,12 +76,27 @@ fun WorkspaceHomeScreen(
}
fun handleWorkspaceClick(space: WorkspaceModel) {
if (space.passKey!=null) { lockedWorkspace = space }
else {
navController.navigate(NavRoutes.WorkspaceHome.withArgs(space.workspaceId))
if (space.isLocked) { lockedWorkspace = space }
else { navController.navigate(NavRoutes.WorkspaceHome.withArgs(space.workspaceId)) }
}
// --- Migration / startup check: existing users with auto-backup already on, no password yet ---
LaunchedEffect(Unit) {
if (backupSettingsViewModel.isAutoBackupUnsafe(settings.backupFrequency)) {
showAutoBackupNeedsPasswordDialog = true
}
}
// --- Mandatory: auto-backup on, no password (migration / post-import / live toggle) ---
if (showAutoBackupNeedsPasswordDialog) {
AutoBackupNeedsPasswordInfoDialog(
onNavigate = {
showAutoBackupNeedsPasswordDialog=false
navController.navigate(NavRoutes.Backup.route)
}
)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow,
topBar = {
@@ -156,7 +177,7 @@ fun WorkspaceHomeScreen(
gridColumns = gridColumns,
iconIndex = space.icon,
radius = radius,
isLocked = space.passKey != null,
isLocked = space.isLocked,
cover = space.cover,
title = space.title,
description = space.description,
@@ -185,7 +206,7 @@ fun WorkspaceHomeScreen(
gridColumns = gridColumns,
iconIndex = space.icon,
radius = radius,
isLocked = space.passKey != null,
isLocked = space.isLocked,
cover = space.cover,
title = space.title,
description = space.description,
@@ -0,0 +1,49 @@
package com.flux.ui.viewModel
import androidx.lifecycle.viewModelScope
import com.flux.other.BackupFrequency
import com.flux.other.crypto.BackupCredentialsStore
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class BackupSettingsViewModel @Inject constructor(
private val credentialsStore: BackupCredentialsStore
) : androidx.lifecycle.ViewModel() {
val hasBackupPassword: StateFlow<Boolean> = credentialsStore.hasPasswordFlow()
/**
* Single source of truth for "auto-backup is currently misconfigured."
* True whenever a non-NEVER frequency is set but no password exists on this device
* covers migration (pre-existing users), post-import, and live toggling identically.
*/
suspend fun isAutoBackupUnsafe(currentFrequencyDays: Int): Boolean =
currentFrequencyDays != BackupFrequency.NEVER.days && credentialsStore.getPassword() == null
fun setPassword(password: CharArray, onDone: () -> Unit = {}) {
viewModelScope.launch {
credentialsStore.setPassword(password)
password.fill('\u0000')
onDone()
}
}
/** Explicit rotation: intentionally orphans any backup encrypted with the old password. */
fun changePassword(newPassword: CharArray, onDone: () -> Unit = {}) {
viewModelScope.launch {
credentialsStore.setPassword(newPassword)
newPassword.fill('\u0000')
onDone()
}
}
fun disableEncryption(onDone: () -> Unit = {}) {
viewModelScope.launch {
credentialsStore.setPassword(null)
onDone()
}
}
}
@@ -2,7 +2,6 @@ package com.flux.ui.viewModel
import android.content.Context
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flux.data.database.FluxBackup
import com.flux.data.database.FluxDatabase
@@ -13,6 +12,10 @@ import com.flux.data.repository.SettingsRepository
import com.flux.other.BackupFrequency
import com.flux.other.BackupManager
import com.flux.other.Constants
import com.flux.other.crypto.BackupCredentialsStore
import com.flux.other.crypto.BackupCryptoException
import com.flux.other.crypto.BackupEncryptor
import com.flux.other.crypto.BackupFileFormat
import com.flux.other.getOrCreateDirectory
import com.flux.other.scheduleNextReminder
import com.flux.other.tryRestoreUriPermission
@@ -25,19 +28,31 @@ import kotlinx.coroutines.withContext
import javax.inject.Inject
import kotlinx.serialization.json.Json
@HiltViewModel
class BackupViewModel @Inject constructor(
private val db: FluxDatabase,
private val settingsRepository: SettingsRepository,
private val backupManager: BackupManager
) : ViewModel() {
private val backupManager: BackupManager,
private val backupEncryptor: BackupEncryptor,
private val credentialsStore: BackupCredentialsStore
) : androidx.lifecycle.ViewModel() {
private val _backupResult = MutableSharedFlow<Result<Unit>>()
val backupResult = _backupResult.asSharedFlow()
/** Fired when an encrypted file can't be opened with whatever password is currently stored (or none). */
private val _passwordRequired = MutableSharedFlow<Uri>()
val passwordRequired = _passwordRequired.asSharedFlow()
/** Fired after a successful import — lets the UI re-check whether auto-backup is now unsafe. */
private val _importCompleted = MutableSharedFlow<Unit>()
val importCompleted = _importCompleted.asSharedFlow()
private val backupJson = Json {
ignoreUnknownKeys = true // old backups won't have new fields → skip them
coerceInputValues = true // null where non-null expected → use default
encodeDefaults = true // ensure new fields are always written on export
ignoreUnknownKeys = true
coerceInputValues = true
encodeDefaults = true
classDiscriminator = "type"
}
@@ -45,36 +60,27 @@ class BackupViewModel @Inject constructor(
val rootUri = settingsRepository.getStorageRoot()
viewModelScope.launch(Dispatchers.IO) {
val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX)
val backupDir = baseDir?.let { dir ->
getOrCreateDirectory(context, dir.uri, Constants.File.FLUX_BACKUP)
}
try {
val baseDir = getOrCreateDirectory(context, rootUri, Constants.File.FLUX)
val backupDir = baseDir?.let { getOrCreateDirectory(context, it.uri, Constants.File.FLUX_BACKUP) }
backupDir?.let { dir ->
val plainJson = writeJsonBackup()
val password = credentialsStore.getPassword()
backupDir?.let { dir ->
val json = writeJsonBackup()
try {
val fileName = "${System.currentTimeMillis()}.json"
val file = dir.createFile("application/json", fileName)
file?.let { docFile ->
saveToUri(context, docFile.uri, json)
val bytes = if (password != null) {
backupEncryptor.encrypt(plainJson.toByteArray(Charsets.UTF_8), password)
.also { password.fill('\u0000') }
} else {
plainJson.toByteArray(Charsets.UTF_8) // no password set → plaintext, unchanged legacy behavior
}
file?.let { saveBytesToUri(context, it.uri, bytes) }
_backupResult.emit(Result.success(Unit))
} catch (e: Exception) {
e.printStackTrace()
_backupResult.emit(Result.failure(e))
}
}
}
}
fun importBackup(context: Context, uri: Uri) {
viewModelScope.launch {
try {
val json = readFromUri(context, uri)
uploadBackupToDatabase(context, json)
_backupResult.emit(Result.success(Unit))
} catch (e: Exception) {
e.printStackTrace()
_backupResult.emit(Result.failure(e))
@@ -82,6 +88,50 @@ class BackupViewModel @Inject constructor(
}
}
/** Entry point from the file picker. Tries the currently stored password first, silently. */
fun importBackup(context: Context, uri: Uri) {
viewModelScope.launch {
attemptImport(context, uri, credentialsStore.getPassword())
}
}
/** Entry point after the user manually supplies a password (stored one didn't work, or none exists). */
fun importBackupWithPassword(context: Context, uri: Uri, password: CharArray) {
viewModelScope.launch {
attemptImport(context, uri, password)
}
}
private suspend fun attemptImport(context: Context, uri: Uri, password: CharArray?) {
try {
val bytes = readBytesFromUri(context, uri)
val plainJson = if (BackupFileFormat.isEncrypted(bytes)) {
if (password == null) {
_passwordRequired.emit(uri)
return
}
try {
String(backupEncryptor.decrypt(bytes, password), Charsets.UTF_8)
} catch (e: BackupCryptoException.WrongPasswordOrCorrupted) {
_passwordRequired.emit(uri) // wrong/old password — ask the user for this file's actual one
return
} finally {
password.fill('\u0000')
}
} else {
String(bytes, Charsets.UTF_8) // legacy plaintext backup, decoded exactly as before
}
uploadBackupToDatabase(context, plainJson)
_backupResult.emit(Result.success(Unit))
_importCompleted.emit(Unit)
} catch (e: Exception) {
e.printStackTrace()
_backupResult.emit(Result.failure(e))
}
}
private suspend fun writeJsonBackup(): String = withContext(Dispatchers.IO) {
val backup = FluxBackup(
workspaces = db.workspaceDao.getAll(),
@@ -94,104 +144,73 @@ class BackupViewModel @Inject constructor(
labels = db.labelDao.getAll(),
events = db.eventDao.loadAllEvents(),
eventInstances = db.eventInstanceDao.getAll(),
settings = db.settingsDao.loadSetting()?: SettingsModel(),
settings = db.settingsDao.loadSetting() ?: SettingsModel(),
progressBoardItems = db.progressBoardDao.getAllBoardItems()
)
backupJson.encodeToString(FluxBackup.serializer(), backup)
}
private suspend fun saveToUri(context: Context, uri: Uri, json: String) =
private suspend fun saveBytesToUri(context: Context, uri: Uri, bytes: ByteArray) =
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
outputStream.write(json.toByteArray())
outputStream.flush()
} ?: throw IllegalStateException("Could not open OutputStream")
context.contentResolver.openOutputStream(uri)?.use { it.write(bytes); it.flush() }
?: throw IllegalStateException("Could not open OutputStream")
}
private suspend fun readFromUri(context: Context, uri: Uri): String =
private suspend fun readBytesFromUri(context: Context, uri: Uri): ByteArray =
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() }
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: throw IllegalStateException("Could not open InputStream")
}
private suspend fun uploadBackupToDatabase(context: Context, json: String) = withContext(Dispatchers.IO) {
val backup = backupJson.decodeFromString(FluxBackup.serializer(), json)
// --- Workspaces ---
backup.workspaces.forEach { ws ->
if (!db.workspaceDao.exists(ws.workspaceId)) db.workspaceDao.upsertWorkspace(ws)
}
backup.workspaces.forEach { ws -> if (!db.workspaceDao.exists(ws.workspaceId)) db.workspaceDao.upsertWorkspace(ws) }
backup.notes.forEach { note -> if (!db.notesDao.exists(note.notesId)) db.notesDao.upsertNote(note) }
// --- Notes ---
backup.notes.forEach { note ->
if (!db.notesDao.exists(note.notesId)) db.notesDao.upsertNote(note)
}
// --- Todos ---
backup.todos.forEach { todo ->
if (!db.todoDao.exists(todo.id)) {
if(todo.recurrence== RecurrenceRule.Weekly) scheduleNextReminder(context, todo.toScheduleRequest())
if (todo.recurrence == RecurrenceRule.Weekly) scheduleNextReminder(context, todo.toScheduleRequest())
db.todoDao.upsertList(todo)
}
}
backup.todoInstances.forEach { instance ->
if (!db.todoInstanceDao.exists(instance.todoId, instance.instanceDate))
db.todoInstanceDao.upsertTodoInstance(instance)
if (!db.todoInstanceDao.exists(instance.todoId, instance.instanceDate)) db.todoInstanceDao.upsertTodoInstance(instance)
}
// --- Habits ---
backup.habits.forEach { habit ->
if (!db.habitDao.exists(habit.id)) {
scheduleNextReminder(context, habit.toScheduleRequest())
db.habitDao.upsertHabit(habit)
}
}
backup.habitInstances.forEach { hi -> if (!db.habitInstanceDao.exists(hi.habitId, hi.instanceDate)) db.habitInstanceDao.upsertInstance(hi) }
// --- Habit Instances ---
backup.habitInstances.forEach { hi ->
if (!db.habitInstanceDao.exists(hi.habitId, hi.instanceDate)) db.habitInstanceDao.upsertInstance(hi)
}
backup.journals.forEach { journal -> if (!db.journalDao.exists(journal.journalId)) db.journalDao.upsertEntry(journal) }
backup.labels.forEach { label -> if (!db.labelDao.exists(label.labelId)) db.labelDao.upsertLabel(label) }
// --- Journals ---
backup.journals.forEach { journal ->
if (!db.journalDao.exists(journal.journalId)) db.journalDao.upsertEntry(journal)
}
// --- Labels ---
backup.labels.forEach { label ->
if (!db.labelDao.exists(label.labelId)) db.labelDao.upsertLabel(label)
}
// --- Events ---
backup.events.forEach { event ->
if (!db.eventDao.exists(event.id)){
if (!db.eventDao.exists(event.id)) {
scheduleNextReminder(context, event.toScheduleRequest())
db.eventDao.upsertEvent(event)
}
}
// --- Event Instances ---
backup.eventInstances.forEach { ei ->
if (!db.eventInstanceDao.exists(ei.eventId, ei.instanceDate))
db.eventInstanceDao.upsertEventInstance(ei)
}
backup.eventInstances.forEach { ei -> if (!db.eventInstanceDao.exists(ei.eventId, ei.instanceDate)) db.eventInstanceDao.upsertEventInstance(ei) }
// --- Settings ---
val current = db.settingsDao.loadSetting()
val merged = if (backup.settings.storageRootUri != null) {
backup.settings
} else {
backup.settings.copy(storageRootUri = current?.storageRootUri)
}
db.settingsDao.upsertSettings(merged)
merged.storageRootUri?.let {
tryRestoreUriPermission(context, it)
}
merged.storageRootUri?.let { tryRestoreUriPermission(context, it) }
// NOTE: we schedule the WorkManager job based on the imported frequency regardless of
// whether a password exists — BackupWorker itself is the guard that skips unsafe runs.
// The UI-level check (isAutoBackupUnsafe) is what prompts the user afterward via importCompleted.
val frequency = merged.backupFrequency
fun mapDaysToFrequency(day: Int): BackupFrequency = when (day) {
0 -> BackupFrequency.NEVER
@@ -200,12 +219,8 @@ class BackupViewModel @Inject constructor(
30 -> BackupFrequency.MONTHLY
else -> BackupFrequency.NEVER
}
backupManager.scheduleBackup(mapDaysToFrequency(frequency))
// --- Progress Board ---
backup.progressBoardItems.forEach { item ->
if (!db.progressBoardDao.exists(item.workspaceId)) db.progressBoardDao.upsertBoardItem(item)
}
backup.progressBoardItems.forEach { item -> if (!db.progressBoardDao.exists(item.workspaceId)) db.progressBoardDao.upsertBoardItem(item) }
}
}
}
@@ -10,5 +10,6 @@ data class ViewModels(
val settingsViewModel: SettingsViewModel,
val backupViewModel: BackupViewModel,
val labelViewModel: LabelViewModel,
val progressBoardViewModel: ProgressBoardViewModel
val progressBoardViewModel: ProgressBoardViewModel,
val backupSettingsViewModel: BackupSettingsViewModel
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

@@ -540,4 +540,29 @@
<string name="delete_alert">Löschwarnung!</string>
<string name="delete_spaces_alert">Du bist dabei, %1$s Bereich(e) mit allen Daten dauerhaft zu löschen. Dies kann nicht rückgängig gemacht werden.</string>
<string name="set_backup_password_title">Backup-Passwort festlegen</string>
<string name="enter_backup_password_title">Backup-Passwort eingeben</string>
<string name="backup_password_setting">Backup-Passwort festlegen</string>
<string name="backup_encrypted">Backups sind verschlüsselt</string>
<string name="backup_not_encrypted">Backups sind nicht verschlüsselt</string>
<string name="backup_password">Backup-Passwort</string>
<string name="backup_password_description">Dieses Passwort kann nicht wiederhergestellt werden, wenn es verloren geht. Sie benötigen es, um dieses Backup auf jedem Gerät wiederherzustellen.</string>
<string name="set_backup_password">Backup-Passwort festlegen</string>
<string name="auto_backup_requires_password">Die automatische Sicherung ist aktiviert, aber es wurde kein Passwort festgelegt. Deshalb können Sicherungen nicht sicher ausgeführt werden. Legen Sie jetzt ein Passwort fest oder deaktivieren Sie die automatische Sicherung.</string>
<string name="go_to_settings">Zu den Einstellungen</string>
<string name="change_backup_password_title">Backup-Passwort ändern?</string>
<string name="change_backup_password_message">Bereits mit Ihrem aktuellen Passwort verschlüsselte Backups können nach dieser Änderung nicht mehr wiederhergestellt werden. Neue Backups verwenden das neue Passwort. Dieser Vorgang kann nicht rückgängig gemacht werden.</string>
<string name="set_password">Passwort festlegen</string>
<string name="turn_off_auto_backup">Automatische Sicherung deaktivieren</string>
<string name="unsupported_backup_version">Nicht unterstützte Backup-Version: %1$s</string>
<string name="backup_wrong_password_or_corrupted">Falsches Passwort oder die Datei ist beschädigt bzw. wurde manipuliert.</string>
<string name="social_link_removed">Social-Link entfernt</string>
<string name="discard_changes">Änderungen verwerfen?</string>
<string name="discard_changes_message">Du hast Änderungen vorgenommen, aber keinen Titel hinzugefügt. Füge einen Titel hinzu, um zu speichern, oder verwerfe die Änderungen.</string>
<string name="discard">Verwerfen</string>
<string name="keep_editing">Weiter bearbeiten</string>
<string name="add_social">Sozialen Link hinzufügen</string>
<string name="changelog">Änderungsprotokoll</string>
<string name="changelog_description">Verfolge die Änderungen jeder Version.</string>
</resources>
@@ -544,4 +544,29 @@
<string name="delete_alert">¡Alerta de eliminación!</string>
<string name="delete_spaces_alert">Estás a punto de eliminar permanentemente los espacios %1$s con todos sus datos. Esta acción no se puede deshacer.</string>
<string name="set_backup_password_title">Establecer contraseña de copia de seguridad</string>
<string name="enter_backup_password_title">Introducir contraseña de copia de seguridad</string>
<string name="backup_password_setting">Establecer una contraseña de copia de seguridad</string>
<string name="backup_encrypted">Las copias de seguridad están cifradas</string>
<string name="backup_not_encrypted">Las copias de seguridad no están cifradas</string>
<string name="backup_password">Contraseña de copia de seguridad</string>
<string name="backup_password_description">Esta contraseña no puede recuperarse si se pierde. La necesitará para restaurar esta copia de seguridad en cualquier dispositivo.</string>
<string name="set_backup_password">Establecer una contraseña de copia de seguridad</string>
<string name="auto_backup_requires_password">La copia de seguridad automática está activada, pero no se ha configurado una contraseña, por lo que no puede ejecutarse de forma segura. Configure una contraseña ahora o desactive la copia de seguridad automática.</string>
<string name="go_to_settings">Ir a ajustes</string>
<string name="change_backup_password_title">¿Cambiar la contraseña de copia de seguridad?</string>
<string name="change_backup_password_message">Las copias de seguridad ya cifradas con su contraseña actual dejarán de poder restaurarse después de este cambio. Las nuevas copias de seguridad usarán la nueva contraseña. Esta acción no se puede deshacer.</string>
<string name="set_password">Establecer contraseña</string>
<string name="turn_off_auto_backup">Desactivar copia de seguridad automática</string>
<string name="unsupported_backup_version">Versión de copia de seguridad no compatible: %1$s</string>
<string name="backup_wrong_password_or_corrupted">La contraseña es incorrecta o el archivo está dañado o ha sido manipulado.</string>
<string name="social_link_removed">Enlace social eliminado</string>
<string name="discard_changes">¿Descartar cambios?</string>
<string name="discard_changes_message">Has realizado cambios pero no has agregado un título. Agrega un título para guardar o descarta los cambios.</string>
<string name="discard">Descartar</string>
<string name="keep_editing">Seguir editando</string>
<string name="add_social">Agregar enlace social</string>
<string name="changelog">Registro de cambios</string>
<string name="changelog_description">Consulta los cambios de cada versión.</string>
</resources>
+27 -2
View File
@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:tools="http://schemas.android.com/tools">
<string name="app_name">Flux</string>
<!-- Authentication -->
@@ -372,7 +372,7 @@
<!-- Progress Tracker -->
<string name="not_started">Non commencé</string>
<string name="in_progress">En cours</string>
<string name="in_progress" tools:ignore="PrivateResource">En cours</string>
<string name="empty">Vide</string>
<string name="target">Objectif</string>
<string name="start">Début</string>
@@ -548,4 +548,29 @@
<string name="delete_alert">Alerte de suppression !</string>
<string name="delete_spaces_alert">Vous êtes sur le point de supprimer définitivement les espaces %1$s avec toutes leurs données. Cette action est irréversible.</string>
<string name="set_backup_password_title">Définir un mot de passe de sauvegarde</string>
<string name="enter_backup_password_title">Saisir le mot de passe de sauvegarde</string>
<string name="backup_password_setting">Définir un mot de passe de sauvegarde</string>
<string name="backup_encrypted">Les sauvegardes sont chiffrées</string>
<string name="backup_not_encrypted">Les sauvegardes ne sont pas chiffrées</string>
<string name="backup_password">Mot de passe de sauvegarde</string>
<string name="backup_password_description">Ce mot de passe ne peut pas être récupéré s\'il est perdu. Vous en aurez besoin pour restaurer cette sauvegarde sur n\'importe quel appareil.</string>
<string name="set_backup_password">Définir un mot de passe de sauvegarde</string>
<string name="auto_backup_requires_password">La sauvegarde automatique est activée, mais aucun mot de passe n\'est défini. Les sauvegardes ne peuvent donc pas être exécutées en toute sécurité. Définissez un mot de passe maintenant ou désactivez la sauvegarde automatique.</string>
<string name="go_to_settings">Aller aux paramètres</string>
<string name="change_backup_password_title">Changer le mot de passe de sauvegarde ?</string>
<string name="change_backup_password_message">Les sauvegardes déjà chiffrées avec votre mot de passe actuel ne pourront plus être restaurées après cette modification. Les nouvelles sauvegardes utiliseront le nouveau mot de passe. Cette action est irréversible.</string>
<string name="set_password">Définir le mot de passe</string>
<string name="turn_off_auto_backup">Désactiver la sauvegarde automatique</string>
<string name="unsupported_backup_version">Version de sauvegarde non prise en charge : %1$s</string>
<string name="backup_wrong_password_or_corrupted">Mot de passe incorrect, ou le fichier est corrompu ou a été modifié.</string>
<string name="social_link_removed">Lien social supprimé</string>
<string name="discard_changes">Ignorer les modifications ?</string>
<string name="discard_changes_message">Vous avez effectué des modifications mais n\'avez pas ajouté de titre. Ajoutez un titre pour enregistrer ou ignorez les modifications.</string>
<string name="discard">Ignorer</string>
<string name="keep_editing">Continuer la modification</string>
<string name="add_social">Ajouter un lien social</string>
<string name="changelog">Journal des modifications</string>
<string name="changelog_description">Suivez les modifications de chaque version.</string>
</resources>
@@ -547,4 +547,29 @@
<string name="delete_alert">हटाने की चेतावनी!</string>
<string name="delete_spaces_alert">%1$s स्पेस और उनका सारा डेटा स्थायी रूप से हटाया जाएगा। इसे वापस नहीं लाया जा सकता।</string>
<string name="set_backup_password_title">बैकअप पासवर्ड सेट करें</string>
<string name="enter_backup_password_title">बैकअप पासवर्ड दर्ज करें</string>
<string name="backup_password_setting">बैकअप पासवर्ड सेट करें</string>
<string name="backup_encrypted">बैकअप एन्क्रिप्टेड हैं</string>
<string name="backup_not_encrypted">बैकअप एन्क्रिप्टेड नहीं हैं</string>
<string name="backup_password">बैकअप पासवर्ड</string>
<string name="backup_password_description">यदि यह पासवर्ड खो जाता है तो इसे पुनर्प्राप्त नहीं किया जा सकता। किसी भी डिवाइस पर इस बैकअप को पुनर्स्थापित करने के लिए इसकी आवश्यकता होगी।</string>
<string name="set_backup_password">बैकअप पासवर्ड सेट करें</string>
<string name="auto_backup_requires_password">स्वचालित बैकअप चालू है, लेकिन कोई पासवर्ड सेट नहीं है, इसलिए बैकअप सुरक्षित रूप से नहीं चल सकते। अभी पासवर्ड सेट करें या स्वचालित बैकअप बंद करें।</string>
<string name="go_to_settings">सेटिंग्स पर जाएँ</string>
<string name="change_backup_password_title">बैकअप पासवर्ड बदलें?</string>
<string name="change_backup_password_message">आपके वर्तमान पासवर्ड से पहले से एन्क्रिप्ट किए गए बैकअप इस परिवर्तन के बाद पुनर्स्थापित नहीं किए जा सकेंगे। नए बैकअप नए पासवर्ड का उपयोग करेंगे। इस परिवर्तन को वापस नहीं किया जा सकता।</string>
<string name="set_password">पासवर्ड सेट करें</string>
<string name="turn_off_auto_backup">स्वचालित बैकअप बंद करें</string>
<string name="unsupported_backup_version">असमर्थित बैकअप संस्करण: %1$s</string>
<string name="backup_wrong_password_or_corrupted">गलत पासवर्ड, या फ़ाइल क्षतिग्रस्त है या उसके साथ छेड़छाड़ की गई है।</string>
<string name="social_link_removed">सोशल लिंक हटाया गया</string>
<string name="discard_changes">परिवर्तन हटाएँ?</string>
<string name="discard_changes_message">आपने परिवर्तन किए हैं लेकिन शीर्षक नहीं जोड़ा है। सहेजने के लिए शीर्षक जोड़ें या परिवर्तन हटा दें।</string>
<string name="discard">हटाएँ</string>
<string name="keep_editing">संपादन जारी रखें</string>
<string name="add_social">सोशल लिंक जोड़ें</string>
<string name="changelog">परिवर्तन लॉग</string>
<string name="changelog_description">हर संस्करण के परिवर्तनों को देखें।</string>
</resources>
@@ -546,4 +546,30 @@
<string name="drag_horizontally_to_reorder">Sleep horizontaal om opnieuw te ordenen</string>
<string name="delete_alert">Verwijderingswaarschuwing!</string>
<string name="delete_spaces_alert">Je staat op het punt %1$s ruimte(n) met alle gegevens permanent te verwijderen. Dit kan niet ongedaan worden gemaakt.</string>
<string name="set_backup_password_title">Back-upwachtwoord instellen</string>
<string name="enter_backup_password_title">Back-upwachtwoord invoeren</string>
<string name="backup_password_setting">Een back-upwachtwoord instellen</string>
<string name="backup_encrypted">Back-ups zijn versleuteld</string>
<string name="backup_not_encrypted">Back-ups zijn niet versleuteld</string>
<string name="backup_password">Back-upwachtwoord</string>
<string name="backup_password_description">Dit wachtwoord kan niet worden hersteld als u het kwijtraakt. U hebt het nodig om deze back-up op elk apparaat te herstellen.</string>
<string name="set_backup_password">Een back-upwachtwoord instellen</string>
<string name="auto_backup_requires_password">Automatische back-up is ingeschakeld, maar er is geen wachtwoord ingesteld. Daardoor kunnen back-ups niet veilig worden uitgevoerd. Stel nu een wachtwoord in of schakel automatische back-up uit.</string>
<string name="go_to_settings">Ga naar instellingen</string>
<string name="change_backup_password_title">Back-upwachtwoord wijzigen?</string>
<string name="change_backup_password_message">Back-ups die al met uw huidige wachtwoord zijn versleuteld, kunnen na deze wijziging niet meer worden hersteld. Nieuwe back-ups gebruiken het nieuwe wachtwoord. Dit kan niet ongedaan worden gemaakt.</string>
<string name="set_password">Wachtwoord instellen</string>
<string name="turn_off_auto_backup">Automatische back-up uitschakelen</string>
<string name="unsupported_backup_version">Niet-ondersteunde back-upversie: %1$s</string>
<string name="backup_wrong_password_or_corrupted">Onjuist wachtwoord of het bestand is beschadigd of ermee is geknoeid.</string>
<string name="social_link_removed">Sociale link verwijderd</string>
<string name="discard_changes">Wijzigingen verwerpen?</string>
<string name="discard_changes_message">Je hebt wijzigingen aangebracht maar nog geen titel toegevoegd. Voeg een titel toe om op te slaan of verwerp de wijzigingen.</string>
<string name="discard">Verwerpen</string>
<string name="keep_editing">Doorgaan met bewerken</string>
<string name="add_social">Sociale link toevoegen</string>
<string name="changelog">Wijzigingslogboek</string>
<string name="changelog_description">Bekijk de wijzigingen in elke versie.</string>
</resources>
@@ -546,4 +546,30 @@
<string name="drag_horizontally_to_reorder">Arraste horizontalmente para reorganizar</string>
<string name="delete_alert">Alerta de exclusão!</string>
<string name="delete_spaces_alert">Você está prestes a excluir permanentemente o(s) espaço(s) %1$s e todos os seus dados. Esta ação não pode ser desfeita.</string>
<string name="set_backup_password_title">Definir senha do backup</string>
<string name="enter_backup_password_title">Inserir senha do backup</string>
<string name="backup_password_setting">Definir uma senha para o backup</string>
<string name="backup_encrypted">Os backups estão criptografados</string>
<string name="backup_not_encrypted">Os backups não estão criptografados</string>
<string name="backup_password">Senha do backup</string>
<string name="backup_password_description">Esta senha não pode ser recuperada caso seja perdida. Você precisará dela para restaurar este backup em qualquer dispositivo.</string>
<string name="set_backup_password">Definir uma senha para o backup</string>
<string name="auto_backup_requires_password">O backup automático está ativado, mas nenhuma senha foi definida, portanto os backups não podem ser executados com segurança. Defina uma senha agora ou desative o backup automático.</string>
<string name="go_to_settings">Ir para as configurações</string>
<string name="change_backup_password_title">Alterar senha do backup?</string>
<string name="change_backup_password_message">Os backups já criptografados com sua senha atual não poderão mais ser restaurados após esta alteração. Os novos backups usarão a nova senha. Esta ação não pode ser desfeita.</string>
<string name="set_password">Definir senha</string>
<string name="turn_off_auto_backup">Desativar backup automático</string>
<string name="unsupported_backup_version">Versão de backup não suportada: %1$s</string>
<string name="backup_wrong_password_or_corrupted">Senha incorreta ou o arquivo está corrompido ou foi adulterado.</string>
<string name="social_link_removed">Link social removido</string>
<string name="discard_changes">Descartar alterações?</string>
<string name="discard_changes_message">Você fez alterações, mas não adicionou um título. Adicione um título para salvar ou descarte as alterações.</string>
<string name="discard">Descartar</string>
<string name="keep_editing">Continuar editando</string>
<string name="add_social">Adicionar link social</string>
<string name="changelog">Registro de alterações</string>
<string name="changelog_description">Acompanhe as alterações de cada versão.</string>
</resources>
+25 -1
View File
@@ -536,7 +536,6 @@
<string name="missed">Пропущено</string>
<string name="pick_a_todo">Выбрать задачу</string>
<!-- values-ru/strings.xml -->
<string name="list_items">Элементы списка</string>
<string name="no_checklist_for_date">Для этой даты нет контрольного списка.</string>
<string name="tracking_starts_later">Отслеживание начнётся позже!</string>
@@ -548,4 +547,29 @@
<string name="delete_alert">Предупреждение об удалении!</string>
<string name="delete_spaces_alert">Вы собираетесь навсегда удалить пространство(а) %1$s вместе со всеми данными. Это действие нельзя отменить.</string>
<string name="set_backup_password_title">Установить пароль резервной копии</string>
<string name="enter_backup_password_title">Введите пароль резервной копии</string>
<string name="backup_password_setting">Установить пароль резервной копии</string>
<string name="backup_encrypted">Резервные копии зашифрованы</string>
<string name="backup_not_encrypted">Резервные копии не зашифрованы</string>
<string name="backup_password">Пароль резервной копии</string>
<string name="backup_password_description">Этот пароль невозможно восстановить, если он будет утерян. Он потребуется для восстановления этой резервной копии на любом устройстве.</string>
<string name="set_backup_password">Установить пароль резервной копии</string>
<string name="auto_backup_requires_password">Автоматическое резервное копирование включено, но пароль не задан, поэтому резервные копии не могут создаваться безопасно. Установите пароль сейчас или отключите автоматическое резервное копирование.</string>
<string name="go_to_settings">Перейти к настройкам</string>
<string name="change_backup_password_title">Изменить пароль резервной копии?</string>
<string name="change_backup_password_message">Резервные копии, уже зашифрованные текущим паролем, больше нельзя будет восстановить после этого изменения. Новые резервные копии будут использовать новый пароль. Это действие нельзя отменить.</string>
<string name="set_password">Установить пароль</string>
<string name="turn_off_auto_backup">Отключить автоматическое резервное копирование</string>
<string name="unsupported_backup_version">Неподдерживаемая версия резервной копии: %1$s</string>
<string name="backup_wrong_password_or_corrupted">Неверный пароль, либо файл повреждён или был изменён.</string>
<string name="social_link_removed">Социальная ссылка удалена</string>
<string name="discard_changes">Отменить изменения?</string>
<string name="discard_changes_message">Вы внесли изменения, но не добавили заголовок. Добавьте заголовок, чтобы сохранить изменения, или отмените их.</string>
<string name="discard">Отменить</string>
<string name="keep_editing">Продолжить редактирование</string>
<string name="add_social">Добавить социальную ссылку</string>
<string name="changelog">Журнал изменений</string>
<string name="changelog_description">Просматривайте изменения в каждой версии.</string>
</resources>
@@ -551,4 +551,30 @@
<string name="drag_horizontally_to_reorder">水平拖动以重新排序</string>
<string name="delete_alert">删除警告!</string>
<string name="delete_spaces_alert">你即将永久删除 %1$s 个空间及其所有数据,此操作无法撤销。</string>
<string name="set_backup_password_title">设置备份密码</string>
<string name="enter_backup_password_title">输入备份密码</string>
<string name="backup_password_setting">设置备份密码</string>
<string name="backup_encrypted">备份已加密</string>
<string name="backup_not_encrypted">备份未加密</string>
<string name="backup_password">备份密码</string>
<string name="backup_password_description">如果丢失,此密码无法恢复。您需要使用它才能在任何设备上恢复此备份。</string>
<string name="set_backup_password">设置备份密码</string>
<string name="auto_backup_requires_password">自动备份已开启,但尚未设置密码,因此无法安全执行备份。请立即设置密码,或关闭自动备份。</string>
<string name="go_to_settings">前往设置</string>
<string name="change_backup_password_title">更改备份密码?</string>
<string name="change_backup_password_message">使用当前密码加密的现有备份在更改后将无法再恢复。新的备份将使用新密码。此操作无法撤销。</string>
<string name="set_password">设置密码</string>
<string name="turn_off_auto_backup">关闭自动备份</string>
<string name="unsupported_backup_version">不支持的备份版本:%1$s</string>
<string name="backup_wrong_password_or_corrupted">密码错误,或文件已损坏或被篡改。</string>
<string name="social_link_removed">社交链接已删除</string>
<string name="discard_changes">放弃更改?</string>
<string name="discard_changes_message">您已进行了修改,但尚未添加标题。添加标题以保存,或放弃更改。</string>
<string name="discard">放弃</string>
<string name="keep_editing">继续编辑</string>
<string name="add_social">添加社交链接</string>
<string name="changelog">更新日志</string>
<string name="changelog_description">查看每个版本的更新内容。</string>
</resources>
+25
View File
@@ -545,4 +545,29 @@
<string name="delete_alert">Delete Alert!</string>
<string name="delete_spaces_alert">You are about to delete %1$s space(s) with their data permanently. This can\'t be undone.</string>
<string name="set_backup_password_title">Set backup password</string>
<string name="enter_backup_password_title">Enter backup password</string>
<string name="backup_password_setting">Set a backup password</string>
<string name="backup_encrypted">Backups are encrypted</string>
<string name="backup_not_encrypted">Backups are not encrypted</string>
<string name="backup_password">Backup password</string>
<string name="backup_password_description">This password cannot be recovered if lost. You will need it to restore this backup on any device.</string>
<string name="set_backup_password">Set a backup password</string>
<string name="auto_backup_requires_password">Automatic backup is turned on, but no password is set, so backups cannot run safely. Set a password now, or turn automatic backup off.</string>
<string name="go_to_settings">Go to settings</string>
<string name="change_backup_password_title">Change backup password?</string>
<string name="change_backup_password_message">Backups already encrypted with your current password will no longer be restorable after this change. New backups will use the new password. This cannot be undone.</string>
<string name="set_password">Set password</string>
<string name="turn_off_auto_backup">Turn off auto-backup</string>
<string name="unsupported_backup_version">Unsupported backup version: %1$s</string>
<string name="backup_wrong_password_or_corrupted">Wrong password, or the file is corrupted or has been tampered with.</string>
<string name="social_link_removed">Social link removed</string>
<string name="discard_changes">Discard Changes?</string>
<string name="discard_changes_message">You\'ve made changes but haven\'t added a title. Add a title to save, or discard your changes.</string>
<string name="discard">Discard</string>
<string name="keep_editing">Keep editing</string>
<string name="add_social">Add Social</string>
<string name="changelog">Changelog</string>
<string name="changelog_description">Track the changes in every version.</string>
</resources>
+2
View File
@@ -24,6 +24,7 @@ hilt = "2.60.1"
hiltNavigationCompose = "1.4.0"
browser = "1.10.0"
documentfile = "1.1.0"
securityCrypto = "1.1.0"
workRuntimeKtx = "2.11.2"
foundationLayout = "1.11.4"
adaptive = "1.2.0"
@@ -50,6 +51,7 @@ androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "
androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
# Hilt (Dependency Injection)
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
+10
View File
@@ -0,0 +1,10 @@
feat:
- Addition of backup encryption to all the export of backup.
- Added social links option in notes and journal.
- Media detection support in journals
- Consistent saving of habits and events
- Changelog in about.
fix:
- Local date bug in events
- Event stale data of EventDetails.kt after edit.