Compare commits
120 Commits
v0.3.14-be
...
v0.3.14-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8674a04a5c | ||
|
|
2f14529902 | ||
|
|
1d74a17b98 | ||
|
|
52435d9837 | ||
|
|
b6fbbe5a91 | ||
|
|
3f85e1167c | ||
|
|
9c05096184 | ||
|
|
ef3bc015b0 | ||
|
|
75fd600448 | ||
|
|
2f01e7770f | ||
|
|
12b6edf872 | ||
|
|
6053f2d16b | ||
|
|
636c5f4df4 | ||
|
|
bb0bd478cf | ||
|
|
79eb080811 | ||
|
|
b5b82836bc | ||
|
|
cef0f2b53d | ||
|
|
dbf031469f | ||
|
|
5b87c933da | ||
|
|
adc4b9a372 | ||
|
|
0ff8f7776e | ||
|
|
c04fdeb491 | ||
|
|
295d8e5326 | ||
|
|
b032ac64f7 | ||
|
|
8ebe99d2c9 | ||
|
|
f0b027557b | ||
|
|
462030bcd7 | ||
|
|
888af9d28d | ||
|
|
ea159527f3 | ||
|
|
0dc0f53a91 | ||
|
|
d5aac7ac14 | ||
|
|
9f58088545 | ||
|
|
b684f1759d | ||
|
|
aa7a264d6c | ||
|
|
6ac537c517 | ||
|
|
2386ae7749 | ||
|
|
7d559acfae | ||
|
|
7783b9b218 | ||
|
|
548f7d7b1e | ||
|
|
4629c07812 | ||
|
|
3b2b7da841 | ||
|
|
25ef53510a | ||
|
|
0064f248d3 | ||
|
|
0c721696f2 | ||
|
|
131ab6214d | ||
|
|
70bc7a1236 | ||
|
|
6c88716a2a | ||
|
|
ff3c37e360 | ||
|
|
58bab443c4 | ||
|
|
a8b0a6d555 | ||
|
|
0a430b4b0a | ||
|
|
8b76c5ce3b | ||
|
|
c81f5f7015 | ||
|
|
cf6b186269 | ||
|
|
bd25ddb92e | ||
|
|
62bdd31af3 | ||
|
|
d4af89bf99 | ||
|
|
1c38a42c0b | ||
|
|
6d1ebb74fb | ||
|
|
9673e6de5c | ||
|
|
ab709e2c69 | ||
|
|
9144708cf0 | ||
|
|
38136de39d | ||
|
|
beb800a76e | ||
|
|
aab738526a | ||
|
|
86bdad61a4 | ||
|
|
e4c56cab03 | ||
|
|
cef1c4e3f6 | ||
|
|
b2721c9faa | ||
|
|
ab4ae62ffe | ||
|
|
74244bab74 | ||
|
|
57112ae692 | ||
|
|
fd1314ccba | ||
|
|
45d99df104 | ||
|
|
17b87f6543 | ||
|
|
d62e82569d | ||
|
|
dc5e00cc07 | ||
|
|
6402511d38 | ||
|
|
83c1f70077 | ||
|
|
f3375f48ef | ||
|
|
e1b911086b | ||
|
|
b60c0cef51 | ||
|
|
0c42185700 | ||
|
|
ee3c779b17 | ||
|
|
b5e6655c84 | ||
|
|
e1b45b9193 | ||
|
|
588713bd55 | ||
|
|
43ad452174 | ||
|
|
2cf9146536 | ||
|
|
f81331baed | ||
|
|
9c9c3b9428 | ||
|
|
4b64d81c21 | ||
|
|
e826f600f0 | ||
|
|
0f845a9784 | ||
|
|
a2805bedca | ||
|
|
d860bbfb90 | ||
|
|
70e2d34410 | ||
|
|
1c49a11824 | ||
|
|
cd2a0000c0 | ||
|
|
1304e49eb4 | ||
|
|
9658cecb88 | ||
|
|
4c23d5bafc | ||
|
|
82238c8c1a | ||
|
|
ead74e1c26 | ||
|
|
d58371be81 | ||
|
|
844d194533 | ||
|
|
84abc929d0 | ||
|
|
7497470875 | ||
|
|
cc5df41daa | ||
|
|
d87b290a32 | ||
|
|
a0f859ad03 | ||
|
|
c86892ec0b | ||
|
|
c85fea0799 | ||
|
|
765e34a01d | ||
|
|
96e7f2eeac | ||
|
|
23dddfd16e | ||
|
|
e2318d0af1 | ||
|
|
ef3b840dce | ||
|
|
9b9c5fa70e | ||
|
|
6f0216cf9f |
@@ -18,10 +18,10 @@ fully respecting your privacy. Currently in early-beta state.
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<p><i>Major versions only, 1 release per 1-3 months</i><br><br>Updates are more polished, new features are matured and tested through to ensure a stable experience.</p>
|
||||
<p><i>Major versions only, 1 release per 1-5 months</i><br><br>Updates are more polished, new features are matured and tested through to ensure a stable experience.</p>
|
||||
</td>
|
||||
<td valign="top">
|
||||
<p><i>Beta versions, 1-2 releases per week</i><br><br>Updates contain new features that may not be fully matured yet and bugs are more likely to occur. Allows you to give early feedback.</p>
|
||||
<p><i>Beta versions, up to 1-2 releases per week</i><br><br>Updates contain new features that may not be fully matured yet and bugs are more likely to occur. Allows you to give early feedback.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -30,8 +30,8 @@ android {
|
||||
defaultConfig {
|
||||
applicationId = "dev.patrickgold.florisboard"
|
||||
minSdk = 23
|
||||
targetSdk = 30
|
||||
versionCode = 64
|
||||
targetSdk = 31
|
||||
versionCode = 68
|
||||
versionName = "0.3.14"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
@@ -75,7 +75,7 @@ android {
|
||||
}
|
||||
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.1.0-rc01"
|
||||
kotlinCompilerExtensionVersion = "1.1.0"
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
@@ -106,7 +106,7 @@ android {
|
||||
create("beta") // Needed because by default the "beta" BuildType does not exist
|
||||
named("beta").configure {
|
||||
applicationIdSuffix = ".beta"
|
||||
versionNameSuffix = "-beta08"
|
||||
versionNameSuffix = "-beta12"
|
||||
proguardFiles.add(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
|
||||
resValue("mipmap", "floris_app_icon", "@mipmap/ic_app_icon_beta")
|
||||
@@ -133,10 +133,6 @@ android {
|
||||
it.useJUnitPlatform()
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
isAbortOnError = false
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
@@ -148,26 +144,29 @@ dependencies {
|
||||
implementation("androidx.activity:activity-ktx:1.4.0")
|
||||
implementation("androidx.autofill:autofill:1.1.0")
|
||||
implementation("androidx.collection:collection-ktx:1.2.0")
|
||||
implementation("androidx.compose.material:material:1.1.0-rc01")
|
||||
implementation("androidx.compose.runtime:runtime-livedata:1.1.0-rc01")
|
||||
implementation("androidx.compose.ui:ui:1.1.0-rc01")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.1.0-rc01")
|
||||
implementation("androidx.compose.material:material:1.1.0")
|
||||
implementation("androidx.compose.runtime:runtime-livedata:1.1.0")
|
||||
implementation("androidx.compose.ui:ui:1.1.0")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview:1.1.0")
|
||||
implementation("androidx.core:core-ktx:1.7.0")
|
||||
implementation("androidx.core:core-splashscreen:1.0.0-alpha02")
|
||||
implementation("androidx.navigation:navigation-compose:2.4.0-rc01")
|
||||
implementation("com.google.accompanist:accompanist-systemuicontroller:0.20.2")
|
||||
implementation("dev.patrickgold.jetpref:jetpref-datastore-model:0.1.0-beta02")
|
||||
implementation("dev.patrickgold.jetpref:jetpref-datastore-ui:0.1.0-beta02")
|
||||
implementation("dev.patrickgold.jetpref:jetpref-material-ui:0.1.0-beta02")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.1")
|
||||
implementation("androidx.room:room-runtime:2.4.0")
|
||||
kapt("androidx.room:room-compiler:2.4.0")
|
||||
implementation("androidx.core:core-splashscreen:1.0.0-beta01")
|
||||
implementation("androidx.navigation:navigation-compose:2.4.1")
|
||||
implementation("com.google.accompanist:accompanist-flowlayout:0.23.0")
|
||||
implementation("com.google.accompanist:accompanist-insets:0.23.0")
|
||||
implementation("com.google.accompanist:accompanist-systemuicontroller:0.23.0")
|
||||
implementation("dev.patrickgold.jetpref:jetpref-datastore-model:0.1.0-beta08")
|
||||
implementation("dev.patrickgold.jetpref:jetpref-datastore-ui:0.1.0-beta08")
|
||||
implementation("dev.patrickgold.jetpref:jetpref-material-ui:0.1.0-beta08")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2")
|
||||
implementation("androidx.room:room-runtime:2.4.1")
|
||||
kapt("androidx.room:room-compiler:2.4.1")
|
||||
|
||||
testImplementation("io.kotest:kotest-runner-junit5:4.6.3")
|
||||
testImplementation("io.kotest:kotest-assertions-core:4.6.3")
|
||||
testImplementation("io.kotest:kotest-property:4.6.3")
|
||||
testImplementation("io.kotest.extensions:kotest-extensions-robolectric:0.4.0")
|
||||
testImplementation("io.kotest:kotest-runner-junit5:5.1.0")
|
||||
testImplementation("io.kotest:kotest-assertions-core:5.1.0")
|
||||
testImplementation("io.kotest:kotest-property:5.1.0")
|
||||
testImplementation("io.kotest.extensions:kotest-extensions-robolectric:0.5.0")
|
||||
testImplementation("nl.jqno.equalsverifier:equalsverifier:3.8.3")
|
||||
|
||||
androidTestImplementation("androidx.test.ext", "junit", "1.1.2")
|
||||
androidTestImplementation("androidx.test.espresso", "espresso-core", "3.3.0")
|
||||
|
||||
@@ -36,12 +36,15 @@
|
||||
|
||||
<application
|
||||
android:name="dev.patrickgold.florisboard.FlorisApplication"
|
||||
android:allowBackup="false"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/backup_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/floris_app_icon"
|
||||
android:label="@string/floris_app_name"
|
||||
android:roundIcon="@mipmap/floris_app_icon_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/FlorisAppTheme">
|
||||
android:theme="@style/FlorisAppTheme"
|
||||
tools:targetApi="s">
|
||||
|
||||
<!-- IME service -->
|
||||
<service
|
||||
@@ -132,6 +135,17 @@
|
||||
android:exported="false">
|
||||
</provider>
|
||||
|
||||
<!-- Default file provider to share files from the "files" or "cache" dir -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.provider.file"
|
||||
android:grantUriPermissions="true"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths"/>
|
||||
</provider>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
|
||||
@@ -235,6 +235,18 @@
|
||||
"authors": [ "patrickgold" ],
|
||||
"direction": "ltr"
|
||||
},
|
||||
{
|
||||
"id": "rusyn",
|
||||
"label": "Rusyn",
|
||||
"authors": [ "svvvst" ],
|
||||
"direction": "ltr"
|
||||
},
|
||||
{
|
||||
"id": "rusyn_us",
|
||||
"label": "Rusyn (Phonetic)",
|
||||
"authors": [ "svvvst" ],
|
||||
"direction": "ltr"
|
||||
},
|
||||
{
|
||||
"id": "sangaline",
|
||||
"label": "Sangaline",
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
[
|
||||
[
|
||||
[
|
||||
{ "code": 1602, "label": "ق", "popup": {
|
||||
"main": { "code": 1647, "label": "ٯ" }
|
||||
} },
|
||||
{ "code": 1608, "label": "و", "popup": {
|
||||
"main": { "code": -255, "label": "وو" }
|
||||
} },
|
||||
{ "code": 1749, "label": "ﻪ", "popup": {
|
||||
"main": { "code": 1577, "label": "ة" }
|
||||
{ "code": 1749, "label": "ە", "popup": {
|
||||
"main": { "code": 1577, "label": "ة" }
|
||||
} },
|
||||
{ "code": 1585, "label": "ر", "popup": {
|
||||
"main": { "code": 1685, "label": "ڕ" }
|
||||
@@ -15,50 +15,51 @@
|
||||
{ "code": 1578, "label": "ت", "popup": {
|
||||
"main": { "code": 1591, "label": "ط" }
|
||||
} },
|
||||
{ "code": 1740, "label": "ی", "popup": {
|
||||
"main": { "code": 1742, "label": "ێ" }
|
||||
} },
|
||||
{ "code": 1740, "label": "ی" },
|
||||
{ "code": 1742, "label": "ێ" },
|
||||
{ "code": 1574, "label": "ﺋ", "popup": {
|
||||
"main": { "code": 1569, "label": "ء" }
|
||||
} },
|
||||
{ "code": 1593, "label": "ع", "popup": {
|
||||
"main": { "code": 1594, "label": "غ" }
|
||||
} },
|
||||
{ "code": 1734, "label": "ۆ" },
|
||||
{ "code": 1662, "label": "پ", "popup": {
|
||||
"main": { "code": 1579, "label": "ث" }
|
||||
} }
|
||||
],
|
||||
[
|
||||
{"code": 1575, "label": "ا"},
|
||||
{ "code": 1587, "label": "س" },
|
||||
{ "code": 1588, "label": "ش" },
|
||||
{ "code": 1583, "label": "د", "popup": {
|
||||
"main": {"code": 1584, "label": "ذ" }
|
||||
],
|
||||
[
|
||||
{ "code": 1575, "label": "ا"},
|
||||
{ "code": 1587, "label": "س" },
|
||||
{ "code": 1588, "label": "ش" },
|
||||
{ "code": 1583, "label": "د", "popup": {
|
||||
"main": {"code": 1584, "label": "ذ" }
|
||||
} },
|
||||
{ "code": 1601, "label": "ف" , "popup": {
|
||||
"main": {"code": 1700, "label": "ڤ" }
|
||||
} },
|
||||
{ "code": 1601, "label": "ف" , "popup": {
|
||||
"main": {"code": 1700, "label": "ڤ" }
|
||||
} },
|
||||
{ "code": 1607, "label": "ھ" },
|
||||
{ "code": 1688, "label": "ژ", "popup": {
|
||||
"main": { "code": 1600, "label": "▬" }
|
||||
} },
|
||||
{ "code": 1604, "label": "ل", "popup": {
|
||||
"main": { "code": 1717, "label": "ڵ" }
|
||||
} },
|
||||
{ "code": 1705, "label": "ک" },
|
||||
{ "code": 1711, "label": "گ" }
|
||||
],
|
||||
[
|
||||
{ "code": 1586, "label": "ز", "popup": {
|
||||
"main": {"code": 1592, "label": "ظ" }
|
||||
} },
|
||||
{ "code": 1582, "label": "خ" },
|
||||
{ "code": 1580, "label": "ج" },
|
||||
{ "code": 1670, "label": "چ" },
|
||||
{ "code": 1581, "label": "ح" },
|
||||
{ "code": 1576, "label": "ب" },
|
||||
{ "code": 1606, "label": "ن" },
|
||||
{ "code": 1605, "label": "م" }
|
||||
{ "code": 1726, "label": "ھ" },
|
||||
{ "code": 1688, "label": "ژ", "popup": {
|
||||
"main": { "code": 1600, "label": "━" }
|
||||
} },
|
||||
{ "code": 1604, "label": "ل", "popup": {
|
||||
"main": { "code": 1717, "label": "ڵ" }
|
||||
} },
|
||||
{ "code": 1705, "label": "ک" },
|
||||
{ "code": 1711, "label": "گ" , "popup": {
|
||||
"main": { "code": 1594, "label": "غ" }
|
||||
} }
|
||||
],
|
||||
[
|
||||
{ "code": 1586, "label": "ز", "popup": {
|
||||
"main": {"code": 1592, "label": "ظ" }
|
||||
} },
|
||||
{ "code": 1582, "label": "خ" },
|
||||
{ "code": 1580, "label": "ج" },
|
||||
{ "code": 1670, "label": "چ" },
|
||||
{ "code": 1581, "label": "ح" },
|
||||
{ "code": 1593, "label": "ع", "popup": {
|
||||
"main": { "code": 1551, "label": "؏" }
|
||||
} },
|
||||
{ "code": 1576, "label": "ب" },
|
||||
{ "code": 1606, "label": "ن" },
|
||||
{ "code": 1605, "label": "م" }
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
[
|
||||
[
|
||||
{ "$": "auto_text_key", "code": 1081, "label": "й" },
|
||||
{ "$": "auto_text_key", "code": 1094, "label": "ц" },
|
||||
{ "$": "auto_text_key", "code": 1091, "label": "у" },
|
||||
{ "$": "auto_text_key", "code": 1082, "label": "к" },
|
||||
{ "$": "auto_text_key", "code": 1077, "label": "е" },
|
||||
{ "$": "auto_text_key", "code": 1085, "label": "н" },
|
||||
{ "$": "auto_text_key", "code": 1075, "label": "г" },
|
||||
{ "$": "auto_text_key", "code": 1096, "label": "ш" },
|
||||
{ "$": "auto_text_key", "code": 1097, "label": "щ" },
|
||||
{ "$": "auto_text_key", "code": 1079, "label": "з" },
|
||||
{ "$": "auto_text_key", "code": 1093, "label": "х" },
|
||||
{ "$": "auto_text_key", "code": 1031, "label": "ї" }
|
||||
],
|
||||
[
|
||||
{ "$": "auto_text_key", "code": 1092 , "label": "ф" },
|
||||
|
||||
{ "$": "auto_text_key", "code": 1110 , "label": "і" },
|
||||
{ "$": "auto_text_key", "code": 1074 , "label": "в" },
|
||||
{ "$": "auto_text_key", "code": 1072 , "label": "а" },
|
||||
{ "$": "auto_text_key", "code": 1087 , "label": "п" },
|
||||
|
||||
|
||||
{ "$": "auto_text_key", "code": 1088 , "label": "р" },
|
||||
{ "$": "auto_text_key", "code": 1086 , "label": "о" },
|
||||
|
||||
{ "$": "auto_text_key", "code": 1083 , "label": "л" },
|
||||
{ "$": "auto_text_key", "code": 1076 , "label": "д" },
|
||||
{ "$": "auto_text_key", "code": 1078 , "label": "ж" },
|
||||
{ "$": "auto_text_key", "code": 1108 , "label": "є" },
|
||||
{ "$": "auto_text_key", "code": 1067 , "label": "ы" }
|
||||
],
|
||||
[
|
||||
{ "$": "auto_text_key", "code": 1169 , "label": "ґ" },
|
||||
{ "$": "auto_text_key", "code": 1103 , "label": "я" },
|
||||
{ "$": "auto_text_key", "code": 1095 , "label": "ч" },
|
||||
{ "$": "auto_text_key", "code": 1089 , "label": "с" },
|
||||
{ "$": "auto_text_key", "code": 1084 , "label": "м" },
|
||||
{ "$": "auto_text_key", "code": 1080 , "label": "и" },
|
||||
{ "$": "auto_text_key", "code": 1090 , "label": "т" },
|
||||
{ "$": "auto_text_key", "code": 1100 , "label": "ь" },
|
||||
{ "$": "auto_text_key", "code": 1073 , "label": "б" },
|
||||
{ "$": "auto_text_key", "code": 1102 , "label": "ю" },
|
||||
{ "$": "auto_text_key", "code": 1025 , "label": "ё" }
|
||||
]
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
[
|
||||
[
|
||||
|
||||
{ "$": "auto_text_key", "code": 1094, "label": "ц" },
|
||||
{ "$": "auto_text_key", "code": 1108 , "label": "є" },
|
||||
|
||||
{ "$": "auto_text_key", "code": 1077, "label": "е" },
|
||||
{ "$": "auto_text_key", "code": 1088 , "label": "р" },
|
||||
{ "$": "auto_text_key", "code": 1090 , "label": "т" },
|
||||
{ "$": "auto_text_key", "code": 1081, "label": "й" },
|
||||
{ "$": "auto_text_key", "code": 1091, "label": "у" },
|
||||
{ "$": "auto_text_key", "code": 1102 , "label": "ю" },
|
||||
{ "$": "auto_text_key", "code": 1110 , "label": "і" },
|
||||
{ "$": "auto_text_key", "code": 1031, "label": "ї" },
|
||||
{ "$": "auto_text_key", "code": 1086 , "label": "о" },
|
||||
{ "$": "auto_text_key", "code": 1025 , "label": "ё" },
|
||||
{ "$": "auto_text_key", "code": 1087 , "label": "п" }
|
||||
],
|
||||
[
|
||||
{ "$": "auto_text_key", "code": 1103 , "label": "я" },
|
||||
{ "$": "auto_text_key", "code": 1072 , "label": "а" },
|
||||
{ "$": "auto_text_key", "code": 1089 , "label": "с" },
|
||||
{ "$": "auto_text_key", "code": 1076 , "label": "д" },
|
||||
{ "$": "auto_text_key", "code": 1092 , "label": "ф" },
|
||||
{ "$": "auto_text_key", "code": 1169 , "label": "ґ" },
|
||||
{ "$": "auto_text_key", "code": 1067 , "label": "ы" },
|
||||
{ "$": "auto_text_key", "code": 1075, "label": "г" },
|
||||
{ "$": "auto_text_key", "code": 1078 , "label": "ж" },
|
||||
{ "$": "auto_text_key", "code": 1082, "label": "к" },
|
||||
{ "$": "auto_text_key", "code": 1083 , "label": "л" },
|
||||
{ "$": "auto_text_key", "code": 1096, "label": "ш" },
|
||||
{ "$": "auto_text_key", "code": 1097, "label": "щ" }
|
||||
],
|
||||
[
|
||||
|
||||
{ "$": "auto_text_key", "code": 1079, "label": "з" },
|
||||
{ "$": "auto_text_key", "code": 1093, "label": "х" },
|
||||
{ "$": "auto_text_key", "code": 1095 , "label": "ч" },
|
||||
{ "$": "auto_text_key", "code": 1074 , "label": "в" },
|
||||
{ "$": "auto_text_key", "code": 1073 , "label": "б" },
|
||||
{ "$": "auto_text_key", "code": 1085, "label": "н" },
|
||||
{ "$": "auto_text_key", "code": 1084 , "label": "м" },
|
||||
{ "$": "auto_text_key", "code": 1080 , "label": "и" },
|
||||
{ "$": "auto_text_key", "code": 1100 , "label": "ь" }
|
||||
]
|
||||
]
|
||||
@@ -150,6 +150,10 @@
|
||||
"id": "ru",
|
||||
"authors": [ "williamtheaker", "33kk" ]
|
||||
},
|
||||
{
|
||||
"id": "rue",
|
||||
"authors": [ "svvvst" ]
|
||||
},
|
||||
{
|
||||
"id": "sk",
|
||||
"authors": [ "stefan-misik", "majso" ]
|
||||
@@ -467,6 +471,15 @@
|
||||
"characters": "org.florisboard.layouts:jcuken_russian"
|
||||
}
|
||||
},
|
||||
{
|
||||
"languageTag": "rue",
|
||||
"composer": "org.florisboard.composers:appender",
|
||||
"currencySet": "org.florisboard.currencysets:euro",
|
||||
"popupMapping": "org.florisboard.localization:rue",
|
||||
"preferred": {
|
||||
"characters": "org.florisboard.layouts:rusyn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"languageTag": "uk",
|
||||
"composer": "org.florisboard.composers:appender",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"all": {
|
||||
"е": {
|
||||
"main": { "$": "auto_text_key", "code": 234, "label": "ê" },
|
||||
"relevant": [{ "$": "auto_text_key", "code": 1105, "label": "ё" }]
|
||||
},
|
||||
"у": {
|
||||
"main": { "$": "auto_text_key", "code": 1263, "label": "ӯ" },
|
||||
"relevant": [
|
||||
{ "$": "auto_text_key", "code": 1118, "label": "ў" },
|
||||
{ "$": "auto_text_key", "code": 1265, "label": "ӱ" },
|
||||
{ "$": "auto_text_key", "code": 375, "label": "ŷ" }
|
||||
]
|
||||
},
|
||||
"г": {
|
||||
"main": { "$": "auto_text_key", "code": 1169, "label": "ґ" }
|
||||
},
|
||||
"і": {
|
||||
"main": { "$": "auto_text_key", "code": 1123, "label": "î" },
|
||||
"relevant": [
|
||||
{ "$": "auto_text_key", "code": 1123, "label": "ѣ" },
|
||||
{ "$": "auto_text_key", "code": 1111, "label": "ї" }
|
||||
]
|
||||
},
|
||||
"о": {
|
||||
"main": { "$": "auto_text_key", "code": 333, "label": "ō" },
|
||||
"relevant": [{ "$": "auto_text_key", "code": 244, "label": "ô" }]
|
||||
},
|
||||
"ь": {
|
||||
"main": { "$": "auto_text_key", "code": 1098, "label": "ъ" }
|
||||
},
|
||||
"~right": {
|
||||
"main": { "code": 44, "label": "," },
|
||||
"relevant": [
|
||||
{ "code": 38, "label": "&" },
|
||||
{ "code": 37, "label": "%" },
|
||||
{ "code": 43, "label": "+" },
|
||||
{ "code": 34, "label": "\"" },
|
||||
{ "code": 45, "label": "-" },
|
||||
{ "code": 58, "label": ":" },
|
||||
{ "code": 39, "label": "'" },
|
||||
{ "code": 64, "label": "@" },
|
||||
{ "code": 59, "label": ";" },
|
||||
{ "code": 47, "label": "/" },
|
||||
{ "code": 40, "label": "(" },
|
||||
{ "code": 41, "label": ")" },
|
||||
{ "code": 35, "label": "#" },
|
||||
{ "code": 33, "label": "!" },
|
||||
{ "code": 63, "label": "?" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"uri": {
|
||||
"~right": {
|
||||
"main": { "code": -255, "label": ".com" },
|
||||
"relevant": [
|
||||
{ "code": -255, "label": ".edu" },
|
||||
{ "code": -255, "label": ".org" },
|
||||
{ "code": -255, "label": ".net" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,12 @@
|
||||
"--surface": "#ffffff",
|
||||
"--surface-variant": "#f5f5f5",
|
||||
|
||||
"--on-primary": "#000000",
|
||||
"--on-secondary": "#000000",
|
||||
"--on-background": "#000000",
|
||||
"--on-surface": "#000000"
|
||||
"--on-background": "#121212",
|
||||
"--on-surface": "#000000",
|
||||
"--on-surface-variant": "#5f5f5f",
|
||||
|
||||
"--shape": "rounded-corner(8dp, 8dp, 8dp, 8dp)",
|
||||
"--shape-variant": "rounded-corner(12dp, 12dp, 12dp, 12dp)"
|
||||
},
|
||||
|
||||
"keyboard": {
|
||||
@@ -22,7 +24,8 @@
|
||||
"background": "var(--surface)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "22sp",
|
||||
"shape": "rounded-corner(20%, 20%, 20%, 20%)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape)"
|
||||
},
|
||||
"key:pressed": {
|
||||
"background": "var(--surface-variant)",
|
||||
@@ -30,30 +33,31 @@
|
||||
},
|
||||
"key[code={c:enter}]": {
|
||||
"background": "var(--primary)",
|
||||
"foreground": "#ffffff"
|
||||
"foreground": "var(--on-surface)"
|
||||
},
|
||||
"key[code={c:enter}]:pressed": {
|
||||
"background": "var(--primary-variant)",
|
||||
"foreground": "#ffffff"
|
||||
"foreground": "var(--on-surface)"
|
||||
},
|
||||
"key[code={c:shift}][mode={m:capslock}]": {
|
||||
"foreground": "var(--secondary)"
|
||||
},
|
||||
"key[code={c:space}]": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#909090",
|
||||
"foreground": "var(--on-surface-variant)",
|
||||
"font-size": "12sp"
|
||||
},
|
||||
"key-hint": {
|
||||
"background": "transparent",
|
||||
"foreground": "#b8b8b8",
|
||||
"foreground": "var(--on-surface-variant)",
|
||||
"font-size": "12sp"
|
||||
},
|
||||
"key-popup": {
|
||||
"background": "#eeeeee",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "22sp",
|
||||
"shape": "rounded-corner(20%, 20%, 20%, 20%)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape)"
|
||||
},
|
||||
"key-popup:focus": {
|
||||
"background": "#bdbdbd",
|
||||
@@ -69,13 +73,19 @@
|
||||
"background": "var(--surface)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rounded-corner(12dp, 12dp, 12dp, 12dp)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape-variant)"
|
||||
},
|
||||
"clipboard-item-popup": {
|
||||
"background": "var(--surface)",
|
||||
"background": "var(--surface-variant)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rounded-corner(12dp, 12dp, 12dp, 12dp)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape-variant)"
|
||||
},
|
||||
|
||||
"glide-trail": {
|
||||
"foreground": "var(--primary)"
|
||||
},
|
||||
|
||||
"one-handed-panel": {
|
||||
@@ -89,12 +99,13 @@
|
||||
"smartbar-primary-action-row-toggle": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"shape": "rounded-corner(50%, 50%, 50%, 50%)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "circle()"
|
||||
},
|
||||
"smartbar-primary-secondary-row-toggle": {
|
||||
"background": "transparent",
|
||||
"foreground": "#121212",
|
||||
"shape": "rounded-corner(50%, 50%, 50%, 50%)"
|
||||
"foreground": "var(--on-surface-variant)",
|
||||
"shape": "circle()"
|
||||
},
|
||||
|
||||
"smartbar-secondary-row": {
|
||||
@@ -106,8 +117,8 @@
|
||||
},
|
||||
"smartbar-action-button": {
|
||||
"background": "transparent",
|
||||
"foreground": "#121212",
|
||||
"shape": "rounded-corner(50%, 50%, 50%, 50%)"
|
||||
"foreground": "var(--on-background)",
|
||||
"shape": "circle()"
|
||||
},
|
||||
|
||||
"smartbar-candidate-row": {
|
||||
@@ -115,37 +126,37 @@
|
||||
},
|
||||
"smartbar-candidate-word": {
|
||||
"background": "transparent",
|
||||
"foreground": "#121212",
|
||||
"foreground": "var(--on-background)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rectangle()"
|
||||
},
|
||||
"smartbar-candidate-word:pressed": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#121212"
|
||||
"foreground": "var(--on-background)"
|
||||
},
|
||||
"smartbar-candidate-clip": {
|
||||
"background": "transparent",
|
||||
"foreground": "#121212",
|
||||
"foreground": "var(--on-background)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rounded-corner(8%, 8%, 8%, 8%)"
|
||||
},
|
||||
"smartbar-candidate-clip:pressed": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#121212"
|
||||
"foreground": "var(--on-background)"
|
||||
},
|
||||
"smartbar-candidate-spacer": {
|
||||
"foreground": "#ffffff40"
|
||||
"foreground": "var(--surface)"
|
||||
},
|
||||
|
||||
"smartbar-key": {
|
||||
"background": "transparent",
|
||||
"foreground": "#121212",
|
||||
"foreground": "var(--on-background)",
|
||||
"font-size": "18sp",
|
||||
"shape": "rounded-corner(20%, 20%, 20%, 20%)"
|
||||
"shape": "var(--shape)"
|
||||
},
|
||||
"smartbar-key:pressed": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#121212"
|
||||
"foreground": "var(--on-surface)"
|
||||
},
|
||||
"smartbar-key:disabled": {
|
||||
"background": "transparent",
|
||||
@@ -153,6 +164,6 @@
|
||||
},
|
||||
|
||||
"system-nav-bar": {
|
||||
"background": "#e0e0e0"
|
||||
"background": "var(--background)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
"--surface": "#424242",
|
||||
"--surface-variant": "#616161",
|
||||
|
||||
"--on-primary": "#ffffff",
|
||||
"--on-secondary": "#ffffff",
|
||||
"--on-background": "#ffffff",
|
||||
"--on-surface": "#ffffff"
|
||||
"--on-background": "#dcdcdc",
|
||||
"--on-surface": "#ffffff",
|
||||
"--on-surface-variant": "#a0a0a0",
|
||||
|
||||
"--shape": "rounded-corner(8dp, 8dp, 8dp, 8dp)",
|
||||
"--shape-variant": "rounded-corner(12dp, 12dp, 12dp, 12dp)"
|
||||
},
|
||||
|
||||
"keyboard": {
|
||||
@@ -22,7 +24,8 @@
|
||||
"background": "var(--surface)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "22sp",
|
||||
"shape": "rounded-corner(20%, 20%, 20%, 20%)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape)"
|
||||
},
|
||||
"key:pressed": {
|
||||
"background": "var(--surface-variant)",
|
||||
@@ -41,19 +44,20 @@
|
||||
},
|
||||
"key[code={c:space}]": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#909090",
|
||||
"foreground": "var(--on-surface-variant)",
|
||||
"font-size": "12sp"
|
||||
},
|
||||
"key-hint": {
|
||||
"background": "transparent",
|
||||
"foreground": "#b8b8b8",
|
||||
"foreground": "var(--on-surface-variant)",
|
||||
"font-size": "12sp"
|
||||
},
|
||||
"key-popup": {
|
||||
"background": "#757575",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "22sp",
|
||||
"shape": "rounded-corner(20%, 20%, 20%, 20%)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape)"
|
||||
},
|
||||
"key-popup:focus": {
|
||||
"background": "#bdbdbd",
|
||||
@@ -69,13 +73,19 @@
|
||||
"background": "var(--surface)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rounded-corner(12dp, 12dp, 12dp, 12dp)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape-variant)"
|
||||
},
|
||||
"clipboard-item-popup": {
|
||||
"background": "#757575",
|
||||
"background": "var(--surface-variant)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rounded-corner(12dp, 12dp, 12dp, 12dp)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "var(--shape-variant)"
|
||||
},
|
||||
|
||||
"glide-trail": {
|
||||
"foreground": "var(--primary)"
|
||||
},
|
||||
|
||||
"one-handed-panel": {
|
||||
@@ -89,12 +99,13 @@
|
||||
"smartbar-primary-action-row-toggle": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "var(--on-surface)",
|
||||
"shape": "rounded-corner(50%, 50%, 50%, 50%)"
|
||||
"shadow-elevation": "2dp",
|
||||
"shape": "circle()"
|
||||
},
|
||||
"smartbar-primary-secondary-row-toggle": {
|
||||
"background": "transparent",
|
||||
"foreground": "#909090",
|
||||
"shape": "rounded-corner(50%, 50%, 50%, 50%)"
|
||||
"foreground": "var(--on-surface-variant)",
|
||||
"shape": "circle()"
|
||||
},
|
||||
|
||||
"smartbar-secondary-row": {
|
||||
@@ -106,8 +117,8 @@
|
||||
},
|
||||
"smartbar-action-button": {
|
||||
"background": "transparent",
|
||||
"foreground": "#dcdcdc",
|
||||
"shape": "rounded-corner(50%, 50%, 50%, 50%)"
|
||||
"foreground": "var(--on-background)",
|
||||
"shape": "circle()"
|
||||
},
|
||||
|
||||
"smartbar-candidate-row": {
|
||||
@@ -115,44 +126,44 @@
|
||||
},
|
||||
"smartbar-candidate-word": {
|
||||
"background": "transparent",
|
||||
"foreground": "#dcdcdc",
|
||||
"foreground": "var(--on-background)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rectangle()"
|
||||
},
|
||||
"smartbar-candidate-word:pressed": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#dcdcdc"
|
||||
"foreground": "var(--on-background)"
|
||||
},
|
||||
"smartbar-candidate-clip": {
|
||||
"background": "transparent",
|
||||
"foreground": "#dcdcdc",
|
||||
"foreground": "var(--on-background)",
|
||||
"font-size": "14sp",
|
||||
"shape": "rounded-corner(8%, 8%, 8%, 8%)"
|
||||
},
|
||||
"smartbar-candidate-clip:pressed": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#dcdcdc"
|
||||
"foreground": "var(--on-background)"
|
||||
},
|
||||
"smartbar-candidate-spacer": {
|
||||
"foreground": "#ffffff40"
|
||||
"foreground": "var(--surface)"
|
||||
},
|
||||
|
||||
"smartbar-key": {
|
||||
"background": "transparent",
|
||||
"foreground": "#dcdcdc",
|
||||
"foreground": "var(--on-background)",
|
||||
"font-size": "18sp",
|
||||
"shape": "rounded-corner(20%, 20%, 20%, 20%)"
|
||||
"shape": "var(--shape)"
|
||||
},
|
||||
"smartbar-key:pressed": {
|
||||
"background": "var(--surface)",
|
||||
"foreground": "#dcdcdc"
|
||||
"foreground": "var(--on-surface)"
|
||||
},
|
||||
"smartbar-key:disabled": {
|
||||
"background": "transparent",
|
||||
"foreground": "var(--surface)"
|
||||
"foreground": "#dcdcdc48"
|
||||
},
|
||||
|
||||
"system-nav-bar": {
|
||||
"background": "#212121"
|
||||
"background": "var(--background)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ import dev.patrickgold.florisboard.ime.nlp.NlpManager
|
||||
import dev.patrickgold.florisboard.ime.text.gestures.GlideTypingManager
|
||||
import dev.patrickgold.florisboard.res.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.res.io.deleteContentsRecursively
|
||||
import dev.patrickgold.jetpref.datastore.JetPrefManager
|
||||
import dev.patrickgold.jetpref.datastore.JetPref
|
||||
import java.io.File
|
||||
import kotlin.Exception
|
||||
|
||||
@@ -80,7 +80,7 @@ class FlorisApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
try {
|
||||
JetPrefManager.init(saveIntervalMs = 1_000)
|
||||
JetPref.configure(saveIntervalMs = 500)
|
||||
Flog.install(
|
||||
context = this,
|
||||
isFloggingEnabled = BuildConfig.DEBUG,
|
||||
@@ -93,12 +93,12 @@ class FlorisApplication : Application() {
|
||||
if (AndroidVersion.ATLEAST_API24_N && !UserManagerCompat.isUserUnlocked(this)) {
|
||||
val context = createDeviceProtectedStorageContext()
|
||||
initICU(context)
|
||||
prefs.initializeForContext(context)
|
||||
prefs.initializeBlocking(context)
|
||||
registerReceiver(BootComplete(), IntentFilter(Intent.ACTION_USER_UNLOCKED))
|
||||
} else {
|
||||
initICU(this)
|
||||
cacheDir?.deleteContentsRecursively()
|
||||
prefs.initializeForContext(this)
|
||||
prefs.initializeBlocking(this)
|
||||
clipboardManager.value.initializeForContext(this)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ class FlorisApplication : Application() {
|
||||
flogError { e.toString() }
|
||||
}
|
||||
cacheDir?.deleteContentsRecursively()
|
||||
prefs.initializeForContext(this@FlorisApplication)
|
||||
prefs.initializeBlocking(this@FlorisApplication)
|
||||
clipboardManager.value.initializeForContext(this@FlorisApplication)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,6 @@ import dev.patrickgold.florisboard.ime.text.TextInputLayout
|
||||
import dev.patrickgold.florisboard.ime.text.smartbar.SecondaryRowPlacement
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeTheme
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUi
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeManager
|
||||
import dev.patrickgold.florisboard.snygg.ui.SnyggSurface
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import java.lang.ref.WeakReference
|
||||
@@ -135,12 +134,25 @@ class FlorisImeService : LifecycleInputMethodService(), EditorInstance.WordHisto
|
||||
}
|
||||
|
||||
fun showUi() {
|
||||
val ims = FlorisImeServiceReference.get() ?: return
|
||||
if (AndroidVersion.ATLEAST_API28_P) {
|
||||
FlorisImeServiceReference.get()?.requestShowSelf(0)
|
||||
ims.requestShowSelf(0)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
ims.systemServiceOrNull(InputMethodManager::class)
|
||||
?.showSoftInputFromInputMethod(ims.currentInputBinding.connectionToken, 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun hideUi() {
|
||||
val ims = FlorisImeServiceReference.get() ?: return
|
||||
if (AndroidVersion.ATLEAST_API28_P) {
|
||||
ims.requestHideSelf(0)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
ims.systemServiceOrNull(InputMethodManager::class)
|
||||
?.hideSoftInputFromInputMethod(ims.currentInputBinding.connectionToken, 0)
|
||||
}
|
||||
FlorisImeServiceReference.get()?.requestHideSelf(0)
|
||||
}
|
||||
|
||||
@@ -185,6 +197,7 @@ class FlorisImeService : LifecycleInputMethodService(), EditorInstance.WordHisto
|
||||
|
||||
private val prefs by florisPreferenceModel()
|
||||
private val keyboardManager by keyboardManager()
|
||||
private val themeManager by themeManager()
|
||||
private val nlpManager by nlpManager()
|
||||
|
||||
private val activeEditorInstance by lazy { EditorInstance(this) }
|
||||
@@ -302,6 +315,7 @@ class FlorisImeService : LifecycleInputMethodService(), EditorInstance.WordHisto
|
||||
flogInfo(LogTopic.IMS_EVENTS)
|
||||
}
|
||||
isWindowShown = true
|
||||
themeManager.updateActiveTheme()
|
||||
}
|
||||
|
||||
override fun onWindowHidden() {
|
||||
@@ -331,7 +345,7 @@ class FlorisImeService : LifecycleInputMethodService(), EditorInstance.WordHisto
|
||||
flogInfo(LogTopic.IMS_EVENTS) {
|
||||
"Creating inline suggestions request because Smartbar and inline suggestions are enabled."
|
||||
}
|
||||
val stylesBundle = ThemeManager.createInlineSuggestionUiStyleBundle(this)
|
||||
val stylesBundle = themeManager.createInlineSuggestionUiStyleBundle(this)
|
||||
val spec = InlinePresentationSpec.Builder(InlineSuggestionUiSmallestSize, InlineSuggestionUiBiggestSize)
|
||||
.setStyle(stylesBundle)
|
||||
.build()
|
||||
@@ -430,7 +444,11 @@ class FlorisImeService : LifecycleInputMethodService(), EditorInstance.WordHisto
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun BoxScope.ImeUi() {
|
||||
val keyboardStyle = FlorisImeTheme.style.get(FlorisImeUi.Keyboard)
|
||||
val activeState by keyboardManager.observeActiveState()
|
||||
val keyboardStyle = FlorisImeTheme.style.get(
|
||||
element = FlorisImeUi.Keyboard,
|
||||
mode = activeState.inputMode.value,
|
||||
)
|
||||
SnyggSurface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -439,7 +457,7 @@ class FlorisImeService : LifecycleInputMethodService(), EditorInstance.WordHisto
|
||||
.onGloballyPositioned { coords -> inputViewSize = coords.size }
|
||||
// Do not remove below line or touch input may get stuck
|
||||
.pointerInteropFilter { false },
|
||||
background = keyboardStyle.background,
|
||||
style = keyboardStyle,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
val bottomOffset by if (configuration.isOrientationPortrait()) {
|
||||
@@ -472,7 +490,6 @@ class FlorisImeService : LifecycleInputMethodService(), EditorInstance.WordHisto
|
||||
.weight(keyboardWeight)
|
||||
.wrapContentHeight(),
|
||||
) {
|
||||
val activeState by keyboardManager.observeActiveState()
|
||||
when (activeState.imeUiMode) {
|
||||
ImeUiMode.TEXT -> TextInputLayout()
|
||||
ImeUiMode.MEDIA -> {}
|
||||
|
||||
@@ -19,8 +19,6 @@ package dev.patrickgold.florisboard.app
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.ViewTreeObserver
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.animation.ExperimentalAnimationApi
|
||||
@@ -39,6 +37,9 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import com.google.accompanist.insets.ProvideWindowInsets
|
||||
import com.google.accompanist.insets.navigationBarsWithImePadding
|
||||
import com.google.accompanist.insets.statusBarsPadding
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.res.ProvideLocalizedResources
|
||||
@@ -59,9 +60,10 @@ import dev.patrickgold.jetpref.datastore.ui.ProvideDefaultDialogPrefStrings
|
||||
|
||||
enum class AppTheme(val id: String) {
|
||||
AUTO("auto"),
|
||||
AUTO_AMOLED("auto_amoled"),
|
||||
LIGHT("light"),
|
||||
DARK("dark"),
|
||||
AMOLED_DARK("amoled_dark"),
|
||||
AMOLED_DARK("amoled_dark");
|
||||
}
|
||||
|
||||
val LocalNavController = staticCompositionLocalOf<NavController> {
|
||||
@@ -97,35 +99,20 @@ class FlorisAppActivity : ComponentActivity() {
|
||||
}
|
||||
|
||||
AppVersionUtils.updateVersionOnInstallAndLastUse(this, prefs)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
|
||||
setContent {
|
||||
ProvideLocalizedResources(resourcesContext) {
|
||||
FlorisAppTheme(theme = appTheme) {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
SystemUiApp()
|
||||
if (isDatastoreReady) {
|
||||
ProvideWindowInsets(windowInsetsAnimationsEnabled = false) {
|
||||
Surface(color = MaterialTheme.colors.background) {
|
||||
SystemUiApp()
|
||||
AppContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PreDraw observer for SplashScreen
|
||||
val content = findViewById<View>(android.R.id.content)
|
||||
content.viewTreeObserver.addOnPreDrawListener(
|
||||
object : ViewTreeObserver.OnPreDrawListener {
|
||||
override fun onPreDraw(): Boolean {
|
||||
return if (isDatastoreReady) {
|
||||
content.viewTreeObserver.removeOnPreDrawListener(this)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -144,12 +131,6 @@ class FlorisAppActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
|
||||
prefs.forceSyncToDisk()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalAnimationApi::class)
|
||||
@Composable
|
||||
private fun AppContent() {
|
||||
@@ -165,7 +146,11 @@ class FlorisAppActivity : ComponentActivity() {
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
neutralLabel = stringRes(R.string.action__default),
|
||||
) {
|
||||
Column {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.statusBarsPadding()
|
||||
.navigationBarsWithImePadding(),
|
||||
) {
|
||||
Routes.AppNavHost(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
navController = navController,
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
|
||||
package dev.patrickgold.florisboard.app.prefs
|
||||
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import dev.patrickgold.florisboard.app.AppTheme
|
||||
import dev.patrickgold.florisboard.app.ui.settings.theme.DisplayColorsAs
|
||||
import dev.patrickgold.florisboard.app.ui.settings.theme.DisplayKbdAfterDialogs
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.landscapeinput.LandscapeInputUiMode
|
||||
import dev.patrickgold.florisboard.ime.onehanded.OneHandedMode
|
||||
@@ -32,12 +33,12 @@ import dev.patrickgold.florisboard.ime.text.smartbar.SecondaryRowPlacement
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeMode
|
||||
import dev.patrickgold.florisboard.ime.theme.extCoreTheme
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.snygg.SnyggLevel
|
||||
import dev.patrickgold.florisboard.util.VersionName
|
||||
import dev.patrickgold.jetpref.datastore.JetPref
|
||||
import dev.patrickgold.jetpref.datastore.model.PreferenceModel
|
||||
import dev.patrickgold.jetpref.datastore.preferenceModel
|
||||
import java.time.LocalTime
|
||||
|
||||
fun florisPreferenceModel() = preferenceModel(AppPrefs::class, ::AppPrefs)
|
||||
fun florisPreferenceModel() = JetPref.getOrCreatePreferenceModel(AppPrefs::class, ::AppPrefs)
|
||||
|
||||
class AppPrefs : PreferenceModel("florisboard-app-prefs") {
|
||||
val advanced = Advanced()
|
||||
@@ -355,6 +356,10 @@ class AppPrefs : PreferenceModel("florisboard-app-prefs") {
|
||||
key = "keyboard__utility_key_action",
|
||||
default = UtilityKeyAction.DYNAMIC_SWITCH_LANGUAGE_EMOJIS,
|
||||
)
|
||||
val spaceBarLanguageDisplayEnabled = boolean(
|
||||
key = "keyboard__space_bar_language_display_enabled",
|
||||
default = true,
|
||||
)
|
||||
val fontSizeMultiplierPortrait = int(
|
||||
key = "keyboard__font_size_multiplier_portrait",
|
||||
default = 100,
|
||||
@@ -433,6 +438,10 @@ class AppPrefs : PreferenceModel("florisboard-app-prefs") {
|
||||
|
||||
val localization = Localization()
|
||||
inner class Localization {
|
||||
val displayLanguageNamesIn = enum(
|
||||
key = "localization__display_language_names_in",
|
||||
default = DisplayLanguageNamesIn.NATIVE_LOCALE,
|
||||
)
|
||||
val activeSubtypeId = long(
|
||||
key = "localization__active_subtype_id",
|
||||
default = Subtype.DEFAULT.id,
|
||||
@@ -555,15 +564,25 @@ class AppPrefs : PreferenceModel("florisboard-app-prefs") {
|
||||
default = extCoreTheme("floris_night"),
|
||||
serializer = ExtensionComponentName.Serializer,
|
||||
)
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
val sunriseTime = localTime(
|
||||
key = "theme__sunrise_time",
|
||||
default = LocalTime.of(6, 0),
|
||||
//val sunriseTime = localTime(
|
||||
// key = "theme__sunrise_time",
|
||||
// default = LocalTime.of(6, 0),
|
||||
//)
|
||||
//val sunsetTime = localTime(
|
||||
// key = "theme__sunset_time",
|
||||
// default = LocalTime.of(18, 0),
|
||||
//)
|
||||
val editorDisplayColorsAs = enum(
|
||||
key = "theme__editor_display_colors_as",
|
||||
default = DisplayColorsAs.HEX8,
|
||||
)
|
||||
@RequiresApi(Build.VERSION_CODES.O)
|
||||
val sunsetTime = localTime(
|
||||
key = "theme__sunset_time",
|
||||
default = LocalTime.of(18, 0),
|
||||
val editorDisplayKbdAfterDialogs = enum(
|
||||
key = "theme__editor_display_kbd_after_dialogs",
|
||||
default = DisplayKbdAfterDialogs.REMEMBER,
|
||||
)
|
||||
val editorLevel = enum(
|
||||
key = "theme__editor_level",
|
||||
default = SnyggLevel.ADVANCED,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import androidx.navigation.compose.composable
|
||||
import dev.patrickgold.florisboard.app.ui.devtools.AndroidLocalesScreen
|
||||
import dev.patrickgold.florisboard.app.ui.devtools.AndroidSettingsScreen
|
||||
import dev.patrickgold.florisboard.app.ui.devtools.DevtoolsScreen
|
||||
import dev.patrickgold.florisboard.app.ui.ext.ExtensionEditScreen
|
||||
import dev.patrickgold.florisboard.app.ui.ext.ExtensionExportScreen
|
||||
import dev.patrickgold.florisboard.app.ui.ext.ExtensionImportScreen
|
||||
import dev.patrickgold.florisboard.app.ui.ext.ExtensionImportScreenType
|
||||
@@ -33,8 +34,12 @@ import dev.patrickgold.florisboard.app.ui.settings.about.AboutScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.about.ProjectLicenseScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.about.ThirdPartyLicensesScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.advanced.AdvancedScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.advanced.BackupScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.advanced.RestoreScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.clipboard.ClipboardScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.dictionary.DictionaryScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.dictionary.UserDictionaryScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.dictionary.UserDictionaryType
|
||||
import dev.patrickgold.florisboard.app.ui.settings.gestures.GesturesScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.keyboard.InputFeedbackScreen
|
||||
import dev.patrickgold.florisboard.app.ui.settings.keyboard.KeyboardScreen
|
||||
@@ -91,12 +96,16 @@ object Routes {
|
||||
const val ImportSpellingAffDic = "settings/spelling/import-aff-dic"
|
||||
|
||||
const val Dictionary = "settings/dictionary"
|
||||
const val UserDictionary = "settings/dictionary/user-dictionary/{type}"
|
||||
fun UserDictionary(type: UserDictionaryType) = UserDictionary.curlyFormat("type" to type.id)
|
||||
|
||||
const val Gestures = "settings/gestures"
|
||||
|
||||
const val Clipboard = "settings/clipboard"
|
||||
|
||||
const val Advanced = "settings/advanced"
|
||||
const val Backup = "settings/advanced/backup"
|
||||
const val Restore = "settings/advanced/restore"
|
||||
|
||||
const val About = "settings/about"
|
||||
const val ProjectLicense = "settings/about/project-license"
|
||||
@@ -112,6 +121,11 @@ object Routes {
|
||||
}
|
||||
|
||||
object Ext {
|
||||
const val Edit = "ext/edit/{id}?create={serial_type}"
|
||||
fun Edit(id: String, serialType: String? = null): String {
|
||||
return Edit.curlyFormat("id" to id, "serial_type" to (serialType ?: ""))
|
||||
}
|
||||
|
||||
const val Export = "ext/export/{id}"
|
||||
fun Export(id: String) = Export.curlyFormat("id" to id)
|
||||
|
||||
@@ -171,12 +185,20 @@ object Routes {
|
||||
composable(Settings.ImportSpellingArchive) { ImportSpellingArchiveScreen() }
|
||||
|
||||
composable(Settings.Dictionary) { DictionaryScreen() }
|
||||
composable(Settings.UserDictionary) { navBackStack ->
|
||||
val type = navBackStack.arguments?.getString("type")?.let { typeId ->
|
||||
UserDictionaryType.values().firstOrNull { it.id == typeId }
|
||||
}
|
||||
UserDictionaryScreen(type!!)
|
||||
}
|
||||
|
||||
composable(Settings.Gestures) { GesturesScreen() }
|
||||
|
||||
composable(Settings.Clipboard) { ClipboardScreen() }
|
||||
|
||||
composable(Settings.Advanced) { AdvancedScreen() }
|
||||
composable(Settings.Backup) { BackupScreen() }
|
||||
composable(Settings.Restore) { RestoreScreen() }
|
||||
|
||||
composable(Settings.About) { AboutScreen() }
|
||||
composable(Settings.ProjectLicense) { ProjectLicenseScreen() }
|
||||
@@ -189,11 +211,18 @@ object Routes {
|
||||
AndroidSettingsScreen(name)
|
||||
}
|
||||
|
||||
composable(Ext.Edit) { navBackStack ->
|
||||
val extensionId = navBackStack.arguments?.getString("id")
|
||||
val serialType = navBackStack.arguments?.getString("serial_type")
|
||||
ExtensionEditScreen(
|
||||
id = extensionId.toString(),
|
||||
createSerialType = serialType.takeIf { it != null && it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
composable(Ext.Export) { navBackStack ->
|
||||
val extensionId = navBackStack.arguments?.getString("id")
|
||||
ExtensionExportScreen(id = extensionId.toString())
|
||||
}
|
||||
|
||||
composable(Ext.Import) { navBackStack ->
|
||||
val type = navBackStack.arguments?.getString("type")?.let { typeId ->
|
||||
ExtensionImportScreenType.values().firstOrNull { it.id == typeId }
|
||||
@@ -201,7 +230,6 @@ object Routes {
|
||||
val uuid = navBackStack.arguments?.getString("uuid")?.takeIf { it != "null" }
|
||||
ExtensionImportScreen(type, uuid)
|
||||
}
|
||||
|
||||
composable(Ext.View) { navBackStack ->
|
||||
val extensionId = navBackStack.arguments?.getString("id")
|
||||
ExtensionViewScreen(id = extensionId.toString())
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.components
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isUnspecified
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
infix fun TextUnit.safeTimes(other: Float): TextUnit {
|
||||
return if (this.isUnspecified) 0.sp else this.times(other)
|
||||
}
|
||||
|
||||
infix fun TextUnit.safeTimes(other: Double): TextUnit {
|
||||
return if (this.isUnspecified) this else this.times(other)
|
||||
}
|
||||
|
||||
infix fun TextUnit.safeTimes(other: Int): TextUnit {
|
||||
return if (this.isUnspecified) this else this.times(other)
|
||||
}
|
||||
|
||||
val DpSizeSaver = Saver<Dp, Float>(
|
||||
save = { it.value },
|
||||
restore = { it.dp },
|
||||
)
|
||||
@@ -17,26 +17,21 @@
|
||||
package dev.patrickgold.florisboard.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.IconButton
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
|
||||
@Composable
|
||||
fun FlorisAppBar(
|
||||
title: String,
|
||||
backArrowVisible: Boolean,
|
||||
navigationIcon: FlorisScreenNavigationIcon?,
|
||||
actions: @Composable RowScope.() -> Unit = { },
|
||||
) {
|
||||
TopAppBar(
|
||||
navigationIcon = backNavBtn(backArrowVisible),
|
||||
navigationIcon = navigationIcon,
|
||||
title = {
|
||||
Text(
|
||||
text = title,
|
||||
@@ -49,19 +44,3 @@ fun FlorisAppBar(
|
||||
elevation = 0.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun backNavBtn(backArrowVisible: Boolean): @Composable (() -> Unit)? {
|
||||
if (!backArrowVisible) return null
|
||||
val navController = LocalNavController.current
|
||||
return {
|
||||
IconButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_back),
|
||||
contentDescription = "Back",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.app.ui.theme.outline
|
||||
|
||||
@Composable
|
||||
fun RowScope.FlorisBulletSpacer(
|
||||
@@ -39,6 +40,6 @@ fun RowScope.FlorisBulletSpacer(
|
||||
.padding(horizontal = 8.dp)
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colors.onSurface.copy(alpha = 0.12f)),
|
||||
.background(MaterialTheme.colors.outline),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
@@ -138,6 +139,35 @@ fun FlorisIconButton(
|
||||
enabled: Boolean = true,
|
||||
iconModifier: Modifier = Modifier,
|
||||
iconColor: Color = Color.Unspecified,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
onClick = onClick,
|
||||
) {
|
||||
val contentAlpha = if (enabled) LocalContentAlpha.current else 0.14f
|
||||
val contentColor = iconColor.takeOrElse { LocalContentColor.current }
|
||||
CompositionLocalProvider(
|
||||
LocalContentAlpha provides contentAlpha,
|
||||
LocalContentColor provides contentColor,
|
||||
) {
|
||||
Icon(
|
||||
modifier = iconModifier,
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FlorisIconButtonWithInnerPadding(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
icon: Painter,
|
||||
enabled: Boolean = true,
|
||||
iconModifier: Modifier = Modifier,
|
||||
iconColor: Color = Color.Unspecified,
|
||||
) {
|
||||
IconButton(
|
||||
modifier = modifier,
|
||||
|
||||
@@ -20,6 +20,7 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -49,13 +50,21 @@ import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.ui.theme.outline
|
||||
|
||||
private val IconRequiredSize = 24.dp
|
||||
private val IconEndPadding = 8.dp
|
||||
|
||||
private val CardContentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
|
||||
object CardDefaults {
|
||||
val IconRequiredSize = 24.dp
|
||||
val IconSpacing = 8.dp
|
||||
|
||||
private val OutlinedBoxShape = RoundedCornerShape(8.dp)
|
||||
val ContentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp)
|
||||
}
|
||||
|
||||
object BoxDefaults {
|
||||
val OutlinedBoxShape = RoundedCornerShape(8.dp)
|
||||
|
||||
val ContentPadding = PaddingValues(all = 0.dp)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
@@ -65,7 +74,7 @@ fun FlorisSimpleCard(
|
||||
secondaryText: String? = null,
|
||||
backgroundColor: Color = MaterialTheme.colors.surface,
|
||||
contentColor: Color = contentColorFor(backgroundColor),
|
||||
contentPadding: PaddingValues = CardContentPadding,
|
||||
contentPadding: PaddingValues = CardDefaults.ContentPadding,
|
||||
icon: (@Composable () -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
@@ -110,7 +119,7 @@ fun FlorisErrorCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showIcon: Boolean = true,
|
||||
contentPadding: PaddingValues = CardContentPadding,
|
||||
contentPadding: PaddingValues = CardDefaults.ContentPadding,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
FlorisSimpleCard(
|
||||
@@ -120,8 +129,8 @@ fun FlorisErrorCard(
|
||||
onClick = onClick,
|
||||
icon = if (showIcon) ({ Icon(
|
||||
modifier = Modifier
|
||||
.padding(end = IconEndPadding)
|
||||
.requiredSize(IconRequiredSize),
|
||||
.padding(end = CardDefaults.IconSpacing)
|
||||
.requiredSize(CardDefaults.IconRequiredSize),
|
||||
painter = painterResource(R.drawable.ic_error_outline),
|
||||
contentDescription = null,
|
||||
) }) else null,
|
||||
@@ -135,7 +144,7 @@ fun FlorisWarningCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showIcon: Boolean = true,
|
||||
contentPadding: PaddingValues = CardContentPadding,
|
||||
contentPadding: PaddingValues = CardDefaults.ContentPadding,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
FlorisSimpleCard(
|
||||
@@ -145,8 +154,8 @@ fun FlorisWarningCard(
|
||||
onClick = onClick,
|
||||
icon = if (showIcon) ({ Icon(
|
||||
modifier = Modifier
|
||||
.padding(end = IconEndPadding)
|
||||
.requiredSize(IconRequiredSize),
|
||||
.padding(end = CardDefaults.IconSpacing)
|
||||
.requiredSize(CardDefaults.IconRequiredSize),
|
||||
painter = painterResource(R.drawable.ic_warning_outline),
|
||||
contentDescription = null,
|
||||
) }) else null,
|
||||
@@ -160,7 +169,7 @@ fun FlorisInfoCard(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
showIcon: Boolean = true,
|
||||
contentPadding: PaddingValues = CardContentPadding,
|
||||
contentPadding: PaddingValues = CardDefaults.ContentPadding,
|
||||
onClick: (() -> Unit)? = null,
|
||||
) {
|
||||
FlorisSimpleCard(
|
||||
@@ -168,8 +177,8 @@ fun FlorisInfoCard(
|
||||
onClick = onClick,
|
||||
icon = if (showIcon) ({ Icon(
|
||||
modifier = Modifier
|
||||
.padding(end = IconEndPadding)
|
||||
.requiredSize(IconRequiredSize),
|
||||
.padding(end = CardDefaults.IconSpacing)
|
||||
.requiredSize(CardDefaults.IconRequiredSize),
|
||||
painter = painterResource(R.drawable.ic_info),
|
||||
contentDescription = null,
|
||||
) }) else null,
|
||||
@@ -186,9 +195,10 @@ fun FlorisOutlinedBox(
|
||||
subtitle: String? = null,
|
||||
onSubtitleClick: (() -> Unit)? = null,
|
||||
borderWidth: Dp = 1.dp,
|
||||
borderColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
shape: Shape = OutlinedBoxShape,
|
||||
content: @Composable () -> Unit,
|
||||
borderColor: Color = MaterialTheme.colors.outline,
|
||||
shape: Shape = BoxDefaults.OutlinedBoxShape,
|
||||
contentPadding: PaddingValues = BoxDefaults.ContentPadding,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
FlorisOutlinedBox(
|
||||
modifier = modifier,
|
||||
@@ -220,10 +230,13 @@ fun FlorisOutlinedBox(
|
||||
borderWidth = borderWidth,
|
||||
borderColor = borderColor,
|
||||
shape = shape,
|
||||
contentPadding = contentPadding,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Rework internal implementation (with same API and visual appearance) of FlorisOutlinedBox
|
||||
// to avoid too much nesting and improve performance
|
||||
@Composable
|
||||
fun FlorisOutlinedBox(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -232,9 +245,10 @@ fun FlorisOutlinedBox(
|
||||
subtitle: (@Composable () -> Unit)? = null,
|
||||
onSubtitleClick: (() -> Unit)? = null,
|
||||
borderWidth: Dp = 1.dp,
|
||||
borderColor: Color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f),
|
||||
shape: Shape = OutlinedBoxShape,
|
||||
content: @Composable () -> Unit,
|
||||
borderColor: Color = MaterialTheme.colors.outline,
|
||||
shape: Shape = BoxDefaults.OutlinedBoxShape,
|
||||
contentPadding: PaddingValues = BoxDefaults.ContentPadding,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
@@ -257,7 +271,12 @@ fun FlorisOutlinedBox(
|
||||
subtitle()
|
||||
}
|
||||
}
|
||||
content()
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(contentPadding),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
if (title != null) {
|
||||
Box(
|
||||
@@ -276,3 +295,9 @@ fun FlorisOutlinedBox(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.defaultFlorisOutlinedBox(): Modifier {
|
||||
return this
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -43,16 +44,19 @@ fun FlorisChip(
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit = { },
|
||||
enabled: Boolean = true,
|
||||
color: Color = MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity),
|
||||
color: Color = Color.Unspecified,
|
||||
shape: Shape = CircleShape,
|
||||
@DrawableRes leadingIcons: List<Int> = listOf(),
|
||||
@DrawableRes trailingIcons: List<Int> = listOf(),
|
||||
) {
|
||||
val backgroundColor = color.takeOrElse {
|
||||
MaterialTheme.colors.onSurface.copy(alpha = TextFieldDefaults.BackgroundOpacity)
|
||||
}
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
color = color,
|
||||
color = backgroundColor,
|
||||
shape = shape,
|
||||
) {
|
||||
Row(
|
||||
|
||||
@@ -31,6 +31,7 @@ fun FlorisConfirmDeleteDialog(
|
||||
what: String,
|
||||
) {
|
||||
JetPrefAlertDialog(
|
||||
modifier = modifier,
|
||||
title = stringRes(R.string.action__delete_confirm_title),
|
||||
confirmLabel = stringRes(R.string.action__delete),
|
||||
onConfirm = onConfirm,
|
||||
@@ -40,3 +41,25 @@ fun FlorisConfirmDeleteDialog(
|
||||
Text(text = stringRes(R.string.action__delete_confirm_message, "name" to what))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FlorisUnsavedChangesDialog(
|
||||
modifier: Modifier = Modifier,
|
||||
onSave: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
JetPrefAlertDialog(
|
||||
modifier = modifier,
|
||||
title = stringRes(R.string.action__discard_confirm_title),
|
||||
confirmLabel = stringRes(R.string.action__save),
|
||||
onConfirm = onSave,
|
||||
dismissLabel = stringRes(R.string.action__discard),
|
||||
onDismiss = onDiscard,
|
||||
onOutsideDismissal = onDismiss,
|
||||
neutralLabel = stringRes(R.string.action__cancel),
|
||||
onNeutral = onDismiss,
|
||||
) {
|
||||
Text(text = stringRes(R.string.action__discard_confirm_message))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,43 +35,55 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.ui.theme.outline
|
||||
|
||||
@Composable
|
||||
fun FlorisDropdownMenu(
|
||||
items: List<String>,
|
||||
fun <T : Any> FlorisDropdownMenu(
|
||||
items: List<T>,
|
||||
expanded: Boolean,
|
||||
selectedIndex: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
labelProvider: (@Composable (T) -> String)? = null,
|
||||
onSelectItem: (Int) -> Unit = { },
|
||||
onExpandRequest: () -> Unit = { },
|
||||
onDismissRequest: () -> Unit = { },
|
||||
) {
|
||||
@Composable
|
||||
fun asString(v: T): String {
|
||||
return labelProvider?.invoke(v) ?: v.toString()
|
||||
}
|
||||
|
||||
Box(modifier = modifier.wrapContentSize(Alignment.TopStart)) {
|
||||
val indicatorRotation by animateFloatAsState(targetValue = if (expanded) 180f else 0f)
|
||||
val index = selectedIndex.coerceIn(items.indices)
|
||||
val color = if (isError) {
|
||||
val color = if (!enabled) {
|
||||
MaterialTheme.colors.outline
|
||||
} else if (isError) {
|
||||
MaterialTheme.colors.error
|
||||
} else {
|
||||
MaterialTheme.colors.onBackground
|
||||
}
|
||||
OutlinedButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
border = if (isError) {
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
border = if (isError && enabled) {
|
||||
BorderStroke(ButtonDefaults.OutlinedBorderSize, MaterialTheme.colors.error)
|
||||
} else {
|
||||
ButtonDefaults.outlinedBorder
|
||||
},
|
||||
onClick = { onExpandRequest() },
|
||||
enabled = enabled,
|
||||
onClick = onExpandRequest,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
text = items[index],
|
||||
text = asString(items[index]),
|
||||
textAlign = TextAlign.Start,
|
||||
fontWeight = FontWeight.Normal,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
color = color,
|
||||
@@ -79,7 +91,11 @@ fun FlorisDropdownMenu(
|
||||
Icon(
|
||||
modifier = Modifier.rotate(indicatorRotation),
|
||||
painter = painterResource(R.drawable.ic_keyboard_arrow_down),
|
||||
tint = color.copy(alpha = ContentAlpha.medium),
|
||||
tint = if (enabled) {
|
||||
color.copy(alpha = ContentAlpha.medium)
|
||||
} else {
|
||||
color
|
||||
},
|
||||
contentDescription = "Dropdown indicator",
|
||||
)
|
||||
}
|
||||
@@ -94,7 +110,7 @@ fun FlorisDropdownMenu(
|
||||
onDismissRequest()
|
||||
},
|
||||
) {
|
||||
Text(text = item)
|
||||
Text(text = asString(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +144,7 @@ fun FlorisDropdownLikeButton(
|
||||
modifier = Modifier.weight(1.0f),
|
||||
text = item,
|
||||
textAlign = TextAlign.Start,
|
||||
fontWeight = FontWeight.Normal,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
color = color,
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -28,6 +27,9 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.prefs.AppPrefs
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.jetpref.datastore.ui.PreferenceLayout
|
||||
@@ -44,11 +46,12 @@ typealias FlorisScreenActions = @Composable RowScope.() -> Unit
|
||||
typealias FlorisScreenBottomBar = @Composable () -> Unit
|
||||
typealias FlorisScreenContent = PreferenceUiContent<AppPrefs>
|
||||
typealias FlorisScreenFab = @Composable () -> Unit
|
||||
typealias FlorisScreenNavigationIcon = @Composable () -> Unit
|
||||
|
||||
interface FlorisScreenScope {
|
||||
var title: String
|
||||
|
||||
var backArrowVisible: Boolean
|
||||
var navigationIconVisible: Boolean
|
||||
|
||||
var previewFieldVisible: Boolean
|
||||
|
||||
@@ -60,22 +63,31 @@ interface FlorisScreenScope {
|
||||
|
||||
fun bottomBar(bottomBar: FlorisScreenBottomBar)
|
||||
|
||||
fun content(content: FlorisScreenContent)
|
||||
|
||||
fun floatingActionButton(fab: FlorisScreenFab)
|
||||
|
||||
fun content(content: FlorisScreenContent)
|
||||
fun navigationIcon(navigationIcon: FlorisScreenNavigationIcon)
|
||||
}
|
||||
|
||||
private class FlorisScreenScopeImpl : FlorisScreenScope {
|
||||
override var title: String by mutableStateOf("")
|
||||
override var backArrowVisible: Boolean by mutableStateOf(true)
|
||||
override var navigationIconVisible: Boolean by mutableStateOf(true)
|
||||
override var previewFieldVisible: Boolean by mutableStateOf(false)
|
||||
override var scrollable: Boolean by mutableStateOf(true)
|
||||
override var iconSpaceReserved: Boolean by mutableStateOf(true)
|
||||
|
||||
private var actions: FlorisScreenActions = { }
|
||||
private var bottomBar: FlorisScreenBottomBar = { }
|
||||
private var content: FlorisScreenContent = { }
|
||||
private var fab: FlorisScreenFab = { }
|
||||
private var actions: FlorisScreenActions = @Composable { }
|
||||
private var bottomBar: FlorisScreenBottomBar = @Composable { }
|
||||
private var content: FlorisScreenContent = @Composable { }
|
||||
private var fab: FlorisScreenFab = @Composable { }
|
||||
private var navigationIcon: FlorisScreenNavigationIcon = @Composable {
|
||||
val navController = LocalNavController.current
|
||||
FlorisIconButton(
|
||||
onClick = { navController.popBackStack() },
|
||||
icon = painterResource(R.drawable.ic_arrow_back),
|
||||
)
|
||||
}
|
||||
|
||||
override fun actions(actions: FlorisScreenActions) {
|
||||
this.actions = actions
|
||||
@@ -93,6 +105,10 @@ private class FlorisScreenScopeImpl : FlorisScreenScope {
|
||||
this.fab = fab
|
||||
}
|
||||
|
||||
override fun navigationIcon(navigationIcon: FlorisScreenNavigationIcon) {
|
||||
this.navigationIcon = navigationIcon
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Render() {
|
||||
val previewFieldController = LocalPreviewFieldController.current
|
||||
@@ -102,7 +118,7 @@ private class FlorisScreenScopeImpl : FlorisScreenScope {
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { FlorisAppBar(title, backArrowVisible, actions) },
|
||||
topBar = { FlorisAppBar(title, navigationIcon.takeIf { navigationIconVisible }, actions) },
|
||||
bottomBar = bottomBar,
|
||||
floatingActionButton = fab,
|
||||
) { innerPadding ->
|
||||
@@ -110,15 +126,15 @@ private class FlorisScreenScopeImpl : FlorisScreenScope {
|
||||
Modifier.florisVerticalScroll()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Box(modifier = modifier.padding(innerPadding)) {
|
||||
PreferenceLayout(
|
||||
florisPreferenceModel(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
iconSpaceReserved = iconSpaceReserved,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
PreferenceLayout(
|
||||
florisPreferenceModel(),
|
||||
modifier = modifier
|
||||
.padding(innerPadding)
|
||||
.fillMaxWidth(),
|
||||
iconSpaceReserved = iconSpaceReserved,
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.app.ui.theme.outline
|
||||
|
||||
private val StepHeaderPaddingVertical = 16.dp
|
||||
private val StepHeaderNumberBoxSize = 40.dp
|
||||
@@ -198,7 +199,7 @@ private fun ColumnScope.Step(
|
||||
val autoStepId by stepState.getCurrentAuto()
|
||||
val backgroundColor = when (ownStepId) {
|
||||
currentStepId -> primaryColor
|
||||
else -> MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
|
||||
else -> MaterialTheme.colors.outline
|
||||
}
|
||||
val contentVisible = ownStepId == currentStepId
|
||||
StepHeader(
|
||||
@@ -243,7 +244,8 @@ private fun ColumnScope.Step(
|
||||
) {
|
||||
Column(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.florisVerticalScroll(),
|
||||
.florisVerticalScroll()
|
||||
.padding(end = 8.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.ProvideTextStyle
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldColors
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.takeOrElse
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.app.ui.theme.outline
|
||||
import dev.patrickgold.florisboard.common.ValidationResult
|
||||
|
||||
@Composable
|
||||
fun FlorisOutlinedTextField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
textStyle: TextStyle = TextStyle.Default,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
singleLine: Boolean = false,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
placeholder: String? = null,
|
||||
isError: Boolean = false,
|
||||
showValidationHint: Boolean = true,
|
||||
showValidationError: Boolean = false,
|
||||
validationResult: ValidationResult? = null,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shape: Shape = MaterialTheme.shapes.small,
|
||||
colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors(),
|
||||
) {
|
||||
var textFieldValueState by remember { mutableStateOf(TextFieldValue(text = value)) }
|
||||
val textFieldValue = textFieldValueState.copy(text = value)
|
||||
|
||||
FlorisOutlinedTextField(
|
||||
value = textFieldValue,
|
||||
onValueChange = {
|
||||
textFieldValueState = it
|
||||
if (value != it.text) {
|
||||
onValueChange(it.text)
|
||||
}
|
||||
},
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = textStyle,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
placeholder = placeholder,
|
||||
isError = isError,
|
||||
showValidationHint = showValidationHint,
|
||||
showValidationError = showValidationError,
|
||||
validationResult = validationResult,
|
||||
visualTransformation = visualTransformation,
|
||||
interactionSource = interactionSource,
|
||||
shape = shape,
|
||||
colors = colors,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FlorisOutlinedTextField(
|
||||
value: TextFieldValue,
|
||||
onValueChange: (TextFieldValue) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
readOnly: Boolean = false,
|
||||
textStyle: TextStyle = MaterialTheme.typography.button,
|
||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
||||
singleLine: Boolean = false,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
placeholder: String? = null,
|
||||
isError: Boolean = false,
|
||||
showValidationHint: Boolean = true,
|
||||
showValidationError: Boolean = false,
|
||||
validationResult: ValidationResult? = null,
|
||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||
shape: Shape = MaterialTheme.shapes.small,
|
||||
colors: TextFieldColors = TextFieldDefaults.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = MaterialTheme.colors.outline,
|
||||
disabledBorderColor = MaterialTheme.colors.outline,
|
||||
),
|
||||
) {
|
||||
val textColor = textStyle.color.takeOrElse {
|
||||
colors.textColor(enabled).value
|
||||
}
|
||||
val mergedTextStyle = textStyle.copy(color = textColor)
|
||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||
val isErrorState = isError || (showValidationError && validationResult?.isInvalid() == true)
|
||||
|
||||
BasicTextField(
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
enabled = enabled,
|
||||
readOnly = readOnly,
|
||||
textStyle = mergedTextStyle,
|
||||
keyboardOptions = keyboardOptions,
|
||||
keyboardActions = keyboardActions,
|
||||
singleLine = singleLine,
|
||||
maxLines = maxLines,
|
||||
visualTransformation = visualTransformation,
|
||||
cursorBrush = SolidColor(colors.cursorColor(isErrorState).value),
|
||||
decorationBox = { innerTextField ->
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = colors.backgroundColor(enabled).value,
|
||||
border = if (isErrorState && enabled) {
|
||||
BorderStroke(ButtonDefaults.OutlinedBorderSize, MaterialTheme.colors.error)
|
||||
} else if (isFocused) {
|
||||
BorderStroke(ButtonDefaults.OutlinedBorderSize, MaterialTheme.colors.primary)
|
||||
} else {
|
||||
ButtonDefaults.outlinedBorder
|
||||
},
|
||||
shape = shape,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.defaultMinSize(
|
||||
minWidth = ButtonDefaults.MinWidth,
|
||||
minHeight = 40.dp,
|
||||
)
|
||||
.padding(ButtonDefaults.ContentPadding),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
ProvideTextStyle(value = mergedTextStyle) {
|
||||
innerTextField()
|
||||
}
|
||||
if (!placeholder.isNullOrBlank()) {
|
||||
Text(
|
||||
text = placeholder,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.56f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (showValidationHint && validationResult?.isValid() == true && validationResult.hasHintMessage()) {
|
||||
Text(
|
||||
text = validationResult.hintMessage(),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.onSurface.copy(alpha = 0.56f),
|
||||
)
|
||||
}
|
||||
|
||||
if (showValidationError && validationResult?.isInvalid() == true && validationResult.hasErrorMessage()) {
|
||||
Text(
|
||||
text = validationResult.errorMessage(),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -42,18 +42,17 @@ import dev.patrickgold.florisboard.common.android.AndroidVersion
|
||||
fun SystemUiApp() {
|
||||
val systemUiController = rememberFlorisSystemUiController()
|
||||
val useDarkIcons = MaterialTheme.colors.isLight
|
||||
val backgroundColor = MaterialTheme.colors.background
|
||||
|
||||
SideEffect {
|
||||
systemUiController.setStatusBarColor(
|
||||
color = backgroundColor,
|
||||
color = Color.Transparent,
|
||||
darkIcons = useDarkIcons,
|
||||
)
|
||||
if (AndroidVersion.ATLEAST_API26_O) {
|
||||
systemUiController.setNavigationBarColor(
|
||||
color = backgroundColor,
|
||||
color = Color.Transparent,
|
||||
darkIcons = useDarkIcons,
|
||||
navigationBarContrastEnforced = true,
|
||||
navigationBarContrastEnforced = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -194,4 +193,3 @@ private class FlorisSystemUiController(
|
||||
return if (context is ContextWrapper) context.findWindow() else null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.ext
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.ListItem
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisTextButton
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMeta
|
||||
|
||||
@Composable
|
||||
fun ExtensionComponentNoneFoundView() {
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
text = stringRes(R.string.ext__meta__components_none_found),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExtensionComponentView(
|
||||
meta: ExtensionMeta,
|
||||
component: ExtensionComponent,
|
||||
modifier: Modifier = Modifier,
|
||||
onDeleteBtnClick: (() -> Unit)? = null,
|
||||
onEditBtnClick: (() -> Unit)? = null,
|
||||
) {
|
||||
val componentName = remember(meta.id, component.id) { ExtensionComponentName(meta.id, component.id).toString() }
|
||||
FlorisOutlinedBox(
|
||||
modifier = modifier,
|
||||
title = component.label,
|
||||
subtitle = componentName,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = if (onDeleteBtnClick == null && onEditBtnClick == null) 8.dp else 0.dp,
|
||||
),
|
||||
) {
|
||||
when (component) {
|
||||
is ThemeExtensionComponent -> {
|
||||
val text = remember(
|
||||
component.authors, component.isNightTheme, component.isBorderless,
|
||||
component.isMaterialYouAware, component.stylesheetPath(),
|
||||
) {
|
||||
buildString {
|
||||
appendLine("authors = ${component.authors}")
|
||||
appendLine("isNightTheme = ${component.isNightTheme}")
|
||||
appendLine("isBorderless = ${component.isBorderless}")
|
||||
appendLine("isMaterialYouAware = ${component.isMaterialYouAware}")
|
||||
append("stylesheetPath = ${component.stylesheetPath()}")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
|
||||
)
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
if (onDeleteBtnClick != null || onEditBtnClick != null) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp),
|
||||
) {
|
||||
if (onDeleteBtnClick != null) {
|
||||
FlorisTextButton(
|
||||
onClick = onDeleteBtnClick,
|
||||
icon = painterResource(R.drawable.ic_delete),
|
||||
text = stringRes(R.string.action__delete),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colors.error,
|
||||
),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (onEditBtnClick != null) {
|
||||
FlorisTextButton(
|
||||
onClick = onEditBtnClick,
|
||||
icon = painterResource(R.drawable.ic_edit),
|
||||
text = stringRes(R.string.action__edit),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun <T : ExtensionComponent> ExtensionComponentListView(
|
||||
modifier: Modifier = Modifier,
|
||||
title: String,
|
||||
components: List<T>,
|
||||
onCreateBtnClick: (() -> Unit)? = null,
|
||||
componentGenerator: @Composable (T) -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier) {
|
||||
ListItem(
|
||||
text = { Text(
|
||||
text = title,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
) },
|
||||
trailing = if (onCreateBtnClick != null) {
|
||||
@Composable {
|
||||
FlorisIconButton(
|
||||
onClick = onCreateBtnClick,
|
||||
icon = painterResource(R.drawable.ic_add),
|
||||
iconColor = MaterialTheme.colors.secondary,
|
||||
)
|
||||
}
|
||||
} else { null },
|
||||
)
|
||||
if (components.isEmpty()) {
|
||||
ExtensionComponentNoneFoundView()
|
||||
} else {
|
||||
for (component in components) {
|
||||
componentGenerator(component)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.ext
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.material.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisButtonBar
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisInfoCard
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedTextField
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisUnsavedChangesDialog
|
||||
import dev.patrickgold.florisboard.app.ui.components.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.settings.advanced.RadioListItem
|
||||
import dev.patrickgold.florisboard.app.ui.settings.theme.DialogProperty
|
||||
import dev.patrickgold.florisboard.app.ui.settings.theme.ThemeEditorScreen
|
||||
import dev.patrickgold.florisboard.app.ui.theme.outline
|
||||
import dev.patrickgold.florisboard.cacheManager
|
||||
import dev.patrickgold.florisboard.common.ValidationResult
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
import dev.patrickgold.florisboard.common.rememberValidationResult
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.ime.keyboard.KeyboardExtension
|
||||
import dev.patrickgold.florisboard.ime.spelling.SpellingExtension
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtension
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponentEditor
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponentImpl
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionEditor
|
||||
import dev.patrickgold.florisboard.res.FlorisRef
|
||||
import dev.patrickgold.florisboard.res.ZipUtils
|
||||
import dev.patrickgold.florisboard.res.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.res.ext.Extension
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionDefaults
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionEditor
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionJsonConfig
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMaintainer
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionManager
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMeta
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionValidation
|
||||
import dev.patrickgold.florisboard.res.ext.validate
|
||||
import dev.patrickgold.florisboard.res.io.subFile
|
||||
import dev.patrickgold.florisboard.res.io.writeJson
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheetJsonConfig
|
||||
import dev.patrickgold.florisboard.themeManager
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
import java.util.*
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
private val TextFieldVerticalPadding = 8.dp
|
||||
private val MetaDataContentPadding = PaddingValues(vertical = 8.dp, horizontal = 16.dp)
|
||||
|
||||
private const val AnimationDuration = 300
|
||||
|
||||
private val ActionScreenEnterTransition = fadeIn(tween(AnimationDuration))
|
||||
private val ActionScreenExitTransition = fadeOut(tween(AnimationDuration))
|
||||
|
||||
sealed class EditorAction {
|
||||
object ManageMetaData : EditorAction()
|
||||
|
||||
object ManageDependencies : EditorAction()
|
||||
|
||||
object ManageFiles : EditorAction()
|
||||
|
||||
data class CreateComponent<T : ExtensionComponent>(val type: KClass<T>) : EditorAction()
|
||||
|
||||
data class ManageComponent(val editor: ExtensionComponent) : EditorAction()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ExtensionEditScreen(id: String, createSerialType: String?) {
|
||||
val context = LocalContext.current
|
||||
val cacheManager by context.cacheManager()
|
||||
val extensionManager by context.extensionManager()
|
||||
|
||||
@Suppress("unchecked_cast")
|
||||
fun <W : CacheManager.ExtEditorWorkspace<T>, T : ExtensionEditor> getOrCreateWorkspace(
|
||||
uuid: String,
|
||||
container: CacheManager.WorkspacesContainer<W>,
|
||||
ext: Extension,
|
||||
): W {
|
||||
val workspace = container.getWorkspaceByUuid(uuid)
|
||||
return workspace ?: container.new(uuid).also { newWorkspace ->
|
||||
val sourceRef = ext.sourceRef
|
||||
if (createSerialType == null) {
|
||||
checkNotNull(sourceRef) { "Extension source ref must not be null" }
|
||||
ZipUtils.unzip(context, sourceRef, newWorkspace.extDir)
|
||||
}
|
||||
newWorkspace.ext = ext
|
||||
newWorkspace.editor = ext.edit() as? T
|
||||
}
|
||||
}
|
||||
|
||||
val ext = extensionManager.getExtensionById(id) ?: remember {
|
||||
val meta = ExtensionMeta(
|
||||
id = ExtensionDefaults.createLocalId("themes", System.currentTimeMillis().toString()),
|
||||
version = "0.0.0",
|
||||
title = "My themes",
|
||||
maintainers = listOf(ExtensionMaintainer(name = "Local")),
|
||||
license = "(none specified)",
|
||||
)
|
||||
when (createSerialType) {
|
||||
ThemeExtension.SERIAL_TYPE -> ThemeExtension(meta, null, emptyList())
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
if (ext != null) {
|
||||
val uuid = rememberSaveable { UUID.randomUUID().toString() }
|
||||
val cacheWorkspace = remember {
|
||||
runCatching {
|
||||
when (ext) {
|
||||
is ThemeExtension -> {
|
||||
getOrCreateWorkspace(uuid, cacheManager.themeExtEditor, ext)
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
cacheWorkspace.onSuccess { workspace ->
|
||||
if (workspace?.editor != null) {
|
||||
ExtensionEditScreenSheetSwitcher(workspace, isCreateExt = createSerialType != null)
|
||||
} else {
|
||||
ExtensionNotFoundScreen(id = id)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
Text(text = remember(error) { error.stackTraceToString() })
|
||||
}
|
||||
} else {
|
||||
ExtensionNotFoundScreen(id)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionEditScreenSheetSwitcher(
|
||||
workspace: CacheManager.ExtEditorWorkspace<*>,
|
||||
isCreateExt: Boolean,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
EditScreen(workspace, isCreateExt)
|
||||
AnimatedVisibility(
|
||||
visible = workspace.currentAction != null,
|
||||
enter = ActionScreenEnterTransition,
|
||||
exit = ActionScreenExitTransition,
|
||||
) {
|
||||
when (val action = workspace.currentAction) {
|
||||
is EditorAction.ManageMetaData -> {
|
||||
ManageMetaDataScreen(workspace, isCreateExt)
|
||||
}
|
||||
is EditorAction.ManageDependencies -> {
|
||||
ManageDependenciesScreen(workspace)
|
||||
}
|
||||
is EditorAction.ManageFiles -> {
|
||||
ManageFilesScreen(workspace)
|
||||
}
|
||||
is EditorAction.CreateComponent<*> -> {
|
||||
CreateComponentScreen(workspace, action.type)
|
||||
}
|
||||
is EditorAction.ManageComponent -> when (action.editor) {
|
||||
is ThemeExtensionComponentEditor -> {
|
||||
ThemeEditorScreen(workspace, action.editor)
|
||||
}
|
||||
else -> {
|
||||
// Render nothing
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Render nothing
|
||||
Box(modifier = Modifier.fillMaxSize())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
private fun EditScreen(
|
||||
workspace: CacheManager.ExtEditorWorkspace<*>,
|
||||
isCreateExt: Boolean,
|
||||
) = FlorisScreen {
|
||||
title = stringRes(if (isCreateExt) {
|
||||
when (workspace.ext) {
|
||||
is KeyboardExtension -> R.string.ext__editor__title_create_keyboard
|
||||
is SpellingExtension -> R.string.ext__editor__title_create_spelling
|
||||
is ThemeExtension -> R.string.ext__editor__title_create_theme
|
||||
else -> R.string.ext__editor__title_create_any
|
||||
}
|
||||
} else {
|
||||
when (workspace.ext) {
|
||||
is KeyboardExtension -> R.string.ext__editor__title_edit_keyboard
|
||||
is SpellingExtension -> R.string.ext__editor__title_edit_spelling
|
||||
is ThemeExtension -> R.string.ext__editor__title_edit_theme
|
||||
else -> R.string.ext__editor__title_edit_any
|
||||
}
|
||||
})
|
||||
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
val extEditor = workspace.editor ?: return@FlorisScreen
|
||||
var showUnsavedChangesDialog by remember { mutableStateOf(false) }
|
||||
var showInvalidMetadataDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleBackPress() {
|
||||
if (workspace.isModified) {
|
||||
showUnsavedChangesDialog = true
|
||||
} else {
|
||||
workspace.close()
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleSave() {
|
||||
if (!extEditor.meta.validate()) {
|
||||
showUnsavedChangesDialog = false
|
||||
showInvalidMetadataDialog = true
|
||||
return
|
||||
}
|
||||
val manifest = extEditor.build()
|
||||
val manifestFile = workspace.saverDir.subFile(ExtensionDefaults.MANIFEST_FILE_NAME)
|
||||
manifestFile.writeJson(manifest, ExtensionJsonConfig)
|
||||
when (extEditor) {
|
||||
is ThemeExtensionEditor -> {
|
||||
for (theme in extEditor.themes) {
|
||||
val stylesheetFile = workspace.saverDir.subFile(theme.stylesheetPath())
|
||||
stylesheetFile.parentFile?.mkdirs()
|
||||
val stylesheetEditor = theme.stylesheetEditor
|
||||
if (stylesheetEditor != null) {
|
||||
val stylesheet = stylesheetEditor.build()
|
||||
stylesheetFile.writeJson(stylesheet, SnyggStylesheetJsonConfig)
|
||||
} else {
|
||||
val unmodifiedStylesheetFile = workspace.extDir.subFile(theme.stylesheetPath())
|
||||
if (unmodifiedStylesheetFile.exists()) {
|
||||
unmodifiedStylesheetFile.copyTo(stylesheetFile, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> { }
|
||||
}
|
||||
val flexArchiveName = ExtensionDefaults.createFlexName(extEditor.meta.id)
|
||||
val flexArchiveFile = workspace.dir.subFile(flexArchiveName)
|
||||
ZipUtils.zip(workspace.saverDir, flexArchiveFile)
|
||||
val sourceRef = if (isCreateExt) {
|
||||
FlorisRef.internal(ExtensionManager.IME_THEME_PATH).subRef(flexArchiveName)
|
||||
} else {
|
||||
workspace.ext!!.sourceRef!!
|
||||
}
|
||||
flexArchiveFile.copyTo(sourceRef.absoluteFile(context), overwrite = true)
|
||||
workspace.close()
|
||||
navController.popBackStack()
|
||||
}
|
||||
|
||||
navigationIcon {
|
||||
FlorisIconButton(
|
||||
onClick = { handleBackPress() },
|
||||
icon = painterResource(R.drawable.ic_arrow_back),
|
||||
)
|
||||
}
|
||||
|
||||
bottomBar {
|
||||
FlorisButtonBar {
|
||||
ButtonBarSpacer()
|
||||
ButtonBarTextButton(text = stringRes(R.string.action__cancel)) {
|
||||
handleBackPress()
|
||||
}
|
||||
ButtonBarButton(text = stringRes(R.string.action__save)) {
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content {
|
||||
BackHandler {
|
||||
handleBackPress()
|
||||
}
|
||||
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
) {
|
||||
this@content.Preference(
|
||||
onClick = { workspace.currentAction = EditorAction.ManageMetaData },
|
||||
iconId = R.drawable.ic_code,
|
||||
title = stringRes(R.string.ext__editor__metadata__title),
|
||||
)
|
||||
this@content.Preference(
|
||||
onClick = { workspace.currentAction = EditorAction.ManageDependencies },
|
||||
iconId = R.drawable.ic_library_books,
|
||||
title = stringRes(R.string.ext__editor__dependencies__title),
|
||||
)
|
||||
this@content.Preference(
|
||||
onClick = { workspace.currentAction = EditorAction.ManageFiles },
|
||||
iconId = R.drawable.ic_file_blank,
|
||||
title = stringRes(R.string.ext__editor__files__title),
|
||||
)
|
||||
}
|
||||
|
||||
when (extEditor) {
|
||||
is ThemeExtensionEditor -> {
|
||||
ExtensionComponentListView(
|
||||
title = stringRes(R.string.ext__meta__components_theme),
|
||||
components = extEditor.themes,
|
||||
onCreateBtnClick = {
|
||||
workspace.currentAction = EditorAction.CreateComponent(ThemeExtensionComponent::class)
|
||||
},
|
||||
) { component ->
|
||||
ExtensionComponentView(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
meta = extEditor.meta,
|
||||
component = component,
|
||||
onDeleteBtnClick = { workspace.update { extEditor.themes.remove(component) } },
|
||||
onEditBtnClick = { workspace.currentAction = EditorAction.ManageComponent(component) },
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Render nothing
|
||||
}
|
||||
}
|
||||
|
||||
if (showUnsavedChangesDialog) {
|
||||
FlorisUnsavedChangesDialog(
|
||||
onSave = {
|
||||
handleSave()
|
||||
},
|
||||
onDiscard = {
|
||||
navController.popBackStack()
|
||||
showUnsavedChangesDialog = false
|
||||
},
|
||||
onDismiss = {
|
||||
showUnsavedChangesDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showInvalidMetadataDialog) {
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(R.string.ext__editor__metadata__title_invalid),
|
||||
confirmLabel = stringRes(R.string.action__ok),
|
||||
onConfirm = {
|
||||
showInvalidMetadataDialog = false
|
||||
},
|
||||
onDismiss = {
|
||||
showInvalidMetadataDialog = false
|
||||
},
|
||||
content = {
|
||||
Text(text = stringRes(R.string.ext__editor__metadata__message_invalid))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManageMetaDataScreen(
|
||||
workspace: CacheManager.ExtEditorWorkspace<*>,
|
||||
isCreateExt: Boolean,
|
||||
) = FlorisScreen {
|
||||
title = stringRes(R.string.ext__editor__metadata__title)
|
||||
|
||||
val meta = workspace.editor?.meta ?: return@FlorisScreen
|
||||
var showValidationErrors by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var id by rememberSaveable { mutableStateOf(meta.id) }
|
||||
val idValidation = rememberValidationResult(ExtensionValidation.MetaId, id)
|
||||
var version by rememberSaveable { mutableStateOf(meta.version) }
|
||||
val versionValidation = rememberValidationResult(ExtensionValidation.MetaVersion, version)
|
||||
var title by rememberSaveable { mutableStateOf(meta.title) }
|
||||
val titleValidation = rememberValidationResult(ExtensionValidation.MetaTitle, title)
|
||||
var description by rememberSaveable { mutableStateOf(meta.description ?: "") }
|
||||
var keywords by rememberSaveable { mutableStateOf(meta.keywords?.joinToString("\n") ?: "") }
|
||||
var homepage by rememberSaveable { mutableStateOf(meta.homepage ?: "") }
|
||||
var issueTracker by rememberSaveable { mutableStateOf(meta.issueTracker ?: "") }
|
||||
var maintainers by rememberSaveable { mutableStateOf(meta.maintainers.joinToString("\n")) }
|
||||
val maintainersValidation = rememberValidationResult(ExtensionValidation.MetaMaintainers, maintainers)
|
||||
var license by rememberSaveable { mutableStateOf(meta.license) }
|
||||
val licenseValidation = rememberValidationResult(ExtensionValidation.MetaLicense, license)
|
||||
|
||||
fun handleBackPress() {
|
||||
workspace.currentAction = null
|
||||
}
|
||||
|
||||
fun handleApply() {
|
||||
val invalid = idValidation.isInvalid() ||
|
||||
versionValidation.isInvalid() ||
|
||||
titleValidation.isInvalid() ||
|
||||
maintainersValidation.isInvalid() ||
|
||||
licenseValidation.isInvalid()
|
||||
if (invalid) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
workspace.update {
|
||||
workspace.editor?.meta = ExtensionMeta(
|
||||
id = id.trim(),
|
||||
version = version.trim(),
|
||||
title = title.trim(),
|
||||
description = description.trim().takeIf { it.isNotBlank() },
|
||||
keywords = keywords.lines().map { it.trim() }.filter { it.isNotBlank() }.takeIf { it.isNotEmpty() },
|
||||
homepage = homepage.trim().takeIf { it.isNotBlank() },
|
||||
issueTracker = issueTracker.trim().takeIf { it.isNotBlank() },
|
||||
maintainers = maintainers.lines().map { it.trim() }.filter { it.isNotBlank() }
|
||||
.map { ExtensionMaintainer.fromOrTakeRaw(it) },
|
||||
license = license.trim(),
|
||||
)
|
||||
}
|
||||
workspace.currentAction = null
|
||||
}
|
||||
}
|
||||
|
||||
navigationIcon {
|
||||
FlorisIconButton(
|
||||
onClick = { handleBackPress() },
|
||||
icon = painterResource(R.drawable.ic_close),
|
||||
)
|
||||
}
|
||||
|
||||
bottomBar {
|
||||
FlorisButtonBar {
|
||||
ButtonBarSpacer()
|
||||
ButtonBarTextButton(text = stringRes(R.string.action__cancel)) {
|
||||
handleBackPress()
|
||||
}
|
||||
ButtonBarButton(text = stringRes(R.string.action__apply)) {
|
||||
handleApply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content {
|
||||
BackHandler {
|
||||
handleBackPress()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(MetaDataContentPadding)) {
|
||||
EditorSheetTextField(
|
||||
enabled = isCreateExt,
|
||||
isRequired = true,
|
||||
value = id,
|
||||
onValueChange = { id = it },
|
||||
label = stringRes(R.string.ext__meta__id),
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = idValidation,
|
||||
)
|
||||
EditorSheetTextField(
|
||||
isRequired = true,
|
||||
value = version,
|
||||
onValueChange = { version = it },
|
||||
label = stringRes(R.string.ext__meta__version),
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = versionValidation,
|
||||
)
|
||||
EditorSheetTextField(
|
||||
isRequired = true,
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = stringRes(R.string.ext__meta__title),
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = titleValidation,
|
||||
)
|
||||
EditorSheetTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = stringRes(R.string.ext__meta__description),
|
||||
)
|
||||
EditorSheetTextField(
|
||||
value = keywords,
|
||||
onValueChange = { keywords = it },
|
||||
label = stringRes(R.string.ext__meta__keywords),
|
||||
singleLine = false,
|
||||
)
|
||||
EditorSheetTextField(
|
||||
value = homepage,
|
||||
onValueChange = { homepage = it },
|
||||
label = stringRes(R.string.ext__meta__homepage),
|
||||
)
|
||||
EditorSheetTextField(
|
||||
value = issueTracker,
|
||||
onValueChange = { issueTracker = it },
|
||||
label = stringRes(R.string.ext__meta__issue_tracker),
|
||||
)
|
||||
EditorSheetTextField(
|
||||
isRequired = true,
|
||||
value = maintainers,
|
||||
onValueChange = { maintainers = it },
|
||||
label = stringRes(R.string.ext__meta__maintainers),
|
||||
singleLine = false,
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = maintainersValidation,
|
||||
)
|
||||
EditorSheetTextField(
|
||||
isRequired = true,
|
||||
value = license,
|
||||
onValueChange = { license = it },
|
||||
label = stringRes(R.string.ext__meta__license),
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = licenseValidation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManageDependenciesScreen(workspace: CacheManager.ExtEditorWorkspace<*>) = FlorisScreen {
|
||||
title = stringRes(R.string.ext__editor__dependencies__title)
|
||||
|
||||
val dependencyList = workspace.editor?.dependencies ?: return@FlorisScreen
|
||||
|
||||
fun handleBackPress() {
|
||||
workspace.currentAction = null
|
||||
}
|
||||
|
||||
navigationIcon {
|
||||
FlorisIconButton(
|
||||
onClick = { handleBackPress() },
|
||||
icon = painterResource(R.drawable.ic_close),
|
||||
)
|
||||
}
|
||||
|
||||
content {
|
||||
BackHandler {
|
||||
handleBackPress()
|
||||
}
|
||||
|
||||
FlorisInfoCard(
|
||||
modifier = Modifier.padding(all = 8.dp),
|
||||
text = """
|
||||
Dependencies are currently not implemented, but are already somewhat
|
||||
integrated as a placeholder for the future.
|
||||
""".trimIndent().replace('\n', ' '),
|
||||
)
|
||||
if (dependencyList.isEmpty()) {
|
||||
Text(text = "no deps found")
|
||||
} else {
|
||||
for (dependency in dependencyList) {
|
||||
Text(text = dependency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ManageFilesScreen(workspace: CacheManager.ExtEditorWorkspace<*>) = FlorisScreen {
|
||||
title = stringRes(R.string.ext__editor__files__title)
|
||||
|
||||
fun handleBackPress() {
|
||||
workspace.currentAction = null
|
||||
}
|
||||
|
||||
navigationIcon {
|
||||
FlorisIconButton(
|
||||
onClick = { handleBackPress() },
|
||||
icon = painterResource(R.drawable.ic_close),
|
||||
)
|
||||
}
|
||||
|
||||
content {
|
||||
BackHandler {
|
||||
handleBackPress()
|
||||
}
|
||||
|
||||
FlorisInfoCard(
|
||||
modifier = Modifier.padding(all = 8.dp),
|
||||
text = """
|
||||
Managing archive files is currently not supported.
|
||||
""".trimIndent().replace('\n', ' '),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CreateFrom {
|
||||
EMPTY,
|
||||
EXISTING;
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun <T : ExtensionComponent> CreateComponentScreen(
|
||||
workspace: CacheManager.ExtEditorWorkspace<*>,
|
||||
type: KClass<T>,
|
||||
) = FlorisScreen {
|
||||
title = stringRes(when (type) {
|
||||
ThemeExtensionComponent::class -> R.string.ext__editor__create_component__title_theme
|
||||
else -> R.string.ext__editor__create_component__title
|
||||
})
|
||||
|
||||
val context = LocalContext.current
|
||||
val extensionManager by context.extensionManager()
|
||||
val themeManager by context.themeManager()
|
||||
|
||||
var createFrom by rememberSaveable { mutableStateOf(CreateFrom.EXISTING) }
|
||||
val extId = workspace.editor?.meta?.id ?: "null"
|
||||
val components = remember<Map<ExtensionComponentName, ExtensionComponent>> {
|
||||
when (val editor = workspace.editor) {
|
||||
is ThemeExtensionEditor -> buildMap {
|
||||
for (theme in editor.themes) {
|
||||
put(ExtensionComponentName(extId, theme.id), theme)
|
||||
}
|
||||
for ((componentName, theme) in themeManager.indexedThemeConfigs.value ?: emptyMap()) {
|
||||
if (componentName.extensionId != extId) {
|
||||
put(componentName, theme)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
emptyMap()
|
||||
}
|
||||
}
|
||||
}
|
||||
var selectedComponentName by rememberSaveable(stateSaver = ExtensionComponentName.Saver) {
|
||||
mutableStateOf(null)
|
||||
}
|
||||
var showValidationErrors by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var newId by rememberSaveable { mutableStateOf("") }
|
||||
val newIdValidation = rememberValidationResult(ExtensionValidation.ComponentId, newId)
|
||||
var newLabel by rememberSaveable { mutableStateOf("") }
|
||||
val newLabelValidation = rememberValidationResult(ExtensionValidation.ComponentLabel, newLabel)
|
||||
var newAuthors by rememberSaveable { mutableStateOf("") }
|
||||
val newAuthorsValidation = rememberValidationResult(ExtensionValidation.ComponentAuthors, newAuthors)
|
||||
|
||||
fun handleBackPress() {
|
||||
workspace.currentAction = null
|
||||
}
|
||||
|
||||
fun handleCreate() {
|
||||
val invalid = createFrom == CreateFrom.EMPTY && (newIdValidation.isInvalid() ||
|
||||
newLabelValidation.isInvalid() || newAuthorsValidation.isInvalid())
|
||||
if (invalid) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
when (val editor = workspace.editor) {
|
||||
is ThemeExtensionEditor -> {
|
||||
when (createFrom) {
|
||||
CreateFrom.EMPTY -> {
|
||||
if (editor.themes.any { it.id == newId.trim() }) {
|
||||
context.showLongToast("A theme with this ID already exists!")
|
||||
} else {
|
||||
val componentEditor = ThemeExtensionComponentEditor(
|
||||
id = newId.trim(),
|
||||
label = newLabel.trim(),
|
||||
authors = newAuthors.lines().map { it.trim() }.filter { it.isNotBlank() },
|
||||
)
|
||||
editor.themes.add(componentEditor)
|
||||
workspace.currentAction = null
|
||||
}
|
||||
}
|
||||
CreateFrom.EXISTING -> {
|
||||
val componentName = selectedComponentName ?: return
|
||||
val componentId = if (editor.themes.any { it.id == componentName.componentId }) {
|
||||
var suffix = 1
|
||||
var tempId: String
|
||||
do {
|
||||
tempId = "${componentName.componentId}_${suffix++}"
|
||||
} while (editor.themes.any { it.id == tempId })
|
||||
tempId
|
||||
} else {
|
||||
componentName.componentId
|
||||
}
|
||||
if (componentName.extensionId == extId) {
|
||||
val component = editor.themes.find { it.id == componentName.componentId } ?: return
|
||||
val componentEditor = component.let { c ->
|
||||
ThemeExtensionComponentEditor(
|
||||
componentId, c.label, c.authors, c.isNightTheme, c.isBorderless,
|
||||
c.isMaterialYouAware, stylesheetPath = "",
|
||||
).also { it.stylesheetEditor = c.stylesheetEditor }
|
||||
}
|
||||
if (componentEditor.stylesheetEditor != null) {
|
||||
val stylesheet = componentEditor.stylesheetEditor!!.build()
|
||||
val stylesheetFile = workspace.extDir.subFile(componentEditor.stylesheetPath())
|
||||
stylesheetFile.parentFile?.mkdirs()
|
||||
stylesheetFile.writeJson(stylesheet, SnyggStylesheetJsonConfig)
|
||||
componentEditor.stylesheetEditor = null
|
||||
} else {
|
||||
val srcStylesheetFile = workspace.extDir.subFile(component.stylesheetPath())
|
||||
val dstStylesheetFile = workspace.extDir.subFile(componentEditor.stylesheetPath())
|
||||
dstStylesheetFile.parentFile?.mkdirs()
|
||||
srcStylesheetFile.copyTo(dstStylesheetFile, overwrite = true)
|
||||
}
|
||||
editor.themes.add(componentEditor)
|
||||
} else {
|
||||
val component = themeManager.indexedThemeConfigs.value?.get(componentName) ?: return
|
||||
val componentEditor = (component as? ThemeExtensionComponentImpl)?.edit() ?: return
|
||||
componentEditor.id = componentId
|
||||
componentEditor.stylesheetPath = ""
|
||||
val externalExt = extensionManager.getExtensionById(componentName.extensionId) ?: return
|
||||
val stylesheetJson = ZipUtils.readFileFromArchive(
|
||||
context, externalExt.sourceRef!!, component.stylesheetPath()
|
||||
).getOrNull() ?: return
|
||||
val dstStylesheetFile = workspace.extDir.subFile(componentEditor.stylesheetPath())
|
||||
dstStylesheetFile.parentFile?.mkdirs()
|
||||
dstStylesheetFile.writeText(stylesheetJson)
|
||||
editor.themes.add(componentEditor)
|
||||
}
|
||||
workspace.currentAction = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun hasSufficientInfoForCreating(): Boolean {
|
||||
return when (createFrom) {
|
||||
CreateFrom.EMPTY -> newId.isNotBlank() && newLabel.isNotBlank() && newAuthors.isNotBlank()
|
||||
CreateFrom.EXISTING -> components.containsKey(selectedComponentName)
|
||||
}
|
||||
}
|
||||
|
||||
navigationIcon {
|
||||
FlorisIconButton(
|
||||
onClick = { handleBackPress() },
|
||||
icon = painterResource(R.drawable.ic_close),
|
||||
)
|
||||
}
|
||||
|
||||
bottomBar {
|
||||
FlorisButtonBar {
|
||||
ButtonBarSpacer()
|
||||
ButtonBarTextButton(text = stringRes(R.string.action__cancel)) {
|
||||
handleBackPress()
|
||||
}
|
||||
ButtonBarButton(
|
||||
text = stringRes(R.string.action__create),
|
||||
enabled = hasSufficientInfoForCreating(),
|
||||
) {
|
||||
handleCreate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
content {
|
||||
BackHandler {
|
||||
handleBackPress()
|
||||
}
|
||||
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
) {
|
||||
RadioListItem(
|
||||
onClick = { createFrom = CreateFrom.EXISTING },
|
||||
selected = createFrom == CreateFrom.EXISTING,
|
||||
text = stringRes(R.string.ext__editor__create_component__from_existing),
|
||||
)
|
||||
RadioListItem(
|
||||
onClick = { createFrom = CreateFrom.EMPTY },
|
||||
selected = createFrom == CreateFrom.EMPTY,
|
||||
text = stringRes(R.string.ext__editor__create_component__from_empty),
|
||||
)
|
||||
}
|
||||
|
||||
if (createFrom == CreateFrom.EXISTING) {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
) {
|
||||
for ((componentName, component) in components) {
|
||||
RadioListItem(
|
||||
onClick = { selectedComponentName = componentName },
|
||||
selected = selectedComponentName == componentName,
|
||||
text = component.label,
|
||||
secondaryText = componentName.toString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (createFrom == CreateFrom.EMPTY) {
|
||||
FlorisInfoCard(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
text = stringRes(R.string.ext__editor__create_component__from_empty_warning),
|
||||
)
|
||||
DialogProperty(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = stringRes(R.string.ext__meta__id),
|
||||
) {
|
||||
FlorisOutlinedTextField(
|
||||
value = newId,
|
||||
onValueChange = { newId = it },
|
||||
singleLine = true,
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = newIdValidation,
|
||||
)
|
||||
}
|
||||
DialogProperty(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = stringRes(R.string.ext__meta__label),
|
||||
) {
|
||||
FlorisOutlinedTextField(
|
||||
value = newLabel,
|
||||
onValueChange = { newLabel = it },
|
||||
singleLine = true,
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = newLabelValidation,
|
||||
)
|
||||
}
|
||||
DialogProperty(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
text = stringRes(R.string.ext__meta__authors),
|
||||
) {
|
||||
FlorisOutlinedTextField(
|
||||
value = newAuthors,
|
||||
onValueChange = { newAuthors = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = newAuthorsValidation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditorSheetTextField(
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
isRequired: Boolean = false,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
singleLine: Boolean = true,
|
||||
showValidationError: Boolean = false,
|
||||
validationResult: ValidationResult? = null,
|
||||
) {
|
||||
val borderColor = MaterialTheme.colors.outline
|
||||
Column(modifier = Modifier.padding(vertical = TextFieldVerticalPadding)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = TextFieldVerticalPadding),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.subtitle2,
|
||||
)
|
||||
if (isRequired) {
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 2.dp),
|
||||
text = "*",
|
||||
style = MaterialTheme.typography.subtitle2,
|
||||
color = MaterialTheme.colors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
FlorisOutlinedTextField(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
enabled = enabled,
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = singleLine,
|
||||
showValidationError = showValidationError,
|
||||
validationResult = validationResult,
|
||||
colors = TextFieldDefaults.outlinedTextFieldColors(
|
||||
unfocusedBorderColor = borderColor,
|
||||
disabledBorderColor = borderColor,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import dev.patrickgold.florisboard.res.ext.ExtensionDefaults
|
||||
fun ExtensionExportScreen(id: String) {
|
||||
val context = LocalContext.current
|
||||
val extensionManager by context.extensionManager()
|
||||
|
||||
val ext = extensionManager.getExtensionById(id)
|
||||
if (ext != null) {
|
||||
ExportScreen(ext)
|
||||
|
||||
@@ -51,6 +51,7 @@ import dev.patrickgold.florisboard.app.ui.components.FlorisButtonBar
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.florisHorizontalScroll
|
||||
import dev.patrickgold.florisboard.cacheManager
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
@@ -213,7 +214,7 @@ fun ExtensionImportScreen(type: ExtensionImportScreenType, initUuid: String?) =
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(horizontal = 16.dp),
|
||||
text = stringRes(R.string.ext__import__no_files_selected),
|
||||
text = stringRes(R.string.state__no_files_selected),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
@@ -251,9 +252,7 @@ private fun FileInfoView(
|
||||
fileInfo: CacheManager.FileInfo,
|
||||
) {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = fileInfo.file.name,
|
||||
subtitle = fileInfo.mediaType ?: "application/unknown",
|
||||
) {
|
||||
|
||||
@@ -69,7 +69,7 @@ fun ExtensionMaintainerChip(
|
||||
if (maintainer.email != null) {
|
||||
FlorisChip(
|
||||
onClick = { context.launchUrl("mailto:${maintainer.email}") },
|
||||
text = maintainer.email,
|
||||
text = maintainer.email.toString(),
|
||||
leadingIcons = listOf(R.drawable.ic_email),
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
)
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.ext
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -28,11 +27,8 @@ import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -44,12 +40,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
@@ -58,23 +50,21 @@ import dev.patrickgold.florisboard.app.ui.components.FlorisConfirmDeleteDialog
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisHyperlinkText
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtension
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponentImpl
|
||||
import dev.patrickgold.florisboard.res.FlorisRef
|
||||
import dev.patrickgold.florisboard.res.ext.Extension
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMaintainer
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMeta
|
||||
|
||||
private val ComponentCardShape = RoundedCornerShape(8.dp)
|
||||
|
||||
@Composable
|
||||
fun ExtensionViewScreen(id: String) {
|
||||
val context = LocalContext.current
|
||||
val extensionManager by context.extensionManager()
|
||||
|
||||
val ext = extensionManager.getExtensionById(id)
|
||||
if (ext != null) {
|
||||
ViewScreen(ext)
|
||||
@@ -129,7 +119,7 @@ private fun ViewScreen(ext: Extension) = FlorisScreen {
|
||||
if (!ext.meta.homepage.isNullOrBlank()) {
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__homepage)) {
|
||||
FlorisHyperlinkText(
|
||||
text = FlorisRef.from(ext.meta.homepage!!).authority,
|
||||
text = FlorisRef.fromUrl(ext.meta.homepage!!).authority,
|
||||
url = ext.meta.homepage!!,
|
||||
)
|
||||
}
|
||||
@@ -137,7 +127,7 @@ private fun ViewScreen(ext: Extension) = FlorisScreen {
|
||||
if (!ext.meta.issueTracker.isNullOrBlank()) {
|
||||
ExtensionMetaRowSimpleText(label = stringRes(R.string.ext__meta__issue_tracker)) {
|
||||
FlorisHyperlinkText(
|
||||
text = FlorisRef.from(ext.meta.issueTracker!!).authority,
|
||||
text = FlorisRef.fromUrl(ext.meta.issueTracker!!).authority,
|
||||
url = ext.meta.issueTracker!!,
|
||||
)
|
||||
}
|
||||
@@ -169,13 +159,24 @@ private fun ViewScreen(ext: Extension) = FlorisScreen {
|
||||
text = stringRes(R.string.action__export),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
text = stringRes(R.string.ext__meta__components),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
ExtensionComponentListView(ext)
|
||||
}
|
||||
|
||||
when (ext) {
|
||||
is ThemeExtension -> {
|
||||
ExtensionComponentListView(
|
||||
title = stringRes(R.string.ext__meta__components_theme),
|
||||
components = ext.themes,
|
||||
) { component ->
|
||||
ExtensionComponentView(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
meta = ext.meta,
|
||||
component = component,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Render nothing
|
||||
}
|
||||
}
|
||||
|
||||
if (extToDelete != null) {
|
||||
@@ -200,82 +201,6 @@ private fun ViewScreen(ext: Extension) = FlorisScreen {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionComponentListView(ext: Extension) {
|
||||
val components = ext.rememberComponents()
|
||||
if (components.isEmpty()) {
|
||||
ExtensionComponentNoneFoundView()
|
||||
} else {
|
||||
for (component in components) {
|
||||
ExtensionComponentView(ext, component) {
|
||||
when (component) {
|
||||
is ThemeExtensionComponent -> {
|
||||
val text = remember(component) {
|
||||
buildString {
|
||||
appendLine("authors = ${component.authors}")
|
||||
appendLine("isNightTheme = ${component.isNightTheme}")
|
||||
appendLine("isBorderless = ${component.isBorderless}")
|
||||
appendLine("isMaterialYouAware = ${component.isMaterialYouAware}")
|
||||
append("stylesheetPath = ${component.stylesheetPath()}")
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
ExtensionComponentNoneFoundView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionComponentView(
|
||||
ext: Extension,
|
||||
component: ExtensionComponent,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val componentName = remember { ExtensionComponentName(ext.meta.id, component.id) }
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxWidth()
|
||||
.border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), ComponentCardShape)
|
||||
.padding(vertical = 8.dp, horizontal = 14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringRes(when (component) {
|
||||
is ThemeExtensionComponent -> R.string.ext__meta__components_label_theme
|
||||
else -> R.string.ext__meta__components_label_generic
|
||||
}, "label" to component.label),
|
||||
style = MaterialTheme.typography.subtitle2,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
text = remember { componentName.toString() },
|
||||
color = LocalContentColor.current.copy(alpha = 0.56f),
|
||||
fontWeight = FontWeight.Normal,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontSize = 10.sp,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionComponentNoneFoundView() {
|
||||
Text(
|
||||
text = stringRes(R.string.ext__meta__components_none_found),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ExtensionMetaRowSimpleText(
|
||||
label: String,
|
||||
@@ -343,7 +268,7 @@ private fun PreviewExtensionViewerScreen() {
|
||||
),
|
||||
dependencies = null,
|
||||
themes = listOf(
|
||||
ThemeExtensionComponent(id = "test", label = "Test", authors = listOf(), stylesheetPath = "test.json"),
|
||||
ThemeExtensionComponentImpl(id = "test", label = "Test", authors = listOf(), stylesheetPath = "test.json"),
|
||||
),
|
||||
)
|
||||
ViewScreen(ext = testExtension)
|
||||
|
||||
@@ -52,7 +52,7 @@ import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
@Composable
|
||||
fun HomeScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.settings__home__title)
|
||||
backArrowVisible = false
|
||||
navigationIconVisible = false
|
||||
previewFieldVisible = true
|
||||
|
||||
val navController = LocalNavController.current
|
||||
@@ -109,13 +109,11 @@ fun HomeScreen() = FlorisScreen {
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("Current version: ${BuildConfig.VERSION_NAME}\n")
|
||||
Text("List of unavailable features (and when they will get re-implemented):\n")
|
||||
Text(" - In-app theme customization (beta09) -> Theme import/export of custom themes is already possible, also the new engine is already pretty advanced and usable with imported theme extensions")
|
||||
Text(" - Glide typing bug fixes (beta09) -> glide works somewhat but long words tend to not get recognized")
|
||||
Text(" - Emoji view (beta10)")
|
||||
Text(" - Landscape fullscreen input (beta10)")
|
||||
Text(" - Word suggestions (will just show word + a number to test out if the UI works) (beta10+, new suggestion algorithm 0.3.15/16)\n")
|
||||
Text("Please do not file issues that these features do not work while the current version is below the intended re-implementation version. Thank you!\n")
|
||||
Text("List of unavailable features, will get implemented/fixed in the upcoming beta releases:\n")
|
||||
Text(" - Glide typing bug fixes -> glide works somewhat but long words tend to not get recognized")
|
||||
Text(" - Basic emoji view")
|
||||
Text(" - Word suggestions (will just show word + a number to test out if the UI works) (new suggestions in 0.4.0)\n")
|
||||
Text("Please do not file issues that above features do not work (especially word suggestions, it is more than known by now and the major goal for 0.4.0 after the preference rework and its hotfix phase has been completed). Thank you!\n")
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
@@ -136,7 +134,7 @@ fun HomeScreen() = FlorisScreen {
|
||||
onClick = { navController.navigate(Routes.Settings.Keyboard) },
|
||||
)
|
||||
Preference(
|
||||
iconId = null,
|
||||
iconId = R.drawable.ic_smartbar,
|
||||
title = stringRes(R.string.settings__smartbar__title),
|
||||
onClick = { navController.navigate(Routes.Settings.Smartbar) },
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ data class Library(val name: String, val licenseText: String)
|
||||
fun ThirdPartyLicensesScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.about__third_party_licenses__title)
|
||||
scrollable = false
|
||||
iconSpaceReserved = false
|
||||
|
||||
val context = LocalContext.current
|
||||
|
||||
|
||||
@@ -17,30 +17,44 @@
|
||||
package dev.patrickgold.florisboard.app.ui.settings.advanced
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.AppTheme
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.Routes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.common.FlorisLocale
|
||||
import dev.patrickgold.florisboard.common.android.AndroidVersion
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.datastore.ui.ListPreference
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import dev.patrickgold.jetpref.datastore.ui.PreferenceGroup
|
||||
import dev.patrickgold.jetpref.datastore.ui.SwitchPreference
|
||||
import dev.patrickgold.jetpref.datastore.ui.listPrefEntries
|
||||
|
||||
@Composable
|
||||
fun AdvancedScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.settings__advanced__title)
|
||||
previewFieldVisible = true
|
||||
previewFieldVisible = false
|
||||
|
||||
val navController = LocalNavController.current
|
||||
|
||||
content {
|
||||
ListPreference(
|
||||
prefs.advanced.settingsTheme,
|
||||
iconId = R.drawable.ic_palette,
|
||||
title = stringRes(R.string.pref__advanced__settings_theme__label),
|
||||
entries = listPrefEntries {
|
||||
entry(
|
||||
key = AppTheme.AUTO,
|
||||
label = stringRes(R.string.settings__system_default),
|
||||
)
|
||||
entry(
|
||||
key = AppTheme.AUTO_AMOLED,
|
||||
label = stringRes(R.string.pref__advanced__settings_theme__auto_amoled),
|
||||
)
|
||||
entry(
|
||||
key = AppTheme.LIGHT,
|
||||
label = stringRes(R.string.pref__advanced__settings_theme__light),
|
||||
@@ -57,6 +71,7 @@ fun AdvancedScreen() = FlorisScreen {
|
||||
)
|
||||
ListPreference(
|
||||
prefs.advanced.settingsLanguage,
|
||||
iconId = R.drawable.ic_language,
|
||||
title = stringRes(R.string.pref__advanced__settings_language__label),
|
||||
entries = listPrefEntries {
|
||||
listOf(
|
||||
@@ -106,14 +121,19 @@ fun AdvancedScreen() = FlorisScreen {
|
||||
label = stringRes(R.string.settings__system_default),
|
||||
)
|
||||
} else {
|
||||
val displayLanguageNamesIn by prefs.localization.displayLanguageNamesIn.observeAsState()
|
||||
val locale = FlorisLocale.fromTag(languageTag)
|
||||
entry(locale.languageTag(), locale.displayName(locale))
|
||||
entry(locale.languageTag(), when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> locale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> locale.displayName(locale)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
SwitchPreference(
|
||||
prefs.advanced.showAppIcon,
|
||||
iconId = R.drawable.ic_preview,
|
||||
title = stringRes(R.string.pref__advanced__show_app_icon__label),
|
||||
summary = when {
|
||||
AndroidVersion.ATLEAST_API29_Q -> stringRes(R.string.pref__advanced__show_app_icon__summary_atleast_q)
|
||||
@@ -123,8 +143,24 @@ fun AdvancedScreen() = FlorisScreen {
|
||||
)
|
||||
SwitchPreference(
|
||||
prefs.advanced.forcePrivateMode,
|
||||
iconId = R.drawable.ic_security,
|
||||
title = stringRes(R.string.pref__advanced__force_private_mode__label),
|
||||
summary = stringRes(R.string.pref__advanced__force_private_mode__summary),
|
||||
)
|
||||
|
||||
PreferenceGroup(title = stringRes(R.string.backup_and_restore__title)) {
|
||||
Preference(
|
||||
onClick = { navController.navigate(Routes.Settings.Backup) },
|
||||
iconId = R.drawable.ic_archive,
|
||||
title = stringRes(R.string.backup_and_restore__back_up__title),
|
||||
summary = stringRes(R.string.backup_and_restore__back_up__summary),
|
||||
)
|
||||
Preference(
|
||||
onClick = { navController.navigate(Routes.Settings.Restore) },
|
||||
iconId = R.drawable.ic_settings_backup_restore,
|
||||
title = stringRes(R.string.backup_and_restore__restore__title),
|
||||
summary = stringRes(R.string.backup_and_restore__restore__summary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.advanced
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.material.Checkbox
|
||||
import androidx.compose.material.RadioButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.ShareCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import dev.patrickgold.florisboard.BuildConfig
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisButtonBar
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.rippleClickable
|
||||
import dev.patrickgold.florisboard.cacheManager
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
import dev.patrickgold.florisboard.common.android.writeFromFile
|
||||
import dev.patrickgold.florisboard.res.FileRegistry
|
||||
import dev.patrickgold.florisboard.res.ZipUtils
|
||||
import dev.patrickgold.florisboard.res.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionManager
|
||||
import dev.patrickgold.florisboard.res.io.subDir
|
||||
import dev.patrickgold.florisboard.res.io.subFile
|
||||
import dev.patrickgold.florisboard.res.io.writeJson
|
||||
import dev.patrickgold.jetpref.datastore.jetprefDatastoreDir
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
object Backup {
|
||||
const val FILE_PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.provider.file"
|
||||
const val METADATA_JSON_NAME = "backup_metadata.json"
|
||||
|
||||
fun defaultFileName(metadata: Metadata): String {
|
||||
return "backup_${metadata.packageName}_${metadata.versionCode}_${metadata.timestamp}.zip"
|
||||
}
|
||||
|
||||
enum class Destination {
|
||||
FILE_SYS,
|
||||
SHARE_INTENT;
|
||||
}
|
||||
|
||||
class FilesSelector {
|
||||
var jetprefDatastore by mutableStateOf(true)
|
||||
var imeKeyboard by mutableStateOf(true)
|
||||
var imeSpelling by mutableStateOf(true)
|
||||
var imeTheme by mutableStateOf(true)
|
||||
|
||||
fun atLeastOneSelected(): Boolean {
|
||||
return jetprefDatastore || imeKeyboard || imeSpelling || imeTheme
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Metadata(
|
||||
@SerialName("package")
|
||||
val packageName: String,
|
||||
val versionCode: Int,
|
||||
val versionName: String,
|
||||
val timestamp: Long,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackupScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.backup_and_restore__back_up__title)
|
||||
previewFieldVisible = false
|
||||
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val cacheManager by context.cacheManager()
|
||||
|
||||
var backupDestination by remember { mutableStateOf(Backup.Destination.FILE_SYS) }
|
||||
val backupFilesSelector = remember { Backup.FilesSelector() }
|
||||
var backupWorkspace: CacheManager.BackupAndRestoreWorkspace? = null
|
||||
|
||||
val backUpToFileSystemLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { uri ->
|
||||
if (uri == null) {
|
||||
// User can modify checkboxes between cancellation and second
|
||||
// trigger, so we make sure to clear out the previous workspace
|
||||
backupWorkspace?.close()
|
||||
backupWorkspace = null
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
runCatching {
|
||||
context.contentResolver.writeFromFile(uri, backupWorkspace!!.zipFile)
|
||||
backupWorkspace!!.close()
|
||||
}.onSuccess {
|
||||
context.showLongToast(R.string.backup_and_restore__back_up__success)
|
||||
navController.popBackStack()
|
||||
}.onFailure { error ->
|
||||
context.showLongToast(R.string.backup_and_restore__back_up__failure, "error_message" to error.localizedMessage)
|
||||
backupWorkspace = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
fun prepareBackupWorkspace() {
|
||||
val workspace = cacheManager.backupAndRestore.new()
|
||||
if (backupFilesSelector.jetprefDatastore) {
|
||||
context.jetprefDatastoreDir.let { dir ->
|
||||
dir.copyRecursively(workspace.inputDir.subDir(dir.name))
|
||||
}
|
||||
}
|
||||
val workspaceFilesDir = workspace.inputDir.subDir("files")
|
||||
if (backupFilesSelector.imeKeyboard) {
|
||||
context.filesDir.subDir(ExtensionManager.IME_KEYBOARD_PATH).let { dir ->
|
||||
dir.copyRecursively(workspaceFilesDir.subDir(ExtensionManager.IME_KEYBOARD_PATH))
|
||||
}
|
||||
}
|
||||
if (backupFilesSelector.imeSpelling) {
|
||||
context.filesDir.subDir(ExtensionManager.IME_SPELLING_PATH).let { dir ->
|
||||
dir.copyRecursively(workspaceFilesDir.subDir(ExtensionManager.IME_SPELLING_PATH))
|
||||
}
|
||||
}
|
||||
if (backupFilesSelector.imeTheme) {
|
||||
context.filesDir.subDir(ExtensionManager.IME_THEME_PATH).let { dir ->
|
||||
dir.copyRecursively(workspaceFilesDir.subDir(ExtensionManager.IME_THEME_PATH))
|
||||
}
|
||||
}
|
||||
workspace.metadata = Backup.Metadata(
|
||||
packageName = BuildConfig.APPLICATION_ID,
|
||||
versionCode = BuildConfig.VERSION_CODE,
|
||||
versionName = BuildConfig.VERSION_NAME,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
)
|
||||
workspace.inputDir.subFile(Backup.METADATA_JSON_NAME).writeJson(workspace.metadata)
|
||||
workspace.zipFile = workspace.outputDir.subFile(Backup.defaultFileName(workspace.metadata))
|
||||
ZipUtils.zip(workspace.inputDir, workspace.zipFile)
|
||||
backupWorkspace = workspace
|
||||
}
|
||||
|
||||
fun prepareAndPerformBackup() {
|
||||
runCatching {
|
||||
if (backupWorkspace == null || backupWorkspace!!.isClosed()) {
|
||||
prepareBackupWorkspace()
|
||||
}
|
||||
when (backupDestination) {
|
||||
Backup.Destination.FILE_SYS -> {
|
||||
backUpToFileSystemLauncher.launch(backupWorkspace!!.zipFile.name)
|
||||
}
|
||||
Backup.Destination.SHARE_INTENT -> {
|
||||
val uri = FileProvider.getUriForFile(context, Backup.FILE_PROVIDER_AUTHORITY, backupWorkspace!!.zipFile)
|
||||
val shareIntent = ShareCompat.IntentBuilder(context)
|
||||
.setStream(uri)
|
||||
.setType(FileRegistry.BackupArchive.mediaType)
|
||||
.createChooserIntent()
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
context.startActivity(shareIntent)
|
||||
}
|
||||
}
|
||||
}.onFailure { error ->
|
||||
context.showLongToast(R.string.backup_and_restore__back_up__failure, "error_message" to error.localizedMessage)
|
||||
backupWorkspace = null
|
||||
}
|
||||
}
|
||||
|
||||
bottomBar {
|
||||
FlorisButtonBar {
|
||||
ButtonBarSpacer()
|
||||
ButtonBarTextButton(
|
||||
onClick = {
|
||||
backupWorkspace?.close()
|
||||
navController.popBackStack()
|
||||
},
|
||||
text = stringRes(R.string.action__cancel),
|
||||
)
|
||||
ButtonBarButton(
|
||||
onClick = {
|
||||
prepareAndPerformBackup()
|
||||
},
|
||||
text = stringRes(R.string.action__back_up),
|
||||
enabled = backupFilesSelector.atLeastOneSelected(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = stringRes(R.string.backup_and_restore__back_up__destination),
|
||||
) {
|
||||
RadioListItem(
|
||||
onClick = {
|
||||
backupDestination = Backup.Destination.FILE_SYS
|
||||
},
|
||||
selected = backupDestination == Backup.Destination.FILE_SYS,
|
||||
text = stringRes(R.string.backup_and_restore__back_up__destination_file_sys),
|
||||
)
|
||||
RadioListItem(
|
||||
onClick = {
|
||||
backupDestination = Backup.Destination.SHARE_INTENT
|
||||
},
|
||||
selected = backupDestination == Backup.Destination.SHARE_INTENT,
|
||||
text = stringRes(R.string.backup_and_restore__back_up__destination_share_intent),
|
||||
)
|
||||
}
|
||||
BackupFilesSelector(
|
||||
filesSelector = backupFilesSelector,
|
||||
title = stringRes(R.string.backup_and_restore__back_up__files),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun BackupFilesSelector(
|
||||
modifier: Modifier = Modifier,
|
||||
filesSelector: Backup.FilesSelector,
|
||||
title: String,
|
||||
) {
|
||||
FlorisOutlinedBox(
|
||||
modifier = modifier.defaultFlorisOutlinedBox(),
|
||||
title = title,
|
||||
) {
|
||||
CheckboxListItem(
|
||||
onClick = { filesSelector.jetprefDatastore = !filesSelector.jetprefDatastore },
|
||||
checked = filesSelector.jetprefDatastore,
|
||||
text = stringRes(R.string.backup_and_restore__back_up__files_jetpref_datastore),
|
||||
)
|
||||
CheckboxListItem(
|
||||
onClick = { filesSelector.imeKeyboard = !filesSelector.imeKeyboard },
|
||||
checked = filesSelector.imeKeyboard,
|
||||
text = stringRes(R.string.backup_and_restore__back_up__files_ime_keyboard),
|
||||
)
|
||||
CheckboxListItem(
|
||||
onClick = { filesSelector.imeSpelling = !filesSelector.imeSpelling },
|
||||
checked = filesSelector.imeSpelling,
|
||||
text = stringRes(R.string.backup_and_restore__back_up__files_ime_spelling),
|
||||
)
|
||||
CheckboxListItem(
|
||||
onClick = { filesSelector.imeTheme = !filesSelector.imeTheme },
|
||||
checked = filesSelector.imeTheme,
|
||||
text = stringRes(R.string.backup_and_restore__back_up__files_ime_theme),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun CheckboxListItem(
|
||||
onClick: () -> Unit,
|
||||
checked: Boolean,
|
||||
text: String,
|
||||
) {
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable(onClick = onClick),
|
||||
icon = {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = null,
|
||||
)
|
||||
},
|
||||
text = text,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun RadioListItem(
|
||||
onClick: () -> Unit,
|
||||
selected: Boolean,
|
||||
text: String,
|
||||
secondaryText: String? = null,
|
||||
) {
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable(onClick = onClick),
|
||||
icon = {
|
||||
RadioButton(
|
||||
selected = selected,
|
||||
onClick = null,
|
||||
)
|
||||
},
|
||||
text = text,
|
||||
secondaryText = secondaryText,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.advanced
|
||||
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.BuildConfig
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.CardDefaults
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisButtonBar
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.cacheManager
|
||||
import dev.patrickgold.florisboard.common.android.readToFile
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
import dev.patrickgold.florisboard.res.FileRegistry
|
||||
import dev.patrickgold.florisboard.res.ZipUtils
|
||||
import dev.patrickgold.florisboard.res.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionManager
|
||||
import dev.patrickgold.florisboard.res.io.deleteContentsRecursively
|
||||
import dev.patrickgold.florisboard.res.io.readJson
|
||||
import dev.patrickgold.florisboard.res.io.subDir
|
||||
import dev.patrickgold.florisboard.res.io.subFile
|
||||
import dev.patrickgold.jetpref.datastore.JetPref
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.text.DateFormat
|
||||
import java.util.*
|
||||
|
||||
object Restore {
|
||||
const val MIN_VERSION_CODE = 64
|
||||
const val PACKAGE_NAME = "dev.patrickgold.florisboard"
|
||||
const val BACKUP_ARCHIVE_FILE_NAME = "backup.zip"
|
||||
|
||||
enum class Mode {
|
||||
MERGE,
|
||||
ERASE_AND_OVERWRITE;
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RestoreScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.backup_and_restore__restore__title)
|
||||
previewFieldVisible = false
|
||||
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val cacheManager by context.cacheManager()
|
||||
|
||||
val restoreFilesSelector = remember { Backup.FilesSelector() }
|
||||
var restoreMode by remember { mutableStateOf(Restore.Mode.MERGE) }
|
||||
// TODO: rememberCoroutineScope() is unusable because it provides the scope in a cancelled state, which does
|
||||
// not make sense at all. I suspect that this is a bug and once it is resolved we can use it here again.
|
||||
val restoreScope = remember { CoroutineScope(Dispatchers.Main) }
|
||||
var restoreWorkspace by remember {
|
||||
mutableStateOf<CacheManager.BackupAndRestoreWorkspace?>(null)
|
||||
}
|
||||
|
||||
val restoreDataFromFileSystemLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
onResult = { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
runCatching {
|
||||
restoreWorkspace?.close()
|
||||
restoreWorkspace = null
|
||||
val workspace = cacheManager.backupAndRestore.new()
|
||||
workspace.zipFile = workspace.inputDir.subFile(Restore.BACKUP_ARCHIVE_FILE_NAME)
|
||||
context.contentResolver.readToFile(uri, workspace.zipFile)
|
||||
ZipUtils.unzip(workspace.zipFile, workspace.outputDir)
|
||||
workspace.metadata = workspace.outputDir.subFile(Backup.METADATA_JSON_NAME).readJson()
|
||||
workspace.restoreWarningId = when {
|
||||
workspace.metadata.versionCode != BuildConfig.VERSION_CODE -> {
|
||||
R.string.backup_and_restore__restore__metadata_warn_different_version
|
||||
}
|
||||
!workspace.metadata.packageName.startsWith(Restore.PACKAGE_NAME) -> {
|
||||
R.string.backup_and_restore__restore__metadata_warn_different_vendor
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
workspace.restoreErrorId = when {
|
||||
workspace.metadata.packageName.isBlank() || workspace.metadata.versionCode < Restore.MIN_VERSION_CODE -> {
|
||||
R.string.backup_and_restore__restore__metadata_error_invalid_metadata
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
restoreWorkspace = workspace
|
||||
}.onFailure { error ->
|
||||
context.showLongToast(R.string.backup_and_restore__restore__failure, "error_message" to error.localizedMessage)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend fun performRestore() {
|
||||
val prefs by florisPreferenceModel()
|
||||
val workspace = restoreWorkspace!!
|
||||
val shouldReset = restoreMode == Restore.Mode.ERASE_AND_OVERWRITE
|
||||
if (restoreFilesSelector.jetprefDatastore) {
|
||||
val datastoreFile = workspace.outputDir
|
||||
.subDir(JetPref.JETPREF_DIR_NAME)
|
||||
.subFile("${prefs.name}.${JetPref.JETPREF_FILE_EXT}")
|
||||
if (datastoreFile.exists()) {
|
||||
prefs.datastorePersistenceHandler?.loadPrefs(datastoreFile, shouldReset)
|
||||
prefs.datastorePersistenceHandler?.persistPrefs()
|
||||
}
|
||||
}
|
||||
val workspaceFilesDir = workspace.outputDir.subDir("files")
|
||||
if (restoreFilesSelector.imeKeyboard) {
|
||||
val srcDir = workspaceFilesDir.subDir(ExtensionManager.IME_KEYBOARD_PATH)
|
||||
val dstDir = context.filesDir.subDir(ExtensionManager.IME_KEYBOARD_PATH)
|
||||
if (shouldReset) {
|
||||
dstDir.deleteContentsRecursively()
|
||||
}
|
||||
if (srcDir.exists()) {
|
||||
srcDir.copyRecursively(dstDir, overwrite = true)
|
||||
}
|
||||
}
|
||||
if (restoreFilesSelector.imeSpelling) {
|
||||
val srcDir = workspaceFilesDir.subDir(ExtensionManager.IME_SPELLING_PATH)
|
||||
val dstDir = context.filesDir.subDir(ExtensionManager.IME_SPELLING_PATH)
|
||||
if (shouldReset) {
|
||||
dstDir.deleteContentsRecursively()
|
||||
}
|
||||
if (srcDir.exists()) {
|
||||
srcDir.copyRecursively(dstDir, overwrite = true)
|
||||
}
|
||||
}
|
||||
if (restoreFilesSelector.imeTheme) {
|
||||
val srcDir = workspaceFilesDir.subDir(ExtensionManager.IME_THEME_PATH)
|
||||
val dstDir = context.filesDir.subDir(ExtensionManager.IME_THEME_PATH)
|
||||
if (shouldReset) {
|
||||
dstDir.deleteContentsRecursively()
|
||||
}
|
||||
if (srcDir.exists()) {
|
||||
srcDir.copyRecursively(dstDir, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bottomBar {
|
||||
FlorisButtonBar {
|
||||
ButtonBarSpacer()
|
||||
ButtonBarTextButton(
|
||||
onClick = {
|
||||
restoreWorkspace?.close()
|
||||
navController.popBackStack()
|
||||
},
|
||||
text = stringRes(R.string.action__cancel),
|
||||
)
|
||||
ButtonBarButton(
|
||||
onClick = {
|
||||
restoreScope.launch(Dispatchers.Main) {
|
||||
try {
|
||||
performRestore()
|
||||
context.showLongToast(R.string.backup_and_restore__restore__success)
|
||||
navController.popBackStack()
|
||||
} catch (e: Throwable) {
|
||||
context.showLongToast(R.string.backup_and_restore__restore__failure, "error_message" to e.localizedMessage)
|
||||
}
|
||||
}
|
||||
},
|
||||
text = stringRes(R.string.action__restore),
|
||||
enabled = restoreWorkspace != null && restoreWorkspace?.restoreErrorId == null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
content {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = stringRes(R.string.backup_and_restore__restore__mode),
|
||||
) {
|
||||
RadioListItem(
|
||||
onClick = {
|
||||
restoreMode = Restore.Mode.MERGE
|
||||
},
|
||||
selected = restoreMode == Restore.Mode.MERGE,
|
||||
text = stringRes(R.string.backup_and_restore__restore__mode_merge),
|
||||
)
|
||||
RadioListItem(
|
||||
onClick = {
|
||||
restoreMode = Restore.Mode.ERASE_AND_OVERWRITE
|
||||
},
|
||||
selected = restoreMode == Restore.Mode.ERASE_AND_OVERWRITE,
|
||||
text = stringRes(R.string.backup_and_restore__restore__mode_erase_and_overwrite),
|
||||
)
|
||||
}
|
||||
FlorisOutlinedButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
restoreDataFromFileSystemLauncher.launch(
|
||||
FileRegistry.BackupArchive.mediaType
|
||||
)
|
||||
}.onFailure { error ->
|
||||
context.showLongToast(R.string.backup_and_restore__restore__failure, "error_message" to error.localizedMessage)
|
||||
}
|
||||
},
|
||||
modifier = Modifier
|
||||
.padding(vertical = 16.dp)
|
||||
.align(Alignment.CenterHorizontally),
|
||||
text = stringRes(R.string.action__select_file),
|
||||
)
|
||||
val workspace = restoreWorkspace
|
||||
if (workspace == null) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(horizontal = 16.dp),
|
||||
text = stringRes(R.string.state__no_file_selected),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
} else {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = stringRes(R.string.backup_and_restore__restore__metadata),
|
||||
) {
|
||||
this@content.Preference(
|
||||
iconId = R.drawable.ic_code,
|
||||
title = workspace.metadata.packageName,
|
||||
)
|
||||
this@content.Preference(
|
||||
iconId = R.drawable.ic_info,
|
||||
title = "${workspace.metadata.versionName} (${workspace.metadata.versionCode})",
|
||||
)
|
||||
this@content.Preference(
|
||||
iconId = R.drawable.ic_schedule,
|
||||
title = remember(workspace.metadata.timestamp) {
|
||||
val formatter = DateFormat.getDateTimeInstance()
|
||||
val calendar = Calendar.getInstance(TimeZone.getTimeZone("UTC"))
|
||||
calendar.timeInMillis = workspace.metadata.timestamp
|
||||
formatter.format(calendar.time)
|
||||
},
|
||||
)
|
||||
if (workspace.restoreErrorId != null) {
|
||||
Column(modifier = Modifier.padding(CardDefaults.ContentPadding)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(9.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
.background(MaterialTheme.colors.error.copy(alpha = 0.56f))
|
||||
)
|
||||
Text(
|
||||
text = stringRes(workspace.restoreErrorId!!),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = MaterialTheme.colors.error,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
} else if (workspace.restoreWarningId != null) {
|
||||
Column(modifier = Modifier.padding(CardDefaults.ContentPadding)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(9.dp)
|
||||
.padding(bottom = 8.dp)
|
||||
.background(LocalContentColor.current.copy(alpha = LocalContentAlpha.current))
|
||||
)
|
||||
Text(
|
||||
text = stringRes(workspace.restoreWarningId!!),
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (workspace.restoreErrorId == null) {
|
||||
BackupFilesSelector(
|
||||
filesSelector = restoreFilesSelector,
|
||||
title = stringRes(R.string.backup_and_restore__restore__files),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,9 +17,10 @@
|
||||
package dev.patrickgold.florisboard.app.ui.settings.dictionary
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.Routes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import dev.patrickgold.jetpref.datastore.ui.SwitchPreference
|
||||
@@ -29,7 +30,7 @@ fun DictionaryScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.settings__dictionary__title)
|
||||
previewFieldVisible = true
|
||||
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
content {
|
||||
SwitchPreference(
|
||||
@@ -40,7 +41,7 @@ fun DictionaryScreen() = FlorisScreen {
|
||||
Preference(
|
||||
title = stringRes(R.string.pref__dictionary__manage_system_user_dictionary__label),
|
||||
summary = stringRes(R.string.pref__dictionary__manage_system_user_dictionary__summary),
|
||||
onClick = { /* TODO */ },
|
||||
onClick = { navController.navigate(Routes.Settings.UserDictionary(UserDictionaryType.SYSTEM)) },
|
||||
enabledIf = { prefs.dictionary.enableSystemUserDictionary isEqualTo true },
|
||||
)
|
||||
SwitchPreference(
|
||||
@@ -51,7 +52,7 @@ fun DictionaryScreen() = FlorisScreen {
|
||||
Preference(
|
||||
title = stringRes(R.string.pref__dictionary__manage_floris_user_dictionary__label),
|
||||
summary = stringRes(R.string.pref__dictionary__manage_floris_user_dictionary__summary),
|
||||
onClick = { /* TODO */ },
|
||||
onClick = { navController.navigate(Routes.Settings.UserDictionary(UserDictionaryType.FLORIS)) },
|
||||
enabledIf = { prefs.dictionary.enableFlorisUserDictionary isEqualTo true },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.dictionary
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.DropdownMenu
|
||||
import androidx.compose.material.DropdownMenuItem
|
||||
import androidx.compose.material.ExtendedFloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedTextField
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.rippleClickable
|
||||
import dev.patrickgold.florisboard.app.ui.settings.theme.DialogProperty
|
||||
import dev.patrickgold.florisboard.common.FlorisLocale
|
||||
import dev.patrickgold.florisboard.common.android.launchActivity
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
import dev.patrickgold.florisboard.common.android.stringRes
|
||||
import dev.patrickgold.florisboard.common.rememberValidationResult
|
||||
import dev.patrickgold.florisboard.ime.dictionary.DictionaryManager
|
||||
import dev.patrickgold.florisboard.ime.dictionary.FREQUENCY_MAX
|
||||
import dev.patrickgold.florisboard.ime.dictionary.FREQUENCY_MIN
|
||||
import dev.patrickgold.florisboard.ime.dictionary.UserDictionaryDao
|
||||
import dev.patrickgold.florisboard.ime.dictionary.UserDictionaryEntry
|
||||
import dev.patrickgold.florisboard.ime.dictionary.UserDictionaryValidation
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val AllLanguagesLocale = FlorisLocale.from(language = "zz")
|
||||
private val UserDictionaryEntryToAdd = UserDictionaryEntry(id = 0, "", 255, null, null)
|
||||
private const val SystemUserDictionaryUiIntentAction = "android.settings.USER_DICTIONARY_SETTINGS"
|
||||
|
||||
enum class UserDictionaryType(val id: String) {
|
||||
FLORIS("floris"),
|
||||
SYSTEM("system");
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserDictionaryScreen(type: UserDictionaryType) = FlorisScreen {
|
||||
title = stringRes(when (type) {
|
||||
UserDictionaryType.FLORIS -> R.string.settings__udm__title_floris
|
||||
UserDictionaryType.SYSTEM -> R.string.settings__udm__title_system
|
||||
})
|
||||
previewFieldVisible = false
|
||||
scrollable = false
|
||||
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val dictionaryManager = DictionaryManager.default()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var currentLocale by remember { mutableStateOf<FlorisLocale?>(null) }
|
||||
var languageList by remember { mutableStateOf(emptyList<FlorisLocale>()) }
|
||||
var wordList by remember { mutableStateOf(emptyList<UserDictionaryEntry>()) }
|
||||
var userDictionaryEntryForDialog by remember { mutableStateOf<UserDictionaryEntry?>(null) }
|
||||
|
||||
fun userDictionaryDao(): UserDictionaryDao? {
|
||||
return when (type) {
|
||||
UserDictionaryType.FLORIS -> dictionaryManager.florisUserDictionaryDao()
|
||||
UserDictionaryType.SYSTEM -> dictionaryManager.systemUserDictionaryDao()
|
||||
}
|
||||
}
|
||||
|
||||
fun getDisplayNameForLocale(locale: FlorisLocale): String {
|
||||
return if (locale == AllLanguagesLocale) {
|
||||
context.stringRes(R.string.settings__udm__all_languages)
|
||||
} else {
|
||||
locale.displayName()
|
||||
}
|
||||
}
|
||||
|
||||
fun buildUi() {
|
||||
if (currentLocale != null) {
|
||||
//subtitle = getDisplayNameForLocale(currentLocale)
|
||||
val locale = if (currentLocale == AllLanguagesLocale) null else currentLocale
|
||||
wordList = userDictionaryDao()?.queryAll(locale) ?: emptyList()
|
||||
if (wordList.isEmpty()) {
|
||||
currentLocale = null
|
||||
}
|
||||
}
|
||||
if (currentLocale == null) {
|
||||
//subtitle = null
|
||||
languageList = userDictionaryDao()
|
||||
?.queryLanguageList()
|
||||
?.sortedBy { it?.displayLanguage() }
|
||||
?.map { it ?: AllLanguagesLocale }
|
||||
?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val importDictionary = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent(),
|
||||
onResult = { uri ->
|
||||
// If uri is null it indicates that the selection activity was cancelled (mostly
|
||||
// by pressing the back button), so we don't display an error message here.
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val db = when (type) {
|
||||
UserDictionaryType.FLORIS -> dictionaryManager.florisUserDictionaryDatabase()
|
||||
UserDictionaryType.SYSTEM -> dictionaryManager.systemUserDictionaryDatabase()
|
||||
}
|
||||
if (db == null) {
|
||||
context.showLongToast("Database handle is null, failed to import")
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
runCatching {
|
||||
db.importCombinedList(context, uri)
|
||||
}.onSuccess {
|
||||
buildUi()
|
||||
context.showLongToast(R.string.settings__udm__dictionary_import_success)
|
||||
}.onFailure { error ->
|
||||
context.showLongToast("Error: ${error.localizedMessage}")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val exportDictionary = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.CreateDocument(),
|
||||
onResult = { uri ->
|
||||
// If uri is null it indicates that the selection activity was cancelled (mostly
|
||||
// by pressing the back button), so we don't display an error message here.
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
val db = when (type) {
|
||||
UserDictionaryType.FLORIS -> dictionaryManager.florisUserDictionaryDatabase()
|
||||
UserDictionaryType.SYSTEM -> dictionaryManager.systemUserDictionaryDatabase()
|
||||
}
|
||||
if (db == null) {
|
||||
context.showLongToast("Database handle is null, failed to export")
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
runCatching {
|
||||
db.exportCombinedList(context, uri)
|
||||
}.onSuccess {
|
||||
context.showLongToast(R.string.settings__udm__dictionary_export_success)
|
||||
}.onFailure { error ->
|
||||
context.showLongToast("Error: ${error.localizedMessage}")
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
navigationIcon {
|
||||
FlorisIconButton(
|
||||
onClick = {
|
||||
if (currentLocale != null) {
|
||||
currentLocale = null
|
||||
buildUi()
|
||||
} else {
|
||||
navController.popBackStack()
|
||||
}
|
||||
},
|
||||
icon = painterResource(if (currentLocale != null) {
|
||||
R.drawable.ic_close
|
||||
} else {
|
||||
R.drawable.ic_arrow_back
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
actions {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
FlorisIconButton(
|
||||
onClick = { expanded = !expanded },
|
||||
icon = painterResource(R.drawable.ic_more_vert),
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
importDictionary.launch("*/*")
|
||||
expanded = false
|
||||
},
|
||||
content = { Text(text = stringRes(R.string.action__import)) },
|
||||
)
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
exportDictionary.launch("my-personal-dictionary.clb")
|
||||
expanded = false
|
||||
},
|
||||
content = { Text(text = stringRes(R.string.action__export)) },
|
||||
)
|
||||
if (type == UserDictionaryType.SYSTEM) {
|
||||
DropdownMenuItem(
|
||||
onClick = {
|
||||
context.launchActivity { it.action = SystemUserDictionaryUiIntentAction }
|
||||
expanded = false
|
||||
},
|
||||
content = { Text(text = stringRes(R.string.settings__udm__open_system_manager_ui)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
floatingActionButton {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { userDictionaryEntryForDialog = UserDictionaryEntryToAdd },
|
||||
icon = { Icon(painter = painterResource(R.drawable.ic_add), contentDescription = null) },
|
||||
text = { Text(text = stringRes(R.string.settings__udm__dialog__title_add)) },
|
||||
)
|
||||
}
|
||||
|
||||
content {
|
||||
BackHandler(currentLocale != null) {
|
||||
currentLocale = null
|
||||
buildUi()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
dictionaryManager.loadUserDictionariesIfNecessary()
|
||||
buildUi()
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
if (languageList.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
text = stringRes(R.string.settings__udm__no_words_in_dictionary),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (currentLocale == null) {
|
||||
items(languageList) { language ->
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable {
|
||||
scope.launch {
|
||||
// Delay makes UI ripple visible and experience better
|
||||
delay(150)
|
||||
currentLocale = language
|
||||
buildUi()
|
||||
}
|
||||
},
|
||||
text = getDisplayNameForLocale(language),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(wordList) { wordEntry ->
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable {
|
||||
userDictionaryEntryForDialog = wordEntry
|
||||
},
|
||||
text = wordEntry.word,
|
||||
secondaryText = stringRes(
|
||||
if (wordEntry.shortcut != null) {
|
||||
R.string.settings__udm__word_summary_freq_shortcut
|
||||
} else {
|
||||
R.string.settings__udm__word_summary_freq
|
||||
},
|
||||
"freq" to wordEntry.freq,
|
||||
"shortcut" to wordEntry.shortcut,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val wordEntry = userDictionaryEntryForDialog
|
||||
if (wordEntry != null) {
|
||||
var showValidationErrors by rememberSaveable { mutableStateOf(false) }
|
||||
val isAddWord = wordEntry === UserDictionaryEntryToAdd
|
||||
var word by rememberSaveable { mutableStateOf(wordEntry.word) }
|
||||
val wordValidation = rememberValidationResult(UserDictionaryValidation.Word, word)
|
||||
var freq by rememberSaveable { mutableStateOf(wordEntry.freq.toString()) }
|
||||
val freqValidation = rememberValidationResult(UserDictionaryValidation.Freq, freq)
|
||||
var shortcut by rememberSaveable { mutableStateOf(wordEntry.shortcut ?: "") }
|
||||
val shortcutValidation = rememberValidationResult(UserDictionaryValidation.Shortcut, shortcut)
|
||||
var locale by rememberSaveable { mutableStateOf(wordEntry.locale ?: "") }
|
||||
val localeValidation = rememberValidationResult(UserDictionaryValidation.Locale, locale)
|
||||
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(if (isAddWord) {
|
||||
R.string.settings__udm__dialog__title_add
|
||||
} else {
|
||||
R.string.settings__udm__dialog__title_edit
|
||||
}),
|
||||
confirmLabel = stringRes(if (isAddWord) {
|
||||
R.string.action__add
|
||||
} else {
|
||||
R.string.action__apply
|
||||
}),
|
||||
onConfirm = {
|
||||
val isInvalid = wordValidation.isInvalid() ||
|
||||
freqValidation.isInvalid() ||
|
||||
shortcutValidation.isInvalid() ||
|
||||
localeValidation.isInvalid()
|
||||
if (isInvalid) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
val entry = UserDictionaryEntry(
|
||||
id = wordEntry.id,
|
||||
word = word.trim(),
|
||||
freq = freq.toInt(10),
|
||||
shortcut = shortcut.trim().takeIf { it.isNotBlank() },
|
||||
locale = locale.trim().takeIf { it.isNotBlank() }?.let {
|
||||
// Normalize tag
|
||||
FlorisLocale.fromTag(it).localeTag()
|
||||
},
|
||||
)
|
||||
if (isAddWord) {
|
||||
userDictionaryDao()?.insert(entry)
|
||||
} else {
|
||||
userDictionaryDao()?.update(entry)
|
||||
}
|
||||
userDictionaryEntryForDialog = null
|
||||
buildUi()
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = {
|
||||
userDictionaryEntryForDialog = null
|
||||
},
|
||||
neutralLabel = if (isAddWord) {
|
||||
null
|
||||
} else {
|
||||
stringRes(R.string.action__delete)
|
||||
},
|
||||
onNeutral = {
|
||||
userDictionaryDao()?.delete(wordEntry)
|
||||
userDictionaryEntryForDialog = null
|
||||
buildUi()
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
DialogProperty(text = stringRes(R.string.settings__udm__dialog__word_label)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = word,
|
||||
onValueChange = { word = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = wordValidation,
|
||||
)
|
||||
}
|
||||
DialogProperty(text = stringRes(
|
||||
R.string.settings__udm__dialog__freq_label,
|
||||
"f_min" to FREQUENCY_MIN, "f_max" to FREQUENCY_MAX,
|
||||
)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = freq,
|
||||
onValueChange = { freq = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = freqValidation,
|
||||
)
|
||||
}
|
||||
DialogProperty(text = stringRes(R.string.settings__udm__dialog__shortcut_label)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = shortcut,
|
||||
onValueChange = { shortcut = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = shortcutValidation,
|
||||
)
|
||||
}
|
||||
DialogProperty(text = stringRes(R.string.settings__udm__dialog__locale_label)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = locale,
|
||||
onValueChange = { locale = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = localeValidation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,11 @@ fun KeyboardScreen() = FlorisScreen {
|
||||
entries = UtilityKeyAction.listEntries(),
|
||||
visibleIf = { prefs.keyboard.utilityKeyEnabled isEqualTo true },
|
||||
)
|
||||
SwitchPreference(
|
||||
prefs.keyboard.spaceBarLanguageDisplayEnabled,
|
||||
title = stringRes(R.string.pref__keyboard__space_bar_language_display_enabled__label),
|
||||
summary = stringRes(R.string.pref__keyboard__space_bar_language_display_enabled__summary),
|
||||
)
|
||||
DialogSliderPreference(
|
||||
primaryPref = prefs.keyboard.fontSizeMultiplierPortrait,
|
||||
secondaryPref = prefs.keyboard.fontSizeMultiplierLandscape,
|
||||
|
||||
@@ -33,9 +33,12 @@ import dev.patrickgold.florisboard.app.ui.Routes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisWarningCard
|
||||
import dev.patrickgold.florisboard.common.observeAsNonNullState
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.keyboard.LayoutType
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.subtypeManager
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.datastore.ui.ListPreference
|
||||
import dev.patrickgold.jetpref.datastore.ui.Preference
|
||||
import dev.patrickgold.jetpref.datastore.ui.PreferenceGroup
|
||||
|
||||
@@ -64,6 +67,11 @@ fun LocalizationScreen() = FlorisScreen {
|
||||
}
|
||||
|
||||
content {
|
||||
ListPreference(
|
||||
prefs.localization.displayLanguageNamesIn,
|
||||
title = stringRes(R.string.settings__localization__display_language_names_in__label),
|
||||
entries = DisplayLanguageNamesIn.listEntries(),
|
||||
)
|
||||
PreferenceGroup(title = stringRes(R.string.settings__localization__group_subtypes__label)) {
|
||||
val subtypes by subtypeManager.subtypes.observeAsNonNullState()
|
||||
if (subtypes.isNullOrEmpty()) {
|
||||
@@ -74,6 +82,7 @@ fun LocalizationScreen() = FlorisScreen {
|
||||
} else {
|
||||
val currencySets by keyboardManager.resources.currencySets.observeAsNonNullState()
|
||||
val layouts by keyboardManager.resources.layouts.observeAsNonNullState()
|
||||
val displayLanguageNamesIn by prefs.localization.displayLanguageNamesIn.observeAsState()
|
||||
for (subtype in subtypes) {
|
||||
val cMeta = layouts[LayoutType.CHARACTERS]?.get(subtype.layoutMap.characters)
|
||||
val sMeta = layouts[LayoutType.SYMBOLS]?.get(subtype.layoutMap.symbols)
|
||||
@@ -85,7 +94,10 @@ fun LocalizationScreen() = FlorisScreen {
|
||||
"currency_set_name" to (currMeta?.label ?: "null"),
|
||||
)
|
||||
Preference(
|
||||
title = subtype.primaryLocale.displayName(),
|
||||
title = when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> subtype.primaryLocale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> subtype.primaryLocale.displayName(subtype.primaryLocale)
|
||||
},
|
||||
summary = summary,
|
||||
onClick = { navController.navigate(
|
||||
Routes.Settings.SubtypeEdit(subtype.id)
|
||||
|
||||
@@ -43,10 +43,13 @@ import androidx.compose.ui.text.input.TextFieldValue
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.florisScrollbar
|
||||
import dev.patrickgold.florisboard.common.FlorisLocale
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
|
||||
const val SelectLocaleScreenResultLanguageTag = "SelectLocaleScreen.languageTag"
|
||||
@@ -56,9 +59,19 @@ fun SelectLocaleScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.settings__localization__subtype_select_locale)
|
||||
scrollable = false
|
||||
|
||||
val prefs by florisPreferenceModel()
|
||||
val navController = LocalNavController.current
|
||||
|
||||
val displayLanguageNamesIn by prefs.localization.displayLanguageNamesIn.observeAsState()
|
||||
var searchTermValue by remember { mutableStateOf(TextFieldValue()) }
|
||||
val systemLocales = remember { FlorisLocale.installedSystemLocales().sortedBy { it.displayName() } }
|
||||
val systemLocales = remember(displayLanguageNamesIn) {
|
||||
FlorisLocale.installedSystemLocales().sortedBy { locale ->
|
||||
when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> locale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> locale.displayName(locale)
|
||||
}.lowercase()
|
||||
}
|
||||
}
|
||||
val filteredSystemLocales = remember(searchTermValue) {
|
||||
if (searchTermValue.text.isBlank()) {
|
||||
systemLocales
|
||||
@@ -121,7 +134,10 @@ fun SelectLocaleScreen() = FlorisScreen {
|
||||
?.set(SelectLocaleScreenResultLanguageTag, systemLocale.languageTag())
|
||||
navController.popBackStack()
|
||||
},
|
||||
text = systemLocale.displayName(),
|
||||
text = when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> systemLocale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> systemLocale.displayName(systemLocale)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Observer
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.Routes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisButtonBar
|
||||
@@ -61,6 +62,7 @@ import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.common.FlorisLocale
|
||||
import dev.patrickgold.florisboard.common.android.AndroidVersion
|
||||
import dev.patrickgold.florisboard.common.observeAsNonNullState
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.core.SubtypeJsonConfig
|
||||
import dev.patrickgold.florisboard.ime.core.SubtypeLayoutMap
|
||||
@@ -72,11 +74,11 @@ import dev.patrickgold.florisboard.ime.text.composing.Appender
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.subtypeManager
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.util.*
|
||||
|
||||
private val SelectComponentName = ExtensionComponentName("00", "00")
|
||||
private val SelectLayoutMap = SubtypeLayoutMap(
|
||||
@@ -163,6 +165,7 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
val selectValue = stringRes(R.string.settings__localization__subtype_select_placeholder)
|
||||
val selectListValues = remember (selectValue) { listOf(selectValue) }
|
||||
|
||||
val prefs by florisPreferenceModel()
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
@@ -170,6 +173,7 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
val keyboardManager by context.keyboardManager()
|
||||
val subtypeManager by context.subtypeManager()
|
||||
|
||||
val displayLanguageNamesIn by prefs.localization.displayLanguageNamesIn.observeAsState()
|
||||
val currencySets by keyboardManager.resources.currencySets.observeAsNonNullState()
|
||||
val layoutExtensions by keyboardManager.resources.layouts.observeAsNonNullState()
|
||||
val popupMappings by keyboardManager.resources.popupMappings.observeAsNonNullState()
|
||||
@@ -288,7 +292,10 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
modifier = Modifier.clickable {
|
||||
subtypeEditor.applySubtype(suggestedPreset.toSubtype())
|
||||
},
|
||||
text = suggestedPreset.locale.displayName(),
|
||||
text = when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> suggestedPreset.locale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> suggestedPreset.locale.displayName(suggestedPreset.locale)
|
||||
},
|
||||
secondaryText = suggestedPreset.preferred.characters.componentId,
|
||||
)
|
||||
}
|
||||
@@ -314,7 +321,10 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
|
||||
SubtypeProperty(stringRes(R.string.settings__localization__subtype_locale)) {
|
||||
FlorisDropdownLikeButton(
|
||||
item = if (primaryLocale == SelectLocale) selectValue else primaryLocale.displayName(),
|
||||
item = if (primaryLocale == SelectLocale) selectValue else when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> primaryLocale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> primaryLocale.displayName(primaryLocale)
|
||||
},
|
||||
isError = showSelectAsError && primaryLocale == SelectLocale,
|
||||
onClick = {
|
||||
navController.navigate(Routes.Settings.SelectLocale)
|
||||
@@ -474,7 +484,10 @@ fun SubtypeEditorScreen(id: Long?) = FlorisScreen {
|
||||
subtypeEditor.applySubtype(subtypePreset.toSubtype())
|
||||
showSubtypePresetsDialog = false
|
||||
},
|
||||
text = subtypePreset.locale.displayName(),
|
||||
text = when (displayLanguageNamesIn) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> subtypePreset.locale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> subtypePreset.locale.displayName(subtypePreset.locale)
|
||||
},
|
||||
secondaryText = subtypePreset.preferred.characters.componentId,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ fun ImportSpellingArchiveScreen() = FlorisScreen {
|
||||
StepButton(
|
||||
onClick = {
|
||||
runCatching {
|
||||
extensionManager.import(importArchiveEditor!!.build().getOrThrow())
|
||||
extensionManager.import(importArchiveEditor!!.build())
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
navController.popBackStack()
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.common.kotlin.curlyFormat
|
||||
import dev.patrickgold.jetpref.datastore.ui.listPrefEntries
|
||||
|
||||
/**
|
||||
* DisplayColorsAs indicates how color strings should be visually presented to the user.
|
||||
*/
|
||||
enum class DisplayColorsAs {
|
||||
HEX8,
|
||||
RGBA;
|
||||
|
||||
companion object {
|
||||
@Composable
|
||||
fun listEntries() = listPrefEntries {
|
||||
entry(
|
||||
key = HEX8,
|
||||
label = stringRes(R.string.enum__display_colors_as__hex8),
|
||||
description = stringRes(R.string.general__example_given).curlyFormat("example" to "#4caf50ff"),
|
||||
showDescriptionOnlyIfSelected = true,
|
||||
)
|
||||
entry(
|
||||
key = RGBA,
|
||||
label = stringRes(R.string.enum__display_colors_as__rgba),
|
||||
description = stringRes(R.string.general__example_given).curlyFormat("example" to "rgba(76,175,80,1.0)"),
|
||||
showDescriptionOnlyIfSelected = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.jetpref.datastore.ui.listPrefEntries
|
||||
|
||||
/**
|
||||
* DisplayPreviewAfterDialogs indicates if the keyboard should auto-open after closing
|
||||
* any dialog. This is useful because the dialog always hides the keyboard and one may
|
||||
* not want to always press the preview field again.
|
||||
*/
|
||||
enum class DisplayKbdAfterDialogs {
|
||||
ALWAYS,
|
||||
NEVER,
|
||||
REMEMBER;
|
||||
|
||||
companion object {
|
||||
@Composable
|
||||
fun listEntries() = listPrefEntries {
|
||||
entry(
|
||||
key = ALWAYS,
|
||||
label = stringRes(R.string.enum__display_kbd_after_dialogs__always),
|
||||
description = stringRes(R.string.enum__display_kbd_after_dialogs__always__description),
|
||||
showDescriptionOnlyIfSelected = true,
|
||||
)
|
||||
entry(
|
||||
key = NEVER,
|
||||
label = stringRes(R.string.enum__display_kbd_after_dialogs__never),
|
||||
description = stringRes(R.string.enum__display_kbd_after_dialogs__never__description),
|
||||
showDescriptionOnlyIfSelected = true,
|
||||
)
|
||||
entry(
|
||||
key = REMEMBER,
|
||||
label = stringRes(R.string.enum__display_kbd_after_dialogs__remember),
|
||||
description = stringRes(R.string.enum__display_kbd_after_dialogs__remember__description),
|
||||
showDescriptionOnlyIfSelected = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.isSpecified
|
||||
import androidx.compose.ui.unit.isUnspecified
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.DpSizeSaver
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisChip
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisDropdownMenu
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedTextField
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisTextButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.rippleClickable
|
||||
import dev.patrickgold.florisboard.common.ValidationResult
|
||||
import dev.patrickgold.florisboard.common.kotlin.curlyFormat
|
||||
import dev.patrickgold.florisboard.common.kotlin.toStringWithoutDotZero
|
||||
import dev.patrickgold.florisboard.common.rememberValidationResult
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionValidation
|
||||
import dev.patrickgold.florisboard.snygg.SnyggLevel
|
||||
import dev.patrickgold.florisboard.snygg.SnyggPropertySetSpec
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDefinedVarValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDpSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggImplicitInheritValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSolidColorValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSpSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggValueEncoder
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggVarValueEncoders
|
||||
import dev.patrickgold.jetpref.material.ui.ExperimentalJetPrefMaterialUi
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefColorPicker
|
||||
import dev.patrickgold.jetpref.material.ui.rememberJetPrefColorPickerState
|
||||
|
||||
internal val SnyggEmptyPropertyInfoForAdding = PropertyInfo(
|
||||
name = "- select -",
|
||||
value = SnyggImplicitInheritValue,
|
||||
)
|
||||
|
||||
data class PropertyInfo(
|
||||
val name: String,
|
||||
val value: SnyggValue,
|
||||
)
|
||||
|
||||
private enum class ShapeCorner {
|
||||
TOP_START,
|
||||
TOP_END,
|
||||
BOTTOM_END,
|
||||
BOTTOM_START;
|
||||
|
||||
@Composable
|
||||
fun label(): String {
|
||||
return stringRes(when (this) {
|
||||
TOP_START -> R.string.enum__shape_corner__top_start
|
||||
TOP_END -> R.string.enum__shape_corner__top_end
|
||||
BOTTOM_END -> R.string.enum__shape_corner__bottom_end
|
||||
BOTTOM_START -> R.string.enum__shape_corner__bottom_start
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun EditPropertyDialog(
|
||||
propertySetSpec: SnyggPropertySetSpec?,
|
||||
initProperty: PropertyInfo,
|
||||
level: SnyggLevel,
|
||||
displayColorsAs: DisplayColorsAs,
|
||||
definedVariables: Map<String, SnyggValue>,
|
||||
onConfirmNewValue: (String, SnyggValue) -> Boolean,
|
||||
onDelete: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val isAddPropertyDialog = initProperty == SnyggEmptyPropertyInfoForAdding
|
||||
var showSelectAsError by rememberSaveable { mutableStateOf(false) }
|
||||
var showAlreadyExistsError by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var propertyName by rememberSaveable {
|
||||
mutableStateOf(if (isAddPropertyDialog && propertySetSpec == null) { "" } else { initProperty.name })
|
||||
}
|
||||
val propertyNameValidation = rememberValidationResult(ExtensionValidation.ThemeComponentVariableName, propertyName)
|
||||
var propertyValueEncoder by remember {
|
||||
mutableStateOf(if (isAddPropertyDialog && propertySetSpec == null) {
|
||||
SnyggImplicitInheritValue
|
||||
} else {
|
||||
initProperty.value.encoder()
|
||||
})
|
||||
}
|
||||
var propertyValue by remember {
|
||||
mutableStateOf(if (isAddPropertyDialog && propertySetSpec == null) {
|
||||
SnyggImplicitInheritValue
|
||||
} else {
|
||||
initProperty.value
|
||||
})
|
||||
}
|
||||
|
||||
fun isPropertyNameValid(): Boolean {
|
||||
return propertyNameValidation.isValid() && propertyName != SnyggEmptyPropertyInfoForAdding.name
|
||||
}
|
||||
|
||||
fun isPropertyValueValid(): Boolean {
|
||||
return when (val value = propertyValue) {
|
||||
is SnyggImplicitInheritValue -> false
|
||||
is SnyggDefinedVarValue -> value.key.isNotBlank()
|
||||
is SnyggSpSizeValue -> value.sp.isSpecified && value.sp.value >= 1f
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(if (isAddPropertyDialog) {
|
||||
R.string.settings__theme_editor__add_property
|
||||
} else {
|
||||
R.string.settings__theme_editor__edit_property
|
||||
}),
|
||||
confirmLabel = stringRes(if (isAddPropertyDialog) {
|
||||
R.string.action__add
|
||||
} else {
|
||||
R.string.action__apply
|
||||
}),
|
||||
onConfirm = {
|
||||
if (!isPropertyNameValid() || !isPropertyValueValid()) {
|
||||
showSelectAsError = true
|
||||
} else {
|
||||
if (!onConfirmNewValue(propertyName, propertyValue)) {
|
||||
showAlreadyExistsError = true
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = onDismiss,
|
||||
neutralLabel = if (!isAddPropertyDialog) { stringRes(R.string.action__delete) } else { null },
|
||||
onNeutral = onDelete,
|
||||
neutralColors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colors.error,
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
AnimatedVisibility(visible = showAlreadyExistsError) {
|
||||
Text(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
text = stringRes(R.string.settings__theme_editor__property_already_exists),
|
||||
color = MaterialTheme.colors.error,
|
||||
)
|
||||
}
|
||||
|
||||
DialogProperty(text = stringRes(R.string.settings__theme_editor__property_name)) {
|
||||
PropertyNameInput(
|
||||
propertySetSpec = propertySetSpec,
|
||||
name = propertyName,
|
||||
nameValidation = propertyNameValidation,
|
||||
onNameChange = { name ->
|
||||
if (propertySetSpec != null) {
|
||||
propertyValueEncoder = SnyggImplicitInheritValue
|
||||
}
|
||||
propertyName = name
|
||||
},
|
||||
level = level,
|
||||
isAddPropertyDialog = isAddPropertyDialog,
|
||||
showSelectAsError = showSelectAsError,
|
||||
)
|
||||
}
|
||||
|
||||
DialogProperty(text = stringRes(R.string.settings__theme_editor__property_value)) {
|
||||
PropertyValueEncoderDropdown(
|
||||
supportedEncoders = remember(propertyName) {
|
||||
propertySetSpec?.propertySpec(propertyName)?.encoders ?: SnyggVarValueEncoders
|
||||
},
|
||||
encoder = propertyValueEncoder,
|
||||
onEncoderChange = { encoder ->
|
||||
propertyValueEncoder = encoder
|
||||
propertyValue = encoder.defaultValue()
|
||||
},
|
||||
enabled = isPropertyNameValid(),
|
||||
isError = showSelectAsError && propertyValueEncoder == SnyggImplicitInheritValue,
|
||||
)
|
||||
|
||||
PropertyValueEditor(
|
||||
value = propertyValue,
|
||||
onValueChange = { propertyValue = it },
|
||||
level = level,
|
||||
displayColorsAs = displayColorsAs,
|
||||
definedVariables = definedVariables,
|
||||
isError = showSelectAsError && !isPropertyValueValid(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PropertyNameInput(
|
||||
propertySetSpec: SnyggPropertySetSpec?,
|
||||
name: String,
|
||||
nameValidation: ValidationResult,
|
||||
onNameChange: (String) -> Unit,
|
||||
level: SnyggLevel,
|
||||
isAddPropertyDialog: Boolean,
|
||||
showSelectAsError: Boolean,
|
||||
) {
|
||||
if (propertySetSpec != null) {
|
||||
val possiblePropertyNames = remember(propertySetSpec) {
|
||||
listOf(SnyggEmptyPropertyInfoForAdding.name) + propertySetSpec.supportedProperties.map { it.name }
|
||||
}
|
||||
val possiblePropertyLabels = possiblePropertyNames.map { translatePropertyName(it, level) }
|
||||
var propertiesExpanded by remember { mutableStateOf(false) }
|
||||
val propertiesSelectedIndex = remember(name) {
|
||||
possiblePropertyNames.indexOf(name).coerceIn(possiblePropertyNames.indices)
|
||||
}
|
||||
FlorisDropdownMenu(
|
||||
items = possiblePropertyLabels,
|
||||
expanded = propertiesExpanded,
|
||||
enabled = isAddPropertyDialog,
|
||||
selectedIndex = propertiesSelectedIndex,
|
||||
isError = showSelectAsError && propertiesSelectedIndex == 0,
|
||||
onSelectItem = { index ->
|
||||
onNameChange(possiblePropertyNames[index])
|
||||
},
|
||||
onExpandRequest = { propertiesExpanded = true },
|
||||
onDismissRequest = { propertiesExpanded = false },
|
||||
)
|
||||
} else {
|
||||
val focusManager = LocalFocusManager.current
|
||||
FlorisOutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = onNameChange,
|
||||
enabled = isAddPropertyDialog,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
|
||||
singleLine = true,
|
||||
showValidationHint = isAddPropertyDialog,
|
||||
showValidationError = showSelectAsError,
|
||||
validationResult = nameValidation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PropertyValueEncoderDropdown(
|
||||
supportedEncoders: List<SnyggValueEncoder>,
|
||||
encoder: SnyggValueEncoder,
|
||||
onEncoderChange: (SnyggValueEncoder) -> Unit,
|
||||
enabled: Boolean = true,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
val encoders = remember(supportedEncoders) {
|
||||
listOf(SnyggImplicitInheritValue) + supportedEncoders
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val selectedIndex = remember(encoder) {
|
||||
encoders.indexOf(encoder).coerceIn(encoders.indices)
|
||||
}
|
||||
FlorisDropdownMenu(
|
||||
items = encoders,
|
||||
labelProvider = { translatePropertyValueEncoderName(it) },
|
||||
expanded = expanded,
|
||||
enabled = enabled,
|
||||
selectedIndex = selectedIndex,
|
||||
isError = isError,
|
||||
onSelectItem = { index ->
|
||||
onEncoderChange(encoders[index])
|
||||
},
|
||||
onExpandRequest = { expanded = true },
|
||||
onDismissRequest = { expanded = false },
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalJetPrefMaterialUi::class)
|
||||
@Composable
|
||||
private fun PropertyValueEditor(
|
||||
value: SnyggValue,
|
||||
onValueChange: (SnyggValue) -> Unit,
|
||||
level: SnyggLevel,
|
||||
displayColorsAs: DisplayColorsAs,
|
||||
definedVariables: Map<String, SnyggValue>,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
when (value) {
|
||||
is SnyggDefinedVarValue -> {
|
||||
val variableKeys = remember(definedVariables) {
|
||||
listOf("") + definedVariables.keys.toList()
|
||||
}
|
||||
val selectedIndex by remember(variableKeys, value.key) {
|
||||
mutableStateOf(variableKeys.indexOf(value.key).coerceIn(variableKeys.indices))
|
||||
}
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FlorisDropdownMenu(
|
||||
modifier = Modifier
|
||||
.padding(end = 12.dp)
|
||||
.weight(1f),
|
||||
items = variableKeys,
|
||||
labelProvider = { translatePropertyName(it, level) },
|
||||
expanded = expanded,
|
||||
selectedIndex = selectedIndex,
|
||||
isError = isError,
|
||||
onSelectItem = { index ->
|
||||
onValueChange(SnyggDefinedVarValue(variableKeys[index]))
|
||||
},
|
||||
onExpandRequest = { expanded = true },
|
||||
onDismissRequest = { expanded = false },
|
||||
)
|
||||
SnyggValueIcon(
|
||||
value = value,
|
||||
definedVariables = definedVariables,
|
||||
)
|
||||
}
|
||||
}
|
||||
is SnyggSolidColorValue -> {
|
||||
val colorPickerState = rememberJetPrefColorPickerState(initColor = value.color)
|
||||
val colorPickerStr = translatePropertyValue(value, level, displayColorsAs)
|
||||
var showEditColorStrDialog by rememberSaveable { mutableStateOf(false) }
|
||||
Column(modifier = Modifier.padding(top = 8.dp)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.rippleClickable {
|
||||
showEditColorStrDialog = true
|
||||
}
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(end = 12.dp)
|
||||
.weight(1f),
|
||||
text = colorPickerStr,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
SnyggValueIcon(
|
||||
value = value,
|
||||
definedVariables = definedVariables,
|
||||
)
|
||||
}
|
||||
JetPrefColorPicker(
|
||||
onColorChange = { onValueChange(SnyggSolidColorValue(it)) },
|
||||
state = colorPickerState,
|
||||
)
|
||||
}
|
||||
if (showEditColorStrDialog) {
|
||||
var showValidationErrors by rememberSaveable { mutableStateOf(false) }
|
||||
var showSyntaxHelp by rememberSaveable { mutableStateOf(false) }
|
||||
var colorStr by rememberSaveable { mutableStateOf(colorPickerStr) }
|
||||
val colorStrValidation = rememberValidationResult(ExtensionValidation.SnyggSolidColorValue, colorStr)
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(R.string.settings__theme_editor__property_value_color_dialog_title),
|
||||
confirmLabel = stringRes(R.string.action__apply),
|
||||
onConfirm = {
|
||||
if (colorStrValidation.isInvalid()) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
val newValue = SnyggSolidColorValue.deserialize(colorStr.trim()).getOrThrow()
|
||||
onValueChange(newValue)
|
||||
colorPickerState.setColor((newValue as SnyggSolidColorValue).color)
|
||||
showEditColorStrDialog = false
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = {
|
||||
showEditColorStrDialog = false
|
||||
},
|
||||
trailingIconTitle = {
|
||||
FlorisIconButton(
|
||||
onClick = { showSyntaxHelp = !showSyntaxHelp },
|
||||
modifier = Modifier.offset(x = 12.dp),
|
||||
icon = painterResource(R.drawable.ic_help_outline),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
AnimatedVisibility(visible = showSyntaxHelp) {
|
||||
Column(modifier = Modifier.padding(bottom = 16.dp)) {
|
||||
Text(text = "Supported color string syntaxes:")
|
||||
Text(
|
||||
text = """
|
||||
#RRGGBBAA
|
||||
-> all in 00h..FFh
|
||||
#RRGGBB
|
||||
-> all in 00h..FFh
|
||||
rgba(r,g,b,a)
|
||||
-> r,g,b in 0..255
|
||||
-> a in 0.0..1.0
|
||||
rgb(r,g,b)
|
||||
-> r,g,b in 0..255
|
||||
""".trimIndent(),
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
FlorisOutlinedTextField(
|
||||
value = colorStr,
|
||||
onValueChange = { colorStr = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = colorStrValidation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is SnyggDpSizeValue -> {
|
||||
var sizeStr by remember {
|
||||
val dp = value.dp.takeUnless { it.isUnspecified } ?: SnyggDpSizeValue.defaultValue().dp
|
||||
mutableStateOf(dp.value.toStringWithoutDotZero())
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FlorisOutlinedTextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = sizeStr,
|
||||
onValueChange = { value ->
|
||||
sizeStr = value
|
||||
val size = sizeStr.toFloatOrNull()?.let { SnyggDpSizeValue(it.dp) }
|
||||
onValueChange(size ?: SnyggDpSizeValue(Dp.Unspecified))
|
||||
},
|
||||
isError = value.dp.isUnspecified || value.dp.value < 0f,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = "dp",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
is SnyggSpSizeValue -> {
|
||||
var sizeStr by remember {
|
||||
val sp = value.sp.takeUnless { it.isUnspecified } ?: SnyggSpSizeValue.defaultValue().sp
|
||||
mutableStateOf(sp.value.toStringWithoutDotZero())
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FlorisOutlinedTextField(
|
||||
modifier = Modifier.weight(1f),
|
||||
value = sizeStr,
|
||||
onValueChange = { value ->
|
||||
sizeStr = value
|
||||
val size = sizeStr.toFloatOrNull()?.let { SnyggSpSizeValue(it.sp) }
|
||||
onValueChange(size ?: SnyggSpSizeValue(TextUnit.Unspecified))
|
||||
},
|
||||
isError = value.sp.isUnspecified || value.sp.value < 1f,
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = "sp",
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
}
|
||||
}
|
||||
is SnyggShapeValue -> when (value) {
|
||||
is SnyggDpShapeValue -> {
|
||||
var showDialogInitDp by rememberSaveable(stateSaver = DpSizeSaver) {
|
||||
mutableStateOf(0.dp)
|
||||
}
|
||||
var showDialogForCorner by rememberSaveable {
|
||||
mutableStateOf<ShapeCorner?>(null)
|
||||
}
|
||||
var topStart by rememberSaveable(stateSaver = DpSizeSaver) {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerDpShapeValue -> value.topStart
|
||||
is SnyggRoundedCornerDpShapeValue -> value.topStart
|
||||
})
|
||||
}
|
||||
var topEnd by rememberSaveable(stateSaver = DpSizeSaver) {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerDpShapeValue -> value.topEnd
|
||||
is SnyggRoundedCornerDpShapeValue -> value.topEnd
|
||||
})
|
||||
}
|
||||
var bottomEnd by rememberSaveable(stateSaver = DpSizeSaver) {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerDpShapeValue -> value.bottomEnd
|
||||
is SnyggRoundedCornerDpShapeValue -> value.bottomEnd
|
||||
})
|
||||
}
|
||||
var bottomStart by rememberSaveable(stateSaver = DpSizeSaver) {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerDpShapeValue -> value.bottomStart
|
||||
is SnyggRoundedCornerDpShapeValue -> value.bottomStart
|
||||
})
|
||||
}
|
||||
val shape = remember(topStart, topEnd, bottomEnd, bottomStart) {
|
||||
when (value) {
|
||||
is SnyggCutCornerDpShapeValue -> {
|
||||
CutCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
is SnyggRoundedCornerDpShapeValue -> {
|
||||
RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shape) {
|
||||
onValueChange(when (value) {
|
||||
is SnyggCutCornerDpShapeValue -> {
|
||||
SnyggCutCornerDpShapeValue(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
is SnyggRoundedCornerDpShapeValue -> {
|
||||
SnyggRoundedCornerDpShapeValue(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
})
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Column {
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitDp = topStart
|
||||
showDialogForCorner = ShapeCorner.TOP_START
|
||||
},
|
||||
text = stringRes(R.string.unit__display_pixel__symbol).curlyFormat("v" to topStart.value.toStringWithoutDotZero()),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitDp = bottomStart
|
||||
showDialogForCorner = ShapeCorner.BOTTOM_START
|
||||
},
|
||||
text = stringRes(R.string.unit__display_pixel__symbol).curlyFormat("v" to bottomStart.value.toStringWithoutDotZero()),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredSize(40.dp)
|
||||
.border(1.dp, MaterialTheme.colors.onBackground, shape),
|
||||
)
|
||||
Column {
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitDp = topEnd
|
||||
showDialogForCorner = ShapeCorner.TOP_END
|
||||
},
|
||||
text = stringRes(R.string.unit__display_pixel__symbol).curlyFormat("v" to topEnd.value.toStringWithoutDotZero()),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitDp = bottomEnd
|
||||
showDialogForCorner = ShapeCorner.BOTTOM_END
|
||||
},
|
||||
text = stringRes(R.string.unit__display_pixel__symbol).curlyFormat("v" to bottomEnd.value.toStringWithoutDotZero()),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
val dialogForCorner = showDialogForCorner
|
||||
if (dialogForCorner != null) {
|
||||
var showValidationErrors by rememberSaveable { mutableStateOf(false) }
|
||||
var size by rememberSaveable {
|
||||
mutableStateOf(showDialogInitDp.value.toStringWithoutDotZero())
|
||||
}
|
||||
val sizeValidation = rememberValidationResult(ExtensionValidation.SnyggDpShapeValue, size)
|
||||
JetPrefAlertDialog(
|
||||
title = dialogForCorner.label(),
|
||||
confirmLabel = stringRes(R.string.action__apply),
|
||||
onConfirm = {
|
||||
if (sizeValidation.isInvalid()) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
val sizeDp = size.toFloat().dp
|
||||
when (dialogForCorner) {
|
||||
ShapeCorner.TOP_START -> topStart = sizeDp
|
||||
ShapeCorner.TOP_END -> topEnd = sizeDp
|
||||
ShapeCorner.BOTTOM_END -> bottomEnd = sizeDp
|
||||
ShapeCorner.BOTTOM_START -> bottomStart = sizeDp
|
||||
}
|
||||
showDialogForCorner = null
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = {
|
||||
showDialogForCorner = null
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
FlorisOutlinedTextField(
|
||||
value = size,
|
||||
onValueChange = { size = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = sizeValidation,
|
||||
)
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
if (sizeValidation.isInvalid()) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
val sizeDp = size.toFloat().dp
|
||||
topStart = sizeDp
|
||||
topEnd = sizeDp
|
||||
bottomEnd = sizeDp
|
||||
bottomStart = sizeDp
|
||||
showDialogForCorner = null
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text = stringRes(R.string.settings__theme_editor__property_value_shape_apply_for_all_corners),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is SnyggPercentShapeValue -> {
|
||||
var showDialogInitPercentage by rememberSaveable {
|
||||
mutableStateOf(0)
|
||||
}
|
||||
var showDialogForCorner by rememberSaveable {
|
||||
mutableStateOf<ShapeCorner?>(null)
|
||||
}
|
||||
var topStart by rememberSaveable {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerPercentShapeValue -> value.topStart
|
||||
is SnyggRoundedCornerPercentShapeValue -> value.topStart
|
||||
})
|
||||
}
|
||||
var topEnd by rememberSaveable {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerPercentShapeValue -> value.topEnd
|
||||
is SnyggRoundedCornerPercentShapeValue -> value.topEnd
|
||||
})
|
||||
}
|
||||
var bottomEnd by rememberSaveable {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerPercentShapeValue -> value.bottomEnd
|
||||
is SnyggRoundedCornerPercentShapeValue -> value.bottomEnd
|
||||
})
|
||||
}
|
||||
var bottomStart by rememberSaveable {
|
||||
mutableStateOf(when (value) {
|
||||
is SnyggCutCornerPercentShapeValue -> value.bottomStart
|
||||
is SnyggRoundedCornerPercentShapeValue -> value.bottomStart
|
||||
})
|
||||
}
|
||||
val shape = remember(topStart, topEnd, bottomEnd, bottomStart) {
|
||||
when (value) {
|
||||
is SnyggCutCornerPercentShapeValue -> {
|
||||
CutCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
is SnyggRoundedCornerPercentShapeValue -> {
|
||||
RoundedCornerShape(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(shape) {
|
||||
onValueChange(when (value) {
|
||||
is SnyggCutCornerPercentShapeValue -> {
|
||||
SnyggCutCornerPercentShapeValue(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
is SnyggRoundedCornerPercentShapeValue -> {
|
||||
SnyggRoundedCornerPercentShapeValue(topStart, topEnd, bottomEnd, bottomStart)
|
||||
}
|
||||
})
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
Column {
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitPercentage = topStart
|
||||
showDialogForCorner = ShapeCorner.TOP_START
|
||||
},
|
||||
text = stringRes(R.string.unit__percent__symbol).curlyFormat("v" to topStart),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitPercentage = bottomStart
|
||||
showDialogForCorner = ShapeCorner.BOTTOM_START
|
||||
},
|
||||
text = stringRes(R.string.unit__percent__symbol).curlyFormat("v" to bottomStart),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredSize(40.dp)
|
||||
.border(1.dp, MaterialTheme.colors.onBackground, shape),
|
||||
)
|
||||
Column {
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitPercentage = topEnd
|
||||
showDialogForCorner = ShapeCorner.TOP_END
|
||||
},
|
||||
text = stringRes(R.string.unit__percent__symbol).curlyFormat("v" to topEnd),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = {
|
||||
showDialogInitPercentage = bottomEnd
|
||||
showDialogForCorner = ShapeCorner.BOTTOM_END
|
||||
},
|
||||
text = stringRes(R.string.unit__percent__symbol).curlyFormat("v" to bottomEnd),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
val dialogForCorner = showDialogForCorner
|
||||
if (dialogForCorner != null) {
|
||||
var showValidationErrors by rememberSaveable { mutableStateOf(false) }
|
||||
var size by rememberSaveable {
|
||||
mutableStateOf(showDialogInitPercentage.toString())
|
||||
}
|
||||
val sizeValidation = rememberValidationResult(ExtensionValidation.SnyggPercentShapeValue, size)
|
||||
JetPrefAlertDialog(
|
||||
title = dialogForCorner.label(),
|
||||
confirmLabel = stringRes(R.string.action__apply),
|
||||
onConfirm = {
|
||||
if (sizeValidation.isInvalid()) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
val sizePercentage = size.toInt()
|
||||
when (showDialogForCorner) {
|
||||
ShapeCorner.TOP_START -> topStart = sizePercentage
|
||||
ShapeCorner.TOP_END -> topEnd = sizePercentage
|
||||
ShapeCorner.BOTTOM_END -> bottomEnd = sizePercentage
|
||||
ShapeCorner.BOTTOM_START -> bottomStart = sizePercentage
|
||||
else -> { }
|
||||
}
|
||||
showDialogForCorner = null
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = {
|
||||
showDialogForCorner = null
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
FlorisOutlinedTextField(
|
||||
value = size,
|
||||
onValueChange = { size = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = sizeValidation,
|
||||
)
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
if (sizeValidation.isInvalid()) {
|
||||
showValidationErrors = true
|
||||
} else {
|
||||
val sizePercentage = size.toInt()
|
||||
topStart = sizePercentage
|
||||
topEnd = sizePercentage
|
||||
bottomEnd = sizePercentage
|
||||
bottomStart = sizePercentage
|
||||
showDialogForCorner = null
|
||||
}
|
||||
},
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
text = stringRes(R.string.settings__theme_editor__property_value_shape_apply_for_all_corners),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.requiredSize(40.dp)
|
||||
.border(1.dp, MaterialTheme.colors.onBackground, value.shape),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Render nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.flowlayout.FlowRow
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisChip
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisDropdownMenu
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisHyperlinkText
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedTextField
|
||||
import dev.patrickgold.florisboard.app.ui.components.florisHorizontalScroll
|
||||
import dev.patrickgold.florisboard.common.kotlin.curlyFormat
|
||||
import dev.patrickgold.florisboard.ime.nlp.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.ime.text.key.InputMode
|
||||
import dev.patrickgold.florisboard.ime.text.key.KeyCode
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUiSpec
|
||||
import dev.patrickgold.florisboard.snygg.SnyggLevel
|
||||
import dev.patrickgold.florisboard.snygg.SnyggRule
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
|
||||
internal val SnyggEmptyRuleForAdding = SnyggRule(element = "- select -")
|
||||
|
||||
@Composable
|
||||
internal fun EditRuleDialog(
|
||||
initRule: SnyggRule,
|
||||
level: SnyggLevel,
|
||||
onConfirmRule: (oldRule: SnyggRule, newRule: SnyggRule) -> Boolean,
|
||||
onDeleteRule: (rule: SnyggRule) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val isAddRuleDialog = initRule == SnyggEmptyRuleForAdding
|
||||
var showSelectAsError by rememberSaveable { mutableStateOf(false) }
|
||||
var showAlreadyExistsError by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val possibleElementNames = remember {
|
||||
listOf(SnyggEmptyRuleForAdding.element) + FlorisImeUiSpec.elements.keys
|
||||
}
|
||||
val possibleElementLabels = possibleElementNames.map { translateElementName(it, level) ?: it }
|
||||
var elementsExpanded by remember { mutableStateOf(false) }
|
||||
var elementsSelectedIndex by rememberSaveable {
|
||||
val index = possibleElementNames.indexOf(initRule.element).coerceIn(possibleElementNames.indices)
|
||||
mutableStateOf(index)
|
||||
}
|
||||
|
||||
val codes = rememberSaveable(saver = IntListSaver) { initRule.codes.toMutableStateList() }
|
||||
var editCodeDialogValue by rememberSaveable { mutableStateOf<Int?>(null) }
|
||||
val groups = rememberSaveable(saver = IntListSaver) { initRule.groups.toMutableStateList() }
|
||||
var modeNormal by rememberSaveable { mutableStateOf(initRule.modes.contains(InputMode.NORMAL.value)) }
|
||||
var modeShiftLock by rememberSaveable { mutableStateOf(initRule.modes.contains(InputMode.SHIFT_LOCK.value)) }
|
||||
var modeCapsLock by rememberSaveable { mutableStateOf(initRule.modes.contains(InputMode.CAPS_LOCK.value)) }
|
||||
var pressedSelector by rememberSaveable { mutableStateOf(initRule.pressedSelector) }
|
||||
var focusSelector by rememberSaveable { mutableStateOf(initRule.focusSelector) }
|
||||
var disabledSelector by rememberSaveable { mutableStateOf(initRule.disabledSelector) }
|
||||
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(if (isAddRuleDialog) {
|
||||
R.string.settings__theme_editor__add_rule
|
||||
} else {
|
||||
R.string.settings__theme_editor__edit_rule
|
||||
}),
|
||||
confirmLabel = stringRes(if (isAddRuleDialog) {
|
||||
R.string.action__add
|
||||
} else {
|
||||
R.string.action__apply
|
||||
}),
|
||||
onConfirm = {
|
||||
if (isAddRuleDialog && elementsSelectedIndex == 0) {
|
||||
showSelectAsError = true
|
||||
} else {
|
||||
val newRule = SnyggRule(
|
||||
element = possibleElementNames[elementsSelectedIndex],
|
||||
codes = codes.toList(),
|
||||
groups = groups.toList(),
|
||||
modes = buildList {
|
||||
if (modeNormal) { add(InputMode.NORMAL.value) }
|
||||
if (modeShiftLock) { add(InputMode.SHIFT_LOCK.value) }
|
||||
if (modeCapsLock) { add(InputMode.CAPS_LOCK.value) }
|
||||
},
|
||||
pressedSelector = pressedSelector,
|
||||
focusSelector = focusSelector,
|
||||
disabledSelector = disabledSelector,
|
||||
)
|
||||
if (!onConfirmRule(initRule, newRule)) {
|
||||
showAlreadyExistsError = true
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = onDismiss,
|
||||
neutralLabel = if (!isAddRuleDialog) {
|
||||
stringRes(R.string.action__delete)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
neutralColors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.error),
|
||||
onNeutral = { onDeleteRule(initRule) },
|
||||
) {
|
||||
Column {
|
||||
AnimatedVisibility(visible = showAlreadyExistsError) {
|
||||
Text(
|
||||
modifier = Modifier.padding(bottom = 16.dp),
|
||||
text = stringRes(R.string.settings__theme_editor__rule_already_exists),
|
||||
color = MaterialTheme.colors.error,
|
||||
)
|
||||
}
|
||||
|
||||
DialogProperty(text = stringRes(R.string.settings__theme_editor__rule_element)) {
|
||||
FlorisDropdownMenu(
|
||||
items = possibleElementLabels,
|
||||
expanded = elementsExpanded,
|
||||
enabled = isAddRuleDialog,
|
||||
selectedIndex = elementsSelectedIndex,
|
||||
isError = showSelectAsError && elementsSelectedIndex == 0,
|
||||
onSelectItem = { elementsSelectedIndex = it },
|
||||
onExpandRequest = { elementsExpanded = true },
|
||||
onDismissRequest = { elementsExpanded = false },
|
||||
)
|
||||
}
|
||||
|
||||
DialogProperty(text = stringRes(R.string.settings__theme_editor__rule_selectors)) {
|
||||
Row(modifier = Modifier.florisHorizontalScroll()) {
|
||||
FlorisChip(
|
||||
onClick = { pressedSelector = !pressedSelector },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> SnyggRule.PRESSED_SELECTOR
|
||||
else -> stringRes(R.string.snygg__rule_selector__pressed)
|
||||
},
|
||||
color = if (pressedSelector) MaterialTheme.colors.primaryVariant else Color.Unspecified,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = { focusSelector = !focusSelector },
|
||||
modifier = Modifier.padding( end = 4.dp),
|
||||
text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> SnyggRule.FOCUS_SELECTOR
|
||||
else -> stringRes(R.string.snygg__rule_selector__focus)
|
||||
},
|
||||
color = if (focusSelector) MaterialTheme.colors.primaryVariant else Color.Unspecified,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = { disabledSelector = !disabledSelector },
|
||||
text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> SnyggRule.DISABLED_SELECTOR
|
||||
else -> stringRes(R.string.snygg__rule_selector__disabled)
|
||||
},
|
||||
color = if (disabledSelector) MaterialTheme.colors.primaryVariant else Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DialogProperty(
|
||||
text = stringRes(R.string.settings__theme_editor__rule_codes),
|
||||
trailingIconTitle = {
|
||||
FlorisIconButton(
|
||||
onClick = { editCodeDialogValue = NATIVE_NULLPTR },
|
||||
modifier = Modifier.offset(x = 12.dp),
|
||||
icon = painterResource(R.drawable.ic_add),
|
||||
)
|
||||
},
|
||||
) {
|
||||
if (codes.isEmpty()) {
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
text = stringRes(R.string.settings__theme_editor__no_codes_defined),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
FlowRow {
|
||||
for (code in codes) {
|
||||
FlorisChip(
|
||||
onClick = { editCodeDialogValue = code },
|
||||
text = code.toString(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DialogProperty(text = stringRes(R.string.settings__theme_editor__rule_modes)) {
|
||||
Row(modifier = Modifier.florisHorizontalScroll()) {
|
||||
FlorisChip(
|
||||
onClick = { modeNormal = !modeNormal },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> remember { "m:${InputMode.NORMAL.toString().lowercase()}" }
|
||||
else -> stringRes(R.string.enum__input_mode__normal)
|
||||
},
|
||||
color = if (modeNormal) MaterialTheme.colors.primaryVariant else Color.Unspecified,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = { modeShiftLock = !modeShiftLock },
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> remember { "m:${InputMode.SHIFT_LOCK.toString().lowercase()}" }
|
||||
else -> stringRes(R.string.enum__input_mode__shift_lock)
|
||||
},
|
||||
color = if (modeShiftLock) MaterialTheme.colors.primaryVariant else Color.Unspecified,
|
||||
)
|
||||
FlorisChip(
|
||||
onClick = { modeCapsLock = !modeCapsLock },
|
||||
text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> remember { "m:${InputMode.CAPS_LOCK.toString().lowercase()}" }
|
||||
else -> stringRes(R.string.enum__input_mode__caps_lock)
|
||||
},
|
||||
color = if (modeCapsLock) MaterialTheme.colors.primaryVariant else Color.Unspecified,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val initCodeValue = editCodeDialogValue
|
||||
if (initCodeValue != null) {
|
||||
var inputCodeString by rememberSaveable(initCodeValue) { mutableStateOf(initCodeValue.toString()) }
|
||||
var showKeyCodesHelp by rememberSaveable(initCodeValue) { mutableStateOf(false) }
|
||||
var showError by rememberSaveable(initCodeValue) { mutableStateOf(false) }
|
||||
var errorId by rememberSaveable(initCodeValue) { mutableStateOf(NATIVE_NULLPTR) }
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(if (initCodeValue == NATIVE_NULLPTR) {
|
||||
R.string.settings__theme_editor__add_code
|
||||
} else {
|
||||
R.string.settings__theme_editor__edit_code
|
||||
}),
|
||||
confirmLabel = stringRes(if (initCodeValue == NATIVE_NULLPTR) {
|
||||
R.string.action__add
|
||||
} else {
|
||||
R.string.action__apply
|
||||
}),
|
||||
onConfirm = {
|
||||
val code = inputCodeString.trim().toIntOrNull(radix = 10)
|
||||
when {
|
||||
code == null || (code !in KeyCode.Spec.CHARACTERS && code !in KeyCode.Spec.INTERNAL) -> {
|
||||
errorId = R.string.settings__theme_editor__code_invalid
|
||||
showError = true
|
||||
}
|
||||
code == initCodeValue -> {
|
||||
editCodeDialogValue = null
|
||||
}
|
||||
codes.contains(code) -> {
|
||||
errorId = R.string.settings__theme_editor__code_already_exists
|
||||
showError = true
|
||||
}
|
||||
else -> {
|
||||
if (initCodeValue != NATIVE_NULLPTR) {
|
||||
codes.remove(initCodeValue)
|
||||
}
|
||||
codes.add(code)
|
||||
editCodeDialogValue = null
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = {
|
||||
editCodeDialogValue = null
|
||||
},
|
||||
neutralLabel = if (initCodeValue != NATIVE_NULLPTR) {
|
||||
stringRes(R.string.action__delete)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
neutralColors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colors.error),
|
||||
onNeutral = {
|
||||
codes.remove(initCodeValue)
|
||||
editCodeDialogValue = null
|
||||
},
|
||||
trailingIconTitle = {
|
||||
FlorisIconButton(
|
||||
onClick = { showKeyCodesHelp = !showKeyCodesHelp },
|
||||
modifier = Modifier.offset(x = 12.dp),
|
||||
icon = painterResource(R.drawable.ic_help_outline),
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column {
|
||||
AnimatedVisibility(visible = showKeyCodesHelp) {
|
||||
Column(modifier = Modifier.padding(bottom = 16.dp)) {
|
||||
Text(text = stringRes(R.string.settings__theme_editor__code_help_text))
|
||||
FlorisHyperlinkText(
|
||||
text = "Characters (unicode-table.com)",
|
||||
url = stringRes(R.string.florisboard__character_key_codes_url),
|
||||
)
|
||||
FlorisHyperlinkText(
|
||||
text = "Internal (github.com)",
|
||||
url = stringRes(R.string.florisboard__internal_key_codes_url),
|
||||
)
|
||||
}
|
||||
}
|
||||
FlorisOutlinedTextField(
|
||||
value = inputCodeString,
|
||||
onValueChange = { v ->
|
||||
inputCodeString = v
|
||||
showError = false
|
||||
},
|
||||
isError = showError,
|
||||
singleLine = true,
|
||||
)
|
||||
AnimatedVisibility(visible = showError) {
|
||||
Text(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
text = stringRes(errorId).curlyFormat(
|
||||
"c_min" to KeyCode.Spec.CHARACTERS_MIN,
|
||||
"c_max" to KeyCode.Spec.CHARACTERS_MAX,
|
||||
"i_min" to KeyCode.Spec.INTERNAL_MIN,
|
||||
"i_max" to KeyCode.Spec.INTERNAL_MAX,
|
||||
),
|
||||
color = MaterialTheme.colors.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.snygg.SnyggLevel
|
||||
import dev.patrickgold.jetpref.datastore.ui.ListPreference
|
||||
import dev.patrickgold.jetpref.datastore.ui.PreferenceLayout
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
|
||||
private val FineTuneContentPadding = PaddingValues(horizontal = 8.dp)
|
||||
|
||||
@Composable
|
||||
fun FineTuneDialog(onDismiss: () -> Unit) {
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(R.string.settings__theme_editor__fine_tune__title),
|
||||
onDismiss = onDismiss,
|
||||
contentPadding = FineTuneContentPadding,
|
||||
) {
|
||||
PreferenceLayout(florisPreferenceModel(), iconSpaceReserved = false) {
|
||||
ListPreference(
|
||||
listPref = prefs.theme.editorLevel,
|
||||
title = stringRes(R.string.settings__theme_editor__fine_tune__level),
|
||||
entries = SnyggLevel.listEntries(),
|
||||
)
|
||||
ListPreference(
|
||||
listPref = prefs.theme.editorDisplayColorsAs,
|
||||
title = stringRes(R.string.settings__theme_editor__fine_tune__display_colors_as),
|
||||
entries = DisplayColorsAs.listEntries(),
|
||||
)
|
||||
ListPreference(
|
||||
listPref = prefs.theme.editorDisplayKbdAfterDialogs,
|
||||
title = stringRes(R.string.settings__theme_editor__fine_tune__display_kbd_after_dialogs),
|
||||
entries = DisplayKbdAfterDialogs.listEntries(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.shape.CutCornerShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDefinedVarValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDpSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSolidColorValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSpSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggValue
|
||||
import dev.patrickgold.jetpref.material.ui.checkeredBackground
|
||||
|
||||
object SnyggValueIcon {
|
||||
interface Spec {
|
||||
val borderWith: Dp
|
||||
val boxShape: Shape
|
||||
val elevation: Dp
|
||||
val gridSize: Dp
|
||||
val iconSize: Dp
|
||||
val iconSizeMinusBorder: Dp
|
||||
}
|
||||
|
||||
object Small : Spec {
|
||||
override val borderWith = Dp.Hairline
|
||||
override val boxShape = RoundedCornerShape(4.dp)
|
||||
override val elevation = 4.dp
|
||||
override val gridSize = 2.dp
|
||||
override val iconSize = 16.dp
|
||||
override val iconSizeMinusBorder = 16.dp
|
||||
}
|
||||
|
||||
object Normal : Spec {
|
||||
override val borderWith = 1.dp
|
||||
override val boxShape = RoundedCornerShape(8.dp)
|
||||
override val elevation = 4.dp
|
||||
override val gridSize = 3.dp
|
||||
override val iconSize = 24.dp
|
||||
override val iconSizeMinusBorder = 22.dp
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun SnyggValueIcon(
|
||||
value: SnyggValue,
|
||||
definedVariables: Map<String, SnyggValue>,
|
||||
modifier: Modifier = Modifier,
|
||||
spec: SnyggValueIcon.Spec = SnyggValueIcon.Normal,
|
||||
) {
|
||||
when (value) {
|
||||
is SnyggSolidColorValue -> {
|
||||
Surface(
|
||||
modifier = modifier.requiredSize(spec.iconSize),
|
||||
color = MaterialTheme.colors.background,
|
||||
elevation = spec.elevation,
|
||||
shape = spec.boxShape,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.checkeredBackground(gridSize = spec.gridSize)
|
||||
.background(value.color),
|
||||
)
|
||||
}
|
||||
}
|
||||
is SnyggShapeValue -> {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.requiredSize(spec.iconSizeMinusBorder)
|
||||
.border(spec.borderWith, MaterialTheme.colors.onBackground, value.alwaysPercentShape())
|
||||
)
|
||||
}
|
||||
is SnyggDpSizeValue -> {
|
||||
Icon(
|
||||
modifier = modifier.requiredSize(spec.iconSize),
|
||||
painter = painterResource(R.drawable.ic_straighten),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
is SnyggSpSizeValue -> {
|
||||
Icon(
|
||||
modifier = modifier.requiredSize(spec.iconSize),
|
||||
painter = painterResource(R.drawable.ic_format_size),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
is SnyggDefinedVarValue -> {
|
||||
val realValue = definedVariables[value.key]
|
||||
if (realValue == null) {
|
||||
Icon(
|
||||
modifier = modifier.requiredSize(spec.iconSize),
|
||||
painter = painterResource(R.drawable.ic_link),
|
||||
contentDescription = null,
|
||||
)
|
||||
} else {
|
||||
val smallSpec = SnyggValueIcon.Small
|
||||
Box(modifier = modifier
|
||||
.requiredSize(spec.iconSize)
|
||||
.offset(y = (-2).dp)) {
|
||||
SnyggValueIcon(
|
||||
modifier = Modifier.offset(x = 8.dp, y = 8.dp),
|
||||
value = realValue,
|
||||
definedVariables = definedVariables,
|
||||
spec = smallSpec,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset(x = 1.dp)
|
||||
.requiredSize(smallSpec.iconSize)
|
||||
.padding(vertical = 2.dp)
|
||||
.background(MaterialTheme.colors.background, spec.boxShape),
|
||||
)
|
||||
Icon(
|
||||
modifier = Modifier.requiredSize(smallSpec.iconSize),
|
||||
painter = painterResource(R.drawable.ic_link),
|
||||
contentDescription = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Render nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val AlwaysPercentUpscaleFactor = 3
|
||||
|
||||
fun SnyggShapeValue.alwaysPercentShape(): Shape {
|
||||
return when (this) {
|
||||
is SnyggRoundedCornerDpShapeValue -> {
|
||||
RoundedCornerShape(
|
||||
this.topStart.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
this.topEnd.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
this.bottomEnd.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
this.bottomStart.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
)
|
||||
}
|
||||
is SnyggCutCornerDpShapeValue -> {
|
||||
CutCornerShape(
|
||||
this.topStart.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
this.topEnd.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
this.bottomEnd.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
this.bottomStart.value.toInt() * AlwaysPercentUpscaleFactor,
|
||||
)
|
||||
}
|
||||
else -> this.shape
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.selection.toggleable
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.ExtendedFloatingActionButton
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Switch
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
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.runtime.toMutableStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.google.accompanist.insets.LocalWindowInsets
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedTextField
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.PreviewKeyboardField
|
||||
import dev.patrickgold.florisboard.app.ui.components.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.florisVerticalScroll
|
||||
import dev.patrickgold.florisboard.app.ui.components.rememberPreviewFieldController
|
||||
import dev.patrickgold.florisboard.app.ui.components.rippleClickable
|
||||
import dev.patrickgold.florisboard.app.ui.ext.ExtensionComponentView
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
import dev.patrickgold.florisboard.common.rememberValidationResult
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUiSpec
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponentEditor
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionEditor
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeManager
|
||||
import dev.patrickgold.florisboard.res.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionValidation
|
||||
import dev.patrickgold.florisboard.res.io.readJson
|
||||
import dev.patrickgold.florisboard.res.io.subFile
|
||||
import dev.patrickgold.florisboard.snygg.SnyggLevel
|
||||
import dev.patrickgold.florisboard.snygg.SnyggPropertySetEditor
|
||||
import dev.patrickgold.florisboard.snygg.SnyggPropertySetSpec
|
||||
import dev.patrickgold.florisboard.snygg.SnyggRule
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheet
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheetEditor
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheetJsonConfig
|
||||
import dev.patrickgold.florisboard.snygg.definedVariablesRule
|
||||
import dev.patrickgold.florisboard.snygg.isDefinedVariablesRule
|
||||
import dev.patrickgold.florisboard.themeManager
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefAlertDialog
|
||||
import dev.patrickgold.jetpref.material.ui.JetPrefListItem
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal val IntListSaver = Saver<SnapshotStateList<Int>, ArrayList<Int>>(
|
||||
save = { ArrayList(it) },
|
||||
restore = { it.toMutableStateList() },
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ThemeEditorScreen(
|
||||
workspace: CacheManager.ExtEditorWorkspace<*>,
|
||||
editor: ThemeExtensionComponentEditor,
|
||||
) = FlorisScreen {
|
||||
title = stringRes(R.string.ext__editor__edit_component__title_theme)
|
||||
scrollable = false
|
||||
|
||||
val prefs by florisPreferenceModel()
|
||||
val context = LocalContext.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val themeManager by context.themeManager()
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val previewFieldController = rememberPreviewFieldController().also { it.isVisible = true }
|
||||
|
||||
val stylesheetEditor = remember {
|
||||
editor.stylesheetEditor ?: run {
|
||||
val stylesheetPath = editor.stylesheetPath()
|
||||
editor.stylesheetPathOnLoad = stylesheetPath
|
||||
val stylesheetFile = workspace.extDir.subFile(stylesheetPath)
|
||||
val stylesheetEditor = if (stylesheetFile.exists()) {
|
||||
try {
|
||||
stylesheetFile.readJson<SnyggStylesheet>(SnyggStylesheetJsonConfig).edit()
|
||||
} catch (e: Throwable) {
|
||||
SnyggStylesheetEditor()
|
||||
}
|
||||
} else {
|
||||
SnyggStylesheetEditor()
|
||||
}
|
||||
if (stylesheetEditor.rules.none { (rule, _) -> rule.isDefinedVariablesRule() }) {
|
||||
stylesheetEditor.rules[SnyggRule.definedVariablesRule()] = SnyggPropertySetEditor()
|
||||
}
|
||||
stylesheetEditor
|
||||
}.also { editor.stylesheetEditor = it }
|
||||
}
|
||||
|
||||
val snyggLevel by prefs.theme.editorLevel.observeAsState()
|
||||
val displayColorsAs by prefs.theme.editorDisplayColorsAs.observeAsState()
|
||||
val displayKbdAfterDialogs by prefs.theme.editorDisplayKbdAfterDialogs.observeAsState()
|
||||
var oldFocusState by remember { mutableStateOf(false) }
|
||||
var snyggRuleToEdit by rememberSaveable(stateSaver = SnyggRule.Saver) { mutableStateOf(null) }
|
||||
var snyggPropertyToEdit by remember { mutableStateOf<PropertyInfo?>(null) }
|
||||
var snyggPropertySetForEditing = remember<SnyggPropertySetEditor?> { null }
|
||||
var snyggPropertySetSpecForEditing = remember<SnyggPropertySetSpec?> { null }
|
||||
var showEditComponentMetaDialog by rememberSaveable { mutableStateOf(false) }
|
||||
var showFineTuneDialog by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
fun handleBackPress() {
|
||||
workspace.currentAction = null
|
||||
}
|
||||
|
||||
navigationIcon {
|
||||
FlorisIconButton(
|
||||
onClick = { handleBackPress() },
|
||||
icon = painterResource(R.drawable.ic_close),
|
||||
)
|
||||
}
|
||||
|
||||
actions {
|
||||
FlorisIconButton(
|
||||
onClick = { showFineTuneDialog = true },
|
||||
icon = painterResource(R.drawable.ic_tune),
|
||||
)
|
||||
}
|
||||
|
||||
floatingActionButton {
|
||||
ExtendedFloatingActionButton(
|
||||
icon = { Icon(
|
||||
painter = painterResource(R.drawable.ic_add),
|
||||
contentDescription = null,
|
||||
) },
|
||||
text = { Text(
|
||||
text = stringRes(R.string.settings__theme_editor__add_rule),
|
||||
) },
|
||||
onClick = { snyggRuleToEdit = SnyggEmptyRuleForAdding },
|
||||
)
|
||||
}
|
||||
|
||||
bottomBar {
|
||||
PreviewKeyboardField(previewFieldController)
|
||||
}
|
||||
|
||||
content {
|
||||
BackHandler {
|
||||
handleBackPress()
|
||||
}
|
||||
|
||||
val isImeVisible = LocalWindowInsets.current.ime.isVisible
|
||||
LaunchedEffect(showEditComponentMetaDialog, showFineTuneDialog, snyggRuleToEdit, snyggPropertyToEdit) {
|
||||
val visible = showEditComponentMetaDialog || showFineTuneDialog ||
|
||||
snyggRuleToEdit != null || snyggPropertyToEdit != null
|
||||
if (visible) {
|
||||
oldFocusState = isImeVisible
|
||||
focusManager.clearFocus()
|
||||
} else {
|
||||
delay(250)
|
||||
when (displayKbdAfterDialogs) {
|
||||
DisplayKbdAfterDialogs.ALWAYS -> {
|
||||
previewFieldController.focusRequester.requestFocus()
|
||||
}
|
||||
DisplayKbdAfterDialogs.NEVER -> {
|
||||
// Do nothing
|
||||
}
|
||||
DisplayKbdAfterDialogs.REMEMBER -> {
|
||||
if (oldFocusState) {
|
||||
previewFieldController.focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(workspace.version) {
|
||||
themeManager.previewThemeInfo = ThemeManager.ThemeInfo.DEFAULT.copy(
|
||||
stylesheet = stylesheetEditor.build().compileToFullyQualified(FlorisImeUiSpec),
|
||||
)
|
||||
onDispose {
|
||||
themeManager.previewThemeInfo = null
|
||||
}
|
||||
}
|
||||
|
||||
val definedVariables = remember(stylesheetEditor.rules) {
|
||||
stylesheetEditor.rules.firstNotNullOfOrNull { (rule, propertySet) ->
|
||||
if (rule.isDefinedVariablesRule()) {
|
||||
propertySet.properties
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} ?: emptyMap()
|
||||
}
|
||||
|
||||
// TODO: (priority = low)
|
||||
// Floris scrollbar does not like lazy lists with non-constant item heights.
|
||||
// Consider building a custom scrollbar tailored for this list specifically.
|
||||
val lazyListState = rememberLazyListState()
|
||||
LazyColumn(
|
||||
//modifier = Modifier.florisScrollbar(lazyListState, isVertical = true),
|
||||
state = lazyListState,
|
||||
) {
|
||||
item {
|
||||
Column {
|
||||
ExtensionComponentView(
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
meta = workspace.editor!!.meta,
|
||||
component = editor,
|
||||
onEditBtnClick = { showEditComponentMetaDialog = true },
|
||||
)
|
||||
if (stylesheetEditor.rules.isEmpty() ||
|
||||
(stylesheetEditor.rules.size == 1 && stylesheetEditor.rules.keys.all { it.isDefinedVariablesRule() })
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp),
|
||||
text = stringRes(R.string.settings__theme_editor__no_rules_defined),
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(stylesheetEditor.rules.entries.toList()) { (rule, propertySet) -> key(rule) {
|
||||
val isVariablesRule = rule.isDefinedVariablesRule()
|
||||
val propertySetSpec = FlorisImeUiSpec.propertySetSpec(rule.element)
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
SnyggRuleRow(
|
||||
rule = rule,
|
||||
level = snyggLevel,
|
||||
showEditBtn = !isVariablesRule,
|
||||
onEditRuleBtnClick = {
|
||||
snyggRuleToEdit = rule
|
||||
},
|
||||
onAddPropertyBtnClick = {
|
||||
snyggPropertySetForEditing = propertySet
|
||||
snyggPropertySetSpecForEditing = propertySetSpec
|
||||
snyggPropertyToEdit = SnyggEmptyPropertyInfoForAdding
|
||||
},
|
||||
)
|
||||
if (isVariablesRule) {
|
||||
Text(
|
||||
modifier = Modifier.padding(bottom = 8.dp, start = 16.dp, end = 16.dp),
|
||||
text = stringRes(R.string.snygg__rule_element__defines_description),
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontStyle = FontStyle.Italic,
|
||||
)
|
||||
}
|
||||
for ((propertyName, propertyValue) in propertySet.properties) {
|
||||
val propertySpec = propertySetSpec?.propertySpec(propertyName)
|
||||
if (propertySpec != null && propertySpec.level <= snyggLevel || isVariablesRule) {
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable {
|
||||
snyggPropertySetForEditing = propertySet
|
||||
snyggPropertySetSpecForEditing = propertySetSpec
|
||||
snyggPropertyToEdit = PropertyInfo(propertyName, propertyValue)
|
||||
},
|
||||
text = translatePropertyName(propertyName, snyggLevel),
|
||||
secondaryText = translatePropertyValue(propertyValue, snyggLevel, displayColorsAs),
|
||||
singleLineSecondaryText = true,
|
||||
trailing = { SnyggValueIcon(propertyValue, definedVariables) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} }
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditComponentMetaDialog) {
|
||||
ComponentMetaEditorDialog(
|
||||
workspace = workspace,
|
||||
editor = editor,
|
||||
onConfirm = { showEditComponentMetaDialog = false },
|
||||
onDismiss = { showEditComponentMetaDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showFineTuneDialog) {
|
||||
FineTuneDialog(onDismiss = { showFineTuneDialog = false })
|
||||
}
|
||||
|
||||
val ruleToEdit = snyggRuleToEdit
|
||||
if (ruleToEdit != null) {
|
||||
EditRuleDialog(
|
||||
initRule = ruleToEdit,
|
||||
level = snyggLevel,
|
||||
onConfirmRule = { oldRule, newRule ->
|
||||
val rules = stylesheetEditor.rules
|
||||
when {
|
||||
oldRule == newRule -> {
|
||||
snyggRuleToEdit = null
|
||||
true
|
||||
}
|
||||
rules.contains(newRule) -> {
|
||||
false
|
||||
}
|
||||
else -> workspace.update {
|
||||
val set = rules.remove(oldRule)
|
||||
when {
|
||||
set != null -> {
|
||||
rules[newRule] = set
|
||||
snyggRuleToEdit = null
|
||||
scope.launch {
|
||||
lazyListState.animateScrollToItem(index = rules.keys.indexOf(newRule))
|
||||
}
|
||||
true
|
||||
}
|
||||
oldRule == SnyggEmptyRuleForAdding -> {
|
||||
rules[newRule] = SnyggPropertySetEditor()
|
||||
snyggRuleToEdit = null
|
||||
scope.launch {
|
||||
lazyListState.animateScrollToItem(index = rules.keys.indexOf(newRule))
|
||||
}
|
||||
true
|
||||
}
|
||||
else -> {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onDeleteRule = { rule ->
|
||||
workspace.update {
|
||||
stylesheetEditor.rules.remove(rule)
|
||||
}
|
||||
snyggRuleToEdit = null
|
||||
},
|
||||
onDismiss = { snyggRuleToEdit = null },
|
||||
)
|
||||
}
|
||||
|
||||
val propertyToEdit = snyggPropertyToEdit
|
||||
if (propertyToEdit != null) {
|
||||
EditPropertyDialog(
|
||||
propertySetSpec = snyggPropertySetSpecForEditing,
|
||||
initProperty = propertyToEdit,
|
||||
level = snyggLevel,
|
||||
displayColorsAs = displayColorsAs,
|
||||
definedVariables = definedVariables,
|
||||
onConfirmNewValue = { name, value ->
|
||||
val properties = snyggPropertySetForEditing?.properties ?: return@EditPropertyDialog false
|
||||
if (propertyToEdit == SnyggEmptyPropertyInfoForAdding && properties.containsKey(name)) {
|
||||
return@EditPropertyDialog false
|
||||
}
|
||||
workspace.update {
|
||||
properties[name] = value
|
||||
}
|
||||
snyggPropertyToEdit = null
|
||||
true
|
||||
},
|
||||
onDelete = {
|
||||
workspace.update {
|
||||
snyggPropertySetForEditing?.properties?.remove(propertyToEdit.name)
|
||||
}
|
||||
snyggPropertyToEdit = null
|
||||
},
|
||||
onDismiss = { snyggPropertyToEdit = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ComponentMetaEditorDialog(
|
||||
workspace: CacheManager.ExtEditorWorkspace<*>,
|
||||
editor: ThemeExtensionComponentEditor,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showValidationErrors by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
var id by rememberSaveable { mutableStateOf(editor.id) }
|
||||
val idValidation = rememberValidationResult(ExtensionValidation.ComponentId, id)
|
||||
var label by rememberSaveable { mutableStateOf(editor.label) }
|
||||
val labelValidation = rememberValidationResult(ExtensionValidation.ComponentLabel, label)
|
||||
var authors by rememberSaveable { mutableStateOf(editor.authors.joinToString("\n")) }
|
||||
val authorsValidation = rememberValidationResult(ExtensionValidation.ComponentAuthors, authors)
|
||||
var isNightTheme by rememberSaveable { mutableStateOf(editor.isNightTheme) }
|
||||
var isBorderless by rememberSaveable { mutableStateOf(editor.isBorderless) }
|
||||
val isMaterialYouAware by rememberSaveable { mutableStateOf(editor.isMaterialYouAware) }
|
||||
var stylesheetPath by rememberSaveable { mutableStateOf(editor.stylesheetPath) }
|
||||
val stylesheetPathValidation = rememberValidationResult(ExtensionValidation.ThemeComponentStylesheetPath, stylesheetPath)
|
||||
|
||||
JetPrefAlertDialog(
|
||||
title = stringRes(R.string.ext__editor__metadata__title),
|
||||
confirmLabel = stringRes(R.string.action__apply),
|
||||
onConfirm = {
|
||||
val allFieldsValid = idValidation.isValid() &&
|
||||
labelValidation.isValid() &&
|
||||
authorsValidation.isValid() &&
|
||||
stylesheetPathValidation.isValid()
|
||||
if (!allFieldsValid) {
|
||||
showValidationErrors = true
|
||||
} else if (id != editor.id && (workspace.editor as? ThemeExtensionEditor)?.themes?.find { it.id == id.trim() } != null) {
|
||||
context.showLongToast("A theme with this ID already exists!")
|
||||
} else {
|
||||
workspace.update {
|
||||
editor.id = id.trim()
|
||||
editor.label = label.trim()
|
||||
editor.authors = authors.lines().map { it.trim() }.filter { it.isNotBlank() }
|
||||
editor.isNightTheme = isNightTheme
|
||||
editor.isBorderless = isBorderless
|
||||
editor.isMaterialYouAware = isMaterialYouAware
|
||||
editor.stylesheetPath = stylesheetPath.trim()
|
||||
}
|
||||
onConfirm()
|
||||
}
|
||||
},
|
||||
dismissLabel = stringRes(R.string.action__cancel),
|
||||
onDismiss = onDismiss,
|
||||
scrollModifier = Modifier.florisVerticalScroll(),
|
||||
) {
|
||||
Column {
|
||||
DialogProperty(text = stringRes(R.string.ext__meta__id)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = id,
|
||||
onValueChange = { id = it },
|
||||
singleLine = true,
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = idValidation,
|
||||
)
|
||||
}
|
||||
DialogProperty(text = stringRes(R.string.ext__meta__label)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = label,
|
||||
onValueChange = { label = it },
|
||||
singleLine = true,
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = labelValidation,
|
||||
)
|
||||
}
|
||||
DialogProperty(text = stringRes(R.string.ext__meta__authors)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = authors,
|
||||
onValueChange = { authors = it },
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = authorsValidation,
|
||||
)
|
||||
}
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.toggleable(isNightTheme) { isNightTheme = it },
|
||||
text = stringRes(R.string.settings__theme_editor__component_meta_is_night_theme),
|
||||
trailing = {
|
||||
Switch(checked = isNightTheme, onCheckedChange = null)
|
||||
},
|
||||
)
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.toggleable(isBorderless) { isBorderless = it },
|
||||
text = stringRes(R.string.settings__theme_editor__component_meta_is_borderless),
|
||||
trailing = {
|
||||
Switch(checked = isBorderless, onCheckedChange = null)
|
||||
},
|
||||
)
|
||||
DialogProperty(text = stringRes(R.string.settings__theme_editor__component_meta_stylesheet_path)) {
|
||||
FlorisOutlinedTextField(
|
||||
value = stylesheetPath,
|
||||
onValueChange = { stylesheetPath = it },
|
||||
singleLine = true,
|
||||
placeholder = if (stylesheetPath.isEmpty()) {
|
||||
ThemeExtensionComponent.defaultStylesheetPath(id.trim())
|
||||
} else {
|
||||
null
|
||||
},
|
||||
showValidationError = showValidationErrors,
|
||||
validationResult = stylesheetPathValidation,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SnyggRuleRow(
|
||||
rule: SnyggRule,
|
||||
level: SnyggLevel,
|
||||
showEditBtn: Boolean,
|
||||
onEditRuleBtnClick: () -> Unit,
|
||||
onAddPropertyBtnClick: () -> Unit,
|
||||
) {
|
||||
@Composable
|
||||
fun Selector(text: String) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.background(MaterialTheme.colors.primaryVariant),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AttributesList(text: String, list: String) {
|
||||
Text(
|
||||
text = "$text = $list",
|
||||
style = MaterialTheme.typography.body2,
|
||||
color = LocalContentColor.current.copy(alpha = 0.56f),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp, horizontal = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = translateElementName(rule, level),
|
||||
style = MaterialTheme.typography.body2,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
if (rule.pressedSelector) {
|
||||
Selector(text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> SnyggRule.PRESSED_SELECTOR
|
||||
else -> stringRes(R.string.snygg__rule_selector__pressed)
|
||||
})
|
||||
}
|
||||
if (rule.focusSelector) {
|
||||
Selector(text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> SnyggRule.FOCUS_SELECTOR
|
||||
else -> stringRes(R.string.snygg__rule_selector__focus)
|
||||
})
|
||||
}
|
||||
if (rule.disabledSelector) {
|
||||
Selector(text = when (level) {
|
||||
SnyggLevel.DEVELOPER -> SnyggRule.DISABLED_SELECTOR
|
||||
else -> stringRes(R.string.snygg__rule_selector__disabled)
|
||||
})
|
||||
}
|
||||
}
|
||||
if (rule.codes.isNotEmpty()) {
|
||||
AttributesList(text = "codes", list = remember(rule.codes) { rule.codes.toString() })
|
||||
}
|
||||
if (rule.modes.isNotEmpty()) {
|
||||
AttributesList(text = "modes", list = remember(rule.modes) { rule.modes.toString() })
|
||||
}
|
||||
}
|
||||
if (showEditBtn) {
|
||||
FlorisIconButton(
|
||||
onClick = onEditRuleBtnClick,
|
||||
icon = painterResource(R.drawable.ic_edit),
|
||||
iconColor = MaterialTheme.colors.primary,
|
||||
iconModifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
FlorisIconButton(
|
||||
onClick = onAddPropertyBtnClick,
|
||||
icon = painterResource(R.drawable.ic_add),
|
||||
iconColor = MaterialTheme.colors.secondary,
|
||||
iconModifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DialogProperty(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
trailingIconTitle: @Composable () -> Unit = { },
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier.padding(bottom = 8.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(vertical = 8.dp),
|
||||
text = text,
|
||||
style = MaterialTheme.typography.subtitle2,
|
||||
)
|
||||
trailingIconTitle()
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
@@ -47,12 +46,13 @@ import dev.patrickgold.florisboard.app.ui.components.FlorisConfirmDeleteDialog
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisTextButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.defaultFlorisOutlinedBox
|
||||
import dev.patrickgold.florisboard.app.ui.components.rippleClickable
|
||||
import dev.patrickgold.florisboard.app.ui.ext.ExtensionImportScreenType
|
||||
import dev.patrickgold.florisboard.common.android.showLongToast
|
||||
import dev.patrickgold.florisboard.common.android.showShortToast
|
||||
import dev.patrickgold.florisboard.common.observeAsNonNullState
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtension
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.Extension
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
@@ -77,7 +77,7 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
|
||||
ThemeManagerScreenAction.MANAGE -> R.string.settings__theme_manager__title_manage
|
||||
else -> error("Theme manager screen action must not be null")
|
||||
})
|
||||
previewFieldVisible = true
|
||||
previewFieldVisible = action != ThemeManagerScreenAction.MANAGE
|
||||
|
||||
val prefs by florisPreferenceModel()
|
||||
val navController = LocalNavController.current
|
||||
@@ -85,15 +85,12 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
|
||||
val extensionManager by context.extensionManager()
|
||||
val themeManager by context.themeManager()
|
||||
|
||||
val indexedThemeConfigs by themeManager.indexedThemeConfigs.observeAsNonNullState()
|
||||
val indexedThemeExtensions by extensionManager.themes.observeAsNonNullState()
|
||||
val selectedManagerThemeId = remember { mutableStateOf<ExtensionComponentName?>(null) }
|
||||
val extGroupedThemes = remember(indexedThemeConfigs) {
|
||||
buildMap<String, MutableList<ThemeExtensionComponent>> {
|
||||
for ((componentName, config) in indexedThemeConfigs) {
|
||||
if (!containsKey(componentName.extensionId)) {
|
||||
put(componentName.extensionId, mutableListOf())
|
||||
}
|
||||
get(componentName.extensionId)!!.add(config)
|
||||
val extGroupedThemes = remember(indexedThemeExtensions) {
|
||||
buildMap<String, List<ThemeExtensionComponent>> {
|
||||
for (ext in indexedThemeExtensions) {
|
||||
put(ext.meta.id, ext.themes)
|
||||
}
|
||||
}.mapValues { (_, configs) -> configs.sortedBy { it.label } }
|
||||
}
|
||||
@@ -134,92 +131,84 @@ fun ThemeManagerScreen(action: ThemeManagerScreenAction?) = FlorisScreen {
|
||||
val grayColor = LocalContentColor.current.copy(alpha = 0.56f)
|
||||
if (action == ThemeManagerScreenAction.MANAGE) {
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
) {
|
||||
Column {
|
||||
this@content.Preference(
|
||||
onClick = { context.showShortToast("TODO for 0.3.14-beta09") },
|
||||
iconId = R.drawable.ic_add,
|
||||
title = stringRes(R.string.ext__editor__create_new_extension),
|
||||
)
|
||||
this@content.Preference(
|
||||
onClick = { navController.navigate(
|
||||
Routes.Ext.Import(ExtensionImportScreenType.EXT_THEME, null)
|
||||
) },
|
||||
iconId = R.drawable.ic_input,
|
||||
title = stringRes(R.string.action__import),
|
||||
)
|
||||
}
|
||||
this@content.Preference(
|
||||
onClick = { navController.navigate(
|
||||
Routes.Ext.Edit("null", ThemeExtension.SERIAL_TYPE)
|
||||
) },
|
||||
iconId = R.drawable.ic_add,
|
||||
title = stringRes(R.string.ext__editor__title_create_theme),
|
||||
)
|
||||
this@content.Preference(
|
||||
onClick = { navController.navigate(
|
||||
Routes.Ext.Import(ExtensionImportScreenType.EXT_THEME, null)
|
||||
) },
|
||||
iconId = R.drawable.ic_input,
|
||||
title = stringRes(R.string.action__import),
|
||||
)
|
||||
}
|
||||
}
|
||||
for ((extensionId, configs) in extGroupedThemes) key(extensionId) {
|
||||
val ext = extensionManager.getExtensionById(extensionId)!!
|
||||
FlorisOutlinedBox(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
title = remember {
|
||||
ext.meta.title
|
||||
},
|
||||
modifier = Modifier.defaultFlorisOutlinedBox(),
|
||||
title = ext.meta.title,
|
||||
onTitleClick = { navController.navigate(Routes.Ext.View(extensionId)) },
|
||||
subtitle = extensionId,
|
||||
onSubtitleClick = { navController.navigate(Routes.Ext.View(extensionId)) },
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
for (config in configs) key(extensionId, config.id) {
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable {
|
||||
setTheme(extensionId, config.id)
|
||||
},
|
||||
icon = {
|
||||
RadioButton(
|
||||
selected = activeThemeId?.extensionId == extensionId &&
|
||||
activeThemeId?.componentId == config.id,
|
||||
onClick = null,
|
||||
)
|
||||
},
|
||||
text = config.label,
|
||||
trailing = {
|
||||
Icon(
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
painter = painterResource(if (config.isNightTheme) {
|
||||
R.drawable.ic_dark_mode
|
||||
} else {
|
||||
R.drawable.ic_light_mode
|
||||
}),
|
||||
contentDescription = null,
|
||||
tint = grayColor,
|
||||
)
|
||||
for (config in configs) key(extensionId, config.id) {
|
||||
JetPrefListItem(
|
||||
modifier = Modifier.rippleClickable {
|
||||
setTheme(extensionId, config.id)
|
||||
},
|
||||
icon = {
|
||||
RadioButton(
|
||||
selected = activeThemeId?.extensionId == extensionId &&
|
||||
activeThemeId?.componentId == config.id,
|
||||
onClick = null,
|
||||
)
|
||||
},
|
||||
text = config.label,
|
||||
trailing = {
|
||||
Icon(
|
||||
modifier = Modifier.size(ButtonDefaults.IconSize),
|
||||
painter = painterResource(if (config.isNightTheme) {
|
||||
R.drawable.ic_dark_mode
|
||||
} else {
|
||||
R.drawable.ic_light_mode
|
||||
}),
|
||||
contentDescription = null,
|
||||
tint = grayColor,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (action == ThemeManagerScreenAction.MANAGE && extensionManager.canDelete(ext)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp),
|
||||
) {
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
themeExtToDelete = ext
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_delete),
|
||||
text = stringRes(R.string.action__delete),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colors.error,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
navController.navigate(Routes.Ext.Edit(ext.meta.id))
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_edit),
|
||||
text = stringRes(R.string.action__edit),
|
||||
)
|
||||
}
|
||||
if (action == ThemeManagerScreenAction.MANAGE && extensionManager.canDelete(ext)) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 6.dp),
|
||||
) {
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
themeExtToDelete = ext
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_delete),
|
||||
text = stringRes(R.string.action__delete),
|
||||
colors = ButtonDefaults.textButtonColors(
|
||||
contentColor = MaterialTheme.colors.error,
|
||||
),
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
FlorisTextButton(
|
||||
onClick = {
|
||||
/*TODO*/context.showShortToast("TODO for 0.3.14-beta09")
|
||||
},
|
||||
icon = painterResource(R.drawable.ic_edit),
|
||||
text = stringRes(R.string.action__edit),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,15 @@
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Button
|
||||
import androidx.compose.material.Card
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.LocalNavController
|
||||
@@ -27,6 +32,7 @@ import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.Routes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisInfoCard
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisScreen
|
||||
import dev.patrickgold.florisboard.common.android.launchUrl
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeMode
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
import dev.patrickgold.jetpref.datastore.ui.ExperimentalJetPrefDatastoreUi
|
||||
@@ -41,22 +47,24 @@ fun ThemeScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.settings__theme__title)
|
||||
previewFieldVisible = true
|
||||
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
|
||||
content {
|
||||
val themeMode by prefs.theme.mode.observeAsState()
|
||||
val dayThemeId by prefs.theme.dayThemeId.observeAsState()
|
||||
val nightThemeId by prefs.theme.nightThemeId.observeAsState()
|
||||
|
||||
FlorisInfoCard(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = """
|
||||
Themes can currently only be customized by writing (or modifying) a custom theme extension and then importing it, using the new FlexCSS stylesheet format packaged in a flex archive.
|
||||
|
||||
beta09 will provide a full in-app UI which allows to create and modify theme extensions hassle-free in a modern theme editor UI.
|
||||
|
||||
Additionally the theme mode "Follow time" is not available in this beta release.
|
||||
""".trimIndent()
|
||||
)
|
||||
Card(modifier = Modifier.padding(8.dp)) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text("If you want to give feedback on the new stylesheet editor and theme engine, please do so in below linked feedback thread:\n")
|
||||
Button(onClick = {
|
||||
context.launchUrl("https://github.com/florisboard/florisboard/discussions/1531")
|
||||
}) {
|
||||
Text("Open Feedback Thread")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ListPreference(
|
||||
prefs.theme.mode,
|
||||
@@ -64,6 +72,14 @@ fun ThemeScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.pref__theme__mode__label),
|
||||
entries = ThemeMode.listEntries(),
|
||||
)
|
||||
if (themeMode == ThemeMode.FOLLOW_TIME) {
|
||||
FlorisInfoCard(
|
||||
modifier = Modifier.padding(8.dp),
|
||||
text = """
|
||||
The theme mode "Follow time" is not available in this beta release.
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
Preference(
|
||||
iconId = R.drawable.ic_palette,
|
||||
title = stringRes(R.string.settings__theme_manager__title_manage),
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.settings.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUi
|
||||
import dev.patrickgold.florisboard.snygg.Snygg
|
||||
import dev.patrickgold.florisboard.snygg.SnyggLevel
|
||||
import dev.patrickgold.florisboard.snygg.SnyggRule
|
||||
import dev.patrickgold.florisboard.snygg.value.RgbaColor
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCircleShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDefinedVarValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDpSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggExplicitInheritValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggImplicitInheritValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggPercentageSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRectangleShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSolidColorValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSpSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggValueEncoder
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
internal fun translateElementName(rule: SnyggRule, level: SnyggLevel): String {
|
||||
return translateElementName(rule.element, level) ?: remember {
|
||||
buildString {
|
||||
if (rule.isAnnotation) {
|
||||
append(SnyggRule.ANNOTATION_MARKER)
|
||||
}
|
||||
append(rule.element)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun translateElementName(element: String, level: SnyggLevel): String? {
|
||||
return when (level) {
|
||||
SnyggLevel.DEVELOPER -> null
|
||||
else -> when (element) {
|
||||
"defines" -> R.string.snygg__rule_element__defines
|
||||
FlorisImeUi.Keyboard -> R.string.snygg__rule_element__keyboard
|
||||
FlorisImeUi.Key -> R.string.snygg__rule_element__key
|
||||
FlorisImeUi.KeyHint -> R.string.snygg__rule_element__key_hint
|
||||
FlorisImeUi.KeyPopup -> R.string.snygg__rule_element__key_popup
|
||||
FlorisImeUi.ClipboardHeader -> R.string.snygg__rule_element__clipboard_header
|
||||
FlorisImeUi.ClipboardItem -> R.string.snygg__rule_element__clipboard_item
|
||||
FlorisImeUi.ClipboardItemPopup -> R.string.snygg__rule_element__clipboard_item_popup
|
||||
FlorisImeUi.GlideTrail -> R.string.snygg__rule_element__glide_trail
|
||||
FlorisImeUi.OneHandedPanel -> R.string.snygg__rule_element__one_handed_panel
|
||||
FlorisImeUi.SmartbarPrimaryRow -> R.string.snygg__rule_element__smartbar_primary_row
|
||||
FlorisImeUi.SmartbarPrimaryActionRowToggle -> R.string.snygg__rule_element__smartbar_primary_action_row_toggle
|
||||
FlorisImeUi.SmartbarPrimarySecondaryRowToggle -> R.string.snygg__rule_element__smartbar_primary_secondary_row_toggle
|
||||
FlorisImeUi.SmartbarSecondaryRow -> R.string.snygg__rule_element__smartbar_secondary_row
|
||||
FlorisImeUi.SmartbarActionRow -> R.string.snygg__rule_element__smartbar_action_row
|
||||
FlorisImeUi.SmartbarActionButton -> R.string.snygg__rule_element__smartbar_action_button
|
||||
FlorisImeUi.SmartbarCandidateRow -> R.string.snygg__rule_element__smartbar_candidate_row
|
||||
FlorisImeUi.SmartbarCandidateWord -> R.string.snygg__rule_element__smartbar_candidate_word
|
||||
FlorisImeUi.SmartbarCandidateClip -> R.string.snygg__rule_element__smartbar_candidate_clip
|
||||
FlorisImeUi.SmartbarCandidateSpacer -> R.string.snygg__rule_element__smartbar_candidate_spacer
|
||||
FlorisImeUi.SmartbarKey -> R.string.snygg__rule_element__smartbar_key
|
||||
FlorisImeUi.SystemNavBar -> R.string.snygg__rule_element__system_nav_bar
|
||||
else -> null
|
||||
}
|
||||
}.let { if (it != null) { stringRes(it) } else { null } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun translatePropertyName(propertyName: String, level: SnyggLevel): String {
|
||||
return when (level) {
|
||||
SnyggLevel.DEVELOPER -> null
|
||||
else -> when (propertyName) {
|
||||
Snygg.Width -> R.string.snygg__property_name__width
|
||||
Snygg.Height -> R.string.snygg__property_name__height
|
||||
Snygg.Background -> R.string.snygg__property_name__background
|
||||
Snygg.Foreground -> R.string.snygg__property_name__foreground
|
||||
Snygg.BorderColor -> R.string.snygg__property_name__border_color
|
||||
Snygg.BorderStyle -> R.string.snygg__property_name__border_style
|
||||
Snygg.BorderWidth -> R.string.snygg__property_name__border_width
|
||||
Snygg.FontFamily -> R.string.snygg__property_name__font_family
|
||||
Snygg.FontSize -> R.string.snygg__property_name__font_size
|
||||
Snygg.FontStyle -> R.string.snygg__property_name__font_style
|
||||
Snygg.FontVariant -> R.string.snygg__property_name__font_variant
|
||||
Snygg.FontWeight -> R.string.snygg__property_name__font_weight
|
||||
Snygg.ShadowElevation -> R.string.snygg__property_name__shadow_elevation
|
||||
Snygg.Shape -> R.string.snygg__property_name__shape
|
||||
"--primary" -> R.string.snygg__property_name__var_primary
|
||||
"--primary-variant" -> R.string.snygg__property_name__var_primary_variant
|
||||
"--secondary" -> R.string.snygg__property_name__var_secondary
|
||||
"--secondary-variant" -> R.string.snygg__property_name__var_secondary_variant
|
||||
"--background" -> R.string.snygg__property_name__var_background
|
||||
"--surface" -> R.string.snygg__property_name__var_surface
|
||||
"--surface-variant" -> R.string.snygg__property_name__var_surface_variant
|
||||
"--on-primary" -> R.string.snygg__property_name__var_on_primary
|
||||
"--on-secondary" -> R.string.snygg__property_name__var_on_secondary
|
||||
"--on-background" -> R.string.snygg__property_name__var_on_background
|
||||
"--on-surface" -> R.string.snygg__property_name__var_on_surface
|
||||
"--on-surface-variant" -> R.string.snygg__property_name__var_on_surface_variant
|
||||
"--shape" -> R.string.snygg__property_name__var_shape
|
||||
"--shape-variant" -> R.string.snygg__property_name__var_shape_variant
|
||||
else -> null
|
||||
}
|
||||
}.let { resId ->
|
||||
when {
|
||||
resId != null -> {
|
||||
stringRes(resId)
|
||||
}
|
||||
propertyName.isBlank() -> {
|
||||
stringRes(R.string.general__select_dropdown_value_placeholder)
|
||||
}
|
||||
else -> {
|
||||
propertyName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun translatePropertyValue(
|
||||
propertyValue: SnyggValue,
|
||||
level: SnyggLevel,
|
||||
displayColorsAs: DisplayColorsAs,
|
||||
): String {
|
||||
return when (propertyValue) {
|
||||
is SnyggSolidColorValue -> remember(propertyValue.color, displayColorsAs) {
|
||||
val color = propertyValue.color
|
||||
when (displayColorsAs) {
|
||||
DisplayColorsAs.HEX8 -> buildString {
|
||||
append("#")
|
||||
append((color.red * RgbaColor.RedMax).roundToInt().toString(16).padStart(2, '0'))
|
||||
append((color.green * RgbaColor.GreenMax).roundToInt().toString(16).padStart(2, '0'))
|
||||
append((color.blue * RgbaColor.BlueMax).roundToInt().toString(16).padStart(2, '0'))
|
||||
append((color.alpha * 0xFF).roundToInt().toString(16).padStart(2, '0'))
|
||||
}
|
||||
DisplayColorsAs.RGBA -> buildString {
|
||||
append("rgba(")
|
||||
append((color.red * RgbaColor.RedMax).roundToInt())
|
||||
append(",")
|
||||
append((color.green * RgbaColor.GreenMax).roundToInt())
|
||||
append(",")
|
||||
append((color.blue * RgbaColor.BlueMax).roundToInt())
|
||||
append(",")
|
||||
append(color.alpha)
|
||||
append(")")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> when (level) {
|
||||
SnyggLevel.DEVELOPER -> null
|
||||
else -> when (propertyValue) {
|
||||
is SnyggDefinedVarValue -> translatePropertyName(propertyValue.key, level)
|
||||
else -> null
|
||||
}
|
||||
} ?: propertyValue.encoder().serialize(propertyValue).getOrElse { propertyValue.toString() }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun translatePropertyValueEncoderName(encoder: SnyggValueEncoder): String {
|
||||
return when (encoder) {
|
||||
SnyggImplicitInheritValue -> R.string.general__select_dropdown_value_placeholder
|
||||
SnyggExplicitInheritValue -> R.string.snygg__property_value__explicit_inherit
|
||||
SnyggSolidColorValue -> R.string.snygg__property_value__solid_color
|
||||
SnyggRectangleShapeValue -> R.string.snygg__property_value__rectangle_shape
|
||||
SnyggCircleShapeValue -> R.string.snygg__property_value__circle_shape
|
||||
SnyggCutCornerDpShapeValue -> R.string.snygg__property_value__cut_corner_shape_dp
|
||||
SnyggCutCornerPercentShapeValue -> R.string.snygg__property_value__cut_corner_shape_percent
|
||||
SnyggRoundedCornerDpShapeValue -> R.string.snygg__property_value__rounded_corner_shape_dp
|
||||
SnyggRoundedCornerPercentShapeValue -> R.string.snygg__property_value__rounded_corner_shape_percent
|
||||
SnyggDpSizeValue -> R.string.snygg__property_value__dp_size
|
||||
SnyggSpSizeValue -> R.string.snygg__property_value__sp_size
|
||||
SnyggPercentageSizeValue -> R.string.snygg__property_value__percentage_size
|
||||
SnyggDefinedVarValue -> R.string.snygg__property_value__defined_var
|
||||
else -> null
|
||||
}.let { if (it != null) { stringRes(it) } else { encoder::class.simpleName ?: "" } }.toString()
|
||||
}
|
||||
@@ -61,7 +61,7 @@ private object Step {
|
||||
@Composable
|
||||
fun SetupScreen() = FlorisScreen {
|
||||
title = stringRes(R.string.setup__title)
|
||||
backArrowVisible = false
|
||||
navigationIconVisible = false
|
||||
scrollable = false
|
||||
|
||||
val navController = LocalNavController.current
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
package dev.patrickgold.florisboard.app.ui.theme
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material.ButtonDefaults
|
||||
import androidx.compose.material.Colors
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.darkColors
|
||||
import androidx.compose.material.lightColors
|
||||
@@ -73,6 +75,10 @@ fun FlorisAppTheme(
|
||||
isSystemInDarkTheme() -> DarkColorPalette
|
||||
else -> LightColorPalette
|
||||
}
|
||||
AppTheme.AUTO_AMOLED -> when {
|
||||
isSystemInDarkTheme() -> AmoledDarkColorPalette
|
||||
else -> LightColorPalette
|
||||
}
|
||||
AppTheme.LIGHT -> LightColorPalette
|
||||
AppTheme.DARK -> DarkColorPalette
|
||||
AppTheme.AMOLED_DARK -> AmoledDarkColorPalette
|
||||
@@ -85,3 +91,7 @@ fun FlorisAppTheme(
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
val Colors.outline: Color
|
||||
@Composable
|
||||
get() = this.onSurface.copy(alpha = ButtonDefaults.OutlinedBorderOpacity)
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.common
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.common.kotlin.CurlyArg
|
||||
import dev.patrickgold.florisboard.common.kotlin.curlyFormat
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
sealed class ValidationResult {
|
||||
companion object {
|
||||
fun resultValid(): ValidationResult {
|
||||
return Valid()
|
||||
}
|
||||
|
||||
fun resultValid(hint: String): ValidationResult {
|
||||
return Valid(hintMessageStr = hint)
|
||||
}
|
||||
|
||||
fun resultValid(@StringRes hint: Int): ValidationResult {
|
||||
return Valid(hintMessageId = hint)
|
||||
}
|
||||
|
||||
fun resultValid(@StringRes hint: Int, vararg args: CurlyArg): ValidationResult {
|
||||
return Valid(hintMessageId = hint, args = args.toList())
|
||||
}
|
||||
|
||||
fun resultInvalid(error: String): ValidationResult {
|
||||
return Invalid(errorMessageStr = error)
|
||||
}
|
||||
|
||||
fun resultInvalid(@StringRes error: Int): ValidationResult {
|
||||
return Invalid(errorMessageId = error)
|
||||
}
|
||||
|
||||
fun resultInvalid(@StringRes error: Int, vararg args: CurlyArg): ValidationResult {
|
||||
return Invalid(errorMessageId = error, args = args.toList())
|
||||
}
|
||||
}
|
||||
|
||||
data class Valid(
|
||||
@StringRes private val hintMessageId: Int? = null,
|
||||
private val hintMessageStr: String? = null,
|
||||
private val args: List<CurlyArg> = emptyList(),
|
||||
) : ValidationResult() {
|
||||
|
||||
fun hasHintMessage(): Boolean {
|
||||
return hintMessageId != null || hintMessageStr != null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun hintMessage(): String {
|
||||
return when {
|
||||
hintMessageId != null -> stringRes(hintMessageId).curlyFormat(args)
|
||||
hintMessageStr != null -> hintMessageStr.curlyFormat(args)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Invalid(
|
||||
@StringRes private val errorMessageId: Int? = null,
|
||||
private val errorMessageStr: String? = null,
|
||||
private val args: List<CurlyArg> = emptyList(),
|
||||
) : ValidationResult() {
|
||||
|
||||
fun hasErrorMessage(): Boolean {
|
||||
return errorMessageId != null || errorMessageStr != null
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun errorMessage(): String {
|
||||
return when {
|
||||
errorMessageId != null -> stringRes(errorMessageId).curlyFormat(args)
|
||||
errorMessageStr != null -> errorMessageStr.curlyFormat(args)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isValid(): Boolean {
|
||||
contract {
|
||||
returns(true) implies (this@ValidationResult is Valid)
|
||||
}
|
||||
return this is Valid
|
||||
}
|
||||
|
||||
fun isInvalid(): Boolean {
|
||||
contract {
|
||||
returns(true) implies (this@ValidationResult is Invalid)
|
||||
}
|
||||
return this is Invalid
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <T : Any> rememberValidationResult(rule: ValidationRule<T>, value: T): ValidationResult {
|
||||
return remember(value) {
|
||||
rule.validator.invoke(ValidationResult.Companion, value)
|
||||
}
|
||||
}
|
||||
|
||||
data class ValidationRule<T : Any>(
|
||||
val klass: KClass<*>,
|
||||
val propertyName: String,
|
||||
val validator: ValidationResult.Companion.(T) -> ValidationResult,
|
||||
)
|
||||
|
||||
class ValidationRuleBuilder<T : Any> {
|
||||
var forKlass: KClass<*>? = null
|
||||
var forProperty: String? = null
|
||||
|
||||
private var validator: (ValidationResult.Companion.(T) -> ValidationResult)? = null
|
||||
fun validator(validator: ValidationResult.Companion.(T) -> ValidationResult) {
|
||||
this.validator = validator
|
||||
}
|
||||
|
||||
fun build() = ValidationRule(forKlass!!, forProperty!!, validator!!)
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun <T : Any> ValidationRule(scope: ValidationRuleBuilder<T>.() -> Unit): ValidationRule<T> {
|
||||
val builder = ValidationRuleBuilder<T>()
|
||||
scope(builder)
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
fun <T : Any> validate(rule: ValidationRule<T>, value: T): ValidationResult {
|
||||
return rule.validator.invoke(ValidationResult.Companion, value)
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import dev.patrickgold.florisboard.common.kotlin.tryOrNull
|
||||
import java.lang.reflect.Modifier
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@@ -121,31 +122,31 @@ abstract class AndroidSettingsHelper(
|
||||
object AndroidSettings {
|
||||
val Global = object : AndroidSettingsHelper(Settings.Global::class, "global") {
|
||||
override fun getString(context: Context, key: String): String? {
|
||||
return Settings.Global.getString(context.contentResolver, key)
|
||||
return tryOrNull { Settings.Global.getString(context.contentResolver, key) }
|
||||
}
|
||||
|
||||
override fun getUriFor(key: String): Uri? {
|
||||
return Settings.Global.getUriFor(key)
|
||||
return tryOrNull { Settings.Global.getUriFor(key) }
|
||||
}
|
||||
}
|
||||
|
||||
val Secure = object : AndroidSettingsHelper(Settings.Secure::class, "secure") {
|
||||
override fun getString(context: Context, key: String): String? {
|
||||
return Settings.Secure.getString(context.contentResolver, key)
|
||||
return tryOrNull { Settings.Secure.getString(context.contentResolver, key) }
|
||||
}
|
||||
|
||||
override fun getUriFor(key: String): Uri? {
|
||||
return Settings.Secure.getUriFor(key)
|
||||
return tryOrNull { Settings.Secure.getUriFor(key) }
|
||||
}
|
||||
}
|
||||
|
||||
val System = object : AndroidSettingsHelper(Settings.System::class, "system") {
|
||||
override fun getString(context: Context, key: String): String? {
|
||||
return Settings.System.getString(context.contentResolver, key)
|
||||
return tryOrNull { Settings.System.getString(context.contentResolver, key) }
|
||||
}
|
||||
|
||||
override fun getUriFor(key: String): Uri? {
|
||||
return Settings.System.getUriFor(key)
|
||||
return tryOrNull { Settings.System.getUriFor(key) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,29 +19,22 @@ package dev.patrickgold.florisboard.common.android
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.StringRes
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.common.kotlin.CurlyArg
|
||||
import dev.patrickgold.florisboard.debug.flogError
|
||||
import dev.patrickgold.florisboard.res.FlorisRef
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
private const val URL_HTTP_PREFIX = "http://"
|
||||
private const val URL_HTTPS_PREFIX = "https://"
|
||||
private const val URL_MAILTO_PREFIX = "mailto:"
|
||||
|
||||
fun Context.launchUrl(url: String) {
|
||||
val link = when {
|
||||
url.startsWith(URL_HTTP_PREFIX) ||
|
||||
url.startsWith(URL_HTTPS_PREFIX) ||
|
||||
url.startsWith(URL_MAILTO_PREFIX) -> url
|
||||
else -> "$URL_HTTPS_PREFIX$url"
|
||||
val intent = Intent().also {
|
||||
it.action = Intent.ACTION_VIEW
|
||||
it.data = FlorisRef.fromUrl(url).uri
|
||||
it.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(link))
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
try {
|
||||
this.startActivity(intent)
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
@@ -14,15 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.app.ui.ext
|
||||
package dev.patrickgold.florisboard.common.kotlin
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import dev.patrickgold.florisboard.res.ext.Extension
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponent
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
@Composable
|
||||
inline fun Extension.rememberComponents(): List<ExtensionComponent> {
|
||||
return remember(this) { this.components() }
|
||||
inline fun <R> tryOrNull(block: () -> R): R? {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
return try {
|
||||
block()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.common.kotlin
|
||||
|
||||
fun Number.toStringWithoutDotZero(): String = this.toString().removeSuffix(".0")
|
||||
@@ -55,6 +55,10 @@ fun String.curlyFormat(argValueFactory: (argName: String) -> String?): String {
|
||||
}
|
||||
|
||||
fun String.curlyFormat(vararg args: CurlyArg): String {
|
||||
return this.curlyFormat(args.asList())
|
||||
}
|
||||
|
||||
fun String.curlyFormat(args: List<CurlyArg>): String {
|
||||
if (args.isEmpty()) return this
|
||||
val sb = StringBuilder(this)
|
||||
for ((n, arg) in args.withIndex()) {
|
||||
|
||||
@@ -30,11 +30,25 @@ import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.prefs.AppPrefs
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.debug.*
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
private class SafePreferenceInstanceWrapper : ReadOnlyProperty<Any?, AppPrefs?> {
|
||||
val cachedPreferenceModel = try {
|
||||
florisPreferenceModel()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
|
||||
override fun getValue(thisRef: Any?, property: KProperty<*>): AppPrefs? {
|
||||
return cachedPreferenceModel?.getValue(thisRef, property)
|
||||
}
|
||||
}
|
||||
|
||||
class CrashDialogActivity : ComponentActivity() {
|
||||
private var stacktraces: List<CrashUtility.Stacktrace> = listOf()
|
||||
private var errorReport: StringBuilder = StringBuilder()
|
||||
private var prefs: AppPrefs? = null
|
||||
private val prefs by SafePreferenceInstanceWrapper()
|
||||
|
||||
private val stacktrace by lazy { findViewById<TextView>(R.id.stacktrace) }
|
||||
private val reportInstructions by lazy { findViewById<TextView>(R.id.report_instructions) }
|
||||
@@ -48,13 +62,6 @@ class CrashDialogActivity : ComponentActivity() {
|
||||
val layout = layoutInflater.inflate(R.layout.crash_dialog, null)
|
||||
setContentView(layout)
|
||||
|
||||
// We secure the PrefHelper usage here because the PrefHelper could potentially be the
|
||||
// source of the crash, thus making the crash dialog unusable if not wrapped.
|
||||
try {
|
||||
prefs = florisPreferenceModel().preferenceModel
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
|
||||
stacktraces = CrashUtility.getUnhandledStacktraces(this)
|
||||
errorReport.apply {
|
||||
appendLine("#### Environment information")
|
||||
|
||||
@@ -29,7 +29,10 @@ import dev.patrickgold.florisboard.FlorisImeService
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.common.android.AndroidVersion
|
||||
import dev.patrickgold.florisboard.debug.*
|
||||
import java.io.File
|
||||
import dev.patrickgold.florisboard.res.io.FsDir
|
||||
import dev.patrickgold.florisboard.res.io.FsFile
|
||||
import dev.patrickgold.florisboard.res.io.subDir
|
||||
import dev.patrickgold.florisboard.res.io.subFile
|
||||
import java.lang.ref.WeakReference
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
@@ -49,6 +52,7 @@ abstract class CrashUtility private constructor() {
|
||||
private const val NOTIFICATION_CHANNEL_ID = "${BuildConfig.APPLICATION_ID}.crashutility"
|
||||
private const val NOTIFICATION_ID = 0xFBAD0100
|
||||
|
||||
private const val UNHANDLED_STACKTRACES_DIR_NAME = "unhandled_stacktraces"
|
||||
private const val UNHANDLED_STACKTRACE_FILE_EXT = "stacktrace"
|
||||
|
||||
private var lastActivityCreated: WeakReference<Activity?> = WeakReference(null)
|
||||
@@ -82,7 +86,7 @@ abstract class CrashUtility private constructor() {
|
||||
UncaughtExceptionHandler(
|
||||
WeakReference(application),
|
||||
WeakReference(oldHandler),
|
||||
application.filesDir.absolutePath
|
||||
context.getUstDir(),
|
||||
)
|
||||
)
|
||||
flogInfo(LogTopic.CRASH_UTILITY) {
|
||||
@@ -174,7 +178,7 @@ abstract class CrashUtility private constructor() {
|
||||
fun getUnhandledStacktraces(context: Context?): List<Stacktrace> {
|
||||
context ?: return listOf()
|
||||
val retList = mutableListOf<Stacktrace>()
|
||||
val ustDir = getUstDir(context)
|
||||
val ustDir = context.getUstDir()
|
||||
if (ustDir.isDirectory) {
|
||||
(ustDir.listFiles { pathname ->
|
||||
pathname.name.endsWith(".$UNHANDLED_STACKTRACE_FILE_EXT")
|
||||
@@ -190,7 +194,7 @@ abstract class CrashUtility private constructor() {
|
||||
}
|
||||
|
||||
fun hasUnhandledStacktraceFiles(context: Context): Boolean {
|
||||
val ustDir = getUstDir(context)
|
||||
val ustDir = context.getUstDir()
|
||||
return if (ustDir.isDirectory) {
|
||||
(ustDir.listFiles { pathname ->
|
||||
pathname.name.endsWith(".$UNHANDLED_STACKTRACE_FILE_EXT")
|
||||
@@ -234,24 +238,20 @@ abstract class CrashUtility private constructor() {
|
||||
/**
|
||||
* Gets a reference to the current unhandled stacktrace directory.
|
||||
*
|
||||
* @param context The current package context.
|
||||
* @return The File object for the directory.
|
||||
*/
|
||||
private fun getUstDir(context: Context): File {
|
||||
val path = context.filesDir.absolutePath
|
||||
return File(path)
|
||||
private fun Context.getUstDir(): FsDir {
|
||||
return this.noBackupFilesDir.subDir(UNHANDLED_STACKTRACES_DIR_NAME).also { it.mkdirs() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a reference to the stacktrace file for given [timestamp].
|
||||
*
|
||||
* @param context The current package context.
|
||||
* @param timestamp The timestamp of the stacktrace file to get.
|
||||
* @return The File object for the stacktrace file.
|
||||
*/
|
||||
private fun getUstFile(context: Context, timestamp: Long): File {
|
||||
val path = context.filesDir.absolutePath
|
||||
return File("$path/$timestamp.$UNHANDLED_STACKTRACE_FILE_EXT")
|
||||
private fun Context.getUstFile(timestamp: Long): FsFile {
|
||||
return this.getUstDir().subFile("$timestamp.$UNHANDLED_STACKTRACE_FILE_EXT")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -328,7 +328,7 @@ abstract class CrashUtility private constructor() {
|
||||
* @param file The file object.
|
||||
* @return The contents of the file or an empty string, if the file does not exist.
|
||||
*/
|
||||
private fun readFile(file: File): String {
|
||||
private fun readFile(file: FsFile): String {
|
||||
val retText = StringBuilder()
|
||||
if (file.exists()) {
|
||||
val newLine = System.lineSeparator()
|
||||
@@ -348,7 +348,7 @@ abstract class CrashUtility private constructor() {
|
||||
* @param text The text to write to the file.
|
||||
* @return The contents of the file or an empty string, if the file does not exist.
|
||||
*/
|
||||
private fun writeToFile(file: File, text: String) {
|
||||
private fun writeToFile(file: FsFile, text: String) {
|
||||
try {
|
||||
file.writeText(text)
|
||||
} catch (e: Exception) {
|
||||
@@ -372,7 +372,7 @@ abstract class CrashUtility private constructor() {
|
||||
class UncaughtExceptionHandler(
|
||||
private val application: WeakReference<Application>,
|
||||
private val oldHandler: WeakReference<Thread.UncaughtExceptionHandler?>,
|
||||
private val path: String
|
||||
private val ustDir: FsDir,
|
||||
) : Thread.UncaughtExceptionHandler {
|
||||
override fun uncaughtException(thread: Thread?, throwable: Throwable?) {
|
||||
flogInfo(LogTopic.CRASH_UTILITY) {
|
||||
@@ -381,13 +381,13 @@ abstract class CrashUtility private constructor() {
|
||||
throwable ?: return
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val stacktrace = Log.getStackTraceString(throwable)
|
||||
val ustFile = File("$path/$timestamp.$UNHANDLED_STACKTRACE_FILE_EXT")
|
||||
val ustFile = ustDir.subFile("$timestamp.$UNHANDLED_STACKTRACE_FILE_EXT")
|
||||
writeToFile(ustFile, stacktrace)
|
||||
val application = application.get()
|
||||
if (application != null) {
|
||||
val lastTimestamp = getLastCrashTimestamp(application)
|
||||
if (lastTimestamp > 0) {
|
||||
val lastFile = getUstFile(application, lastTimestamp)
|
||||
val lastFile = application.getUstFile(lastTimestamp)
|
||||
val lastStacktrace = readFile(lastFile)
|
||||
if (lastStacktrace == stacktrace) {
|
||||
// Delete last stacktrace if it matches previous unhandled one
|
||||
|
||||
@@ -59,10 +59,11 @@ import androidx.compose.ui.unit.dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButton
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisIconButtonWithInnerPadding
|
||||
import dev.patrickgold.florisboard.app.ui.components.FlorisStaggeredVerticalGrid
|
||||
import dev.patrickgold.florisboard.app.ui.components.florisVerticalScroll
|
||||
import dev.patrickgold.florisboard.app.ui.components.rippleClickable
|
||||
import dev.patrickgold.florisboard.app.ui.components.safeTimes
|
||||
import dev.patrickgold.florisboard.app.ui.theme.Green500
|
||||
import dev.patrickgold.florisboard.clipboardManager
|
||||
import dev.patrickgold.florisboard.common.android.AndroidKeyguardManager
|
||||
@@ -79,7 +80,9 @@ import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.snygg.SnyggPropertySet
|
||||
import dev.patrickgold.florisboard.snygg.ui.SnyggSurface
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBackground
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBorder
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggClip
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggShadow
|
||||
import dev.patrickgold.florisboard.snygg.ui.solidColor
|
||||
import dev.patrickgold.florisboard.snygg.ui.spSize
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
@@ -129,10 +132,10 @@ fun ClipboardInputLayout(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(FlorisImeSizing.smartbarHeight)
|
||||
.snyggBackground(headerStyle.background),
|
||||
.snyggBackground(headerStyle),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FlorisIconButton(
|
||||
FlorisIconButtonWithInnerPadding(
|
||||
onClick = { activeState.imeUiMode = ImeUiMode.TEXT },
|
||||
modifier = Modifier
|
||||
.padding(HeaderIconPadding)
|
||||
@@ -147,7 +150,7 @@ fun ClipboardInputLayout(
|
||||
color = headerStyle.foreground.solidColor(),
|
||||
fontSize = headerStyle.fontSize.spSize(),
|
||||
)
|
||||
FlorisIconButton(
|
||||
FlorisIconButtonWithInnerPadding(
|
||||
onClick = { prefs.clipboard.historyEnabled.set(!historyEnabled) },
|
||||
modifier = Modifier
|
||||
.padding(HeaderIconPadding)
|
||||
@@ -161,7 +164,7 @@ fun ClipboardInputLayout(
|
||||
iconColor = headerStyle.foreground.solidColor(),
|
||||
enabled = !deviceLocked && popupItem == null,
|
||||
)
|
||||
FlorisIconButton(
|
||||
FlorisIconButtonWithInnerPadding(
|
||||
onClick = {
|
||||
clipboardManager.clearHistory()
|
||||
context.showShortToast(R.string.clipboard__cleared_history)
|
||||
@@ -174,7 +177,7 @@ fun ClipboardInputLayout(
|
||||
iconColor = headerStyle.foreground.solidColor(),
|
||||
enabled = !deviceLocked && historyEnabled && popupItem == null,
|
||||
)
|
||||
FlorisIconButton(
|
||||
FlorisIconButtonWithInnerPadding(
|
||||
onClick = {
|
||||
context.showShortToast("TODO: implement inline clip item editing")
|
||||
},
|
||||
@@ -200,8 +203,7 @@ fun ClipboardInputLayout(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(ItemMargin),
|
||||
background = style.background,
|
||||
shape = style.shape,
|
||||
style = style,
|
||||
clip = true,
|
||||
contentPadding = ItemPadding,
|
||||
clickAndSemanticsModifier = Modifier.combinedClickable(
|
||||
@@ -292,8 +294,10 @@ fun ClipboardInputLayout(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(ItemMargin)
|
||||
.snyggBackground(popupStyle.background, popupStyle.shape)
|
||||
.snyggClip(popupStyle.shape),
|
||||
.snyggShadow(popupStyle)
|
||||
.snyggBorder(popupStyle)
|
||||
.snyggBackground(popupStyle)
|
||||
.snyggClip(popupStyle),
|
||||
) {
|
||||
PopupAction(
|
||||
iconId = R.drawable.ic_pin,
|
||||
@@ -346,7 +350,7 @@ fun ClipboardInputLayout(
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
|
||||
text = stringRes(R.string.clipboard__empty__title),
|
||||
color = itemStyle.foreground.solidColor(),
|
||||
fontSize = itemStyle.fontSize.spSize() * 1.1f,
|
||||
fontSize = itemStyle.fontSize.spSize() safeTimes 1.1f,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
@@ -372,8 +376,7 @@ fun ClipboardInputLayout(
|
||||
.padding(ItemMargin)
|
||||
.fillMaxWidth()
|
||||
.wrapContentHeight(),
|
||||
background = itemStyle.background,
|
||||
shape = itemStyle.shape,
|
||||
style = itemStyle,
|
||||
contentPadding = ItemPadding,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
@@ -381,7 +384,7 @@ fun ClipboardInputLayout(
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
text = stringRes(R.string.clipboard__disabled__title),
|
||||
color = itemStyle.foreground.solidColor(),
|
||||
fontSize = itemStyle.fontSize.spSize() * 1.1f,
|
||||
fontSize = itemStyle.fontSize.spSize() safeTimes 1.1f,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
@@ -422,7 +425,7 @@ fun ClipboardInputLayout(
|
||||
modifier = Modifier.padding(top = 24.dp, bottom = 8.dp),
|
||||
text = stringRes(R.string.clipboard__locked__title),
|
||||
color = itemStyle.foreground.solidColor(),
|
||||
fontSize = itemStyle.fontSize.spSize() * 1.1f,
|
||||
fontSize = itemStyle.fontSize.spSize() safeTimes 1.1f,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
@@ -470,7 +473,7 @@ private fun ClipCategoryTitle(
|
||||
text = text.uppercase(),
|
||||
color = style.foreground.solidColor(),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = style.fontSize.spSize() * 0.8f,
|
||||
fontSize = style.fontSize.spSize() safeTimes 0.8f,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.core
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.res.stringRes
|
||||
import dev.patrickgold.jetpref.datastore.ui.listPrefEntries
|
||||
|
||||
/**
|
||||
* DisplayLocalesIn indicates how language names should be visually presented to the user.
|
||||
*/
|
||||
enum class DisplayLanguageNamesIn {
|
||||
/** Language names are displayed in the locale which is set for the whole device. */
|
||||
SYSTEM_LOCALE,
|
||||
/** Language names are displayed in the locale referred by itself. */
|
||||
NATIVE_LOCALE;
|
||||
|
||||
companion object {
|
||||
@Composable
|
||||
fun listEntries() = listPrefEntries {
|
||||
entry(
|
||||
key = SYSTEM_LOCALE,
|
||||
label = stringRes(R.string.enum__display_language_names_in__system_locale),
|
||||
description = stringRes(R.string.enum__display_language_names_in__system_locale__description),
|
||||
showDescriptionOnlyIfSelected = true,
|
||||
)
|
||||
entry(
|
||||
key = NATIVE_LOCALE,
|
||||
label = stringRes(R.string.enum__display_language_names_in__native_locale),
|
||||
description = stringRes(R.string.enum__display_language_names_in__native_locale__description),
|
||||
showDescriptionOnlyIfSelected = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,8 +33,12 @@ import androidx.room.RoomDatabase
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import androidx.room.Update
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.common.FlorisLocale
|
||||
import dev.patrickgold.florisboard.res.ExternalContentUtils
|
||||
import dev.patrickgold.florisboard.common.ValidationRule
|
||||
import dev.patrickgold.florisboard.common.android.readText
|
||||
import dev.patrickgold.florisboard.common.android.writeText
|
||||
import dev.patrickgold.florisboard.common.kotlin.tryOrNull
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
private const val WORDS_TABLE = "words"
|
||||
@@ -132,8 +136,8 @@ interface UserDictionaryDatabase {
|
||||
|
||||
fun reset()
|
||||
|
||||
fun importCombinedList(context: Context, uri: Uri): Result<Unit> {
|
||||
return ExternalContentUtils.readTextFromUri(context, uri,6_192_000) { src ->
|
||||
fun importCombinedList(context: Context, uri: Uri) {
|
||||
context.contentResolver.readText(uri) { src ->
|
||||
var isFirstLine = true
|
||||
src.forEachLine { line ->
|
||||
if (isFirstLine) {
|
||||
@@ -144,41 +148,45 @@ interface UserDictionaryDatabase {
|
||||
var freq: Int? = null
|
||||
var locale: String? = null
|
||||
var shortcut: String? = null
|
||||
line.split(';').forEach { property ->
|
||||
for (property in line.split(';')) {
|
||||
val keyValuePair = property.split('=')
|
||||
if (keyValuePair.size == 2) {
|
||||
val key = keyValuePair[0].trim().lowercase()
|
||||
val value = keyValuePair[1].trim()
|
||||
when (key) {
|
||||
"w", "word" -> word = value.ifBlank { null }
|
||||
"f", "freq" -> runCatching { value.toInt(10) }.onSuccess {
|
||||
freq = it.coerceIn(FREQUENCY_MIN, FREQUENCY_MAX)
|
||||
check(keyValuePair.size == 2) { "Error at source line `$line`: Key-Value pair expected, but either only key or too many values provided" }
|
||||
val key = keyValuePair[0].trim().lowercase()
|
||||
val value = keyValuePair[1].trim()
|
||||
when (key) {
|
||||
"w", "word" -> word = value.ifBlank { null }
|
||||
"f", "freq" -> {
|
||||
val number = value.toIntOrNull(10)
|
||||
checkNotNull(number) { "Error at source line `$line`: Freq is not a valid decimal number" }
|
||||
check(number in FREQUENCY_MIN..FREQUENCY_MAX) {
|
||||
"Error at source line `$line`: Freq not within range of $FREQUENCY_MIN and $FREQUENCY_MAX"
|
||||
}
|
||||
"l", "locale" -> locale = when (value) {
|
||||
"all", "null", "" -> null
|
||||
else -> value.ifBlank { null }
|
||||
}
|
||||
"s", "shortcut" -> shortcut = value.ifBlank { null }
|
||||
freq = number
|
||||
}
|
||||
"l", "locale" -> locale = when (value) {
|
||||
"all", "null", "" -> null
|
||||
else -> value.ifBlank { null }
|
||||
}
|
||||
"s", "shortcut" -> shortcut = value.ifBlank { null }
|
||||
}
|
||||
}
|
||||
if (word != null && freq != null) {
|
||||
val alreadyExistingEntries = userDictionaryDao().queryExact(
|
||||
word!!, locale?.let { FlorisLocale.fromTag(it) }
|
||||
)
|
||||
if (alreadyExistingEntries.isNotEmpty()) {
|
||||
userDictionaryDao().update(UserDictionaryEntry(alreadyExistingEntries[0].id, word!!, freq!!, locale, shortcut))
|
||||
} else {
|
||||
userDictionaryDao().insert(UserDictionaryEntry(0, word!!, freq!!, locale, shortcut))
|
||||
}
|
||||
checkNotNull(word) { "Error at source line `$line`: Word cannot be empty or missing" }
|
||||
checkNotNull(freq) { "Error at source line `$line`: Freq cannot be empty or missing" }
|
||||
val alreadyExistingEntries = userDictionaryDao().queryExact(
|
||||
word, locale?.let { FlorisLocale.fromTag(it) },
|
||||
)
|
||||
if (alreadyExistingEntries.isNotEmpty()) {
|
||||
userDictionaryDao().update(UserDictionaryEntry(alreadyExistingEntries[0].id, word, freq, locale, shortcut))
|
||||
} else {
|
||||
userDictionaryDao().insert(UserDictionaryEntry(0, word, freq, locale, shortcut))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportCombinedList(context: Context, uri: Uri): Result<Unit> {
|
||||
return ExternalContentUtils.writeTextToUri(context, uri) { dst ->
|
||||
fun exportCombinedList(context: Context, uri: Uri) {
|
||||
context.contentResolver.writeText(uri) { dst ->
|
||||
StringBuilder().apply {
|
||||
append("dictionary=")
|
||||
append(uri.lastPathSegment)
|
||||
@@ -461,3 +469,60 @@ class SystemUserDictionaryDatabase(context: Context) : UserDictionaryDatabase {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
object UserDictionaryValidation {
|
||||
private val WordRegex = """^[^\s;,]+${'$'}""".toRegex()
|
||||
|
||||
val Word = ValidationRule<String> {
|
||||
forKlass = UserDictionaryEntry::class
|
||||
forProperty = "word"
|
||||
validator { input ->
|
||||
val str = input.trim()
|
||||
when {
|
||||
input.isBlank() -> resultInvalid(error = R.string.settings__udm__dialog__word_error_empty)
|
||||
!str.matches(WordRegex) -> resultInvalid(error = R.string.settings__udm__dialog__word_error_invalid, "regex" to WordRegex)
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Freq = ValidationRule<String> {
|
||||
forKlass = UserDictionaryEntry::class
|
||||
forProperty = "freq"
|
||||
validator { input ->
|
||||
val freq = input.trim().toIntOrNull(10)
|
||||
when {
|
||||
input.isBlank() -> resultInvalid(error = R.string.settings__udm__dialog__freq_error_empty)
|
||||
freq == null -> resultInvalid(error = R.string.settings__udm__dialog__freq_error_empty)
|
||||
freq < FREQUENCY_MIN || freq > FREQUENCY_MAX -> resultInvalid(error = R.string.settings__udm__dialog__freq_error_invalid)
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Shortcut = ValidationRule<String> {
|
||||
forKlass = UserDictionaryEntry::class
|
||||
forProperty = "shortcut"
|
||||
validator { input ->
|
||||
val str = input.trim()
|
||||
when {
|
||||
input.isBlank() -> resultValid() // Is optional
|
||||
!str.matches(WordRegex) -> resultInvalid(error = R.string.settings__udm__dialog__shortcut_error_invalid, "regex" to WordRegex)
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Locale = ValidationRule<String> {
|
||||
forKlass = UserDictionaryEntry::class
|
||||
forProperty = "locale"
|
||||
validator { input ->
|
||||
val str = input.trim()
|
||||
when {
|
||||
input.isBlank() -> resultValid() // Is optional
|
||||
tryOrNull { FlorisLocale.fromTag(str) } == null -> resultInvalid(error = R.string.settings__udm__dialog__locale_error_invalid)
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ package dev.patrickgold.florisboard.ime.keyboard
|
||||
|
||||
import android.content.Context
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.core.Subtype
|
||||
import dev.patrickgold.florisboard.ime.text.key.InputMode
|
||||
import dev.patrickgold.florisboard.ime.text.key.KeyCode
|
||||
@@ -30,6 +31,8 @@ interface ComputingEvaluator {
|
||||
|
||||
fun context(): Context?
|
||||
|
||||
fun displayLanguageNamesIn(): DisplayLanguageNamesIn
|
||||
|
||||
fun evaluateEnabled(data: KeyData): Boolean
|
||||
|
||||
fun evaluateVisible(data: KeyData): Boolean
|
||||
@@ -48,6 +51,8 @@ object DefaultComputingEvaluator : ComputingEvaluator {
|
||||
|
||||
override fun context(): Context? = null
|
||||
|
||||
override fun displayLanguageNamesIn() = DisplayLanguageNamesIn.NATIVE_LOCALE
|
||||
|
||||
override fun evaluateEnabled(data: KeyData): Boolean = true
|
||||
|
||||
override fun evaluateVisible(data: KeyData): Boolean = true
|
||||
@@ -71,8 +76,11 @@ fun ComputingEvaluator.computeLabel(data: KeyData): String? {
|
||||
KeyCode.PHONE_WAIT -> evaluator.context()?.getString(R.string.key__phone_wait)
|
||||
KeyCode.SPACE, KeyCode.CJK_SPACE -> {
|
||||
when (evaluator.keyboard().mode) {
|
||||
KeyboardMode.CHARACTERS -> {
|
||||
evaluator.activeSubtype().primaryLocale.let { it.displayName() }
|
||||
KeyboardMode.CHARACTERS -> evaluator.activeSubtype().primaryLocale.let { locale ->
|
||||
when (displayLanguageNamesIn()) {
|
||||
DisplayLanguageNamesIn.SYSTEM_LOCALE -> locale.displayName()
|
||||
DisplayLanguageNamesIn.NATIVE_LOCALE -> locale.displayName(locale)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ import dev.patrickgold.florisboard.res.ext.ExtensionMeta
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
private const val SERIAL_TYPE = "ime.extension.keyboard"
|
||||
|
||||
@SerialName(SERIAL_TYPE)
|
||||
@SerialName(KeyboardExtension.SERIAL_TYPE)
|
||||
@Serializable
|
||||
data class KeyboardExtension(
|
||||
override val meta: ExtensionMeta,
|
||||
@@ -39,6 +37,10 @@ data class KeyboardExtension(
|
||||
val subtypePresets: List<SubtypePreset> = listOf(),
|
||||
) : Extension() {
|
||||
|
||||
companion object {
|
||||
const val SERIAL_TYPE = "ime.extension.keyboard"
|
||||
}
|
||||
|
||||
override fun serialType() = SERIAL_TYPE
|
||||
|
||||
override fun components(): List<ExtensionComponent> {
|
||||
|
||||
@@ -36,6 +36,7 @@ import dev.patrickgold.florisboard.debug.flogError
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.glideTypingManager
|
||||
import dev.patrickgold.florisboard.ime.ImeUiMode
|
||||
import dev.patrickgold.florisboard.ime.core.DisplayLanguageNamesIn
|
||||
import dev.patrickgold.florisboard.ime.core.InputEventDispatcher
|
||||
import dev.patrickgold.florisboard.ime.core.InputKeyEvent
|
||||
import dev.patrickgold.florisboard.ime.core.InputKeyEventReceiver
|
||||
@@ -474,8 +475,17 @@ class KeyboardManager(context: Context) : InputKeyEventReceiver {
|
||||
* enabled by the user.
|
||||
*/
|
||||
private fun handleSpace(ev: InputKeyEvent) = activeEditorInstance?.apply {
|
||||
if (prefs.keyboard.spaceBarSwitchesToCharacters.get() && activeState.keyboardMode != KeyboardMode.CHARACTERS) {
|
||||
activeState.keyboardMode = KeyboardMode.CHARACTERS
|
||||
if (prefs.keyboard.spaceBarSwitchesToCharacters.get()) {
|
||||
when (activeState.keyboardMode) {
|
||||
KeyboardMode.NUMERIC_ADVANCED,
|
||||
KeyboardMode.SYMBOLS,
|
||||
KeyboardMode.SYMBOLS2 -> {
|
||||
activeState.keyboardMode = KeyboardMode.CHARACTERS
|
||||
}
|
||||
else -> {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prefs.correction.doubleSpacePeriod.get()) {
|
||||
if (ev.isConsecutiveEventOf(inputEventDispatcher.lastKeyEventUp, prefs.keyboard.longPressDelay.get().toLong())) {
|
||||
@@ -736,6 +746,10 @@ class KeyboardManager(context: Context) : InputKeyEventReceiver {
|
||||
|
||||
override fun context(): Context = appContext
|
||||
|
||||
override fun displayLanguageNamesIn(): DisplayLanguageNamesIn {
|
||||
return prefs.localization.displayLanguageNamesIn.get()
|
||||
}
|
||||
|
||||
override fun evaluateEnabled(data: KeyData): Boolean {
|
||||
return when (data.code) {
|
||||
KeyCode.CLIPBOARD_COPY,
|
||||
|
||||
@@ -46,7 +46,7 @@ fun RowScope.OneHandedPanel(
|
||||
Column(
|
||||
modifier = modifier
|
||||
.weight(weight)
|
||||
.snyggBackground(oneHandedPanelStyle.background),
|
||||
.snyggBackground(oneHandedPanelStyle),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
|
||||
@@ -34,12 +34,15 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.ui.components.safeTimes
|
||||
import dev.patrickgold.florisboard.ime.keyboard.Key
|
||||
import dev.patrickgold.florisboard.ime.text.key.KeyCode
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeTheme
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUi
|
||||
import dev.patrickgold.florisboard.snygg.ui.SnyggSurface
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBackground
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBorder
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggShadow
|
||||
import dev.patrickgold.florisboard.snygg.ui.solidColor
|
||||
import dev.patrickgold.florisboard.snygg.ui.spSize
|
||||
|
||||
@@ -53,11 +56,10 @@ fun PopupBaseBox(
|
||||
val popupStyle = FlorisImeTheme.style.get(
|
||||
element = FlorisImeUi.KeyPopup,
|
||||
)
|
||||
val fontSize = popupStyle.fontSize.spSize() * fontSizeMultiplier
|
||||
val fontSize = popupStyle.fontSize.spSize() safeTimes fontSizeMultiplier
|
||||
SnyggSurface(
|
||||
modifier = modifier,
|
||||
background = popupStyle.background,
|
||||
shape = popupStyle.shape,
|
||||
style = popupStyle,
|
||||
clip = true,
|
||||
) {
|
||||
key.label?.let { label ->
|
||||
@@ -106,7 +108,9 @@ fun PopupExtBox(
|
||||
)
|
||||
Column(
|
||||
modifier = modifier
|
||||
.snyggBackground(popupStyle.background, popupStyle.shape),
|
||||
.snyggShadow(popupStyle)
|
||||
.snyggBorder(popupStyle)
|
||||
.snyggBackground(popupStyle),
|
||||
) {
|
||||
for (row in elements.asReversed()) {
|
||||
Row(
|
||||
@@ -124,14 +128,14 @@ fun PopupExtBox(
|
||||
} else {
|
||||
popupStyle
|
||||
}
|
||||
val elemFontSize = elemStyle.fontSize.spSize() * fontSizeMultiplier *
|
||||
val elemFontSize = elemStyle.fontSize.spSize() safeTimes fontSizeMultiplier safeTimes
|
||||
if (element.data.code == KeyCode.URI_COMPONENT_TLD) { 0.6f } else { 1.0f }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(elemWidth, elemHeight)
|
||||
.run {
|
||||
if (activeElementIndex == element.orderedIndex) {
|
||||
snyggBackground(elemStyle.background, elemStyle.shape)
|
||||
snyggBackground(elemStyle)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@@ -22,15 +22,12 @@ import dev.patrickgold.florisboard.res.ext.Extension
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionEditor
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMeta
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMetaEditor
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import java.io.File
|
||||
|
||||
private const val SERIAL_TYPE = "ime.extension.spelling"
|
||||
|
||||
@SerialName(SERIAL_TYPE)
|
||||
@SerialName(SpellingExtension.SERIAL_TYPE)
|
||||
@Serializable
|
||||
data class SpellingExtension(
|
||||
override val meta: ExtensionMeta,
|
||||
@@ -38,6 +35,10 @@ data class SpellingExtension(
|
||||
val spelling: SpellingExtensionConfig,
|
||||
) : Extension() {
|
||||
|
||||
companion object {
|
||||
const val SERIAL_TYPE = "ime.extension.spelling"
|
||||
}
|
||||
|
||||
@Transient var dict: SpellingDict? = null
|
||||
|
||||
override fun serialType() = SERIAL_TYPE
|
||||
@@ -56,21 +57,23 @@ data class SpellingExtension(
|
||||
}
|
||||
|
||||
override fun edit() = SpellingExtensionEditor(
|
||||
meta.edit(),
|
||||
meta = meta,
|
||||
dependencies?.toMutableList() ?: mutableListOf(),
|
||||
workingDir,
|
||||
spelling.edit(),
|
||||
)
|
||||
}
|
||||
|
||||
data class SpellingExtensionEditor(
|
||||
override val meta: ExtensionMetaEditor,
|
||||
override var workingDir: File?,
|
||||
override var meta: ExtensionMeta,
|
||||
override val dependencies: MutableList<String>,
|
||||
var workingDir: File?,
|
||||
val spelling: SpellingExtensionConfigEditor,
|
||||
) : ExtensionEditor {
|
||||
fun build() = runCatching {
|
||||
SpellingExtension(
|
||||
meta = meta.build().getOrThrow(),
|
||||
spelling = spelling.build().getOrThrow(),
|
||||
override fun build(): SpellingExtension {
|
||||
return SpellingExtension(
|
||||
meta = meta,
|
||||
spelling = spelling.build(),
|
||||
).also {
|
||||
it.workingDir = workingDir
|
||||
}
|
||||
@@ -97,7 +100,7 @@ data class SpellingExtensionConfigEditor(
|
||||
var affFile: String = "",
|
||||
var dicFile: String = "",
|
||||
) {
|
||||
fun build() = runCatching {
|
||||
fun build(): SpellingExtensionConfig {
|
||||
val config = SpellingExtensionConfig(
|
||||
locale.trim().let { FlorisLocale.from(it) },
|
||||
originalSourceId.trim().ifBlank { null },
|
||||
@@ -106,6 +109,6 @@ data class SpellingExtensionConfigEditor(
|
||||
)
|
||||
check(config.affFile.isNotBlank()) { "Spelling extension aff file path cannot be blank" }
|
||||
check(config.dicFile.isNotBlank()) { "Spelling extension dic file path cannot be blank" }
|
||||
return@runCatching config
|
||||
return config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,15 +24,14 @@ import androidx.lifecycle.MutableLiveData
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.assetManager
|
||||
import dev.patrickgold.florisboard.common.FlorisLocale
|
||||
import dev.patrickgold.florisboard.common.android.read
|
||||
import dev.patrickgold.florisboard.debug.flogDebug
|
||||
import dev.patrickgold.florisboard.debug.flogError
|
||||
import dev.patrickgold.florisboard.debug.flogInfo
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.res.ExternalContentUtils
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMaintainer
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMaintainerEditor
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionDefaults
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMetaEditor
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMaintainer
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMeta
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import java.io.File
|
||||
@@ -43,7 +42,7 @@ class SpellingManager(context: Context) {
|
||||
companion object {
|
||||
private const val SOURCE_ID_RAW: String = "raw"
|
||||
|
||||
private const val IMPORT_ARCHIVE_MAX_SIZE: Int = 25_165_820 // 24 MiB
|
||||
private const val IMPORT_ARCHIVE_MAX_SIZE: Long = 25_165_820 // 24 MiB
|
||||
private const val IMPORT_ARCHIVE_TEMP_NAME: String = "__temp000__ime_spelling_import_archive"
|
||||
private const val IMPORT_NEW_DICT_TEMP_NAME: String = "__temp000__ime_spelling_import_new_dict"
|
||||
|
||||
@@ -200,15 +199,16 @@ class SpellingManager(context: Context) {
|
||||
tempDictDir.mkdirs()
|
||||
val entries = zipFile.entries()
|
||||
val extensionEditor = SpellingExtensionEditor(
|
||||
meta = ExtensionMetaEditor(
|
||||
meta = ExtensionMeta(
|
||||
id = ExtensionDefaults.createLocalId("spelling"),
|
||||
version = manifest.version ?: "0.0.0",
|
||||
title = manifest.name ?: "Imported spelling dict",
|
||||
description = manifest.description ?: "",
|
||||
maintainers = manifest.author?.let { mutableListOf(ExtensionMaintainer.fromOrTakeRaw(it).edit()) }
|
||||
?: mutableListOf(ExtensionMaintainerEditor(name = "Unknown")),
|
||||
maintainers = manifest.author?.let { mutableListOf(ExtensionMaintainer.fromOrTakeRaw(it)) }
|
||||
?: mutableListOf(ExtensionMaintainer(name = "Unknown")),
|
||||
license = "unknown",
|
||||
),
|
||||
dependencies = mutableListOf(),
|
||||
workingDir = tempDictDir,
|
||||
spelling = SpellingExtensionConfigEditor().apply {
|
||||
locale = supportedLocale.languageTag()
|
||||
@@ -275,13 +275,14 @@ class SpellingManager(context: Context) {
|
||||
tempDictDir.mkdirs()
|
||||
val entries = zipFile.entries()
|
||||
val extensionEditor = SpellingExtensionEditor(
|
||||
meta = ExtensionMetaEditor(
|
||||
meta = ExtensionMeta(
|
||||
id = ExtensionDefaults.createLocalId("spelling"),
|
||||
version = "0.0.0",
|
||||
title = "FreeOffice import",
|
||||
maintainers = mutableListOf(ExtensionMaintainerEditor(name = "Unknown")),
|
||||
maintainers = mutableListOf(ExtensionMaintainer(name = "Unknown")),
|
||||
license = "unknown",
|
||||
),
|
||||
dependencies = mutableListOf(),
|
||||
workingDir = tempDictDir,
|
||||
spelling = SpellingExtensionConfigEditor().apply {
|
||||
locale = supportedLocale!!.languageTag()
|
||||
@@ -366,9 +367,9 @@ class SpellingManager(context: Context) {
|
||||
private fun saveTempFile(uri: Uri) = runCatching<File> {
|
||||
val tempFile = File(appContext.cacheDir, IMPORT_ARCHIVE_TEMP_NAME)
|
||||
tempFile.deleteRecursively() // Just to make sure we clean up old mess
|
||||
ExternalContentUtils.readFromUri(appContext, uri, IMPORT_ARCHIVE_MAX_SIZE) { bis ->
|
||||
appContext.contentResolver.read(uri, IMPORT_ARCHIVE_MAX_SIZE) { bis ->
|
||||
tempFile.outputStream().use { os -> bis.copyTo(os) }
|
||||
}.getOrThrow()
|
||||
}
|
||||
tempFile
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,8 @@ enum class SwipeAction {
|
||||
MOVE_CURSOR_START_OF_PAGE,
|
||||
MOVE_CURSOR_END_OF_PAGE,
|
||||
REDO,
|
||||
SELECT_CHARACTERS_PRECISELY,
|
||||
SELECT_WORDS_PRECISELY,
|
||||
SHIFT,
|
||||
SHOW_INPUT_METHOD_PICKER,
|
||||
SWITCH_TO_PREV_SUBTYPE,
|
||||
@@ -161,6 +163,14 @@ enum class SwipeAction {
|
||||
key = DELETE_WORDS_PRECISELY,
|
||||
label = stringRes(R.string.enum__swipe_action__delete_words_precisely),
|
||||
)
|
||||
entry(
|
||||
key = SELECT_CHARACTERS_PRECISELY,
|
||||
label = stringRes(R.string.enum__swipe_action__select_characters_precisely),
|
||||
)
|
||||
entry(
|
||||
key = SELECT_WORDS_PRECISELY,
|
||||
label = stringRes(R.string.enum__swipe_action__select_words_precisely),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,16 @@
|
||||
package dev.patrickgold.florisboard.ime.text.key
|
||||
|
||||
object KeyCode {
|
||||
object Spec {
|
||||
const val CHARACTERS_MIN = 1
|
||||
const val CHARACTERS_MAX = 65535
|
||||
val CHARACTERS = CHARACTERS_MIN..CHARACTERS_MAX
|
||||
|
||||
const val INTERNAL_MIN = -9999
|
||||
const val INTERNAL_MAX = -1
|
||||
val INTERNAL = INTERNAL_MIN..INTERNAL_MAX
|
||||
}
|
||||
|
||||
const val UNSPECIFIED = 0
|
||||
|
||||
const val PHONE_WAIT = 59 // ;
|
||||
|
||||
@@ -58,6 +58,7 @@ import androidx.compose.ui.unit.toSize
|
||||
import dev.patrickgold.florisboard.FlorisImeService
|
||||
import dev.patrickgold.florisboard.app.prefs.AppPrefs
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.app.ui.components.safeTimes
|
||||
import dev.patrickgold.florisboard.common.FlorisRect
|
||||
import dev.patrickgold.florisboard.common.Pointer
|
||||
import dev.patrickgold.florisboard.common.PointerMap
|
||||
@@ -114,6 +115,8 @@ fun TextKeyboardLayout(
|
||||
|
||||
val glideEnabled by prefs.glide.enabled.observeAsState()
|
||||
val glideShowTrail by prefs.glide.showTrail.observeAsState()
|
||||
val glideTrailColor = FlorisImeTheme.style.get(element = FlorisImeUi.GlideTrail)
|
||||
.foreground.solidColor(default = Color.Green)
|
||||
|
||||
val keyboard = renderInfo.keyboard
|
||||
val controller = remember { TextKeyboardLayoutController(context) }.also {
|
||||
@@ -155,7 +158,8 @@ fun TextKeyboardLayout(
|
||||
MotionEvent.ACTION_MOVE,
|
||||
MotionEvent.ACTION_POINTER_UP,
|
||||
MotionEvent.ACTION_UP,
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
MotionEvent.ACTION_CANCEL
|
||||
-> {
|
||||
val clonedEvent = MotionEvent.obtain(event)
|
||||
touchEventChannel
|
||||
.trySend(clonedEvent)
|
||||
@@ -182,12 +186,14 @@ fun TextKeyboardLayout(
|
||||
controller.fadingGlide,
|
||||
targetDist,
|
||||
controller.fadingGlideRadius,
|
||||
radiusReductionFactor
|
||||
radiusReductionFactor,
|
||||
glideTrailColor,
|
||||
)
|
||||
}
|
||||
if (controller.isGliding && controller.glideDataForDrawing.isNotEmpty()) {
|
||||
controller.drawGlideTrail(
|
||||
this, controller.glideDataForDrawing, targetDist, radius, radiusReductionFactor
|
||||
this, controller.glideDataForDrawing, targetDist, radius,
|
||||
radiusReductionFactor, glideTrailColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +284,7 @@ private fun TextKeyButton(
|
||||
isPressed = key.isPressed && key.isEnabled,
|
||||
isDisabled = !key.isEnabled,
|
||||
)
|
||||
val fontSize = keyStyle.fontSize.spSize() * fontSizeMultiplier * when (key.computedData.code) {
|
||||
val fontSize = keyStyle.fontSize.spSize() safeTimes fontSizeMultiplier safeTimes when (key.computedData.code) {
|
||||
KeyCode.VIEW_CHARACTERS,
|
||||
KeyCode.VIEW_SYMBOLS,
|
||||
KeyCode.VIEW_SYMBOLS2 -> 0.80f
|
||||
@@ -290,11 +296,17 @@ private fun TextKeyButton(
|
||||
modifier = Modifier
|
||||
.requiredSize(key.visibleBounds.size.toDpSize())
|
||||
.absoluteOffset { key.visibleBounds.topLeft.toIntOffset() },
|
||||
background = keyStyle.background,
|
||||
style = keyStyle,
|
||||
clip = true,
|
||||
shape = keyStyle.shape,
|
||||
) {
|
||||
key.label?.let { label ->
|
||||
if (key.computedData.code == KeyCode.SPACE) {
|
||||
val prefs by florisPreferenceModel()
|
||||
val displayLanguageName by prefs.keyboard.spaceBarLanguageDisplayEnabled.observeAsState()
|
||||
if (!displayLanguageName) {
|
||||
return@let
|
||||
}
|
||||
}
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.wrapContentSize()
|
||||
@@ -317,13 +329,13 @@ private fun TextKeyButton(
|
||||
mode = renderInfo.state.inputMode.value,
|
||||
isPressed = key.isPressed,
|
||||
)
|
||||
val hintFontSize = keyHintStyle.fontSize.spSize() * fontSizeMultiplier
|
||||
val hintFontSize = keyHintStyle.fontSize.spSize() safeTimes fontSizeMultiplier
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.padding(end = (key.visibleBounds.width / 12f).toDp())
|
||||
.wrapContentSize()
|
||||
.align(Alignment.TopEnd)
|
||||
.snyggBackground(keyHintStyle.background, keyHintStyle.shape),
|
||||
.snyggBackground(keyHintStyle),
|
||||
text = hintedLabel,
|
||||
color = keyHintStyle.foreground.solidColor(),
|
||||
fontFamily = FontFamily.Monospace,
|
||||
@@ -496,7 +508,10 @@ private class TextKeyboardLayoutController(
|
||||
pointer
|
||||
) || pointer.hasTriggeredGestureMove || pointer.shouldBlockNextUp
|
||||
) {
|
||||
if (pointer.hasTriggeredGestureMove && pointer.initialKey?.computedData?.code == KeyCode.DELETE) {
|
||||
if (pointer.hasTriggeredGestureMove &&
|
||||
pointer.initialKey?.computedData?.code == KeyCode.DELETE &&
|
||||
prefs.gestures.deleteKeySwipeLeft.get() != SwipeAction.SELECT_CHARACTERS_PRECISELY &&
|
||||
prefs.gestures.deleteKeySwipeLeft.get() != SwipeAction.SELECT_WORDS_PRECISELY) {
|
||||
activeEditorInstance?.apply {
|
||||
if (selection.isSelectionMode) {
|
||||
deleteBackwards()
|
||||
@@ -723,7 +738,7 @@ private class TextKeyboardLayoutController(
|
||||
|
||||
return when (event.type) {
|
||||
SwipeGesture.Type.TOUCH_MOVE -> when (prefs.gestures.deleteKeySwipeLeft.get()) {
|
||||
SwipeAction.DELETE_CHARACTERS_PRECISELY -> {
|
||||
SwipeAction.DELETE_CHARACTERS_PRECISELY, SwipeAction.SELECT_CHARACTERS_PRECISELY -> {
|
||||
activeEditorInstance?.apply {
|
||||
if (abs(event.relUnitCountX) > 0) {
|
||||
inputFeedbackController?.gestureMovingSwipe(TextKeyData.DELETE)
|
||||
@@ -739,7 +754,7 @@ private class TextKeyboardLayoutController(
|
||||
pointer.shouldBlockNextUp = true
|
||||
true
|
||||
}
|
||||
SwipeAction.DELETE_WORDS_PRECISELY -> {
|
||||
SwipeAction.DELETE_WORDS_PRECISELY, SwipeAction.SELECT_WORDS_PRECISELY -> {
|
||||
activeEditorInstance?.apply {
|
||||
if (abs(event.relUnitCountX) > 0) {
|
||||
inputFeedbackController?.gestureMovingSwipe(TextKeyData.DELETE)
|
||||
@@ -881,6 +896,7 @@ private class TextKeyboardLayoutController(
|
||||
targetDist: Float,
|
||||
initialRadius: Float,
|
||||
radiusReductionFactor: Float,
|
||||
color: Color,
|
||||
) {
|
||||
var radius = initialRadius
|
||||
var drawnPoints = 0
|
||||
@@ -902,7 +918,7 @@ private class TextKeyboardLayoutController(
|
||||
gestureData[i].first.x * (1 - j.toFloat() / numPoints) + gestureData[i - 1].first.x * (j.toFloat() / numPoints)
|
||||
val intermediateY =
|
||||
gestureData[i].first.y * (1 - j.toFloat() / numPoints) + gestureData[i - 1].first.y * (j.toFloat() / numPoints)
|
||||
drawScope.drawCircle(Color.Green, radius, center = Offset(intermediateX, intermediateY))
|
||||
drawScope.drawCircle(color, radius, center = Offset(intermediateX, intermediateY))
|
||||
drawnPoints += 1
|
||||
prevX = intermediateX
|
||||
prevY = intermediateY
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
package dev.patrickgold.florisboard.ime.text.smartbar
|
||||
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -29,12 +28,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -81,7 +78,7 @@ fun CandidatesRow(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.snyggBackground(rowStyle.background)
|
||||
.snyggBackground(rowStyle)
|
||||
.florisHorizontalScroll(),
|
||||
) {
|
||||
for (inlineSuggestion in inlineSuggestions) {
|
||||
@@ -92,7 +89,7 @@ fun CandidatesRow(modifier: Modifier = Modifier) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.snyggBackground(rowStyle.background)
|
||||
.snyggBackground(rowStyle)
|
||||
.then(
|
||||
if (displayMode == CandidatesDisplayMode.DYNAMIC_SCROLLABLE && candidates.size > 1) {
|
||||
Modifier.florisHorizontalScroll()
|
||||
@@ -134,7 +131,7 @@ fun CandidatesRow(modifier: Modifier = Modifier) {
|
||||
.width(1.dp)
|
||||
.fillMaxHeight(0.6f)
|
||||
.align(Alignment.CenterVertically)
|
||||
.snyggBackground(spacerStyle.foreground),
|
||||
.snyggBackground(spacerStyle),
|
||||
)
|
||||
}
|
||||
CandidateItem(
|
||||
@@ -175,7 +172,7 @@ private fun CandidateItem(
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.snyggBackground(style.background, style.shape)
|
||||
.snyggBackground(style)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
|
||||
@@ -53,6 +53,8 @@ import dev.patrickgold.florisboard.ime.keyboard.FlorisImeSizing
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeTheme
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUi
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBackground
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBorder
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggShadow
|
||||
import dev.patrickgold.florisboard.snygg.ui.solidColor
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
|
||||
@@ -140,7 +142,9 @@ private fun SmartbarPrimaryRow(modifier: Modifier = Modifier) = key(FlorisImeUi.
|
||||
.padding(4.dp)
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.snyggBackground(actionsToggleStyle.background, actionsToggleStyle.shape),
|
||||
.snyggShadow(actionsToggleStyle)
|
||||
.snyggBorder(actionsToggleStyle)
|
||||
.snyggBackground(actionsToggleStyle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val rotation by animateFloatAsState(
|
||||
@@ -201,7 +205,9 @@ private fun SmartbarPrimaryRow(modifier: Modifier = Modifier) = key(FlorisImeUi.
|
||||
.padding(4.dp)
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.snyggBackground(secondaryToggleStyle.background, secondaryToggleStyle.shape),
|
||||
.snyggShadow(secondaryToggleStyle)
|
||||
.snyggBorder(secondaryToggleStyle)
|
||||
.snyggBackground(secondaryToggleStyle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
@@ -245,7 +251,7 @@ private fun SmartbarPrimaryRow(modifier: Modifier = Modifier) = key(FlorisImeUi.
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(FlorisImeSizing.smartbarHeight)
|
||||
.snyggBackground(rowStyle.background),
|
||||
.snyggBackground(rowStyle),
|
||||
) {
|
||||
if (primaryRowFlipToggles) {
|
||||
SecondaryRowToggle()
|
||||
@@ -276,7 +282,7 @@ private fun SmartbarSecondaryRow(modifier: Modifier = Modifier) = key(FlorisImeU
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(FlorisImeSizing.smartbarHeight)
|
||||
.snyggBackground(rowStyle.background),
|
||||
.snyggBackground(rowStyle),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ import dev.patrickgold.florisboard.ime.theme.FlorisImeTheme
|
||||
import dev.patrickgold.florisboard.ime.theme.FlorisImeUi
|
||||
import dev.patrickgold.florisboard.keyboardManager
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBackground
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggBorder
|
||||
import dev.patrickgold.florisboard.snygg.ui.snyggShadow
|
||||
import dev.patrickgold.florisboard.snygg.ui.solidColor
|
||||
import dev.patrickgold.jetpref.datastore.model.observeAsState
|
||||
|
||||
@@ -65,7 +67,7 @@ fun SmartbarActionRow() = with(LocalDensity.current) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.snyggBackground(rowStyle.background),
|
||||
.snyggBackground(rowStyle),
|
||||
) {
|
||||
val width = constraints.maxWidth.toDp()
|
||||
val height = constraints.maxHeight.toDp()
|
||||
@@ -91,7 +93,8 @@ fun SmartbarActionRow() = with(LocalDensity.current) {
|
||||
.padding(4.dp)
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.snyggBackground(moreStyle.background, moreStyle.shape),
|
||||
.snyggShadow(moreStyle)
|
||||
.snyggBackground(moreStyle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
@@ -128,7 +131,9 @@ fun SmartbarActionRow() = with(LocalDensity.current) {
|
||||
.padding(4.dp)
|
||||
.fillMaxHeight()
|
||||
.aspectRatio(1f)
|
||||
.snyggBackground(buttonStyle.background, buttonStyle.shape),
|
||||
.snyggShadow(buttonStyle)
|
||||
.snyggBorder(buttonStyle)
|
||||
.snyggBackground(buttonStyle),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (icon != null) {
|
||||
|
||||
@@ -23,180 +23,13 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.patrickgold.florisboard.common.observeAsNonNullState
|
||||
import dev.patrickgold.florisboard.ime.text.key.InputMode
|
||||
import dev.patrickgold.florisboard.ime.text.key.KeyCode
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheet
|
||||
import dev.patrickgold.florisboard.themeManager
|
||||
|
||||
private val LocalConfig = staticCompositionLocalOf<ThemeExtensionComponent> { error("not init") }
|
||||
private val LocalStyle = staticCompositionLocalOf<SnyggStylesheet> { error("not init") }
|
||||
|
||||
val FlorisImeThemeBaseStyle = SnyggStylesheet {
|
||||
defines {
|
||||
"primary" to rgbaColor(76, 175, 80)
|
||||
"primaryVariant" to rgbaColor(56, 142, 60)
|
||||
"secondary" to rgbaColor(245, 124, 0)
|
||||
"secondaryVariant" to rgbaColor(230, 81, 0)
|
||||
"background" to rgbaColor(33, 33, 33)
|
||||
"surface" to rgbaColor(66, 66, 66)
|
||||
"surfaceVariant" to rgbaColor(97, 97, 97)
|
||||
|
||||
"onPrimary" to rgbaColor(255, 255, 255)
|
||||
"onSecondary" to rgbaColor(255, 255, 255)
|
||||
"onBackground" to rgbaColor(255, 255, 255)
|
||||
"onSurface" to rgbaColor(255, 255, 255)
|
||||
}
|
||||
|
||||
FlorisImeUi.Keyboard {
|
||||
background = `var`("background")
|
||||
}
|
||||
FlorisImeUi.Key {
|
||||
background = `var`("surface")
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(22.sp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
FlorisImeUi.Key(pressedSelector = true) {
|
||||
background = `var`("surfaceVariant")
|
||||
foreground = `var`("onSurface")
|
||||
}
|
||||
FlorisImeUi.Key(codes = listOf(KeyCode.ENTER)) {
|
||||
background = `var`("primary")
|
||||
foreground = `var`("onSurface")
|
||||
}
|
||||
FlorisImeUi.Key(codes = listOf(KeyCode.ENTER), pressedSelector = true) {
|
||||
background = `var`("primaryVariant")
|
||||
foreground = `var`("onSurface")
|
||||
}
|
||||
FlorisImeUi.Key(
|
||||
codes = listOf(KeyCode.SHIFT),
|
||||
modes = listOf(InputMode.CAPS_LOCK.value),
|
||||
) {
|
||||
foreground = rgbaColor(255, 152, 0)
|
||||
}
|
||||
FlorisImeUi.Key(codes = listOf(KeyCode.SPACE)) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(144, 144, 144)
|
||||
fontSize = size(12.sp)
|
||||
}
|
||||
FlorisImeUi.KeyHint {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(184, 184, 184)
|
||||
fontSize = size(12.sp)
|
||||
}
|
||||
FlorisImeUi.KeyPopup {
|
||||
background = rgbaColor(117, 117, 117)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(22.sp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
FlorisImeUi.KeyPopup(focusSelector = true) {
|
||||
background = rgbaColor(189, 189, 189)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(22.sp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
|
||||
FlorisImeUi.ClipboardHeader {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(16.sp)
|
||||
}
|
||||
FlorisImeUi.ClipboardItem {
|
||||
background = `var`("surface")
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(14.sp)
|
||||
shape = roundedCornerShape(12.dp)
|
||||
}
|
||||
FlorisImeUi.ClipboardItemPopup {
|
||||
background = rgbaColor(117, 117, 117)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(14.sp)
|
||||
shape = roundedCornerShape(12.dp)
|
||||
}
|
||||
|
||||
FlorisImeUi.OneHandedPanel {
|
||||
background = rgbaColor(27, 94, 32)
|
||||
foreground = rgbaColor(238, 238, 238)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarPrimaryRow {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
}
|
||||
FlorisImeUi.SmartbarPrimaryActionRowToggle {
|
||||
background = `var`("surface")
|
||||
foreground = `var`("onSurface")
|
||||
shape = roundedCornerShape(50)
|
||||
}
|
||||
FlorisImeUi.SmartbarPrimarySecondaryRowToggle {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(144, 144, 144)
|
||||
shape = roundedCornerShape(50)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarSecondaryRow {
|
||||
background = rgbaColor(33, 33, 33)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarActionRow {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
}
|
||||
FlorisImeUi.SmartbarActionButton {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
shape = roundedCornerShape(50)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarCandidateRow {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateWord {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
fontSize = size(14.sp)
|
||||
shape = rectangleShape()
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateWord(pressedSelector = true) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateClip {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
fontSize = size(14.sp)
|
||||
shape = roundedCornerShape(8)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateClip(pressedSelector = true) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateSpacer {
|
||||
foreground = rgbaColor(255, 255, 255, 0.25f)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarKey {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
fontSize = size(18.sp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
FlorisImeUi.SmartbarKey(pressedSelector = true) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
}
|
||||
FlorisImeUi.SmartbarKey(disabledSelector = true) {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = `var`("surface")
|
||||
}
|
||||
|
||||
FlorisImeUi.SystemNavBar {
|
||||
background = rgbaColor(33, 33, 33)
|
||||
}
|
||||
}
|
||||
|
||||
object FlorisImeTheme {
|
||||
val config: ThemeExtensionComponent
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.theme
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import dev.patrickgold.florisboard.ime.text.key.InputMode
|
||||
import dev.patrickgold.florisboard.ime.text.key.KeyCode
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheet
|
||||
|
||||
val FlorisImeThemeBaseStyle = SnyggStylesheet {
|
||||
defines {
|
||||
"primary" to rgbaColor(76, 175, 80)
|
||||
"primaryVariant" to rgbaColor(56, 142, 60)
|
||||
"secondary" to rgbaColor(245, 124, 0)
|
||||
"secondaryVariant" to rgbaColor(230, 81, 0)
|
||||
"background" to rgbaColor(33, 33, 33)
|
||||
"surface" to rgbaColor(66, 66, 66)
|
||||
"surfaceVariant" to rgbaColor(97, 97, 97)
|
||||
|
||||
"onBackground" to rgbaColor(255, 255, 255)
|
||||
"onSurface" to rgbaColor(255, 255, 255)
|
||||
}
|
||||
|
||||
FlorisImeUi.Keyboard {
|
||||
background = `var`("background")
|
||||
}
|
||||
FlorisImeUi.Key {
|
||||
background = `var`("surface")
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(22.sp)
|
||||
shadowElevation = size(2.dp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
FlorisImeUi.Key(pressedSelector = true) {
|
||||
background = `var`("surfaceVariant")
|
||||
foreground = `var`("onSurface")
|
||||
}
|
||||
FlorisImeUi.Key(codes = listOf(KeyCode.ENTER)) {
|
||||
background = `var`("primary")
|
||||
foreground = `var`("onSurface")
|
||||
}
|
||||
FlorisImeUi.Key(codes = listOf(KeyCode.ENTER), pressedSelector = true) {
|
||||
background = `var`("primaryVariant")
|
||||
foreground = `var`("onSurface")
|
||||
}
|
||||
FlorisImeUi.Key(
|
||||
codes = listOf(KeyCode.SHIFT),
|
||||
modes = listOf(InputMode.CAPS_LOCK.value),
|
||||
) {
|
||||
foreground = rgbaColor(255, 152, 0)
|
||||
}
|
||||
FlorisImeUi.Key(codes = listOf(KeyCode.SPACE)) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(144, 144, 144)
|
||||
fontSize = size(12.sp)
|
||||
}
|
||||
FlorisImeUi.KeyHint {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(184, 184, 184)
|
||||
fontSize = size(12.sp)
|
||||
}
|
||||
FlorisImeUi.KeyPopup {
|
||||
background = rgbaColor(117, 117, 117)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(22.sp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
FlorisImeUi.KeyPopup(focusSelector = true) {
|
||||
background = rgbaColor(189, 189, 189)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(22.sp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
|
||||
FlorisImeUi.ClipboardHeader {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(16.sp)
|
||||
}
|
||||
FlorisImeUi.ClipboardItem {
|
||||
background = `var`("surface")
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(14.sp)
|
||||
shape = roundedCornerShape(12.dp)
|
||||
}
|
||||
FlorisImeUi.ClipboardItemPopup {
|
||||
background = rgbaColor(117, 117, 117)
|
||||
foreground = `var`("onSurface")
|
||||
fontSize = size(14.sp)
|
||||
shape = roundedCornerShape(12.dp)
|
||||
}
|
||||
|
||||
FlorisImeUi.GlideTrail {
|
||||
foreground = `var`("primary")
|
||||
}
|
||||
|
||||
FlorisImeUi.OneHandedPanel {
|
||||
background = rgbaColor(27, 94, 32)
|
||||
foreground = rgbaColor(238, 238, 238)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarPrimaryRow {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
}
|
||||
FlorisImeUi.SmartbarPrimaryActionRowToggle {
|
||||
background = `var`("surface")
|
||||
foreground = `var`("onSurface")
|
||||
shape = circleShape()
|
||||
}
|
||||
FlorisImeUi.SmartbarPrimarySecondaryRowToggle {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(144, 144, 144)
|
||||
shape = circleShape()
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarSecondaryRow {
|
||||
background = rgbaColor(33, 33, 33)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarActionRow {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
}
|
||||
FlorisImeUi.SmartbarActionButton {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
shape = circleShape()
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarCandidateRow {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateWord {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
fontSize = size(14.sp)
|
||||
shape = rectangleShape()
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateWord(pressedSelector = true) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateClip {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
fontSize = size(14.sp)
|
||||
shape = roundedCornerShape(8)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateClip(pressedSelector = true) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
}
|
||||
FlorisImeUi.SmartbarCandidateSpacer {
|
||||
foreground = rgbaColor(255, 255, 255, 0.25f)
|
||||
}
|
||||
|
||||
FlorisImeUi.SmartbarKey {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
fontSize = size(18.sp)
|
||||
shape = roundedCornerShape(20)
|
||||
}
|
||||
FlorisImeUi.SmartbarKey(pressedSelector = true) {
|
||||
background = `var`("surface")
|
||||
foreground = rgbaColor(220, 220, 220)
|
||||
}
|
||||
FlorisImeUi.SmartbarKey(disabledSelector = true) {
|
||||
background = rgbaColor(0, 0, 0, 0f)
|
||||
foreground = `var`("surface")
|
||||
}
|
||||
|
||||
FlorisImeUi.SystemNavBar {
|
||||
background = rgbaColor(33, 33, 33)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ object FlorisImeUi {
|
||||
const val ClipboardItem = "clipboard-item"
|
||||
const val ClipboardItemPopup = "clipboard-item-popup"
|
||||
|
||||
const val GlideTrail = "glide-trail"
|
||||
|
||||
const val OneHandedPanel = "one-handed-panel"
|
||||
|
||||
const val SmartbarPrimaryRow = "smartbar-primary-row"
|
||||
|
||||
@@ -20,11 +20,13 @@ import dev.patrickgold.florisboard.snygg.Snygg
|
||||
import dev.patrickgold.florisboard.snygg.SnyggLevel
|
||||
import dev.patrickgold.florisboard.snygg.SnyggPropertySetSpecBuilder
|
||||
import dev.patrickgold.florisboard.snygg.SnyggSpec
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerShapeDpValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerShapePercentValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCircleShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggCutCornerPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDpSizeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRectangleShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerShapeDpValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerShapePercentValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggRoundedCornerPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSolidColorValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSpSizeValue
|
||||
|
||||
@@ -42,23 +44,43 @@ fun SnyggPropertySetSpecBuilder.foreground() {
|
||||
supportedValues(SnyggSolidColorValue),
|
||||
)
|
||||
}
|
||||
fun SnyggPropertySetSpecBuilder.fontSize() {
|
||||
fun SnyggPropertySetSpecBuilder.border() {
|
||||
property(
|
||||
name = Snygg.BorderColor,
|
||||
level = SnyggLevel.ADVANCED,
|
||||
supportedValues(SnyggSolidColorValue),
|
||||
)
|
||||
property(
|
||||
name = Snygg.BorderWidth,
|
||||
level = SnyggLevel.ADVANCED,
|
||||
supportedValues(SnyggDpSizeValue),
|
||||
)
|
||||
}
|
||||
fun SnyggPropertySetSpecBuilder.font() {
|
||||
property(
|
||||
name = Snygg.FontSize,
|
||||
level = SnyggLevel.ADVANCED,
|
||||
supportedValues(SnyggSpSizeValue),
|
||||
)
|
||||
}
|
||||
fun SnyggPropertySetSpecBuilder.shadow() {
|
||||
property(
|
||||
name = Snygg.ShadowElevation,
|
||||
level = SnyggLevel.ADVANCED,
|
||||
supportedValues(SnyggDpSizeValue),
|
||||
)
|
||||
}
|
||||
fun SnyggPropertySetSpecBuilder.shape() {
|
||||
property(
|
||||
name = Snygg.Shape,
|
||||
level = SnyggLevel.ADVANCED,
|
||||
supportedValues(
|
||||
SnyggRectangleShapeValue,
|
||||
SnyggCutCornerShapeDpValue,
|
||||
SnyggCutCornerShapePercentValue,
|
||||
SnyggRoundedCornerShapeDpValue,
|
||||
SnyggRoundedCornerShapePercentValue,
|
||||
SnyggCircleShapeValue,
|
||||
SnyggRoundedCornerDpShapeValue,
|
||||
SnyggRoundedCornerPercentShapeValue,
|
||||
SnyggCutCornerDpShapeValue,
|
||||
SnyggCutCornerPercentShapeValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -70,38 +92,49 @@ object FlorisImeUiSpec : SnyggSpec({
|
||||
element(FlorisImeUi.Key) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
element(FlorisImeUi.KeyHint) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
shape()
|
||||
font()
|
||||
}
|
||||
element(FlorisImeUi.KeyPopup) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
|
||||
element(FlorisImeUi.ClipboardHeader) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
}
|
||||
element(FlorisImeUi.ClipboardItem) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
element(FlorisImeUi.ClipboardItemPopup) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
|
||||
element(FlorisImeUi.GlideTrail) {
|
||||
foreground()
|
||||
}
|
||||
|
||||
element(FlorisImeUi.OneHandedPanel) {
|
||||
@@ -116,11 +149,15 @@ object FlorisImeUiSpec : SnyggSpec({
|
||||
background()
|
||||
foreground()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
element(FlorisImeUi.SmartbarPrimarySecondaryRowToggle) {
|
||||
background()
|
||||
foreground()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
|
||||
element(FlorisImeUi.SmartbarSecondaryRow) {
|
||||
@@ -134,6 +171,8 @@ object FlorisImeUiSpec : SnyggSpec({
|
||||
background()
|
||||
foreground()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
|
||||
element(FlorisImeUi.SmartbarCandidateRow) {
|
||||
@@ -142,13 +181,13 @@ object FlorisImeUiSpec : SnyggSpec({
|
||||
element(FlorisImeUi.SmartbarCandidateWord) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
shape()
|
||||
}
|
||||
element(FlorisImeUi.SmartbarCandidateClip) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
shape()
|
||||
}
|
||||
element(FlorisImeUi.SmartbarCandidateSpacer) {
|
||||
@@ -158,8 +197,10 @@ object FlorisImeUiSpec : SnyggSpec({
|
||||
element(FlorisImeUi.SmartbarKey) {
|
||||
background()
|
||||
foreground()
|
||||
fontSize()
|
||||
font()
|
||||
shape()
|
||||
shadow()
|
||||
border()
|
||||
}
|
||||
|
||||
element(FlorisImeUi.SystemNavBar) {
|
||||
|
||||
@@ -17,51 +17,43 @@
|
||||
package dev.patrickgold.florisboard.ime.theme
|
||||
|
||||
import dev.patrickgold.florisboard.res.ext.Extension
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionEditor
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionMeta
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
private const val SERIAL_TYPE = "ime.extension.theme"
|
||||
|
||||
@SerialName(SERIAL_TYPE)
|
||||
@SerialName(ThemeExtension.SERIAL_TYPE)
|
||||
@Serializable
|
||||
data class ThemeExtension(
|
||||
class ThemeExtension(
|
||||
override val meta: ExtensionMeta,
|
||||
override val dependencies: List<String>? = null,
|
||||
val themes: List<ThemeExtensionComponent>,
|
||||
val themes: List<ThemeExtensionComponentImpl>,
|
||||
) : Extension() {
|
||||
|
||||
companion object {
|
||||
const val SERIAL_TYPE = "ime.extension.theme"
|
||||
}
|
||||
|
||||
override fun serialType() = SERIAL_TYPE
|
||||
|
||||
override fun components() = themes
|
||||
|
||||
override fun edit(): ExtensionEditor {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ThemeExtensionComponent(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val authors: List<String>,
|
||||
@SerialName("isNight")
|
||||
val isNightTheme: Boolean = true,
|
||||
val isBorderless: Boolean = false,
|
||||
val isMaterialYouAware: Boolean = false,
|
||||
@SerialName("stylesheet")
|
||||
val stylesheetPath: String? = null,
|
||||
) : ExtensionComponent {
|
||||
fun stylesheetPath() = "stylesheets/$id.json"
|
||||
}
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun extCoreTheme(id: String): ExtensionComponentName {
|
||||
return ExtensionComponentName(
|
||||
extensionId = "org.florisboard.themes",
|
||||
componentId = id,
|
||||
override fun edit() = ThemeExtensionEditor(
|
||||
meta = meta,
|
||||
dependencies = dependencies?.toMutableList() ?: mutableListOf(),
|
||||
themes = themes.map { it.edit() }.toMutableList(),
|
||||
)
|
||||
}
|
||||
|
||||
class ThemeExtensionEditor(
|
||||
override var meta: ExtensionMeta,
|
||||
override val dependencies: MutableList<String>,
|
||||
val themes: MutableList<ThemeExtensionComponentEditor>,
|
||||
) : ExtensionEditor {
|
||||
|
||||
override fun build() = ThemeExtension(
|
||||
meta = meta,
|
||||
dependencies = dependencies.takeUnless { it.isEmpty() }?.toList(),
|
||||
themes = themes.map { it.build() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.ime.theme
|
||||
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponent
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheetEditor
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Suppress("NOTHING_TO_INLINE")
|
||||
inline fun extCoreTheme(id: String) = ExtensionComponentName(
|
||||
extensionId = "org.florisboard.themes",
|
||||
componentId = id,
|
||||
)
|
||||
|
||||
interface ThemeExtensionComponent : ExtensionComponent {
|
||||
companion object {
|
||||
fun defaultStylesheetPath(id: String): String {
|
||||
return "stylesheets/$id.json"
|
||||
}
|
||||
}
|
||||
|
||||
override val id: String
|
||||
override val label: String
|
||||
override val authors: List<String>
|
||||
val isNightTheme: Boolean
|
||||
val isBorderless: Boolean
|
||||
val isMaterialYouAware: Boolean
|
||||
val stylesheetPath: String?
|
||||
|
||||
fun stylesheetPath(): String = stylesheetPath.takeUnless { it.isNullOrBlank() } ?: defaultStylesheetPath(id)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ThemeExtensionComponentImpl(
|
||||
override val id: String,
|
||||
override val label: String,
|
||||
override val authors: List<String>,
|
||||
@SerialName("isNight")
|
||||
override val isNightTheme: Boolean = true,
|
||||
override val isBorderless: Boolean = false,
|
||||
override val isMaterialYouAware: Boolean = false,
|
||||
@SerialName("stylesheet")
|
||||
override val stylesheetPath: String? = null,
|
||||
) : ThemeExtensionComponent {
|
||||
|
||||
fun edit() = ThemeExtensionComponentEditor(
|
||||
id, label, authors, isNightTheme, isBorderless, isMaterialYouAware, stylesheetPath ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
class ThemeExtensionComponentEditor(
|
||||
override var id: String = "",
|
||||
override var label: String = "",
|
||||
override var authors: List<String> = emptyList(),
|
||||
override var isNightTheme: Boolean = true,
|
||||
override var isBorderless: Boolean = false,
|
||||
override var isMaterialYouAware: Boolean = false,
|
||||
override var stylesheetPath: String = "",
|
||||
) : ThemeExtensionComponent {
|
||||
|
||||
var stylesheetPathOnLoad: String? = null
|
||||
var stylesheetEditor: SnyggStylesheetEditor? = null
|
||||
|
||||
fun build(): ThemeExtensionComponentImpl {
|
||||
val component = ThemeExtensionComponentImpl(
|
||||
id = id.trim(),
|
||||
label = label.trim(),
|
||||
authors = authors.filterNot { it.isBlank() },
|
||||
isNightTheme = isNightTheme,
|
||||
isBorderless = isBorderless,
|
||||
isMaterialYouAware = isMaterialYouAware,
|
||||
stylesheetPath = stylesheetPath.takeUnless { it.isBlank() },
|
||||
)
|
||||
check(id.isNotBlank()) { "Theme component ID cannot be blank" }
|
||||
check(label.isNotBlank()) { "Theme component label cannot be blank" }
|
||||
check(authors.isNotEmpty()) { "Theme component authors must contain at least one non-blank author field" }
|
||||
return component
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,13 @@ package dev.patrickgold.florisboard.ime.theme
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.graphics.drawable.Icon
|
||||
import android.graphics.drawable.LayerDrawable
|
||||
import android.graphics.drawable.RippleDrawable
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.TypedValue
|
||||
@@ -30,6 +35,7 @@ import androidx.autofill.inline.common.ImageViewStyle
|
||||
import androidx.autofill.inline.common.TextViewStyle
|
||||
import androidx.autofill.inline.common.ViewStyle
|
||||
import androidx.autofill.inline.v1.InlineSuggestionUi
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.ColorUtils
|
||||
@@ -38,7 +44,7 @@ import androidx.lifecycle.MutableLiveData
|
||||
import dev.patrickgold.florisboard.R
|
||||
import dev.patrickgold.florisboard.app.prefs.florisPreferenceModel
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.common.android.AndroidVersion
|
||||
import dev.patrickgold.florisboard.common.ViewUtils
|
||||
import dev.patrickgold.florisboard.extensionManager
|
||||
import dev.patrickgold.florisboard.res.ZipUtils
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionComponentName
|
||||
@@ -53,9 +59,9 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import java.time.LocalTime
|
||||
import kotlin.properties.Delegates
|
||||
|
||||
|
||||
/**
|
||||
* Core class which manages the keyboard theme. Note, that this does not affect the UI theme of the
|
||||
* Settings Activities.
|
||||
@@ -72,92 +78,15 @@ class ThemeManager(context: Context) {
|
||||
var previewThemeId: ExtensionComponentName? by Delegates.observable(null) { _, _, _ ->
|
||||
updateActiveTheme()
|
||||
}
|
||||
var previewThemeInfo: ThemeInfo? by Delegates.observable(null) { _, _, _ ->
|
||||
updateActiveTheme()
|
||||
}
|
||||
|
||||
private val cachedThemeInfos = mutableListOf<ThemeInfo>()
|
||||
private val activeThemeGuard = Mutex(locked = false)
|
||||
private val _activeThemeInfo = MutableLiveData(ThemeInfo.DEFAULT)
|
||||
val activeThemeInfo: LiveData<ThemeInfo> get() = _activeThemeInfo
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a new inline suggestion UI bundle based on the attributes of the given [style].
|
||||
*
|
||||
* @param context The context of the parent view/controller.
|
||||
* @param style The theme from which the color attributes should be fetched. Defaults to
|
||||
* [FlorisImeThemeBaseStyle].
|
||||
*
|
||||
* @return A bundle containing all necessary attributes for the inline suggestion views to properly display.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun createInlineSuggestionUiStyleBundle(context: Context, style: SnyggStylesheet = FlorisImeThemeBaseStyle): Bundle {
|
||||
val chipStyle = style.getStatic(FlorisImeUi.SmartbarPrimaryActionRowToggle)
|
||||
val bgColor = chipStyle.background.solidColor().toArgb()
|
||||
val fgColor = chipStyle.foreground.solidColor().toArgb()
|
||||
val bgDrawableId = R.drawable.chip_background
|
||||
val stylesBuilder = UiVersions.newStylesBuilder()
|
||||
val suggestionStyle = InlineSuggestionUi.newStyleBuilder()
|
||||
.setSingleIconChipStyle(
|
||||
ViewStyle.Builder()
|
||||
.setBackground(
|
||||
Icon.createWithResource(context, bgDrawableId).setTint(bgColor)
|
||||
)
|
||||
.setPadding(0, 0, 0, 0)
|
||||
.build()
|
||||
)
|
||||
.setChipStyle(
|
||||
ViewStyle.Builder()
|
||||
.setBackground(
|
||||
Icon.createWithResource(context, bgDrawableId).setTint(bgColor)
|
||||
)
|
||||
.setPadding(
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_start).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_top).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_end).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_bottom).toInt(),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.setStartIconStyle(
|
||||
ImageViewStyle.Builder()
|
||||
.setLayoutMargin(0, 0, 0, 0)
|
||||
.build()
|
||||
)
|
||||
.setTitleStyle(
|
||||
TextViewStyle.Builder()
|
||||
.setLayoutMargin(
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_start).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_top).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_end).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_bottom).toInt(),
|
||||
)
|
||||
.setTextColor(fgColor)
|
||||
.setTextSize(16f)
|
||||
.build()
|
||||
)
|
||||
.setSubtitleStyle(
|
||||
TextViewStyle.Builder()
|
||||
.setLayoutMargin(
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_start).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_top).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_end).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_bottom).toInt(),
|
||||
)
|
||||
.setTextColor(ColorUtils.setAlphaComponent(fgColor, 150))
|
||||
.setTextSize(14f)
|
||||
.build()
|
||||
)
|
||||
.setEndIconStyle(
|
||||
ImageViewStyle.Builder()
|
||||
.setLayoutMargin(0, 0, 0, 0)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
stylesBuilder.addStyle(suggestionStyle)
|
||||
return stylesBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
extensionManager.themes.observeForever { themeExtensions ->
|
||||
val map = buildMap {
|
||||
@@ -189,9 +118,13 @@ class ThemeManager(context: Context) {
|
||||
* Updates the current theme ref and loads the corresponding theme, as well as notifies all
|
||||
* callback receivers about the new theme.
|
||||
*/
|
||||
private fun updateActiveTheme(action: () -> Unit = { }) = scope.launch {
|
||||
fun updateActiveTheme(action: () -> Unit = { }) = scope.launch {
|
||||
activeThemeGuard.withLock {
|
||||
action()
|
||||
previewThemeInfo?.let { previewThemeInfo ->
|
||||
_activeThemeInfo.postValue(previewThemeInfo)
|
||||
return@withLock
|
||||
}
|
||||
val activeName = evaluateActiveThemeName()
|
||||
val cachedInfo = cachedThemeInfos.find { it.name == activeName }
|
||||
if (cachedInfo != null) {
|
||||
@@ -235,24 +168,105 @@ class ThemeManager(context: Context) {
|
||||
prefs.theme.dayThemeId.get()
|
||||
}
|
||||
ThemeMode.FOLLOW_TIME -> {
|
||||
if (AndroidVersion.ATLEAST_API26_O) {
|
||||
val current = LocalTime.now()
|
||||
val sunrise = prefs.theme.sunriseTime.get()
|
||||
val sunset = prefs.theme.sunsetTime.get()
|
||||
if (current in sunrise..sunset) {
|
||||
prefs.theme.dayThemeId.get()
|
||||
} else {
|
||||
prefs.theme.nightThemeId.get()
|
||||
}
|
||||
} else {
|
||||
//if (AndroidVersion.ATLEAST_API26_O) {
|
||||
// val current = LocalTime.now()
|
||||
// val sunrise = prefs.theme.sunriseTime.get()
|
||||
// val sunset = prefs.theme.sunsetTime.get()
|
||||
// if (current in sunrise..sunset) {
|
||||
// prefs.theme.dayThemeId.get()
|
||||
// } else {
|
||||
// prefs.theme.nightThemeId.get()
|
||||
// }
|
||||
//} else {
|
||||
prefs.theme.nightThemeId.get()
|
||||
}
|
||||
//}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new inline suggestion UI bundle based on the attributes of the given [style].
|
||||
*
|
||||
* @param context The context of the parent view/controller.
|
||||
* @param style The theme from which the color attributes should be fetched. Defaults to
|
||||
* [FlorisImeThemeBaseStyle].
|
||||
*
|
||||
* @return A bundle containing all necessary attributes for the inline suggestion views to properly display.
|
||||
*/
|
||||
@SuppressLint("RestrictedApi")
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
fun createInlineSuggestionUiStyleBundle(
|
||||
context: Context,
|
||||
style: SnyggStylesheet = activeThemeInfo.value?.stylesheet ?: FlorisImeThemeBaseStyle,
|
||||
): Bundle {
|
||||
val chipStyle = style.getStatic(FlorisImeUi.SmartbarPrimaryActionRowToggle)
|
||||
val bgColor = chipStyle.background.solidColor()
|
||||
val fgColor = chipStyle.foreground.solidColor()
|
||||
val bgDrawableId = R.drawable.autofill_inline_suggestion_chip_background
|
||||
val stylesBuilder = UiVersions.newStylesBuilder()
|
||||
val suggestionStyle = InlineSuggestionUi.newStyleBuilder()
|
||||
.setSingleIconChipStyle(
|
||||
ViewStyle.Builder()
|
||||
.setBackground(
|
||||
Icon.createWithResource(context, bgDrawableId).setTint(bgColor.toArgb())
|
||||
)
|
||||
.setPadding(0, 0, 0, 0)
|
||||
.build()
|
||||
)
|
||||
.setChipStyle(
|
||||
ViewStyle.Builder()
|
||||
.setBackground(
|
||||
Icon.createWithResource(context, bgDrawableId).setTint(bgColor.toArgb())
|
||||
)
|
||||
.setPadding(
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_start).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_top).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_end).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_bg_padding_bottom).toInt(),
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.setStartIconStyle(
|
||||
ImageViewStyle.Builder()
|
||||
.setLayoutMargin(0, 0, 0, 0)
|
||||
.build()
|
||||
)
|
||||
.setTitleStyle(
|
||||
TextViewStyle.Builder()
|
||||
.setLayoutMargin(
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_start).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_top).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_end).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_title_margin_bottom).toInt(),
|
||||
)
|
||||
.setTextColor(fgColor.toArgb())
|
||||
.setTextSize(16f)
|
||||
.build()
|
||||
)
|
||||
.setSubtitleStyle(
|
||||
TextViewStyle.Builder()
|
||||
.setLayoutMargin(
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_start).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_top).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_end).toInt(),
|
||||
context.resources.getDimension(R.dimen.suggestion_chip_fg_subtitle_margin_bottom).toInt(),
|
||||
)
|
||||
.setTextColor(ColorUtils.setAlphaComponent(fgColor.toArgb(), 150))
|
||||
.setTextSize(14f)
|
||||
.build()
|
||||
)
|
||||
.setEndIconStyle(
|
||||
ImageViewStyle.Builder()
|
||||
.setLayoutMargin(0, 0, 0, 0)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
stylesBuilder.addStyle(suggestionStyle)
|
||||
return stylesBuilder.build()
|
||||
}
|
||||
|
||||
private fun getColorFromThemeAttribute(
|
||||
context: Context, typedValue: TypedValue, @AttrRes attr: Int
|
||||
context: Context, typedValue: TypedValue, @AttrRes attr: Int,
|
||||
): Int? {
|
||||
return if (context.theme.resolveAttribute(attr, typedValue, true)) {
|
||||
if (typedValue.type == TypedValue.TYPE_REFERENCE) {
|
||||
@@ -273,7 +287,7 @@ class ThemeManager(context: Context) {
|
||||
companion object {
|
||||
val DEFAULT = ThemeInfo(
|
||||
name = extCoreTheme("base"),
|
||||
config = ThemeExtensionComponent(id = "base", label = "Base", authors = listOf()),
|
||||
config = ThemeExtensionComponentImpl(id = "base", label = "Base", authors = listOf()),
|
||||
stylesheet = FlorisImeThemeBaseStyle.compileToFullyQualified(FlorisImeUiSpec),
|
||||
)
|
||||
}
|
||||
@@ -289,4 +303,48 @@ class ThemeManager(context: Context) {
|
||||
val DEFAULT = RemoteColors("undefined", null, null, null)
|
||||
}
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
private fun autofillChipBackgroundOf(
|
||||
bgColor: Color,
|
||||
rippleColor: Color,
|
||||
): Drawable {
|
||||
val cornerRadius = ViewUtils.dp2px(32f)
|
||||
val shadowColors = intArrayOf(
|
||||
Color.Transparent.toArgb(),
|
||||
Color.Transparent.toArgb(),
|
||||
Color(red = 0x00, green = 0x00, blue = 0x00, alpha = 0x1F).toArgb(),
|
||||
)
|
||||
val shadowDistribution = floatArrayOf(0f, 0.5f, 1f)
|
||||
val padding = ViewUtils.dp2px(5f).toInt()
|
||||
|
||||
fun gradientDrawableOf() = GradientDrawable().also {
|
||||
it.shape = GradientDrawable.RECTANGLE
|
||||
it.cornerRadius = cornerRadius
|
||||
it.setGradientCenter(0.5f, 0.5f)
|
||||
it.gradientType = GradientDrawable.LINEAR_GRADIENT
|
||||
it.setColors(shadowColors, shadowDistribution)
|
||||
}
|
||||
|
||||
val layerList = LayerDrawable(arrayOf(
|
||||
gradientDrawableOf().also {
|
||||
it.orientation = GradientDrawable.Orientation.BOTTOM_TOP
|
||||
},
|
||||
gradientDrawableOf().also {
|
||||
it.orientation = GradientDrawable.Orientation.LEFT_RIGHT
|
||||
},
|
||||
gradientDrawableOf().also {
|
||||
it.orientation = GradientDrawable.Orientation.TOP_BOTTOM
|
||||
},
|
||||
gradientDrawableOf().also {
|
||||
it.orientation = GradientDrawable.Orientation.RIGHT_LEFT
|
||||
},
|
||||
GradientDrawable().also {
|
||||
it.shape = GradientDrawable.RECTANGLE
|
||||
it.cornerRadius = cornerRadius
|
||||
it.setColor(bgColor.toArgb())
|
||||
},
|
||||
)).also { it.setLayerInset(4, padding, padding, padding, padding) }
|
||||
return RippleDrawable(ColorStateList.valueOf(rippleColor.toArgb()), layerList, null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2021 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.res
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.BufferedReader
|
||||
import java.io.BufferedWriter
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
@Deprecated("Use ContentResolver extension funs instead.")
|
||||
object ExternalContentUtils {
|
||||
@Deprecated("Use ContentResolver extension funs instead. This method will stay supported until all usages of ExternalContentUtils are replaced.")
|
||||
inline fun <R> readFromUri(context: Context, uri: Uri, maxSize: Int = Int.MAX_VALUE, block: (it: BufferedInputStream) -> R): Result<R> {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.AT_MOST_ONCE)
|
||||
}
|
||||
return runCatching {
|
||||
val contentResolver = context.contentResolver
|
||||
?: throw NullPointerException("System content resolver not available")
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
?: throw NullPointerException("Cannot open input stream for given uri '$uri'")
|
||||
val assetFileDescriptor = contentResolver.openAssetFileDescriptor(uri, "r")
|
||||
?: throw NullPointerException("Cannot open asset file descriptor for given uri '$uri'")
|
||||
if (assetFileDescriptor.length > maxSize) {
|
||||
throw Exception("Contents of given uri '$uri' exceeds maximum size of $maxSize bytes!")
|
||||
}
|
||||
inputStream.buffered().use { block(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use ContentResolver extension funs instead. This method will stay supported until all usages of ExternalContentUtils are replaced.")
|
||||
inline fun <R> readTextFromUri(context: Context, uri: Uri, maxSize: Int, block: (it: BufferedReader) -> R): Result<R> {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.AT_MOST_ONCE)
|
||||
}
|
||||
return runCatching {
|
||||
val contentResolver = context.contentResolver
|
||||
?: throw NullPointerException("System content resolver not available")
|
||||
val inputStream = contentResolver.openInputStream(uri)
|
||||
?: throw NullPointerException("Cannot open input stream for given uri '$uri'")
|
||||
val assetFileDescriptor = contentResolver.openAssetFileDescriptor(uri, "r")
|
||||
?: throw NullPointerException("Cannot open asset file descriptor for given uri '$uri'")
|
||||
if (assetFileDescriptor.length > maxSize) {
|
||||
throw Exception("Contents of given uri '$uri' exceeds maximum size of $maxSize bytes!")
|
||||
}
|
||||
inputStream.bufferedReader(Charsets.UTF_8).use { block(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use ContentResolver extension funs instead. This method will stay supported until all usages of ExternalContentUtils are replaced.")
|
||||
inline fun writeToUri(context: Context, uri: Uri, block: (it: BufferedOutputStream) -> Unit): Result<Unit> {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.AT_MOST_ONCE)
|
||||
}
|
||||
return runCatching {
|
||||
val contentResolver = context.contentResolver
|
||||
?: throw NullPointerException("System content resolver not available")
|
||||
// Must use "rwt" mode to ensure destination file length is truncated after writing.
|
||||
val outputStream = contentResolver.openOutputStream(uri, "rwt")
|
||||
?: throw NullPointerException("Cannot open output stream for given uri '$uri'")
|
||||
outputStream.buffered().use { block(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Deprecated("Use ContentResolver extension funs instead. This method will stay supported until all usages of ExternalContentUtils are replaced.")
|
||||
inline fun writeTextToUri(context: Context, uri: Uri, block: (it: BufferedWriter) -> Unit): Result<Unit> {
|
||||
contract {
|
||||
callsInPlace(block, InvocationKind.AT_MOST_ONCE)
|
||||
}
|
||||
return runCatching {
|
||||
val contentResolver = context.contentResolver
|
||||
?: throw NullPointerException("System content resolver not available")
|
||||
// Must use "rwt" mode to ensure destination file length is truncated after writing.
|
||||
val outputStream = contentResolver.openOutputStream(uri, "rwt")
|
||||
?: throw NullPointerException("Cannot open output stream for given uri '$uri'")
|
||||
outputStream.bufferedWriter(Charsets.UTF_8).use { block(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,15 @@ import dev.patrickgold.florisboard.res.cache.CacheManager
|
||||
import dev.patrickgold.florisboard.res.io.FsFile
|
||||
|
||||
object FileRegistry {
|
||||
val BackupArchive = Entry(
|
||||
type = Type.BINARY,
|
||||
fileExt = "zip",
|
||||
mediaType = "application/zip",
|
||||
alternativeMediaTypes = listOf(
|
||||
"application/octet-stream",
|
||||
),
|
||||
)
|
||||
|
||||
val FlexExtension = Entry(
|
||||
type = Type.BINARY,
|
||||
fileExt = "flex",
|
||||
|
||||
@@ -64,6 +64,10 @@ value class FlorisRef private constructor(val uri: Uri) {
|
||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
||||
internal const val AUTHORITY_INTERNAL = "internal"
|
||||
|
||||
private const val URL_HTTP_PREFIX = "http://"
|
||||
private const val URL_HTTPS_PREFIX = "https://"
|
||||
private const val URL_MAILTO_PREFIX = "mailto:"
|
||||
|
||||
/**
|
||||
* Constructs a new [FlorisRef] pointing to a UI screen within the app
|
||||
* user interface.
|
||||
@@ -153,6 +157,22 @@ value class FlorisRef private constructor(val uri: Uri) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new reference from given [url], which is a URL.
|
||||
*
|
||||
* @param url An URL pointing to a web page. If the scheme is missing, `https` is assumed.
|
||||
*
|
||||
* @return The newly constructed reference.
|
||||
*/
|
||||
fun fromUrl(url: String): FlorisRef {
|
||||
return FlorisRef(when {
|
||||
url.startsWith(URL_HTTP_PREFIX) ||
|
||||
url.startsWith(URL_HTTPS_PREFIX) ||
|
||||
url.startsWith(URL_MAILTO_PREFIX) -> Uri.parse(url)
|
||||
else -> Uri.parse("$URL_HTTPS_PREFIX$url").normalizeScheme()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a new reference from given [scheme] and [path], this can
|
||||
* point to any destination, regardless of within FlorisBoard or not.
|
||||
|
||||
@@ -70,6 +70,17 @@ object ZipUtils {
|
||||
}
|
||||
}
|
||||
|
||||
fun zip(srcDir: FsDir, dstFile: FsFile) {
|
||||
check(srcDir.exists() && srcDir.isDirectory) { "Cannot zip standalone file." }
|
||||
dstFile.parentFile?.mkdirs()
|
||||
dstFile.delete()
|
||||
FileOutputStream(dstFile).use { outStream ->
|
||||
ZipOutputStream(outStream).use { zipOut ->
|
||||
zip(srcDir, zipOut, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun zip(context: Context, srcDir: FsDir, uri: Uri) = runCatching {
|
||||
check(srcDir.exists() && srcDir.isDirectory) { "Cannot zip standalone file." }
|
||||
context.contentResolver.write(uri) { fileOut ->
|
||||
|
||||
@@ -19,14 +19,22 @@ package dev.patrickgold.florisboard.res.cache
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.neverEqualPolicy
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.patrickgold.florisboard.app.ui.ext.EditorAction
|
||||
import dev.patrickgold.florisboard.app.ui.settings.advanced.Backup
|
||||
import dev.patrickgold.florisboard.appContext
|
||||
import dev.patrickgold.florisboard.common.android.query
|
||||
import dev.patrickgold.florisboard.common.android.readToFile
|
||||
import dev.patrickgold.florisboard.ime.nlp.NATIVE_NULLPTR
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionEditor
|
||||
import dev.patrickgold.florisboard.res.FileRegistry
|
||||
import dev.patrickgold.florisboard.res.ZipUtils
|
||||
import dev.patrickgold.florisboard.res.ext.Extension
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionDefaults
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionEditor
|
||||
import dev.patrickgold.florisboard.res.ext.ExtensionJsonConfig
|
||||
import dev.patrickgold.florisboard.res.io.FsDir
|
||||
import dev.patrickgold.florisboard.res.io.FsFile
|
||||
@@ -47,21 +55,22 @@ import java.util.*
|
||||
|
||||
class CacheManager(context: Context) {
|
||||
companion object {
|
||||
private const val InputDirName = "input"
|
||||
private const val OutputDirName = "output"
|
||||
|
||||
private const val ImporterDirName = "importer"
|
||||
private const val ImporterInputDirName = "input"
|
||||
private const val ImporterOutputDirName = "output"
|
||||
|
||||
private const val ExporterDirName = "exporter"
|
||||
|
||||
private const val EditorDirName = "editor"
|
||||
private const val BackupAndRestoreDirName = "backup-and-restore"
|
||||
}
|
||||
|
||||
private val appContext by context.appContext()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
val importer = WorkspacesContainer<ImporterWorkspace>(ImporterDirName)
|
||||
val exporter = WorkspacesContainer<ExporterWorkspace>(ExporterDirName)
|
||||
val editor = WorkspacesContainer<EditorWorkspace>(EditorDirName)
|
||||
val importer = WorkspacesContainer(ImporterDirName) { ImporterWorkspace(it) }
|
||||
val exporter = WorkspacesContainer(ExporterDirName) { ExporterWorkspace(it) }
|
||||
val themeExtEditor = WorkspacesContainer(EditorDirName) { ExtEditorWorkspace<ThemeExtensionEditor>(it) }
|
||||
val backupAndRestore = WorkspacesContainer(BackupAndRestoreDirName) { BackupAndRestoreWorkspace(it) }
|
||||
|
||||
fun readFromUriIntoCache(uri: Uri) = readFromUriIntoCache(listOf(uri))
|
||||
|
||||
@@ -96,12 +105,19 @@ class CacheManager(context: Context) {
|
||||
return workspace
|
||||
}
|
||||
|
||||
inner class WorkspacesContainer<T : Workspace> internal constructor(val dirName: String) {
|
||||
open inner class WorkspacesContainer<T : Workspace> internal constructor(
|
||||
val dirName: String,
|
||||
val factory: (uuid: String) -> T,
|
||||
) {
|
||||
private val workspacesGuard = Mutex(locked = false)
|
||||
private val workspaces = mutableListOf<T>()
|
||||
|
||||
val dir: FsDir = appContext.cacheDir.subDir(dirName)
|
||||
|
||||
fun new(uuid: String = UUID.randomUUID().toString()): T {
|
||||
return factory(uuid).also { it.mkdirs(); add(it) }
|
||||
}
|
||||
|
||||
internal fun add(workspace: T) = scope.launch {
|
||||
workspacesGuard.withLock {
|
||||
workspaces.add(workspace)
|
||||
@@ -130,6 +146,10 @@ class CacheManager(context: Context) {
|
||||
dir.mkdirs()
|
||||
}
|
||||
|
||||
fun isOpen() = dir.exists()
|
||||
|
||||
fun isClosed() = !dir.exists()
|
||||
|
||||
override fun close() {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
@@ -138,8 +158,8 @@ class CacheManager(context: Context) {
|
||||
inner class ImporterWorkspace(uuid: String) : Workspace(uuid) {
|
||||
override val dir: FsDir = importer.dir.subDir(uuid)
|
||||
|
||||
val inputDir: FsDir = dir.subDir(ImporterInputDirName)
|
||||
val outputDir: FsDir = dir.subDir(ImporterOutputDirName)
|
||||
val inputDir: FsDir = dir.subDir(InputDirName)
|
||||
val outputDir: FsDir = dir.subDir(OutputDirName)
|
||||
|
||||
var inputFileInfos = emptyList<FileInfo>()
|
||||
|
||||
@@ -159,8 +179,55 @@ class CacheManager(context: Context) {
|
||||
override val dir: FsDir = exporter.dir.subDir(uuid)
|
||||
}
|
||||
|
||||
inner class EditorWorkspace(uuid: String) : Workspace(uuid) {
|
||||
override val dir: FsDir = editor.dir.subDir(uuid)
|
||||
inner class ExtEditorWorkspace<T : ExtensionEditor>(uuid: String) : Workspace(uuid) {
|
||||
override val dir: FsDir = themeExtEditor.dir.subDir(uuid)
|
||||
|
||||
val extDir: FsDir = dir.subDir("ext")
|
||||
val saverDir: FsDir = dir.subDir("saver")
|
||||
|
||||
var currentAction by mutableStateOf<EditorAction?>(null)
|
||||
var ext: Extension? = null
|
||||
var editor by mutableStateOf<T?>(null, neverEqualPolicy())
|
||||
var version by mutableStateOf(0)
|
||||
|
||||
val isModified get() = version > 0
|
||||
|
||||
override fun mkdirs() {
|
||||
super.mkdirs()
|
||||
extDir.mkdirs()
|
||||
saverDir.mkdirs()
|
||||
}
|
||||
|
||||
inline fun <R> update(block: T.() -> R): R {
|
||||
// Method is designed to only be called when editor has been previously initialized
|
||||
val ret = block(editor!!)
|
||||
editor = editor
|
||||
version++
|
||||
return ret
|
||||
}
|
||||
}
|
||||
|
||||
inner class BackupAndRestoreWorkspace(uuid: String) : Workspace(uuid) {
|
||||
override val dir: FsDir = backupAndRestore.dir.subDir(uuid)
|
||||
|
||||
val inputDir: FsDir = dir.subDir(InputDirName)
|
||||
val outputDir: FsDir = dir.subDir(OutputDirName)
|
||||
|
||||
lateinit var zipFile: FsFile
|
||||
lateinit var metadata: Backup.Metadata
|
||||
var restoreWarningId: Int? = null
|
||||
var restoreErrorId: Int? = null
|
||||
|
||||
override fun mkdirs() {
|
||||
super.mkdirs()
|
||||
inputDir.mkdirs()
|
||||
outputDir.mkdirs()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
super.close()
|
||||
backupAndRestore.remove(this)
|
||||
}
|
||||
}
|
||||
|
||||
data class FileInfo(
|
||||
|
||||
@@ -114,6 +114,8 @@ abstract class Extension {
|
||||
}
|
||||
|
||||
interface ExtensionEditor {
|
||||
val meta: ExtensionMetaEditor
|
||||
val workingDir: FsDir?
|
||||
var meta: ExtensionMeta
|
||||
val dependencies: MutableList<String>
|
||||
|
||||
fun build(): Extension
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
|
||||
package dev.patrickgold.florisboard.res.ext
|
||||
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import dev.patrickgold.florisboard.common.kotlin.tryOrNull
|
||||
import dev.patrickgold.jetpref.datastore.model.PreferenceSerializer
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.Serializable
|
||||
@@ -48,11 +50,16 @@ data class ExtensionComponentName(
|
||||
companion object {
|
||||
private const val DELIMITER = ":"
|
||||
|
||||
fun from(str: String) = runCatching<ExtensionComponentName> {
|
||||
fun from(str: String): ExtensionComponentName {
|
||||
val data = str.split(DELIMITER)
|
||||
check(data.size == 2) { "Extension component name must be of format <ext_id>:<comp_id>" }
|
||||
ExtensionComponentName(data[0], data[1])
|
||||
return ExtensionComponentName(data[0], data[1])
|
||||
}
|
||||
|
||||
val Saver = Saver<ExtensionComponentName?, String>(
|
||||
save = { it.toString() },
|
||||
restore = { tryOrNull { from(it) } },
|
||||
)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
@@ -67,15 +74,15 @@ data class ExtensionComponentName(
|
||||
}
|
||||
|
||||
override fun serialize(encoder: Encoder, value: ExtensionComponentName) {
|
||||
encoder.encodeString("${value.extensionId}:${value.componentId}")
|
||||
encoder.encodeString(value.toString())
|
||||
}
|
||||
|
||||
override fun deserialize(value: String): ExtensionComponentName? {
|
||||
return from(value).getOrNull()
|
||||
return tryOrNull { from(value) }
|
||||
}
|
||||
|
||||
override fun deserialize(decoder: Decoder): ExtensionComponentName {
|
||||
return from(decoder.decodeString()).getOrThrow()
|
||||
return from(decoder.decodeString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +58,6 @@ data class ExtensionMaintainer(
|
||||
}
|
||||
}
|
||||
|
||||
fun edit() = ExtensionMaintainerEditor(name, email ?: "", url ?: "")
|
||||
|
||||
override fun toString() = buildString {
|
||||
append(name)
|
||||
if (email != null && email.isNotBlank()) {
|
||||
@@ -71,22 +69,6 @@ data class ExtensionMaintainer(
|
||||
}
|
||||
}
|
||||
|
||||
data class ExtensionMaintainerEditor(
|
||||
var name: String = "",
|
||||
var email: String = "",
|
||||
var url: String = "",
|
||||
) {
|
||||
fun build() = runCatching {
|
||||
val maintainer = ExtensionMaintainer(
|
||||
name.trim(),
|
||||
email.trim().ifBlank { null },
|
||||
url.trim().ifBlank { null },
|
||||
)
|
||||
check(maintainer.name.isNotBlank()) { "Extension maintainer name cannot be blank" }
|
||||
return@runCatching maintainer
|
||||
}
|
||||
}
|
||||
|
||||
private class ExtensionMaintainerSerializer : KSerializer<ExtensionMaintainer> {
|
||||
override val descriptor = PrimitiveSerialDescriptor("ExtensionMaintainer", PrimitiveKind.STRING)
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ val ExtensionJsonConfig = Json {
|
||||
|
||||
class ExtensionManager(context: Context) {
|
||||
companion object {
|
||||
private const val IME_KEYBOARD_PATH = "ime/keyboard"
|
||||
private const val IME_SPELLING_PATH = "ime/spelling"
|
||||
private const val IME_THEME_PATH = "ime/theme"
|
||||
const val IME_KEYBOARD_PATH = "ime/keyboard"
|
||||
const val IME_SPELLING_PATH = "ime/spelling"
|
||||
const val IME_THEME_PATH = "ime/theme"
|
||||
}
|
||||
|
||||
private val appContext by context.appContext()
|
||||
@@ -155,6 +155,7 @@ class ExtensionManager(context: Context) {
|
||||
}
|
||||
|
||||
init {
|
||||
value = emptyList()
|
||||
ioScope.launch {
|
||||
internalModuleDir.mkdirs()
|
||||
staticExtensions = indexAssetsModule()
|
||||
|
||||
@@ -21,7 +21,7 @@ import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonNames
|
||||
|
||||
/**
|
||||
* Interface for an `extension.json` file, which serves as a configuration of an extension
|
||||
* Class for an `extension.json` file, which serves as a configuration of an extension
|
||||
* package for FlorisBoard (`.flex` archive files).
|
||||
*
|
||||
* Files which are always read (case sensitive):
|
||||
@@ -39,7 +39,8 @@ import kotlinx.serialization.json.JsonNames
|
||||
data class ExtensionMeta(
|
||||
/**
|
||||
* The unique identifier of this extension, adhering to
|
||||
* [Java™ package name standards](https://docs.oracle.com/javase/tutorial/java/package/namingpkgs.html).
|
||||
* [Java™ package name standards](https://docs.oracle.com/javase/tutorial/java/package/namingpkgs.html)
|
||||
* and this regex: `^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$`
|
||||
*/
|
||||
val id: String,
|
||||
|
||||
@@ -100,41 +101,4 @@ data class ExtensionMeta(
|
||||
* Use an SPDX license expression if this extension has multiple licenses.
|
||||
*/
|
||||
val license: String,
|
||||
) {
|
||||
fun edit() = ExtensionMetaEditor(
|
||||
id, version, title, description ?: "", keywords?.toMutableList() ?: mutableListOf(),
|
||||
homepage ?: "", issueTracker ?: "", maintainers.map { it.edit() }.toMutableList(), license
|
||||
)
|
||||
}
|
||||
|
||||
data class ExtensionMetaEditor(
|
||||
var id: String = "",
|
||||
var version: String = "",
|
||||
var title: String = "",
|
||||
var description: String = "",
|
||||
var keywords: MutableList<String> = mutableListOf(),
|
||||
var homepage: String = "",
|
||||
var issueTracker: String = "",
|
||||
var maintainers: MutableList<ExtensionMaintainerEditor> = mutableListOf(),
|
||||
var license: String = "",
|
||||
) {
|
||||
fun build() = runCatching {
|
||||
val meta = ExtensionMeta(
|
||||
id.trim(),
|
||||
version.trim(),
|
||||
title.trim(),
|
||||
description.trim().ifBlank { null },
|
||||
keywords.mapNotNull { it.trim().ifBlank { null } }.ifEmpty { null },
|
||||
homepage.trim().ifBlank { null },
|
||||
issueTracker.trim().ifBlank { null },
|
||||
maintainers.map { it.build().getOrThrow() },
|
||||
license.trim(),
|
||||
)
|
||||
check(meta.id.isNotBlank()) { "Extension ID cannot be blank" }
|
||||
check(meta.version.isNotBlank()) { "Extension version string cannot be blank" }
|
||||
check(meta.title.isNotBlank()) { "Extension title cannot be blank" }
|
||||
check(meta.maintainers.isNotEmpty()) { "At least one extension maintainer must be defined" }
|
||||
check(meta.license.isNotBlank()) { "Extension license identifier cannot be blank" }
|
||||
return@runCatching meta
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Patrick Goldinger
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package dev.patrickgold.florisboard.res.ext
|
||||
|
||||
import androidx.core.text.trimmedLength
|
||||
import dev.patrickgold.florisboard.common.ValidationRule
|
||||
import dev.patrickgold.florisboard.common.validate
|
||||
import dev.patrickgold.florisboard.ime.theme.ThemeExtensionComponent
|
||||
import dev.patrickgold.florisboard.snygg.SnyggStylesheet
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggDpShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggPercentShapeValue
|
||||
import dev.patrickgold.florisboard.snygg.value.SnyggSolidColorValue
|
||||
|
||||
// TODO: (priority=medium)
|
||||
// make all strings available for localize
|
||||
object ExtensionValidation {
|
||||
private val MetaIdRegex = """^[a-z][a-z0-9_]*(\.[a-z0-9][a-z0-9_]*)*${'$'}""".toRegex()
|
||||
private val ComponentIdRegex = """^[a-z][a-z0-9_]*${'$'}""".toRegex()
|
||||
private val ThemeComponentStylesheetPathRegex = """^[^:*<>"']*${'$'}""".toRegex()
|
||||
val ThemeComponentVariableNameRegex = """^[a-zA-Z0-9-_]+${'$'}""".toRegex()
|
||||
|
||||
val MetaId = ValidationRule<String> {
|
||||
forKlass = ExtensionMeta::class
|
||||
forProperty = "id"
|
||||
validator { str ->
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a package name")
|
||||
MetaIdRegex.matches(str) -> resultValid()
|
||||
else -> resultInvalid("Package name does not match regex $MetaIdRegex")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MetaVersion = ValidationRule<String> {
|
||||
forKlass = ExtensionMeta::class
|
||||
forProperty = "version"
|
||||
validator { str ->
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a version")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MetaTitle = ValidationRule<String> {
|
||||
forKlass = ExtensionMeta::class
|
||||
forProperty = "title"
|
||||
validator { str ->
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a title")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MetaMaintainers = ValidationRule<String> {
|
||||
forKlass = ExtensionMeta::class
|
||||
forProperty = "maintainers"
|
||||
validator { str ->
|
||||
val maintainers = str.lines().filter { it.isNotBlank() }
|
||||
when {
|
||||
maintainers.isEmpty() -> resultInvalid(error = "Please enter at least one valid maintainer")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val MetaLicense = ValidationRule<String> {
|
||||
forKlass = ExtensionMeta::class
|
||||
forProperty = "license"
|
||||
validator { str ->
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a license identifier")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ComponentId = ValidationRule<String> {
|
||||
forKlass = ExtensionComponent::class
|
||||
forProperty = "id"
|
||||
validator { str ->
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a component ID")
|
||||
!ComponentIdRegex.matches(str) -> resultInvalid(error = "Please enter a component ID matching $ComponentIdRegex")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ComponentLabel = ValidationRule<String> {
|
||||
forKlass = ExtensionComponent::class
|
||||
forProperty = "label"
|
||||
validator { str ->
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a component label")
|
||||
str.trimmedLength() > 30 -> resultValid(hint = "Your component label is quite long, which may lead to clipping in the UI")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ComponentAuthors = ValidationRule<String> {
|
||||
forKlass = ExtensionComponent::class
|
||||
forProperty = "authors"
|
||||
validator { str ->
|
||||
val authors = str.lines().filter { it.isNotBlank() }
|
||||
when {
|
||||
authors.isEmpty() -> resultInvalid(error = "Please enter at least one valid author")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ThemeComponentStylesheetPath = ValidationRule<String> {
|
||||
forKlass = ThemeExtensionComponent::class
|
||||
forProperty = "stylesheetPath"
|
||||
validator { str ->
|
||||
when {
|
||||
str.isEmpty() -> resultValid()
|
||||
str.isBlank() -> resultInvalid(error = "The stylesheet path must not be blank")
|
||||
!ThemeComponentStylesheetPathRegex.matches(str) -> {
|
||||
resultInvalid(error = "Please enter a valid stylesheet path matching $ThemeComponentStylesheetPathRegex")
|
||||
}
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val ThemeComponentVariableName = ValidationRule<String> {
|
||||
forKlass = SnyggStylesheet::class
|
||||
forProperty = "propertyName"
|
||||
validator { input ->
|
||||
val str = input.trim()
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a variable name")
|
||||
str == "-" || str.startsWith("--") -> resultValid()
|
||||
!ThemeComponentVariableNameRegex.matches(str) -> {
|
||||
resultInvalid(error = "Please enter a valid variable name matching $ThemeComponentVariableNameRegex")
|
||||
}
|
||||
else -> resultValid(hint = "By convention a FlorisCSS variable name starts with two dashes (--)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val SnyggSolidColorValue = ValidationRule<String> {
|
||||
forKlass = SnyggSolidColorValue::class
|
||||
forProperty = "color"
|
||||
validator { input ->
|
||||
val str = input.trim()
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a color string")
|
||||
dev.patrickgold.florisboard.snygg.value.SnyggSolidColorValue.deserialize(str).isFailure -> {
|
||||
resultInvalid(error = "Please enter a valid color string")
|
||||
}
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val SnyggDpShapeValue = ValidationRule<String> {
|
||||
forKlass = SnyggDpShapeValue::class
|
||||
forProperty = "corner"
|
||||
validator { str ->
|
||||
val floatValue = str.toFloatOrNull()
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a dp size")
|
||||
floatValue == null -> resultInvalid(error = "Please enter a valid number")
|
||||
floatValue < 0f -> resultInvalid(error = "Please enter a positive number (>=0)")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val SnyggPercentShapeValue = ValidationRule<String> {
|
||||
forKlass = SnyggPercentShapeValue::class
|
||||
forProperty = "corner"
|
||||
validator { str ->
|
||||
val intValue = str.toIntOrNull()
|
||||
when {
|
||||
str.isBlank() -> resultInvalid(error = "Please enter a percent size")
|
||||
intValue == null -> resultInvalid(error = "Please enter a valid number")
|
||||
intValue < 0 -> resultInvalid(error = "Please enter a positive number (>=0)")
|
||||
intValue > 50 -> resultValid(hint = "Any value above 50% will behave as if you set 50%, consider lowering your percent size")
|
||||
else -> resultValid()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun ExtensionMeta.validate(): Boolean {
|
||||
return with(ExtensionValidation) {
|
||||
validate(MetaId, id).isValid() &&
|
||||
validate(MetaVersion, version).isValid() &&
|
||||
validate(MetaTitle, title).isValid() &&
|
||||
validate(MetaMaintainers, maintainers.joinToString("\n")).isValid() &&
|
||||
validate(MetaLicense, license).isValid()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user