Compare commits

..

120 Commits

Author SHA1 Message Date
Patrick Goldinger
8674a04a5c Release v0.3.14-beta12 2022-02-14 21:38:40 +01:00
florisboard-bot
2f14529902 Update translations from Crowdin 2022-02-14 21:25:00 +01:00
Patrick Goldinger
1d74a17b98 Merge pull request #1569 from florisboard/prefs-rework-polishing-1
Preference Rework Polishing Part 1
2022-02-14 21:18:45 +01:00
Patrick Goldinger
52435d9837 Add option to specify language name display type (#1568) 2022-02-14 20:14:12 +01:00
Patrick Goldinger
b6fbbe5a91 Fix transparent colors not drawing with a checkered background 2022-02-14 00:44:22 +01:00
Patrick Goldinger
3f85e1167c Fix blank screen on initial app launch after install (#1537) 2022-02-14 00:20:46 +01:00
Patrick Goldinger
9c05096184 Rework internal implementation of window insets within Settings UI 2022-02-13 21:24:23 +01:00
Patrick Goldinger
ef3bc015b0 Add "display kbd after dialog" option to fine-tune editor options 2022-02-13 20:19:46 +01:00
Patrick Goldinger
75fd600448 Remove ExternalContentUtils.kt 2022-02-13 19:21:09 +01:00
Patrick Goldinger
2f01e7770f Fix behavior and visual display for URLs where no scheme is specified 2022-02-13 19:17:44 +01:00
Patrick Goldinger
12b6edf872 Add missing icon for Smartbar and Advanced screen (#1428) 2022-02-13 18:27:11 +01:00
Patrick Goldinger
6053f2d16b Fix Scrollbar overlaps with text on welcome screen (#1427) 2022-02-13 17:51:47 +01:00
Patrick Goldinger
636c5f4df4 Add "System default (AMOLED)" option for Settings UI theme (#1539) 2022-02-13 17:40:23 +01:00
Patrick Goldinger
bb0bd478cf Fix crash in devtools system settings viewer (#1387) 2022-02-13 15:50:22 +01:00
Patrick Goldinger
79eb080811 Upgrade AGP to 7.1.1 / Upgrade other dependencies 2022-02-13 15:37:47 +01:00
Patrick Goldinger
b5b82836bc Fix NPE due to incorrect variable usage in restore screen (#1567) 2022-02-13 15:06:09 +01:00
Patrick Goldinger
cef0f2b53d Merge pull request #1565 from florisboard/improve-theme-engine-and-add-border
Improve theme editor/stylesheets and implement boder color/width
2022-02-13 14:57:33 +01:00
Patrick Goldinger
dbf031469f Fix follow system and time not updating (#1557) 2022-02-13 14:50:49 +01:00
Patrick Goldinger
5b87c933da Split FlorisImeTheme and BaseStyle into separate files 2022-02-13 12:37:17 +01:00
Patrick Goldinger
adc4b9a372 Improve ordering of value types in stylesheet property editor dialog
Value was changed from explicit inherit > var > *encoders to *encoders > var > explicit inherit. This speeds up the selection process, as users naturally read top to bottom and inherit is almost never used by a user anyways.
2022-02-11 19:58:47 +01:00
Patrick Goldinger
0ff8f7776e Improve float number display in theme editor (remove .0 suffix) 2022-02-11 19:49:28 +01:00
Patrick Goldinger
c04fdeb491 Fix alpha color value in HEX8 mode missing leading zero 2022-02-11 19:28:01 +01:00
Patrick Goldinger
295d8e5326 Implement border-color and border-width for stylesheets (#1105) 2022-02-11 18:59:56 +01:00
Patrick Goldinger
b032ac64f7 Add ability to show/hide keyboard UI on Android 8.1 and lower 2022-02-11 00:22:57 +01:00
Patrick Goldinger
8ebe99d2c9 Merge pull request #1562 from florisboard/reimplement-user-dictionary-manager-ui
Re-implement user-dictionary settings UI
2022-02-10 23:16:49 +01:00
Patrick Goldinger
f0b027557b Fix state issues within the user dictionary UI 2022-02-10 23:03:13 +01:00
Patrick Goldinger
462030bcd7 Re-implement user-dictionary settings UI (#1544) 2022-02-10 22:51:02 +01:00
Patrick Goldinger
888af9d28d Release v0.3.14-beta11 2022-02-08 19:43:54 +01:00
Patrick Goldinger
ea159527f3 Restrict variable name input validation and fix behavior bugs 2022-02-08 19:33:03 +01:00
Patrick Goldinger
0dc0f53a91 Fix missing elevation theme-attribute causing crash on Android 9 (#1553) 2022-02-08 19:11:20 +01:00
Patrick Goldinger
d5aac7ac14 Release v0.3.14-beta10 2022-02-07 21:09:41 +01:00
florisboard-bot
9f58088545 Update translations from Crowdin 2022-02-07 20:24:27 +01:00
Patrick Goldinger
b684f1759d Add option to select text via delete key (#705, #1534) 2022-02-06 23:21:49 +01:00
Patrick Goldinger
aa7a264d6c Add ability to hide language name on the space bar (#371, #1025) 2022-02-06 22:50:05 +01:00
Patrick Goldinger
6ac537c517 Fix spacebar switches to full keyboard in number-only fields (#1248)
Additionally also fixed it for phone and phone2 layouts.
2022-02-06 22:22:45 +01:00
Patrick Goldinger
2386ae7749 Merge pull request #1538 from florisboard/improve-theme-editor-ui
Improve theme editor UI
2022-02-06 22:12:40 +01:00
Patrick Goldinger
7d559acfae Improve redundancy definition of shape in SnyggShapeValue 2022-02-06 21:21:54 +01:00
Patrick Goldinger
7783b9b218 Add shadow elevation to theme and UI 2022-02-06 20:08:36 +01:00
Patrick Goldinger
548f7d7b1e Move SnyggValueIcon to own source file 2022-02-06 19:10:25 +01:00
Patrick Goldinger
4629c07812 Fix manual color string editing disregarding display colors as option 2022-02-06 12:16:43 +01:00
Patrick Goldinger
3b2b7da841 Add fine tune editor dialog / Add "Display colors as" option 2022-02-05 13:59:14 +01:00
Patrick Goldinger
25ef53510a Improve Snygg shape UI and naming implementation 2022-02-05 12:04:03 +01:00
Patrick Goldinger
0064f248d3 Move theme property translations to own file 2022-02-05 11:32:32 +01:00
Patrick Goldinger
0c721696f2 Improve default theme variable usage 2022-02-04 16:08:06 +01:00
Patrick Goldinger
131ab6214d Remove term "component" from title strings 2022-02-04 14:44:18 +01:00
Patrick Goldinger
70bc7a1236 Add translation for var references in basic and advanced mode
... in stylesheet editor
2022-02-04 14:35:26 +01:00
Patrick Goldinger
6c88716a2a Add Snygg circle shape value 2022-02-04 14:29:47 +01:00
Patrick Goldinger
ff3c37e360 Add ability to style color of glide trail
(property `foreground` is the color of glide trail)
2022-02-04 00:21:06 +01:00
Patrick Goldinger
58bab443c4 Add ability to modify color values manually 2022-02-03 23:32:26 +01:00
Patrick Goldinger
a8b0a6d555 Upgrade dependencies / Upgrade Kotlin to 1.6.10 2022-02-03 20:40:34 +01:00
Patrick Goldinger
0a430b4b0a Release v0.3.14-beta09 2022-02-02 23:46:47 +01:00
Patrick Goldinger
8b76c5ce3b Update README.md 2022-02-02 23:29:11 +01:00
florisboard-bot
c81f5f7015 Update translations from Crowdin 2022-02-02 23:25:36 +01:00
GoRaN
cf6b186269 Update kurdish.json (#1410)
* Update kurdish.json

Update and correct some characters place with there codes.

* Update kurdish.json
2022-02-02 23:08:27 +01:00
Patrick Goldinger
bd25ddb92e Merge pull request #1524 from svvvst/master
Add Rusyn Language Language Set, Layout, Popups
2022-02-02 23:05:03 +01:00
Patrick Goldinger
62bdd31af3 Fix rue popup mapping missing from extension manifest 2022-02-02 22:59:06 +01:00
Patrick Goldinger
d4af89bf99 Merge pull request #1513 from florisboard/add-theme-editor-ui
Add extension editor UI / theme editor UI
2022-02-02 22:51:21 +01:00
Patrick Goldinger
1c38a42c0b Fix system nav bar not using background var reference in default themes
This is done to avoid confusion why the system nav bar color does not change with the background.
2022-02-02 22:28:56 +01:00
Patrick Goldinger
6d1ebb74fb Adjust feedback and beta info test in Settings UI 2022-02-02 22:26:29 +01:00
Patrick Goldinger
9673e6de5c Clean-up old theme strings 2022-02-02 22:14:20 +01:00
Patrick Goldinger
ab709e2c69 Add create extension ability / Fix a lot of state and validation bugs 2022-02-02 21:57:29 +01:00
Patrick Goldinger
9144708cf0 Fix autofill chip not applying correct style (#1507) 2022-02-01 22:25:02 +01:00
Patrick Goldinger
38136de39d Add save extension function to extension editor 2022-01-31 22:21:23 +01:00
Patrick Goldinger
beb800a76e Add ability to modify shape property values in stylesheet editor 2022-01-31 21:40:23 +01:00
Patrick Goldinger
aab738526a Add ability to add new component from existing in extension editor 2022-01-31 00:48:03 +01:00
Patrick Goldinger
86bdad61a4 Add (semi-)live-preview to stylesheet editor
Semi-live only because you must apply a property value before the change reflects on the keyboard UI. THis is also done because instantly updating the stylesheet when using the color picker would create lag.
2022-01-30 23:18:52 +01:00
Patrick Goldinger
e4c56cab03 Fix bugs and fine-tune appearance across extension editor 2022-01-30 20:12:46 +01:00
Patrick Goldinger
cef1c4e3f6 Add hint to text field and trim input in component meta dialog 2022-01-30 18:29:30 +01:00
Patrick Goldinger
b2721c9faa Add input validation to component meta editor dialog 2022-01-30 18:07:26 +01:00
Patrick Goldinger
ab4ae62ffe Improve SnyggRule backend 2022-01-30 15:43:44 +01:00
Patrick Goldinger
74244bab74 Add empty component screen in extension editor 2022-01-29 14:51:51 +01:00
svvvst
57112ae692 Added missing character to layout. 2022-01-28 14:04:15 -04:00
svvvst
fd1314ccba Fixed layout. 2022-01-28 14:03:24 -04:00
svvvst
45d99df104 Added missing character. 2022-01-28 14:02:36 -04:00
Patrick Goldinger
17b87f6543 Remove old theme related strings 2022-01-28 17:40:00 +01:00
Patrick Goldinger
d62e82569d Add ability to modify component meta data in stylesheet editor 2022-01-28 17:28:05 +01:00
Patrick Goldinger
dc5e00cc07 Fix KeyCode range being too restrictive 2022-01-27 23:51:45 +01:00
Patrick Goldinger
6402511d38 Eradicate nasty bug in SnyggRule comparator and equality logic
This bug caused some rules to be magically hidden, because they were either
incorrectly resolved as equal or the comparator returned `=` even though the
codes, groups or modes did not match at all.
2022-01-27 23:49:20 +01:00
Patrick Goldinger
83c1f70077 Implement property apply in stylesheet editor 2022-01-27 19:07:15 +01:00
Patrick Goldinger
f3375f48ef Update AGP to 7.1.0 and Gradle to 7.2 2022-01-27 00:12:01 +01:00
Patrick Goldinger
e1b911086b Fix minor theming issues in stylesheet editor 2022-01-26 23:38:25 +01:00
Patrick Goldinger
b60c0cef51 Improve property set button layout in stylesheet editor 2022-01-26 23:14:01 +01:00
Patrick Goldinger
0c42185700 Add sp size property value field in stylesheet editor 2022-01-26 22:52:46 +01:00
Patrick Goldinger
ee3c779b17 Add custom design for text field in stylesheet editor 2022-01-26 22:08:52 +01:00
Patrick Goldinger
b5e6655c84 Add color picker to property editor in stylesheet editor 2022-01-26 19:38:29 +01:00
Patrick Goldinger
e1b45b9193 Add property value for vars and type selector in stylesheet editor 2022-01-24 20:38:16 +01:00
svvvst
588713bd55 Add Rusyn 2022-01-24 15:23:16 -04:00
svvvst
43ad452174 Add rusyn layouts 2022-01-24 15:21:56 -04:00
svvvst
2cf9146536 Add Rusyn popup mapping 2022-01-24 15:15:37 -04:00
svvvst
f81331baed Add Rusyn language keyboard 2022-01-24 15:14:25 -04:00
Patrick Goldinger
9c9c3b9428 Add skeleton for property editor in stylesheet editor 2022-01-24 01:09:59 +01:00
Patrick Goldinger
4b64d81c21 Extract EditRuleDialog in own file 2022-01-23 23:15:03 +01:00
Patrick Goldinger
e826f600f0 Rework stylesheet editor to use lazy list, avoiding layout freeze 2022-01-23 23:04:36 +01:00
Patrick Goldinger
0f845a9784 Add modes and finish rule mod functionality in stylesheet editor 2022-01-23 21:53:04 +01:00
Patrick Goldinger
a2805bedca Add codes modifier UI in rule editor for stylesheet editor 2022-01-23 13:30:07 +01:00
Patrick Goldinger
d860bbfb90 Add rule dialog in stylesheet editor 2022-01-22 20:47:28 +01:00
Patrick Goldinger
70e2d34410 Add variables box in stylesheet editor 2022-01-21 20:36:44 +01:00
Patrick Goldinger
1c49a11824 Add property preview in stylesheet editor 2022-01-21 19:57:29 +01:00
Patrick Goldinger
cd2a0000c0 Add rule and properties view in stylesheet editor 2022-01-21 00:50:18 +01:00
Patrick Goldinger
1304e49eb4 Add strings for all rule and property names in stylesheet editor 2022-01-20 23:55:43 +01:00
svvvst
9658cecb88 Create rue.json 2022-01-20 00:32:53 -04:00
svvvst
4c23d5bafc Create rusyn.json
Custom Rusyn keyboard layout.
2022-01-19 21:00:39 -04:00
Patrick Goldinger
82238c8c1a Add base skeleton for stylesheet editing 2022-01-20 00:35:13 +01:00
Patrick Goldinger
ead74e1c26 Fix issues with loosing state on rotating screen in extension editor 2022-01-19 23:43:46 +01:00
Patrick Goldinger
d58371be81 Add basic component creator screen 2022-01-19 00:48:53 +01:00
Patrick Goldinger
844d194533 Add stub screens for dependencies and files 2022-01-18 22:55:40 +01:00
Patrick Goldinger
84abc929d0 Improve meta data editor screen for extension editor 2022-01-18 21:27:33 +01:00
Patrick Goldinger
7497470875 Add discard changes dialog / Improve action screen code 2022-01-18 01:34:19 +01:00
Patrick Goldinger
cc5df41daa Rework and refactor UI code base for extension editor 2022-01-17 23:49:14 +01:00
Patrick Goldinger
d87b290a32 Add meta data manager in extension editor 2022-01-17 01:51:16 +01:00
Patrick Goldinger
a0f859ad03 Add basic skeleton for in-app extension editing 2022-01-16 23:49:01 +01:00
Patrick Goldinger
c86892ec0b Merge pull request #1498 from florisboard/add-backup-and-restore-feature
Add backup and restore feature
2022-01-12 22:33:24 +01:00
Patrick Goldinger
c85fea0799 Enable encrypted backup for app data (#272, #1324) 2022-01-12 22:32:38 +01:00
Patrick Goldinger
765e34a01d Improve restore metadata UI 2022-01-11 00:29:19 +01:00
Patrick Goldinger
96e7f2eeac Add restore data functionality 2022-01-10 23:00:00 +01:00
Patrick Goldinger
23dddfd16e Add restore screen UI and general B&R code improvements
B&R..Backup&Restore

Restore action itself does nothing atm
2022-01-08 20:31:39 +01:00
Patrick Goldinger
e2318d0af1 Add ability to share generated backup through share menu 2022-01-07 17:38:22 +01:00
Patrick Goldinger
ef3b840dce Add backup data screen (local file sys only)
Also add skeleton for restore data screen
2022-01-07 02:51:43 +01:00
Patrick Goldinger
9b9c5fa70e Change unhandled stacktraces dir to prevent backup interference
Old location:
`files/{timestamp}.stacktrace`

New location:
`no_backup/unhandled_stacktraces/{timestamp}.stacktrace`

Additionally clean up some "magics" and improve code in the crash utility.
2022-01-06 23:00:08 +01:00
Patrick Goldinger
6f0216cf9f Raise targetSdk from API 30 to 31 2022-01-06 00:59:06 +01:00
176 changed files with 10370 additions and 3980 deletions

View File

@@ -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>

View File

@@ -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")

View File

@@ -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>

View File

@@ -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",

View File

@@ -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": "م" }
]
]
]

View File

@@ -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": "ё" }
]
]

View File

@@ -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": "ь" }
]
]

View File

@@ -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",

View File

@@ -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" }
]
}
}
}

View File

@@ -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)"
}
}

View File

@@ -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)"
}
}

View File

@@ -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)
}
}

View File

@@ -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 -> {}

View File

@@ -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,

View File

@@ -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,
)
}
}

View File

@@ -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())

View File

@@ -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 },
)

View File

@@ -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",
)
}
}
}

View File

@@ -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),
)
}

View File

@@ -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,

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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))
}
}

View File

@@ -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,

View File

@@ -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,
)
}
}
}

View File

@@ -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()
}

View File

@@ -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,
)
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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,
)
)
}
}

View File

@@ -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)

View File

@@ -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",
) {

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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) },
)

View File

@@ -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

View File

@@ -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),
)
}
}
}

View File

@@ -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,
)
}

View File

@@ -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),
)
}
}
}
}

View File

@@ -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 },
)
}

View File

@@ -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,
)
}
}
}
}
}
}

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)
},
)
}
}

View File

@@ -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,
)
}

View File

@@ -193,7 +193,7 @@ fun ImportSpellingArchiveScreen() = FlorisScreen {
StepButton(
onClick = {
runCatching {
extensionManager.import(importArchiveEditor!!.build().getOrThrow())
extensionManager.import(importArchiveEditor!!.build())
}.fold(
onSuccess = {
navController.popBackStack()

View File

@@ -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,
)
}
}
}

View File

@@ -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,
)
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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,
)
}
}
}
}
}

View File

@@ -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(),
)
}
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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),
)
}
}
}
}

View File

@@ -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),

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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) }
}
}
}

View File

@@ -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) {

View File

@@ -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
}
}

View File

@@ -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")

View File

@@ -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()) {

View File

@@ -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")

View File

@@ -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

View File

@@ -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,
)
}

View File

@@ -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,
)
}
}
}

View File

@@ -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()
}
}
}
}

View File

@@ -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
}

View File

@@ -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> {

View File

@@ -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,

View File

@@ -46,7 +46,7 @@ fun RowScope.OneHandedPanel(
Column(
modifier = modifier
.weight(weight)
.snyggBackground(oneHandedPanelStyle.background),
.snyggBackground(oneHandedPanelStyle),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly,
) {

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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),
)
}
}
}

View File

@@ -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 // ;

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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),
)
}
}

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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"

View File

@@ -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) {

View File

@@ -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() },
)
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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) }
}
}
}

View File

@@ -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",

View File

@@ -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.

View File

@@ -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 ->

View File

@@ -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(

View File

@@ -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
}

View File

@@ -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())
}
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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
}
}
)

View File

@@ -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