Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8c009d44a | ||
|
|
d61b864ad1 | ||
|
|
8f7d847f6a | ||
|
|
c3b06fbbf7 | ||
|
|
8e281098b6 | ||
|
|
7f9546fa06 | ||
|
|
1750d1d96c | ||
|
|
dbe6b47ab3 |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||