diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a8066c --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +/build +/captures +/native +/android/build +/android/release +/common/build +/desktop/build +/cli/build +.idea/ +.gradle/ +local.properties +*.key +*.aab +*.jar +/html \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md index b5ea315..2f2aecf 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,62 @@ -# linux-cli-lib +## Linux Command Library (Mobile+CLI+Web) +![Icon](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandLibrary/master/art/web_hi_res_144.png) + +The app currently has **7680** manual pages, **22+** basic categories and a bunch of general terminal tips. It works 100% offline, doesn't need an internet connection and has no tracking software. + +[![Play Store](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandBibliotheca/master/art/play_store_badge.png)](https://play.google.com/store/apps/details?id=com.inspiredandroid.linuxcommandbibliotheca) +[![F-Droid](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandBibliotheca/master/art/fdroid_badge.png)](https://f-droid.org/en/packages/com.inspiredandroid.linuxcommandbibliotheca/) +[![Web](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandBibliotheca/master/art/web_badge.png)](https://linuxcommandlibrary.com) + +### Mobile screenshots + +

+ + + + +

+ + + +### CLI screenshot + + + +Execute `gradle :cli:buildJar` to create jar file for Linux, Windows and Mac. + +### Content + +#### Categories + +One-liners, System information, System control, Users & Groups, Files & Folders, Input, Printing, JSON, Network, Search & Find, GIT, SSH, Video & Audio, Package manager, Hacking tools, Terminal games, Crypto currencies, VIM Texteditor, Emacs Texteditor, Nano Texteditor, Pico Texteditor, Micro Texteditor + +#### Tips + +Clear and reset the terminal, List of recent commands, Close a frozen window/application, Tab Completion, Temporary aliases, Permanent aliases, Chain commands, Command syntax, Cursor navigation, Redirection, Special characters in commands, View file permissions, Modify file permissions, Set file permissions via binary references + +### CI/CD + +[Github Action](.github/workflows/android.yml) to automatically create a new Github release with APK and JAR and upload an AAB to the Play Store. + +### Tests + +Android Jetpack Compose screen tests: [ComposeTests.kt](android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeTests.kt) + +Android Jetpack Compose deeplinking tests: [ComposeDeeplinkTests.kt](android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeDeeplinkTests.kt) + +Common code unit tests: [CommonTests.kt](common/src/commonTest/kotlin/CommonTests.kt) + +### Licensing + +The source code is licensed under the Apache 2.0 license and the copyright of the man pages in the `database.db` file are copyrighted by their respective authors. + +### Thanks to + +http://letsgokoyo.com - App Icon + +https://www.commandlinefu.com - Lots of one-liners + +https://icons8.com - Icons + +https://tldr.sh - TLDR diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..74991c0 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,78 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.compose.compiler) +} + +group = "com.inspiredandroid.linuxcommandbibliotheca" + +dependencies { + implementation(project(":common")) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.material) + implementation(libs.androidx.navigation.compose) + implementation(libs.accompanist.appcompat.theme) + implementation(libs.accompanist.systemuicontroller) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.preference) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material.icons.core) + + implementation(libs.koin.core) + implementation(libs.koin.android) + implementation(libs.koin.androidx.compose) + implementation(libs.androidx.foundation) + implementation(libs.kotlinx.collections.immutable) + + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.test.manifest) + debugImplementation(libs.androidx.ui.tooling) +} + +android { + compileSdk = 36 + defaultConfig { + applicationId = "com.inspiredandroid.linuxcommandbibliotheca" + minSdk = 24 + targetSdk = 36 + versionCode = + libs.versions.androidVersionCode + .get() + .toInt() + versionName = libs.versions.appVersion.get() + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + getByName("release") { + isMinifyEnabled = true + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + getByName("debug") { + isMinifyEnabled = false + } + } + + sourceSets["main"].assets.setSrcDirs(listOf("../assets")) + + buildFeatures { + compose = true + buildConfig = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + lint { + abortOnError = false + } + namespace = "com.inspiredandroid.linuxcommandbibliotheca" +} diff --git a/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeDeeplinkTests.kt b/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeDeeplinkTests.kt new file mode 100644 index 0000000..a73e52c --- /dev/null +++ b/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeDeeplinkTests.kt @@ -0,0 +1,88 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.linuxcommandlibrary.shared.initDatabase +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Test if deeplinks from/to website open correct screens. If test started the first time + * "Always open urls with app" dialog has to be accepted. Or the app has to be signed with the + * release key for autoVerify to take effect. + */ +@RunWith(AndroidJUnit4::class) +class ComposeDeeplinkTests { + + private lateinit var scenario: ActivityScenario + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @Before + fun setUp() { + val context: Context = ApplicationProvider.getApplicationContext() + initDatabase(context) + + // Clear bookmarks + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putString("KEY_BOOKMARKS", "").apply() + } + + private fun openUrl(url: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + scenario = ActivityScenario.launch(intent) + } + + @Test + fun testBasicCategories() { + openUrl("https://linuxcommandlibrary.com/basics") + + composeTestRule.onNodeWithContentDescription("TopAppBarTitle") + .assertTextEquals("Basics") + } + + @Test + fun testBasicCategory() { + openUrl("https://linuxcommandlibrary.com/basic/usersgroups") + + composeTestRule.onNodeWithContentDescription("TopAppBarTitle") + .assertTextEquals("Users & Groups") + } + + @Test + fun testTips() { + openUrl("https://linuxcommandlibrary.com/tips") + + composeTestRule.onNodeWithContentDescription("TopAppBarTitle") + .assertTextEquals("Tips") + } + + @Test + fun testCommandList() { + openUrl("https://linuxcommandlibrary.com/") + + composeTestRule.onNodeWithContentDescription("Back").performClick() + composeTestRule.onNodeWithContentDescription("TopAppBarTitle") + .assertTextEquals("Commands") + } + + @Test + fun testCommandDetail() { + openUrl("https://linuxcommandlibrary.com/man/2048") + + composeTestRule.onNodeWithContentDescription("TopAppBarTitle") + .assertTextEquals("2048") + } +} diff --git a/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeScreenshots.kt b/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeScreenshots.kt new file mode 100644 index 0000000..b064178 --- /dev/null +++ b/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeScreenshots.kt @@ -0,0 +1,138 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import android.content.Context +import android.graphics.Bitmap +import androidx.appcompat.app.AppCompatDelegate.MODE_NIGHT_YES +import androidx.appcompat.app.AppCompatDelegate.setDefaultNightMode +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.ComposeTestRule +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.preference.PreferenceManager +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.linuxcommandlibrary.shared.copyDatabase +import com.linuxcommandlibrary.shared.initDatabase +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.java.KoinJavaComponent.inject +import java.io.FileOutputStream + +/** + * Take screenshots of Phone and Tablet. + * Phone = Pixel 2 1080x1920 + * Tablet 7" = Nexus 7 1200x1920 + * + * Pull images from device to art folder for readme: + * run pull_screenshots.sh + */ +@RunWith(AndroidJUnit4::class) +class ComposeScreenshots { + + private lateinit var scenario: ActivityScenario + + @get:Rule + val composeTestRule = createEmptyComposeRule() + + @Before + fun setUp() { + val context: Context = ApplicationProvider.getApplicationContext() + copyDatabase(context) + initDatabase(context) + + val dataManager: com.inspiredandroid.linuxcommandbibliotheca.DataManager by inject(com.inspiredandroid.linuxcommandbibliotheca.DataManager::class.java) + dataManager.updateDatabaseVersion() + + // Clear bookmarks + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putString("KEY_BOOKMARKS", "").apply() + + // Clear files folder + InstrumentationRegistry.getInstrumentation().targetContext.filesDir.listFiles()?.forEach { + it.deleteRecursively() + } + scenario = ActivityScenario.launch(MainActivity::class.java) + } + + @Test + fun takeTabletLightAndDarkScreenshots() { + takeTabletScreenshots("") + scenario = ActivityScenario.launch(MainActivity::class.java) + scenario.onActivity { + setDefaultNightMode(MODE_NIGHT_YES) + } + takeTabletScreenshots("-dark") + } + + private fun takeTabletScreenshots(prefix: String) { + // Tips + composeTestRule.onNodeWithText("Tips").performClick() + composeTestRule.takeScreenshot("screen-1-tablet$prefix.png") + + // Basics + composeTestRule.onNodeWithText("Basics").performClick() + composeTestRule.takeScreenshot("screen-2-tablet$prefix.png") + } + + @Test + fun takePhoneLightAndDarkScreenshots() { + takePhoneScreenshots("") + scenario = ActivityScenario.launch(MainActivity::class.java) + scenario.onActivity { + setDefaultNightMode(MODE_NIGHT_YES) + } + takePhoneScreenshots("-dark") + } + + private fun takePhoneScreenshots(prefix: String) { + // Command list + composeTestRule.onNodeWithText("Commands").performClick() + + composeTestRule.onNodeWithContentDescription("Search").performClick() + composeTestRule.onNodeWithContentDescription("SearchField") + .performTextInput("mk") + + composeTestRule.takeScreenshot("screen-4$prefix.png") + + // Command detail + composeTestRule.onNodeWithText("mkdir").performClick() + composeTestRule.onNodeWithText("SYNOPSIS").performClick() + composeTestRule.onNodeWithText("DESCRIPTION").performClick() + composeTestRule.takeScreenshot("screen-1$prefix.png") + + // Basics + composeTestRule.onNodeWithText("Basics").performClick() + composeTestRule.onNodeWithText("System information").performClick() + composeTestRule.takeScreenshot("screen-3$prefix.png") + + // Tips + composeTestRule.onNodeWithText("Tips").performClick() + composeTestRule.onNodeWithContentDescription("Scroll") + .performScrollToNode(hasText("$ [command] --help")) + composeTestRule.takeScreenshot("screen-2$prefix.png") + } + + private fun ComposeTestRule.takeScreenshot(file: String) { + // TODO: Find better way to wait for animations to finish + runBlocking { + delay(1000L) + } + onRoot() + .captureToImage() + .asAndroidBitmap() + .save(file) + } + + private fun Bitmap.save(file: String) { + val path = InstrumentationRegistry.getInstrumentation().targetContext.filesDir.canonicalPath + FileOutputStream("$path/$file").use { out -> + compress(Bitmap.CompressFormat.PNG, 100, out) + } + } +} diff --git a/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeTests.kt b/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeTests.kt new file mode 100644 index 0000000..698fb21 --- /dev/null +++ b/android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeTests.kt @@ -0,0 +1,124 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import android.content.Context +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.preference.PreferenceManager +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.linuxcommandlibrary.shared.copyDatabase +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.initDatabase +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.koin.java.KoinJavaComponent.inject + +/** + * Test for navigation, search and booksmarks. More tests to come + */ +@RunWith(AndroidJUnit4::class) +class ComposeTests { + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setUp() { + val context: Context = ApplicationProvider.getApplicationContext() + copyDatabase(context) + initDatabase(context) + + val dataManager: com.inspiredandroid.linuxcommandbibliotheca.DataManager by inject(com.inspiredandroid.linuxcommandbibliotheca.DataManager::class.java) + dataManager.updateDatabaseVersion() + + // Clear bookmarks + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit().putString("KEY_BOOKMARKS", "").apply() + + composeTestRule.setContent { LinuxApp() } + } + + /** + * Click though BottomNavigationBar and assert that TopAppBar titles are correct + */ + @Test + fun testBottomNavigation() { + composeTestRule.onNodeWithText("Tips").performClick() + composeTestRule.onNodeWithContentDescription("TopAppBarTitle").assertTextEquals("Tips") + composeTestRule.onNodeWithText("Commands").performClick() + composeTestRule.onNodeWithContentDescription("Back").performClick() + composeTestRule.onNodeWithContentDescription("TopAppBarTitle").assertTextEquals("Commands") + composeTestRule.onNodeWithText("Basics").performClick() + composeTestRule.onNodeWithContentDescription("TopAppBarTitle").assertTextEquals("Basics") + } + + /** + * Test if info is shown when search for a command that doesn't exist and if command description + * is shown when search for an existing command + */ + @Test + fun testSearch() { + composeTestRule.onNodeWithText("Commands").performClick() + + composeTestRule.onNodeWithContentDescription("Search").performClick() + + // Search for a command that doesn't exist + composeTestRule.onNodeWithContentDescription("SearchField") + .performTextInput("CommandThatDoesn'tExist") + composeTestRule.onNodeWithText("404 command not found").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("SearchField").performTextClearance() + + // Search for an existing command + val firstCommand = databaseHelper.getCommands().last() + composeTestRule.onNodeWithContentDescription("SearchField") + .performTextInput(firstCommand.name) + composeTestRule.onNodeWithText(firstCommand.description).assertIsDisplayed() + } + + /** + * Test if bookmarks in command detail and command list are shown correctly + */ + @Test + fun testBookmarks() { + val firstCommand = databaseHelper.getCommands().first() + + composeTestRule.onNodeWithText("Commands").performClick() + + composeTestRule.onNodeWithContentDescription("Search").performClick() + + // Search for first command and go to command detail screen + composeTestRule.onNodeWithContentDescription("SearchField") + .performTextInput(firstCommand.name) + composeTestRule.onNodeWithText(firstCommand.description).performClick() + + // Click bookmark icon and check if icon/contentDescription changed + composeTestRule.onNodeWithContentDescription("Add bookmark").performClick() + composeTestRule.mainClock.advanceTimeBy(1000) + composeTestRule.waitForIdle() + composeTestRule.onNodeWithContentDescription("Remove bookmark").assertIsDisplayed() + + // Go back to search/list and check if bookmark icon is visible + composeTestRule.onNodeWithContentDescription("Back").performClick() + composeTestRule.onNodeWithContentDescription("Bookmarked").assertIsDisplayed() + } + + @Test + fun testBasicsScreen() { + val firstBasicCategory = databaseHelper.getBasics().first() + + // Click on first category + composeTestRule.onNodeWithText(firstBasicCategory.title).performClick() + composeTestRule.onNodeWithContentDescription("TopAppBarTitle") + .assertTextEquals(firstBasicCategory.title) + + val basicGroup = databaseHelper.getBasicGroupsByQuery(firstBasicCategory.id).first() + + // Click on first group + composeTestRule.onNodeWithText(basicGroup.description).performClick() + + // Check if commands of group expanded and therefore share icon(s) are visible + composeTestRule.onAllNodesWithContentDescription("Share").assertAny(isEnabled()) + } +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8c69ed8 --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/DataManager.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/DataManager.kt new file mode 100644 index 0000000..5d84a63 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/DataManager.kt @@ -0,0 +1,57 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import android.content.Context +import android.content.SharedPreferences +import androidx.core.content.edit +import androidx.preference.PreferenceManager + +class DataManager(context: Context) { + + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + + private val bookmarksIds = getBookmarkIds() + + private fun getBookmarkIds(): MutableList { + val bookmarksChain = prefs.getString(KEY_BOOKMARKS, "") ?: "" + return bookmarksChain.split(",").mapNotNull { it.trim().toLongOrNull() }.toMutableList() + } + + private fun saveBookmarkIds() { + val bookmarksChain = bookmarksIds.joinToString(separator = ",") + prefs.edit { putString(KEY_BOOKMARKS, bookmarksChain) } + } + + fun addBookmark(id: Long) { + bookmarksIds.add(id) + saveBookmarkIds() + } + + fun removeBookmark(id: Long) { + bookmarksIds.remove(id) + saveBookmarkIds() + } + + fun hasBookmark(id: Long): Boolean = bookmarksIds.contains(id) + + fun isDatabaseUpToDate(): Boolean { + val databaseVersion = prefs.getInt(KEY_DATABASE_VERSION, 0) + return databaseVersion == CURRENT_DATABASE_VERSION + } + + fun updateDatabaseVersion() { + prefs.edit { putInt(KEY_DATABASE_VERSION, CURRENT_DATABASE_VERSION) } + } + + fun setAutoExpandSections(autoExpand: Boolean) { + prefs.edit { putBoolean(KEY_AUTO_EXPAND_SECTIONS, autoExpand) } + } + + fun isAutoExpandSections(): Boolean = prefs.getBoolean(KEY_AUTO_EXPAND_SECTIONS, false) + + companion object { + const val KEY_BOOKMARKS = "KEY_BOOKMARKS" + const val KEY_DATABASE_VERSION = "DATABASE_VERSION" + const val KEY_AUTO_EXPAND_SECTIONS = "auto_expand_sections" + const val CURRENT_DATABASE_VERSION = 32 + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ExtensionFunctions.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ExtensionFunctions.kt new file mode 100644 index 0000000..cef38a4 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ExtensionFunctions.kt @@ -0,0 +1,30 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import androidx.navigation.NavBackStackEntry +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.getHtmlFileName + +fun NavBackStackEntry.getCommandId(): Long? { + var commandId = + arguments?.getString("commandId")?.toLongOrNull() + if (commandId == null) { + // get id by command name when opened via deeplink + val commandName = arguments?.getString("commandName") ?: "" + val command = databaseHelper.getCommand(commandName) + commandId = command?.id + } + return commandId +} + +fun NavBackStackEntry.getCategoryId(): Long? { + var categoryId = + arguments?.getString("categoryId")?.toLongOrNull() + if (categoryId == null) { + // get id by category name when opened via deeplink + val categoryName = + arguments?.getString("deepLinkCategoryName") ?: "" + val categories = databaseHelper.getBasics() + categoryId = categories.firstOrNull { it.getHtmlFileName() == categoryName }?.id + } + return categoryId +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/IconResources.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/IconResources.kt new file mode 100644 index 0000000..32de97d --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/IconResources.kt @@ -0,0 +1,264 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import databases.BasicCategory +import databases.BasicGroup + +fun BasicGroup.getIconResource(): Int = when (id.toInt()) { + 1205 -> R.drawable.ic_info_40dp + 1206 -> R.drawable.ic_icons8_restore_window + 1207 -> R.drawable.ic_icons8_compass + 1208 -> R.drawable.ic_icons8_console + 1212 -> R.drawable.ic_icons8_hand_with_pen + 1209 -> R.drawable.ic_info_40dp + 1211 -> R.drawable.ic_icons8_keyboard + 1210 -> R.drawable.ic_icons8_compass + 1214 -> R.drawable.ic_info_40dp + 1215 -> R.drawable.ic_icons8_keyboard + 1213 -> R.drawable.ic_icons8_compass + 1199 -> R.drawable.ic_file + 1200 -> R.drawable.ic_icons8_compass + 1201 -> R.drawable.ic_icons8_copy + 1202 -> R.drawable.ic_icons8_hand_with_pen + 1203 -> R.drawable.ic_search_40dp + 1204 -> R.drawable.ic_icons8_restore_window + 1165 -> R.drawable.ic_icons8_copy + 1166 -> R.drawable.ic_icons8_new + 1167 -> R.drawable.ic_icons8_save + 1168 -> R.drawable.ic_info_40dp + 1169 -> R.drawable.ic_icons8_plus + 1170 -> R.drawable.ic_icons8_numbered_list + 1171 -> R.drawable.ic_delete_black_24dp + 1172 -> R.drawable.ic_icons8_merge + 1173 -> R.drawable.ic_icons8_arrow + 1174 -> R.drawable.ic_icons8_arrow + 1175 -> R.drawable.ic_icons8_plus + 1176 -> R.drawable.ic_delete_black_24dp + 1177 -> R.drawable.ic_icons8_save + 1178 -> R.drawable.ic_available_updates + 1179 -> R.drawable.ic_icons8_plus + 1180 -> R.drawable.ic_icons8_user_male_circle + 1181 -> R.drawable.ic_icons8_user_male_circle + 1182 -> R.drawable.ic_info_40dp + 1183 -> R.drawable.ic_icons8_undo + 1184 -> R.drawable.ic_icons8_hide + 1185 -> R.drawable.ic_icons8_save + 1186 -> R.drawable.ic_icons8_save + 1187 -> R.drawable.ic_icons8_visible + 1188 -> R.drawable.ic_delete_black_24dp + 1189 -> R.drawable.ic_delete_black_24dp + 1190 -> R.drawable.ic_delete_black_24dp + 1191 -> R.drawable.ic_delete_black_24dp + 1192 -> R.drawable.ic_delete_black_24dp + 125 -> R.drawable.ic_file_download_black_24dp + 126 -> R.drawable.ic_file + 127 -> R.drawable.ic_delete_black_24dp + 128 -> R.drawable.ic_search_40dp + 129 -> R.drawable.ic_info_40dp + 130 -> R.drawable.ic_icons8_synchronize + 131 -> R.drawable.ic_arrow_upward_black_24dp + 132 -> R.drawable.ic_add_rule + 7, 5, 1236, 1248, 1235, 1247, 1246, 1238, 8, 6, 10, 1241, 1245, 9, 1244, 1243, 1, 4, 1242, 2, 3, 0, 1237, 2630 -> R.drawable.ic_icon_controller + 1163 -> R.drawable.ic_vpn_key_black_24dp + 1164, 1162, 1161, 1160, 1159 -> R.drawable.ic_icons8_connected + 1231, 1232 -> R.drawable.ic_file + 1158 -> R.drawable.ic_icons8_circled_pause + 26, 91, 92, 93, 94, 95 -> R.drawable.ic_search_40dp + 96, 97, 98, 100, 2399, 99 -> R.drawable.ic_file + 41 -> R.drawable.ic_electronics + 42 -> R.drawable.ic_battery_90_black_24dp + 43, 44 -> R.drawable.ic_bluetooth_black_24dp + 45 -> R.drawable.ic_icons8_network_card + 46 -> R.drawable.ic_memory_slot + 56, 65 -> R.drawable.ic_icons8_tv_off + 57 -> R.drawable.ic_icons8_tv_on + 47 -> R.drawable.ic_icons8_linux + 48 -> R.drawable.ic_icons8_root_server + 49 -> R.drawable.ic_usb_black_48dp + 50 -> R.drawable.ic_icons8_flow_chart + 51 -> R.drawable.ic_ip_address + 58 -> R.drawable.ic_refresh_black_24dp + 59 -> R.drawable.ic_power_settings_new_black_24dp + 55 -> R.drawable.ic_icons8_calendar_1 + 52, 60 -> R.drawable.ic_timer_black_24dp + 53 -> R.drawable.ic_icons8_hdd + 61 -> R.drawable.ic_stop_bluetooth + 62 -> R.drawable.ic_bluetooth_start + 63 -> R.drawable.ic_stop_wifi + 64 -> R.drawable.ic_wifi_start + 28 -> R.drawable.ic_icons8_work + 29 -> R.drawable.ic_icons8_undo + 31 -> R.drawable.ic_icons8_kitchen_scales + 81 -> R.drawable.ic_file_download_white + 191 -> R.drawable.ic_vip_lookup_white_48dp + 83 -> R.drawable.ic_icons8_ping_pong + 189 -> R.drawable.ic_settings_black_24dp + 27, 32 -> R.drawable.ic_icons8_show_property + 16 -> R.drawable.ic_file_move_white + 15 -> R.drawable.ic_file_copy_white_48dp + 20 -> R.drawable.ic_change_folder_white + 13 -> R.drawable.ic_file_content_white + 19 -> R.drawable.ic_folder_list_white + 18 -> R.drawable.ic_delete_folder_white_48dp + 17 -> R.drawable.ic_create_new_folder_white + 12 -> R.drawable.ic_delete_file_white + 11 -> R.drawable.ic_create_file_white + 14 -> R.drawable.ic_file_edit_white_48dp + 21 -> R.drawable.ic_icons8_home + 22 -> R.drawable.ic_icons8_mother + 23 -> R.drawable.ic_icons8_downloads_folder + 25 -> R.drawable.ic_file_link_white_48dp + 30 -> R.drawable.ic_icons8_exe + 102 -> R.drawable.ic_remove_user_group + 101 -> R.drawable.ic_icons8_add_user_group + 103 -> R.drawable.ic_edit_group + 107 -> R.drawable.ic_user_password + 104 -> R.drawable.ic_icons8_add_user + 105 -> R.drawable.ic_icons8_remove_user_male + 108 -> R.drawable.ic_icons8_moderator_male + 106 -> R.drawable.ic_icons8_edit_user + 110 -> R.drawable.ic_add_user_to_group_white_48dp + 111 -> R.drawable.ic_add_user_to_group_white_48dp + 112 -> R.drawable.ic_remove_user_from_group_white_48dp + 114 -> R.drawable.ic_list_user_white_48dp + 33 -> R.drawable.ic_icons8_reuse + 34 -> R.drawable.ic_icons8_delete_trash + 35 -> R.drawable.ic_icons8_add_trash + 36 -> R.drawable.ic_icons8_file_preview + 37 -> R.drawable.ic_file_permission_white_48dp + 38 -> R.drawable.ic_icons8_user_folder + 24 -> R.drawable.ic_folder_path_white + 39 -> R.drawable.ic_icons8_user_male_circle + 40 -> R.drawable.ic_icons8_group_foreground_selected + 113 -> R.drawable.ic_list_groups_white_48dp + 109 -> R.drawable.ic_info_40dp + 1193 -> R.drawable.ic_icons8_print_file + 1194 -> R.drawable.ic_icons8_visible + 1195 -> R.drawable.ic_icons8_cancel + 1196 -> R.drawable.ic_info_40dp + 1197 -> R.drawable.ic_icons8_circled_play + 1198 -> R.drawable.ic_icons8_circled_pause + 1230 -> R.drawable.ic_icons8_plus + 1229 -> R.drawable.ic_delete_black_24dp + 1228 -> R.drawable.ic_icons8_plus + 1227 -> R.drawable.ic_icons8_plus + 1226 -> R.drawable.ic_icons8_print + 80 -> R.drawable.ic_list_interfaces_white_48dp + 82 -> R.drawable.ic_vip_lookup_white_48dp + 84 -> R.drawable.ic_settings_black_24dp + 85 -> R.drawable.ic_icons8_visible + 86 -> R.drawable.ic_fingerprint_black_24dp + 88 -> R.drawable.ic_dns_black_24dp + 89 -> R.drawable.ic_ip_address + 90 -> R.drawable.ic_list_sockets_white_48dp + 143 -> R.drawable.ic_search_executeable_man_white_48dp + 87 -> R.drawable.ic_search_source_man_white_48dp + 320 -> R.drawable.ic_icons8_show_property + 138 -> R.drawable.ic_fingerprint_black_24dp + 116 -> R.drawable.ic_vpn_key_black_24dp + 221 -> R.drawable.ic_list_sockets_white_48dp + 137 -> R.drawable.ic_file_edit_white_48dp + 71 -> R.drawable.ic_volume_off_black_24dp + 67 -> R.drawable.ic_webcam_white_48dp + 66 -> R.drawable.ic_desktop_windows_black_24dp + 68 -> R.drawable.ic_icons8_talk + 69, 70 -> R.drawable.ic_volume_up_black_24dp + 72, 73, 75, 76, 77, 78, 1239, 1240, 74 -> R.drawable.ic_switch_video_white_48dp + 430, 431, 432, 433, 434, 435, 436, 437, 438, 439, 440 -> R.drawable.ic_videogame_asset_black_24dp + 443 -> R.drawable.ic_vpn_key_black_24dp + 450 -> R.drawable.ic_wifi_black_24dp + 446, 1495, 457, 1516, 1509 -> R.drawable.ic_info_black_24dp + 451 -> R.drawable.ic_report_black_24dp + 445 -> R.drawable.ic_loupe_black_24dp + 444 -> R.drawable.ic_fingerprint_black_24dp + 448 -> R.drawable.ic_public_black_24dp + 447 -> R.drawable.ic_healing_black_24dp + 441 -> R.drawable.ic_flash_on_black_24dp + 449 -> R.drawable.ic_storage_black_24dp + 454 -> R.drawable.ic_file + 456 -> R.drawable.ic_search_black_24dp + 460 -> R.drawable.ic_add_rule + 453 -> R.drawable.ic_file_download_black_24dp + 458 -> R.drawable.ic_available_updates + 459 -> R.drawable.ic_arrow_upward_black_24dp + 455, 1519, 1511, 1528, 1529, 1530, 1531, 1532 -> R.drawable.ic_delete_black_24dp + 461 -> R.drawable.ic_icons8_show_property + 1492 -> R.drawable.ic_icons8_home + 1493 -> R.drawable.ic_icons8_mother + 1496 -> R.drawable.ic_icons8_work + 1497 -> R.drawable.ic_icons8_undo + 1494 -> R.drawable.ic_icons8_calendar_1 + 1503 -> R.drawable.ic_vpn_key_black_24dp + 1487, 1488, 1489, 1490, 1491 -> R.drawable.ic_file + 1498 -> R.drawable.ic_icons8_cancel + 1499, 1500, 1501, 1502, 1504 -> R.drawable.ic_icons8_connected + 1505 -> R.drawable.ic_icons8_copy + 1515 -> R.drawable.ic_icons8_new + 1506, 1525, 1512 -> R.drawable.ic_icons8_save + 1517, 1510, 1523 -> R.drawable.ic_icons8_plus + 1518 -> R.drawable.ic_icons8_numbered_list + 1522 -> R.drawable.ic_icons8_merge + 1520, 1521 -> R.drawable.ic_icons8_arrow + 1524 -> R.drawable.ic_icons8_synchronize + 1507, 1508 -> R.drawable.ic_icons8_user_male_circle + 1513 -> R.drawable.ic_icons8_redo + 1514 -> R.drawable.ic_icons8_hide + 1527 -> R.drawable.ic_icons8_visible + 178 -> R.drawable.ic_icons8_show_property + 154 -> R.drawable.ic_icons8_exe + 141 -> R.drawable.ic_icons8_reuse + 144 -> R.drawable.ic_icons8_delete_trash + 411 -> R.drawable.ic_icons8_add_trash + 247 -> R.drawable.ic_icons8_visible + 169 -> R.drawable.ic_icons8_cancel + 3731 -> R.drawable.ic_info_black_24dp + 186 -> R.drawable.ic_icons8_circled_play + 187 -> R.drawable.ic_icons8_circled_pause + 226 -> R.drawable.ic_icons8_file_preview + 231 -> R.drawable.ic_file_permission_white_48dp + 1217 -> R.drawable.ic_icons8_coin_wallet + 1218 -> R.drawable.ic_icons8_bot + 1216 -> R.drawable.ic_icons8_golden_fever + 1233 -> R.drawable.ic_icons8_teacher_hiring + 117 -> R.drawable.ic_fingerprint_black_24dp + 115 -> R.drawable.ic_flash_on_black_24dp + 118 -> R.drawable.ic_loupe_black_24dp + 119 -> R.drawable.ic_info_40dp + 120 -> R.drawable.ic_healing_black_24dp + 121 -> R.drawable.ic_public_black_24dp + 122 -> R.drawable.ic_storage_black_24dp + 123 -> R.drawable.ic_wifi_black_24dp + 124 -> R.drawable.ic_report_black_24dp + 79 -> R.drawable.ic_vpn_key_black_24dp + 54 -> R.drawable.ic_icons8_document + 1222, 1221, 1223 -> R.drawable.ic_icon_mouse + 1220 -> R.drawable.ic_icons8_clipboard + 1219 -> R.drawable.ic_icons8_treatment + 1225, 1224 -> R.drawable.ic_icons8_keyboard + 2403 -> R.drawable.ic_icon_mouse + 2400 -> R.drawable.ic_icons8_keyboard + 2402 -> R.drawable.ic_file + else -> R.drawable.ic_icons8_console +} + +fun BasicCategory.getIconResource(): Int = when (title) { + "One-liners" -> R.drawable.ic_icons8_hand_with_pen + "System information" -> R.drawable.ic_icon_system_task + "System control" -> R.drawable.ic_settings_black_40dp + "Users & Groups" -> R.drawable.ic_icon_user + "Files & Folders" -> R.drawable.ic_file + "Printing" -> R.drawable.ic_icons8_print + "Network" -> R.drawable.ic_network_card_40dp + "Search & Find" -> R.drawable.ic_search_40dp + "GIT" -> R.drawable.ic_icon_git + "SSH" -> R.drawable.ic_icons8_console + "Video & Audio" -> R.drawable.ic_video_trimming_40dp + "Package manager" -> R.drawable.ic_package_40 + "Hacking tools" -> R.drawable.ic_icon_skull + "Terminal games" -> R.drawable.ic_icon_controller + "VIM Texteditor", "Emacs Texteditor", "Nano Texteditor", "Pico Texteditor", "Micro Texteditor" -> R.drawable.ic_icons8_text + "Crypto currencies" -> R.drawable.ic_icon_bitcoin + "Input" -> R.drawable.ic_icon_mouse + "JSON" -> R.drawable.ic_icon_json + "Fun" -> R.drawable.ic_icon_fun + else -> R.drawable.ic_icon_mouse +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/InitializeDatabaseActivity.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/InitializeDatabaseActivity.kt new file mode 100644 index 0000000..4d06450 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/InitializeDatabaseActivity.kt @@ -0,0 +1,39 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import android.content.Intent +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.InitializeDatabaseScreen +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +class InitializeDatabaseActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + LinuxTheme { + InitializeDatabaseScreen { + startActivity(Intent(this, MainActivity::class.java)) + finish() + } + } + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/LinuxApplication.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/LinuxApplication.kt new file mode 100644 index 0000000..80bf1e5 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/LinuxApplication.kt @@ -0,0 +1,38 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import android.app.Application +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basiccategories.BasicCategoriesViewModel +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups.BasicGroupsViewModel +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail.CommandDetailViewModel +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commandlist.CommandListViewModel +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search.SearchViewModel +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.tips.TipsViewModel +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.GlobalContext.startKoin +import org.koin.core.module.dsl.* +import org.koin.dsl.module + +class LinuxApplication : Application() { + + override fun onCreate() { + super.onCreate() + + startKoin { + androidLogger() + androidContext(this@LinuxApplication) + modules(appModule) + } + } + + private val appModule = module { + single { DataManager(androidContext()) } + + viewModel { BasicGroupsViewModel(get()) } + viewModel { BasicCategoriesViewModel() } + viewModel { (commandId: Long) -> CommandDetailViewModel(commandId, get()) } + viewModel { TipsViewModel() } + viewModel { CommandListViewModel(get()) } + viewModel { SearchViewModel() } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/MainActivity.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/MainActivity.kt new file mode 100644 index 0000000..eda3208 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/MainActivity.kt @@ -0,0 +1,251 @@ +package com.inspiredandroid.linuxcommandbibliotheca + +import android.content.Intent +import android.os.Bundle +import androidx.activity.SystemBarStyle +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +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.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.navigation.navDeepLink +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.BottomBar +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.TopBar +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basiccategories.BasicCategoriesScreen +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups.BasicGroupsScreen +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail.CommandDetailScreen +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commandlist.CommandListScreen +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search.SearchScreen +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.tips.TipsScreen +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LocalCustomColors +import com.linuxcommandlibrary.shared.hasDatabase +import com.linuxcommandlibrary.shared.initDatabase +import org.koin.android.ext.android.inject + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +class MainActivity : AppCompatActivity() { + + private val dataManager by inject() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT)) + super.onCreate(savedInstanceState) + + if (!hasDatabase(this) || !dataManager.isDatabaseUpToDate()) { + startActivity(Intent(this, InitializeDatabaseActivity::class.java)) + dataManager.updateDatabaseVersion() + finish() + return + } + + initDatabase(this) + + setContent { + LinuxTheme { + Box( + Modifier + .background(MaterialTheme.colors.primary) + .statusBarsPadding(), + ) { + Box( + Modifier + .background(LocalCustomColors.current.navBarBackground) + .systemBarsPadding(), + ) { + LinuxApp() + } + } + } + } + } +} + +const val DEEPLINK_URI = "https://linuxcommandlibrary.com" + +@Composable +fun LinuxApp() { + val navController = rememberNavController() + val navBackStackEntry = navController.currentBackStackEntryAsState() + val searchTextValue = remember { + mutableStateOf( + TextFieldValue(text = "", selection = TextRange(0)), + ) + } + val showSearch = remember { mutableStateOf(false) } + val onNavigate: (String) -> Unit = remember(navController) { { route -> navController.navigate(route) } } + + Scaffold( + topBar = { + TopBar( + navBackStackEntry = navBackStackEntry, + textFieldValue = searchTextValue, + onNavigateBack = { + navController.popBackStack() + }, + isSearchVisible = showSearch, + ) + }, + bottomBar = { + BottomBar( + navController = navController, + resetSearch = { + searchTextValue.value = TextFieldValue(text = "", selection = TextRange(0)) + showSearch.value = false + }, + ) + }, + ) { innerPadding -> + Box( + modifier = Modifier.padding(innerPadding), + ) { + NavHost( + navController = navController, + startDestination = Screen.Basics.route, + ) { + composable( + Screen.Basics.route, + deepLinks = listOf( + navDeepLink { uriPattern = "$DEEPLINK_URI/basics" }, + navDeepLink { uriPattern = "$DEEPLINK_URI/basics.html" }, + ), + ) { + BasicCategoriesScreen( + onNavigate = onNavigate, + ) + } + composable( + Screen.Commands.route, + deepLinks = listOf( + navDeepLink { uriPattern = "$DEEPLINK_URI/" }, + navDeepLink { uriPattern = "$DEEPLINK_URI/index.html" }, + ), + ) { + CommandListScreen( + onNavigate = onNavigate, + ) + } + composable( + Screen.Tips.route, + deepLinks = listOf( + navDeepLink { uriPattern = "$DEEPLINK_URI/tips" }, + navDeepLink { uriPattern = "$DEEPLINK_URI/tips.html" }, + ), + ) { + TipsScreen(onNavigate) + } + composable( + "basicgroups?categoryId={categoryId}&categoryName={categoryName}", + arguments = listOf( + navArgument("categoryId") { defaultValue = "" }, + navArgument("categoryName") {}, + ), + deepLinks = listOf( + navDeepLink { + uriPattern = "$DEEPLINK_URI/basic/{categoryName}.html" + }, + navDeepLink { uriPattern = "$DEEPLINK_URI/basic/{categoryName}" }, + ), + ) { backStackEntry -> + val categoryId = backStackEntry.getCategoryId() + if (categoryId != null) { + BasicGroupsScreen( + categoryId = categoryId, + onNavigate = onNavigate, + ) + } else { + // open tips screen on invalid deeplink parameters + TipsScreen(onNavigate) + } + } + composable( + "command?commandId={commandId}&commandName={commandName}", + arguments = listOf( + navArgument("commandId") { defaultValue = "" }, + navArgument("commandName") {}, + ), + deepLinks = listOf( + navDeepLink { uriPattern = "$DEEPLINK_URI/man/{commandName}.html" }, + navDeepLink { uriPattern = "$DEEPLINK_URI/man/{commandName}" }, + ), + ) { backStackEntry -> + val commandId = backStackEntry.getCommandId() + if (commandId != null) { + CommandDetailScreen( + commandId = commandId, + onNavigate = onNavigate, + ) + } else { + // open tips screen on invalid deeplink parameters + TipsScreen(onNavigate) + } + } + } + + val isSearchVisible by remember(navBackStackEntry, searchTextValue) { + derivedStateOf { + searchTextValue.value.text.isNotEmpty() && + navBackStackEntry.value?.destination?.route?.startsWith("command?") == false + } + } + AnimatedVisibility( + visible = isSearchVisible, + enter = fadeIn(animationSpec = tween(300)), + exit = fadeOut(animationSpec = tween(durationMillis = 300, delayMillis = 300)), // work around for navigation overlaps + ) { + SearchScreen( + searchText = searchTextValue.value.text, + onNavigate = remember(navController) { { route -> navController.navigate(route) } }, + ) + } + } + } +} + +sealed class Screen( + val route: String, + @StringRes val resourceId: Int, + @DrawableRes val drawableRes: Int, +) { + data object Commands : Screen("commands", R.string.commands, R.drawable.ic_search_40dp) + data object Basics : Screen("basics", R.string.basics, R.drawable.ic_puzzle) + data object Tips : Screen("tips", R.string.tips, R.drawable.ic_idea) +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/BottomBar.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/BottomBar.kt new file mode 100644 index 0000000..479fcde --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/BottomBar.kt @@ -0,0 +1,88 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.composables + +import androidx.compose.foundation.layout.size +import androidx.compose.material.BottomNavigation +import androidx.compose.material.BottomNavigationItem +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.inspiredandroid.linuxcommandbibliotheca.Screen +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LocalCustomColors + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +val bottomBarItems = listOf( + Screen.Basics, + Screen.Tips, + Screen.Commands, +) + +@Composable +fun BottomBar( + navController: NavHostController, + resetSearch: () -> Unit, +) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentDestination = navBackStackEntry?.destination + + BottomNavigation( + backgroundColor = LocalCustomColors.current.navBarBackground, + elevation = 0.dp, + ) { + bottomBarItems.forEach { screen -> + BottomNavigationItem( + icon = { + Icon( + painter = painterResource(id = screen.drawableRes), + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + }, + label = { Text(stringResource(screen.resourceId)) }, + selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true, + selectedContentColor = MaterialTheme.colors.primary, + unselectedContentColor = MaterialTheme.colors.onSurface, + onClick = { + // Pop back stack if current route starts with "command?" + // This specific logic might need adjustment based on detailed navigation graph behavior + // For example, ensure it doesn't pop too much or interfere with expected navigation. + // A more robust way might be to navigate to a specific point before the command details. + while (navController.currentBackStackEntry?.destination?.route?.startsWith("command?") == true) { + navController.popBackStack() + } + navController.navigate(screen.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + resetSearch() + }, + ) + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/CommandView.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/CommandView.kt new file mode 100644 index 0000000..7263f2e --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/CommandView.kt @@ -0,0 +1,166 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.composables + +import android.content.Context +import android.content.Intent +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Share +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.ParagraphStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.inspiredandroid.linuxcommandbibliotheca.R +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme +import com.linuxcommandlibrary.shared.CommandElement +import com.linuxcommandlibrary.shared.databaseHelper +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun CommandView( + command: String, + elements: ImmutableList, + onNavigate: (String) -> Unit = {}, + verticalPadding: Dp = 6.dp, +) { + val codeColor = MaterialTheme.colors.primary + val baseAnnotatedString = remember(elements, codeColor) { + buildAnnotatedString { + elements.forEach { element -> + when (element) { + is CommandElement.Text -> { + append(element.text) + } + is CommandElement.Man -> { + val start = this.length + withStyle(style = SpanStyle(color = codeColor)) { + append(element.man) + } + val end = this.length + addLink( + LinkAnnotation.Clickable( + tag = "man:${element.man}", + linkInteractionListener = { + val manCommand = databaseHelper.getCommand(element.man) + if (manCommand != null) { + onNavigate("command?commandId=${manCommand.id}&commandName=${manCommand.name}") + } + }, + ), + start, + end, + ) + } + is CommandElement.Url -> { + val start = this.length + withStyle(style = SpanStyle(color = codeColor)) { + append(element.command) + } + val end = this.length + addLink( + LinkAnnotation.Url(element.url), + start, + end, + ) + } + } + } + } + } + + val finalAnnotatedString = remember(baseAnnotatedString) { + if (baseAnnotatedString.text.isEmpty()) { + baseAnnotatedString + } else { + buildAnnotatedString { + append(baseAnnotatedString) + addStyle( + style = ParagraphStyle(), // Default ParagraphStyle + start = 0, + end = baseAnnotatedString.text.length, + ) + } + } + } + + Row(modifier = Modifier.padding(start = 12.dp, end = 4.dp).padding(vertical = verticalPadding)) { + Text( + text = finalAnnotatedString, + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically), + style = MaterialTheme.typography.subtitle2, + ) + + val context = LocalContext.current + val shareAction = remember(context, command) { { shareCommand(context, command) } } + IconButton( + modifier = Modifier.align(Alignment.CenterVertically), + onClick = shareAction, + ) { + Icon( + imageVector = Icons.Filled.Share, + contentDescription = stringResource(R.string.share), + ) + } + } +} + +fun shareCommand(context: Context, command: String) { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "text/plain" + intent.putExtra( + Intent.EXTRA_TEXT, + command.dropWhile { it == '$' || it.isWhitespace() }.replace("\\n", ""), + ) + try { + context.startActivity(Intent.createChooser(intent, "Share command")) + } catch (e: Exception) { + e.printStackTrace() + } +} + +@Composable +@Preview +fun CommandViewPreview() { + LinuxTheme { + CommandView( + command = "$ find ex?mple.txt", + elements = listOf( + CommandElement.Text("$ "), + CommandElement.Man("find"), + CommandElement.Text(" ex?mple.txt"), + ).toImmutableList(), + ) + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/HighlightedText.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/HighlightedText.kt new file mode 100644 index 0000000..8d3e7f0 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/HighlightedText.kt @@ -0,0 +1,80 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.composables + +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.Preview +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun HighlightedText( + text: String, + pattern: String, + maxLines: Int = 1, +) { + if (pattern.isEmpty()) { + Text( + text = text, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } else if (text.equals(pattern, ignoreCase = true)) { + Text( + text = text, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.primary, + ) + } else { + val highlightColor = MaterialTheme.colors.primary + val annotatedString = remember(text, pattern, highlightColor) { + buildAnnotatedString { + val splitText = text.split(pattern, ignoreCase = true) + splitText.forEachIndexed { index, s -> + append(s) + if (index != splitText.size - 1) { + withStyle(style = SpanStyle(color = highlightColor)) { + append(pattern) + } + } + } + } + } + Text( + text = annotatedString, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +@Preview +fun HighlightedTextPreview() { + LinuxTheme { + HighlightedText( + text = "pacman", + pattern = "cm", + ) + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/NestedCommandView.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/NestedCommandView.kt new file mode 100644 index 0000000..0cb1311 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/NestedCommandView.kt @@ -0,0 +1,70 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.composables + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme +import com.linuxcommandlibrary.shared.CommandElement +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun NestedCommandView( + text: String, + command: String, + commandElements: ImmutableList, + onNavigate: (String) -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(40.dp), + textAlign = TextAlign.Center, + ) + CommandView( + command = command, + elements = commandElements, + onNavigate = onNavigate, + ) + } +} + +@Composable +@Preview +fun NestedCommandViewPreview() { + LinuxTheme { + NestedCommandView( + text = "", + command = "$ find ex?mple.txt", + commandElements = listOf( + CommandElement.Text("$ "), + CommandElement.Man("find"), + CommandElement.Text(" ex?mple.txt"), + ).toImmutableList(), + onNavigate = {}, + ) + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/NestedText.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/NestedText.kt new file mode 100644 index 0000000..63b7481 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/NestedText.kt @@ -0,0 +1,50 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.composables + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun NestedText(textLeft: String, textRight: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + textLeft, + fontWeight = FontWeight.Bold, + modifier = Modifier.width(40.dp), + textAlign = TextAlign.Center, + ) + Text(textRight, modifier = Modifier.padding(8.dp)) + } +} + +@Composable +@Preview +fun NestedTextPreview() { + LinuxTheme { + NestedText(textLeft = ">>", textRight = "redirect") + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/SectionTitle.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/SectionTitle.kt new file mode 100644 index 0000000..8457e9e --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/SectionTitle.kt @@ -0,0 +1,46 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.composables + +import androidx.compose.foundation.layout.padding +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +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 com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun SectionTitle(modifier: Modifier = Modifier, title: String) { + Text( + text = title, + fontSize = 20.sp, + style = MaterialTheme.typography.subtitle1, + fontWeight = FontWeight.Bold, + modifier = modifier.padding(bottom = 4.dp), + ) +} + +@Preview +@Composable +fun SectionTitlePreview() { + LinuxTheme { + SectionTitle(title = "List of recent commands") + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/TopBar.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/TopBar.kt new file mode 100644 index 0000000..9e9eb51 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/composables/TopBar.kt @@ -0,0 +1,360 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.composables + +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +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 +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry +import com.inspiredandroid.linuxcommandbibliotheca.R +import com.inspiredandroid.linuxcommandbibliotheca.getCommandId +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.AppInfoDialog +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.BookmarkFeedbackDialog +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail.CommandDetailViewModel +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.getHtmlFileName +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun TopBar( + navBackStackEntry: State, + textFieldValue: MutableState, + onNavigateBack: () -> Unit, + isSearchVisible: MutableState, +) { + val route = navBackStackEntry.value?.destination?.route + + if (route == "commands" || route == "basics") { + val hideSearchCallback = remember { { isSearchVisible.value = false } } + val showSearchCallback = remember { { isSearchVisible.value = true } } + SearchTopBar( + title = getTitleByRoute(navBackStackEntry.value), + textFieldValue = textFieldValue, + isSearchVisible = isSearchVisible.value, + hideSearch = hideSearchCallback, + showSearch = showSearchCallback, + ) + } else if (route?.startsWith("command?") == true) { + DetailTopBar( + commandId = navBackStackEntry.value?.getCommandId() ?: 0, + title = getTitleByRoute(navBackStackEntry.value), + onNavigateBack = onNavigateBack, + ) + } else { + val title = getTitleByRoute(navBackStackEntry.value) + val showBackIcon = route != "tips" // Simplified from original when + val showAppInfoIcon = route == "tips" + GenericTopBar( + title = title, + showBackIcon = showBackIcon, + onNavigateBack = onNavigateBack, + showAppInfoIcon = showAppInfoIcon, + ) + } +} + +@Composable +private fun GenericTopBar( + title: String, + showBackIcon: Boolean, + onNavigateBack: () -> Unit, + showAppInfoIcon: Boolean, +) { + var showDialog by remember { mutableStateOf(false) } + + TopAppBar( + title = { + Text( + title, + modifier = Modifier.semantics { contentDescription = "TopAppBarTitle" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + backgroundColor = MaterialTheme.colors.primary, + contentColor = Color.White, + navigationIcon = if (showBackIcon) { + { + IconButton(onClick = { onNavigateBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + } + } else { + null + }, + actions = { + if (showAppInfoIcon) { + IconButton(onClick = { + showDialog = true + }) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = stringResource(R.string.info), + ) + } + } + }, + ) + if (showDialog) { + AppInfoDialog(onDismiss = { showDialog = false }) + } +} + +@Composable +private fun DetailTopBar( + commandId: Long, + viewModel: CommandDetailViewModel = koinViewModel( + key = commandId.toString(), + viewModelStoreOwner = LocalActivity.current as ViewModelStoreOwner, + parameters = { parametersOf(commandId) }, + ), + title: String, + onNavigateBack: () -> Unit, +) { + val uiState by viewModel.state.collectAsStateWithLifecycle() + val isAllExpanded by remember { derivedStateOf { uiState.isAllExpanded() } } + + TopAppBar( + title = { + Text( + title, + modifier = Modifier.semantics { contentDescription = "TopAppBarTitle" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + backgroundColor = MaterialTheme.colors.primary, + contentColor = Color.White, + navigationIcon = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + ) + } + }, + actions = { + IconButton(onClick = { + viewModel.onToggleAllExpanded() + }) { + if (isAllExpanded) { + Icon( + painterResource(R.drawable.ic_collapse_all), + contentDescription = stringResource(R.string.collapse_all), + ) + } else { + Icon( + painterResource(R.drawable.ic_expand_all), + contentDescription = stringResource(R.string.expand_all), + ) + } + } + IconButton(onClick = { + if (uiState.isBookmarked) { + viewModel.removeBookmark() + } else { + viewModel.addBookmark() + } + }) { + if (uiState.isBookmarked) { + Icon( + painterResource(R.drawable.ic_bookmark_black_24dp), + contentDescription = stringResource(R.string.remove_bookmark), + ) + } else { + Icon( + painterResource(R.drawable.ic_bookmark_border_black_24dp), + contentDescription = stringResource(R.string.add_bookmark), + ) + } + } + }, + ) + + if (uiState.showBookmarkDialog) { + BookmarkFeedbackDialog(onDismiss = { viewModel.hideBookmarkDialog() }) + } +} + +@Composable +private fun SearchTopBar( + title: String, + textFieldValue: MutableState, + isSearchVisible: Boolean, + hideSearch: () -> Unit, + showSearch: () -> Unit, +) { + val focusRequester = remember { FocusRequester() } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colors.primary) + .heightIn(min = 56.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSearchVisible) { + IconButton(onClick = { + hideSearch() + textFieldValue.value = TextFieldValue("") + }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(id = R.string.back), + tint = Color.White, + ) + } + } + if (isSearchVisible) { + OutlinedTextField( + value = textFieldValue.value, + onValueChange = { textFieldValue.value = it }, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .semantics { contentDescription = "SearchField" } + .padding(start = 8.dp, end = 8.dp), + placeholder = { Text("Search...", color = Color.White.copy(alpha = 0.7f)) }, + textStyle = MaterialTheme.typography.subtitle1.copy(color = Color.White), + colors = TextFieldDefaults.outlinedTextFieldColors( + textColor = Color.White, + cursorColor = Color.White, + focusedBorderColor = Color.Transparent, + unfocusedBorderColor = Color.Transparent, + disabledBorderColor = Color.Transparent, + backgroundColor = Color.Transparent, + trailingIconColor = LocalContentColor.current.copy(alpha = LocalContentAlpha.current), + placeholderColor = LocalContentColor.current.copy(alpha = 0.7f), + ), + maxLines = 1, + singleLine = true, + ) + if (textFieldValue.value.text.isNotEmpty()) { + IconButton(onClick = { + textFieldValue.value = TextFieldValue("") + }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.reset), + tint = Color.White, + ) + } + } + } else { + Text( + title, + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) // Standard title padding when no nav icon + .semantics { contentDescription = "TopAppBarTitle" }, + style = MaterialTheme.typography.h6.copy(color = LocalContentColor.current), // Use h6 for title as per Material + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = Color.White, + ) + } + + if (!isSearchVisible) { + IconButton(onClick = { + showSearch() + }) { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.search), + tint = Color.White, + ) + } + } + } + + LaunchedEffect(isSearchVisible) { + if (isSearchVisible) { + focusRequester.requestFocus() + } + } +} + +@Composable +private fun getTitleByRoute(backStackEntry: NavBackStackEntry?): String { + if (backStackEntry == null) { + return "Linux" + } + return when (val route = backStackEntry.destination.route) { + "commands" -> stringResource(R.string.commands) + "basics" -> stringResource(R.string.basics) + "tips" -> stringResource(R.string.tips) + else -> { + if (route?.startsWith("command?") == true) { + backStackEntry.arguments?.getString("commandName") ?: "" + } else if (route?.startsWith("basicgroups?") == true) { + val deeplinkName = backStackEntry.arguments?.getString("categoryName") ?: "" + remember(deeplinkName) { + val categories = databaseHelper.getBasics() + val category = categories.firstOrNull { it.getHtmlFileName() == deeplinkName } + category?.title ?: "Not found" + } + } else { + "" + } + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/AppInfoDialog.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/AppInfoDialog.kt new file mode 100644 index 0000000..649b9a7 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/AppInfoDialog.kt @@ -0,0 +1,162 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.inspiredandroid.linuxcommandbibliotheca.R +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme +import com.linuxcommandlibrary.shared.* + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +// Todo: Replace with AlertDialog + +@Composable +fun AppInfoDialog( + onDismiss: () -> Unit = {}, +) { + val uriHandler = LocalUriHandler.current + Dialog(onDismissRequest = { onDismiss() }) { + Card( + elevation = 8.dp, + shape = RoundedCornerShape(6.dp), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Text( + stringResource(R.string.app_name), + style = MaterialTheme.typography.h5, + modifier = Modifier.padding(8.dp), + ) + Row { + Button( + modifier = Modifier.padding(start = 6.dp), + content = { + Text( + "Rate the app", + color = Color.White, + ) + }, + onClick = { + uriHandler.openUri("https://play.google.com/store/apps/details?id=com.inspiredandroid.linuxcommandbibliotheca") + }, + ) + IconButton(onClick = { + uriHandler.openUri("https://github.com/SimonSchubert/LinuxCommandLibrary") + }) { + Icon( + painterResource(R.drawable.ic_icons8_github), + contentDescription = "View on GitHub", // TODO: Use stringResource + modifier = Modifier.size(40.dp), + ) + } + } + Text( + "Version: ${Version.appVersion}", + style = MaterialTheme.typography.caption, + modifier = Modifier.padding(8.dp), + ) + Column( + modifier = Modifier + .padding(8.dp) + .weight(1f) + .verticalScroll(rememberScrollState()), + ) { + Text("Support this project", style = MaterialTheme.typography.h6) + Text("By using my referral links for these amazing products.") + Spacer(Modifier.height(4.dp)) + Image( + modifier = Modifier + .fillMaxWidth() + .clickable { + val link = "https://linuxcommandlibrary.com/proton-free-2025" + uriHandler.openUri(link) + }, + painter = painterResource(R.mipmap.proton_free_horizontal), + contentDescription = "Proton services promotion", // TODO: Use stringResource + ) + Spacer(Modifier.height(8.dp)) + Image( + modifier = Modifier + .fillMaxWidth() + .clickable { + val link = "https://linuxcommandlibrary.com/linode-2025" + uriHandler.openUri(link) + }, + painter = painterResource(R.mipmap.linode_horizontal), + contentDescription = "Linode cloud hosting promotion", // TODO: Use stringResource + ) + + Spacer(Modifier.height(8.dp)) + Text("Man pages", style = MaterialTheme.typography.h6) + Text("Licence information about the man page is usually specified in the man detail page under the category Author, Copyright or Licence. If there is no information on the page you can find the information in the man page source file on your linux system. If you have questions or can't find what you need, you can contact me at sschubert89@gmail.com.") + + Spacer(Modifier.height(8.dp)) + Text("TLDR pages", style = MaterialTheme.typography.h6) + Text( + "The MIT License (MIT) Copyright (c) 2014 the TLDR team and contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.", + ) + Spacer(Modifier.height(8.dp)) + Text( + "Thanks to commandlinefu.com for the one-liners and icons8.com for the icons", + style = MaterialTheme.typography.h6, + ) + } + TextButton( + content = { Text("OK") }, + modifier = Modifier + .align(Alignment.End) + .padding(end = 6.dp), + onClick = onDismiss, + ) + } + } + } +} + +@Preview +@Composable +fun AppInfoDialogPreview() { + LinuxTheme { + AppInfoDialog() + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/BookmarkFeedbackDialog.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/BookmarkFeedbackDialog.kt new file mode 100644 index 0000000..48159f2 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/BookmarkFeedbackDialog.kt @@ -0,0 +1,50 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Card +import androidx.compose.material.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.inspiredandroid.linuxcommandbibliotheca.R +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.SectionTitle +import kotlinx.coroutines.delay + +@Composable +fun BookmarkFeedbackDialog(onDismiss: () -> Unit) { + LaunchedEffect(Unit) { + delay(600) + onDismiss() + } + + Dialog(onDismissRequest = onDismiss) { + Card( + elevation = 8.dp, + shape = RoundedCornerShape(6.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp), + ) { + Icon( + painterResource(R.drawable.ic_bookmark_black_24dp), + contentDescription = null, // Decorative, as title says "Bookmarked" + modifier = Modifier.size(48.dp), + ) + + Spacer(Modifier.height(8.dp)) + + SectionTitle(title = "Bookmarked") + } + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/InitializeDatabaseScreen.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/InitializeDatabaseScreen.kt new file mode 100644 index 0000000..78dc3d9 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/InitializeDatabaseScreen.kt @@ -0,0 +1,71 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.LinearProgressIndicator +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.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.res.painterResource +import androidx.compose.ui.unit.dp +import com.inspiredandroid.linuxcommandbibliotheca.R +import com.linuxcommandlibrary.shared.copyDatabase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@Composable +fun InitializeDatabaseScreen(onFinish: () -> Unit = {}) { + var status by remember { + mutableIntStateOf(0) + } + val coroutineScope = rememberCoroutineScope() + val context = LocalContext.current + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painterResource(R.mipmap.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .size(240.dp) + .align(Alignment.CenterHorizontally), + ) + Text( + "Initialize database", + color = MaterialTheme.colors.onSurface, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + LinearProgressIndicator( + progress = status.div(100f), + modifier = Modifier.padding(top = 16.dp), + ) + } + + LaunchedEffect(Unit) { + coroutineScope.launch(Dispatchers.IO) { + copyDatabase(context) { progress -> + status = progress + } + withContext(Dispatchers.Main) { + onFinish() + } + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basiccategories/BasicCategoriesScreen.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basiccategories/BasicCategoriesScreen.kt new file mode 100644 index 0000000..3597de2 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basiccategories/BasicCategoriesScreen.kt @@ -0,0 +1,69 @@ +@file:OptIn(ExperimentalMaterialApi::class) + +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basiccategories + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ListItem +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.inspiredandroid.linuxcommandbibliotheca.getIconResource +import com.linuxcommandlibrary.shared.getHtmlFileName +import org.koin.androidx.compose.koinViewModel + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun BasicCategoriesScreen( + viewModel: BasicCategoriesViewModel = koinViewModel(), + onNavigate: (String) -> Unit, +) { + LazyVerticalGrid( + modifier = Modifier.fillMaxSize(), + columns = GridCells.Adaptive(minSize = 300.dp), + ) { + items( + items = viewModel.basicCategories, + key = { it.id }, + contentType = { "basic_category_item" }, + ) { basicCategory -> + ListItem( + text = { Text(basicCategory.title) }, + icon = { + Icon( + painterResource(basicCategory.getIconResource()), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + }, + modifier = Modifier.clickable { + onNavigate( + "basicgroups?categoryId=${basicCategory.id}&categoryName=${basicCategory.getHtmlFileName()}", + ) + }, + ) + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basiccategories/BasicCategoriesViewModel.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basiccategories/BasicCategoriesViewModel.kt new file mode 100644 index 0000000..0cab6be --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basiccategories/BasicCategoriesViewModel.kt @@ -0,0 +1,27 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basiccategories + +import androidx.lifecycle.ViewModel +import com.linuxcommandlibrary.shared.databaseHelper +import databases.BasicCategory +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +class BasicCategoriesViewModel : ViewModel() { + + var basicCategories: ImmutableList = databaseHelper.getBasics().toImmutableList() +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsScreen.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsScreen.kt new file mode 100644 index 0000000..ae0dd4d --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsScreen.kt @@ -0,0 +1,114 @@ +@file:OptIn(ExperimentalMaterialApi::class) + +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ListItem +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.inspiredandroid.linuxcommandbibliotheca.getIconResource +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.CommandView +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.HighlightedText +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.getCommandList +import databases.BasicGroup +import kotlinx.collections.immutable.toImmutableList +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun BasicGroupsScreen( + categoryId: Long, + viewModel: BasicGroupsViewModel = koinViewModel( + parameters = { parametersOf(categoryId) }, + ), + onNavigate: (String) -> Unit = {}, +) { + val uiState by viewModel.uiState.collectAsState() + + LazyColumn(Modifier.fillMaxSize()) { + items( + items = uiState.basicGroups, + key = { it.id }, + contentType = { "basic_group_item" }, + ) { basicGroup -> + BasicGroupColumn( + basicGroup = basicGroup, + isExpanded = !uiState.collapsedMap.getOrDefault(basicGroup.id, true), + onToggleCollapse = { viewModel.toggleCollapse(basicGroup.id) }, + onNavigate = onNavigate, + ) + } + } +} + +@Composable +fun BasicGroupColumn( + basicGroup: BasicGroup, + searchText: String = "", + isExpanded: Boolean, + onToggleCollapse: () -> Unit, + onNavigate: (String) -> Unit = {}, +) { + ListItem( + text = { + HighlightedText( + text = basicGroup.description, + pattern = searchText, + maxLines = 3, + ) + }, + icon = { + Icon( + painterResource(basicGroup.getIconResource()), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + }, + modifier = Modifier.clickable { onToggleCollapse() }, + ) + + if (isExpanded) { + ExpandedGroupContent(basicGroup = basicGroup, onNavigate = onNavigate) + } +} + +@Composable +private fun ExpandedGroupContent(basicGroup: BasicGroup, onNavigate: (String) -> Unit) { + val commands = remember(basicGroup.id) { + databaseHelper.getBasicCommands(basicGroup.id).toImmutableList() + } + commands.forEach { basicCommand -> + CommandView( + command = basicCommand.command, + elements = basicCommand.command.getCommandList(basicCommand.mans).toImmutableList(), + onNavigate = onNavigate, + ) + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsUiState.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsUiState.kt new file mode 100644 index 0000000..ef047fe --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsUiState.kt @@ -0,0 +1,12 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups + +import databases.BasicGroup +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +data class BasicGroupsUiState( + val basicGroups: ImmutableList = persistentListOf(), + val collapsedMap: ImmutableMap = persistentMapOf(), +) diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModel.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModel.kt new file mode 100644 index 0000000..b5aec81 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModel.kt @@ -0,0 +1,45 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups + +import androidx.lifecycle.ViewModel +import com.linuxcommandlibrary.shared.databaseHelper +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +class BasicGroupsViewModel(categoryId: Long) : ViewModel() { + + private val _uiState = MutableStateFlow(BasicGroupsUiState()) + val uiState = _uiState.asStateFlow() + + init { + val groups = databaseHelper.getBasicGroupsByQuery(categoryId).toImmutableList() + _uiState.value = BasicGroupsUiState(basicGroups = groups) + } + + fun isGroupCollapsed(id: Long): Boolean = _uiState.value.collapsedMap.getOrDefault(id, true) + + fun toggleCollapse(id: Long) { + _uiState.update { currentState -> + val newMap = currentState.collapsedMap.toMutableMap() + newMap[id] = !currentState.collapsedMap.getOrDefault(id, true) + currentState.copy(collapsedMap = newMap.toPersistentMap()) + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailScreen.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailScreen.kt new file mode 100644 index 0000000..f877f55 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailScreen.kt @@ -0,0 +1,221 @@ +@file:OptIn(ExperimentalMaterialApi::class, ExperimentalLayoutApi::class) + +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail + +import android.text.style.StyleSpan +import android.text.style.UnderlineSpan +import androidx.activity.compose.LocalActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.Chip +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ListItem +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.text.HtmlCompat +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.CommandView +import com.linuxcommandlibrary.shared.CommandElement +import com.linuxcommandlibrary.shared.databaseHelper +import databases.CommandSection +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun CommandDetailScreen( + commandId: Long, + viewModel: CommandDetailViewModel = koinViewModel( + key = commandId.toString(), + viewModelStoreOwner = LocalActivity.current as ViewModelStoreOwner, + parameters = { parametersOf(commandId) }, + ), + onNavigate: (String) -> Unit, +) { + val uiState by viewModel.state.collectAsStateWithLifecycle() + + LazyColumn(Modifier.fillMaxSize()) { + items( + items = uiState.sections, // This should ideally be ImmutableList from ViewModel + key = { it.id }, + contentType = { "command_section_item" }, + ) { section -> + CommandSectionColumn( + section = section, + isExpanded = uiState.expandedSectionsMap.getOrDefault(section.id, false), + onToggleExpanded = { id -> viewModel.onToggleExpanded(id) }, + onNavigate = onNavigate, + ) + } + } +} + +@Composable +private fun CommandSectionColumn( + section: CommandSection, + isExpanded: Boolean, + onToggleExpanded: (Long) -> Unit, + onNavigate: (String) -> Unit, +) { + ListItem( + text = { + Text( + section.title.uppercase(), + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + ) + }, + modifier = Modifier.clickable { + onToggleExpanded(section.id) + }, + ) + + if (isExpanded) { + when (section.title) { + "TLDR" -> TldrSectionContent(content = section.content, onNavigate = onNavigate) + "SEE ALSO" -> SeeAlsoSectionContent(content = section.content, onNavigate = onNavigate) + else -> DefaultSectionContent(content = section.content) + } + } +} + +@Composable +private fun TldrSectionContent(content: String, onNavigate: (String) -> Unit) { + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + ) { + val tldrParts = content.split("") + tldrParts.forEachIndexed { index, s -> + val split = s.split("") + if (split.size > 1) { + Text( + text = split[0], + fontWeight = FontWeight.Bold, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), + ) + + val command = "$ " + split[1].replace("
", "").replace("`", "") + CommandView( + command = command, + elements = listOf(CommandElement.Text(command)).toImmutableList(), + onNavigate = onNavigate, + verticalPadding = 4.dp, + ) + } + if (index != tldrParts.lastIndex && split.size > 1) { // Add spacer only if content was added + Spacer(Modifier.height(6.dp)) + } + } + } +} + +@Composable +private fun SeeAlsoSectionContent(content: String, onNavigate: (String) -> Unit) { + val commands = getCommands(content) + if (commands.isNotEmpty()) { + FlowRow( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + commands.forEach { name -> + Chip(onClick = { + onNavigate("command?commandName=$name") + }) { + Text( + text = name, + color = MaterialTheme.colors.onSurface, + ) + } + } + } + } else { + // fallback to default rendering if no commands were parsed (e.g. plain text) + DefaultSectionContent(content = content, isFallback = true) + } +} + +@Composable +private fun DefaultSectionContent(content: String, isFallback: Boolean = false) { + Text( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = if (isFallback) 0.dp else 8.dp, bottom = 8.dp), + text = content.toAnnotatedString(), + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f), + ) +} + +private fun getCommands(input: String): ImmutableList { + val commands = input.split(",").map { it.trim() } + + return commands + .map { command -> + command.replace(Regex("\\(\\d+\\)$"), "").trim() + }.filter { + databaseHelper.getCommand(it) != null + }.toImmutableList() +} + +private fun String.toAnnotatedString(): AnnotatedString { + val spanned = HtmlCompat.fromHtml(this, HtmlCompat.FROM_HTML_MODE_LEGACY) + + val trimmedText = spanned.toString().trim('\n', ' ') + + return buildAnnotatedString { + append(trimmedText) + + spanned.getSpans(0, trimmedText.length, Any::class.java).forEach { span -> + val start = spanned.getSpanStart(span) + val end = spanned.getSpanEnd(span) + + when (span) { + is StyleSpan -> when (span.style) { + android.graphics.Typeface.BOLD -> { + addStyle(SpanStyle(fontWeight = FontWeight.Bold), start, end) + } + android.graphics.Typeface.ITALIC -> { + addStyle(SpanStyle(fontStyle = FontStyle.Italic), start, end) + } + } + is UnderlineSpan -> { + addStyle(SpanStyle(textDecoration = TextDecoration.Underline), start, end) + } + } + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailUiState.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailUiState.kt new file mode 100644 index 0000000..8516238 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailUiState.kt @@ -0,0 +1,14 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail + +import databases.CommandSection +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap + +data class CommandDetailUiState( + val sections: ImmutableList, + val expandedSectionsMap: ImmutableMap, + val isBookmarked: Boolean = false, + val showBookmarkDialog: Boolean = false, +) { + fun isAllExpanded(): Boolean = expandedSectionsMap.all { it.value } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailViewModel.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailViewModel.kt new file mode 100644 index 0000000..3b7eb43 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commanddetail/CommandDetailViewModel.kt @@ -0,0 +1,90 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commanddetail + +import androidx.lifecycle.ViewModel +import com.inspiredandroid.linuxcommandbibliotheca.DataManager +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.getSortPriority +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +class CommandDetailViewModel( + private val commandId: Long, + private val dataManager: DataManager, +) : ViewModel() { + + val state: MutableStateFlow + + init { + val sectionsData = databaseHelper.getSections(commandId).sortedBy { it.getSortPriority() } + val isAutoExpandEnabled = dataManager.isAutoExpandSections() + state = MutableStateFlow( + CommandDetailUiState( + sections = sectionsData.toImmutableList(), + expandedSectionsMap = sectionsData.associate { + it.id to isAutoExpandEnabled + }.toImmutableMap(), + isBookmarked = dataManager.hasBookmark(commandId), + ), + ) + } + + fun onToggleAllExpanded() { + val isAllExpanded = state.value.isAllExpanded() + state.update { + val currentMap = it.expandedSectionsMap + val updatedMap = currentMap.toMutableMap() + updatedMap.replaceAll { _, _ -> !isAllExpanded } + it.copy(expandedSectionsMap = updatedMap.toImmutableMap()) + } + dataManager.setAutoExpandSections(!isAllExpanded) + } + + fun onToggleExpanded(id: Long) { + state.update { + val currentMap = it.expandedSectionsMap + val updatedMap = currentMap.toMutableMap() + updatedMap[id] = !updatedMap.getOrDefault(id, false) + it.copy(expandedSectionsMap = updatedMap.toImmutableMap()) + } + } + + fun removeBookmark() { + state.update { + it.copy(isBookmarked = false) + } + dataManager.removeBookmark(commandId) + } + + fun addBookmark() { + state.update { + it.copy( + isBookmarked = true, + showBookmarkDialog = true, + ) + } + dataManager.addBookmark(commandId) + } + + fun hideBookmarkDialog() { + state.update { + it.copy(showBookmarkDialog = false) + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListScreen.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListScreen.kt new file mode 100644 index 0000000..d0cdd5c --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListScreen.kt @@ -0,0 +1,128 @@ +@file:OptIn( + ExperimentalMaterialApi::class, + ExperimentalMaterialApi::class, + ExperimentalMaterialApi::class, +) + +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commandlist + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.ListItem +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import com.inspiredandroid.linuxcommandbibliotheca.R +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.HighlightedText +import com.inspiredandroid.linuxcommandbibliotheca.ui.theme.LinuxTheme +import databases.Command +import org.koin.androidx.compose.koinViewModel + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun CommandListScreen( + viewModel: CommandListViewModel = koinViewModel(), + onNavigate: (String) -> Unit, +) { + val commands by viewModel.commands.collectAsState() + + LazyColumn { + items( + items = commands, + key = { it.id }, + contentType = { "command_list_item" }, + ) { command -> + CommandListItem( + command = command, + onNavigate = onNavigate, + isBookmarked = viewModel.hasBookmark(command.id), + ) + } + } +} + +@Composable +fun CommandListItem( + command: Command, + searchText: String = "", + onNavigate: (String) -> Unit, + isBookmarked: Boolean, +) { + ListItem( + text = { + HighlightedText( + text = command.name, + pattern = searchText, + ) + }, + trailing = { + if (isBookmarked) { + Icon( + painterResource(R.drawable.ic_bookmark_black_24dp), + contentDescription = stringResource(R.string.remove_bookmark), + ) + } + }, + secondaryText = { + HighlightedText( + text = command.description, + pattern = searchText, + ) + }, + modifier = Modifier.clickable( + onClick = remember(command.id, command.name, onNavigate) { + { onNavigate("command?commandId=${command.id}&commandName=${command.name}") } + }, + ), + ) +} + +@Preview +@Composable +fun CommandListItemPreview() { + val command = Command(0L, 0L, "cowsay", "A talking cow says moo.") + LinuxTheme { + CommandListItem( + command = command, + searchText = "cow", + onNavigate = { }, + isBookmarked = true, + ) + } +} + +@Preview +@Composable +fun CommandListScreenPreview() { + // This preview is more complex due to the ViewModel dependency. + // For a proper preview, you'd typically use a fake ViewModel implementation + // or mock data source. For now, this will just show an empty screen + // or potentially crash if the ViewModel koinViewModel() can't be resolved in preview. + // To make it useful, one might need to adjust Koin setup for previews or pass + // a fake ViewModel. + // LinuxTheme { + // CommandListScreen(onNavigate = {}) + // } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListViewModel.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListViewModel.kt new file mode 100644 index 0000000..b03c3c8 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/commandlist/CommandListViewModel.kt @@ -0,0 +1,63 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commandlist + +import android.content.SharedPreferences +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.inspiredandroid.linuxcommandbibliotheca.DataManager +import com.linuxcommandlibrary.shared.databaseHelper +import databases.Command +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +class CommandListViewModel(private val dataManager: DataManager) : ViewModel() { + + private val _commands = MutableStateFlow>(persistentListOf()) + val commands = _commands.asStateFlow() + + private val preferenceListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == DataManager.KEY_BOOKMARKS) { + updateCommands() + } + } + + init { + updateCommands() + dataManager.prefs.registerOnSharedPreferenceChangeListener(preferenceListener) + } + + private fun updateCommands() { + viewModelScope.launch(Dispatchers.IO) { + _commands.update { + databaseHelper.getCommands().sortedBy { !hasBookmark(it.id) }.toImmutableList() + } + } + } + + fun hasBookmark(id: Long): Boolean = dataManager.hasBookmark(id) + + override fun onCleared() { + dataManager.prefs.unregisterOnSharedPreferenceChangeListener(preferenceListener) + super.onCleared() + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchScreen.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchScreen.kt new file mode 100644 index 0000000..fc9e0f1 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchScreen.kt @@ -0,0 +1,97 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups.BasicGroupColumn +import com.inspiredandroid.linuxcommandbibliotheca.ui.screens.commandlist.CommandListItem +import org.koin.androidx.compose.koinViewModel + +@Composable +fun SearchScreen( + searchText: String, + viewModel: SearchViewModel = koinViewModel(), + onNavigate: (String) -> Unit, +) { + // Removed the early return for searchText.isEmpty() as ViewModel now handles it by emitting an empty state. + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(searchText) { + viewModel.search(searchText) + } + + val keyboardController = LocalSoftwareKeyboardController.current + val lazyListState = rememberLazyListState() + + val showEmptyMessage by remember(uiState.filteredCommands, uiState.filteredBasicGroups) { + derivedStateOf { + uiState.filteredCommands.isEmpty() && uiState.filteredBasicGroups.isEmpty() + } + } + + // Only show "404" if search text is not empty and results are empty + if (searchText.isNotEmpty() && showEmptyMessage) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable(enabled = false, onClick = {}) + .background(MaterialTheme.colors.background), + ) { + Text("404 command not found", modifier = Modifier.align(Alignment.Center)) + } + } else { + LazyColumn( + Modifier + .fillMaxSize() + .background(MaterialTheme.colors.background), + state = lazyListState, + ) { + items( + items = uiState.filteredBasicGroups, + key = { "basicGroup_${it.id}" }, + contentType = { "search_basic_group_item" }, + ) { basicGroup -> + BasicGroupColumn( + basicGroup = basicGroup, + searchText = searchText, + onNavigate = onNavigate, + isExpanded = !uiState.collapsedMap.getOrDefault(basicGroup.id, false), + onToggleCollapse = { viewModel.toggleCollapse(basicGroup.id) }, + ) + } + items( + items = uiState.filteredCommands, + key = { "command_${it.id}" }, + contentType = { "search_command_item" }, + ) { command -> + CommandListItem( + command = command, + searchText = searchText, + onNavigate = onNavigate, + isBookmarked = false, // Or fetch actual bookmark status if relevant + ) + } + } + } + + LaunchedEffect(lazyListState.isScrollInProgress) { + if (lazyListState.isScrollInProgress) { + keyboardController?.hide() + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchUiState.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchUiState.kt new file mode 100644 index 0000000..6d71885 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchUiState.kt @@ -0,0 +1,14 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search + +import databases.BasicGroup +import databases.Command +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf + +data class SearchUiState( + val filteredCommands: ImmutableList = persistentListOf(), + val filteredBasicGroups: ImmutableList = persistentListOf(), + val collapsedMap: ImmutableMap = persistentMapOf(), +) diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchViewModel.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchViewModel.kt new file mode 100644 index 0000000..9f1cb82 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchViewModel.kt @@ -0,0 +1,66 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.sortedSearch +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toPersistentMap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException + +class SearchViewModel : ViewModel() { + + private val _uiState = MutableStateFlow(SearchUiState()) + val uiState = _uiState.asStateFlow() + + fun isGroupCollapsed(id: Long): Boolean = _uiState.value.collapsedMap.getOrDefault(id, false) + + fun toggleCollapse(id: Long) { + _uiState.update { currentState -> + val newMap = currentState.collapsedMap.toMutableMap() + newMap[id] = !currentState.collapsedMap.getOrDefault(id, false) + currentState.copy(collapsedMap = newMap.toPersistentMap()) + } + } + + private var searchJob: Job? = null + fun search(searchText: String) { + searchJob?.cancel() + if (searchText.isBlank()) { + _uiState.update { + it.copy( + filteredCommands = persistentListOf(), + filteredBasicGroups = persistentListOf(), + ) + } + return + } + searchJob = viewModelScope.launch(Dispatchers.IO) { + try { + ensureActive() + + val commands = databaseHelper.getCommandsByQuery(searchText).sortedSearch(searchText) + val basicGroups = databaseHelper.getBasicGroupsByQuery(searchText) + + ensureActive() + + _uiState.update { currentState -> + currentState.copy( + filteredCommands = commands.toImmutableList(), + filteredBasicGroups = basicGroups.toImmutableList(), + ) + } + } catch (ignore: CancellationException) { + // Preserve previous results on cancellation + } + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/tips/TipsScreen.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/tips/TipsScreen.kt new file mode 100644 index 0000000..81a91c7 --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/tips/TipsScreen.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalMaterialApi::class) + +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.tips + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid +import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells +import androidx.compose.foundation.lazy.staggeredgrid.items +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.CommandView +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.NestedCommandView +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.NestedText +import com.inspiredandroid.linuxcommandbibliotheca.ui.composables.SectionTitle +import kotlinx.collections.immutable.toImmutableList +import org.koin.androidx.compose.koinViewModel + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Composable +fun TipsScreen( + onNavigate: (String) -> Unit = {}, + viewModel: TipsViewModel = koinViewModel(), +) { + LazyVerticalStaggeredGrid( + modifier = Modifier + .semantics { contentDescription = "Scroll" }, + columns = StaggeredGridCells.Adaptive(minSize = 300.dp), + ) { + items( + items = viewModel.tips, + key = { it.tip.id }, + contentType = { "tip_item" }, + ) { tip -> + TipItemCard(tip = tip, onNavigate = onNavigate) + } + } +} + +@Composable +private fun TipItemCard(tip: MergedTip, onNavigate: (String) -> Unit) { + Card( + elevation = 4.dp, + modifier = Modifier + .padding(4.dp) + .fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(8.dp)) { + SectionTitle(title = tip.tip.title) + TipSections(sections = tip.sections, onNavigate = onNavigate) + } + } +} + +@Composable +private fun TipSections(sections: List, onNavigate: (String) -> Unit) { + // Assuming MergedTip.sections is already ImmutableList from ViewModel refactor + // If not, it should be passed as ImmutableList + sections.forEach { element -> + when (element) { + is TipSectionElement.Text -> { + Text(element.text) + } + + is TipSectionElement.Code -> { + CommandView( + command = element.command, + elements = element.elements.toImmutableList(), // elements within TipSectionElement should also be ImmutableList + onNavigate = onNavigate, + ) + } + + is TipSectionElement.NestedCode -> { + NestedCommandView( + text = element.text, + command = element.command, + commandElements = element.elements.toImmutableList(), // elements within TipSectionElement should also be ImmutableList + onNavigate = onNavigate, + ) + } + + is TipSectionElement.NestedText -> { + NestedText( + textLeft = element.text, + textRight = element.info, + ) + } + } + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/tips/TipsViewModel.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/tips/TipsViewModel.kt new file mode 100644 index 0000000..1d76f4f --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/tips/TipsViewModel.kt @@ -0,0 +1,71 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.tips + +import androidx.lifecycle.ViewModel +import com.linuxcommandlibrary.shared.CommandElement +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.getCommandList +import databases.Tip +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +sealed class TipSectionElement { + data class Text(val text: String) : TipSectionElement() + data class Code(val command: String, val elements: ImmutableList) : TipSectionElement() + + data class NestedCode( + val text: String, + val command: String, + val elements: ImmutableList, + ) : TipSectionElement() + + data class NestedText(val text: String, val info: String) : TipSectionElement() +} + +data class MergedTip(val tip: Tip, val sections: ImmutableList) + +class TipsViewModel : ViewModel() { + + var tips: ImmutableList + + init { + val tipSectionsFromDB = databaseHelper.getTipSections() // Renamed to avoid confusion with MergedTip.sections + tips = databaseHelper.getTips().map { tip -> + MergedTip( + tip, + tipSectionsFromDB.filter { it.tip_id == tip.id }.map { section -> + when (section.type) { + 0L -> { + val text = + section.data1.replace("\\n", "").replace("", "").replace("", "") + .replace("\\'", "") + TipSectionElement.Text(text) + } + + 1L -> { + TipSectionElement.Code( + section.data1, + section.data1.getCommandList(section.extra).toImmutableList(), + ) + } + + 3L -> { + if (section.data2.startsWith("$")) { + TipSectionElement.NestedCode( + section.data1, + section.data2, + section.data2.getCommandList(section.extra).toImmutableList(), + ) + } else { + TipSectionElement.NestedText(section.data1, section.data2) + } + } + + else -> { + TipSectionElement.Text("") + } + } + }.toImmutableList(), // Convert list of sections for a tip to ImmutableList + ) + }.toImmutableList() // Convert final list of MergedTip to ImmutableList + } +} diff --git a/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/theme/LinuxTheme.kt b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/theme/LinuxTheme.kt new file mode 100644 index 0000000..5bba69a --- /dev/null +++ b/android/src/main/java/com/inspiredandroid/linuxcommandbibliotheca/ui/theme/LinuxTheme.kt @@ -0,0 +1,88 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Typography +import androidx.compose.material.darkColors +import androidx.compose.material.lightColors +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import com.inspiredandroid.linuxcommandbibliotheca.R + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +data class CustomColors( + val navBarBackground: Color = Color(0xFF00695C), +) + +val LocalCustomColors = compositionLocalOf { CustomColors() } + +@Composable +fun LinuxTheme(content: @Composable () -> Unit) { + val darkMode = isSystemInDarkTheme() + val darkColors = darkColors( + primary = Color(0xFFe45151), + secondary = Color.White, + background = Color(0xFF262626), + surface = Color(0xFF262626), + ) + val lightColors = lightColors( + primary = Color(0xFFe45151), + secondary = Color.Black, + background = Color.White, + surface = Color.White, + ) + val colorSchema = if (darkMode) darkColors else lightColors + + val techMonoFont = FontFamily( + Font(R.font.share_tech_mono), + ) + + val codeTextStyle = TextStyle( + fontFamily = techMonoFont, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + color = colorSchema.secondary, + ) + + val typography = Typography( + subtitle2 = codeTextStyle, + ) + + val customColors = if (darkMode) { + CustomColors( + navBarBackground = Color(0xFF2D2D2D), + ) + } else { + CustomColors( + navBarBackground = Color(0xFFFAFAFA), + ) + } + CompositionLocalProvider(LocalCustomColors provides customColors) { + MaterialTheme( + colors = colorSchema, + typography = typography, + content = content, + ) + } +} diff --git a/android/src/main/res/drawable/ic_add_rule.xml b/android/src/main/res/drawable/ic_add_rule.xml new file mode 100644 index 0000000..78ef5c4 --- /dev/null +++ b/android/src/main/res/drawable/ic_add_rule.xml @@ -0,0 +1,46 @@ + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_add_user_to_group_white_48dp.xml b/android/src/main/res/drawable/ic_add_user_to_group_white_48dp.xml new file mode 100644 index 0000000..7c380be --- /dev/null +++ b/android/src/main/res/drawable/ic_add_user_to_group_white_48dp.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_android_black_24dp.xml b/android/src/main/res/drawable/ic_android_black_24dp.xml new file mode 100644 index 0000000..0bf31ac --- /dev/null +++ b/android/src/main/res/drawable/ic_android_black_24dp.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_arrow_upward_black_24dp.xml b/android/src/main/res/drawable/ic_arrow_upward_black_24dp.xml new file mode 100644 index 0000000..a8010ae --- /dev/null +++ b/android/src/main/res/drawable/ic_arrow_upward_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_available_updates.xml b/android/src/main/res/drawable/ic_available_updates.xml new file mode 100644 index 0000000..ba36b38 --- /dev/null +++ b/android/src/main/res/drawable/ic_available_updates.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_baseline_bookmark_24.xml b/android/src/main/res/drawable/ic_baseline_bookmark_24.xml new file mode 100644 index 0000000..78ef499 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_bookmark_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_baseline_bookmark_border_24.xml b/android/src/main/res/drawable/ic_baseline_bookmark_border_24.xml new file mode 100644 index 0000000..56ae1c5 --- /dev/null +++ b/android/src/main/res/drawable/ic_baseline_bookmark_border_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_battery_90_black_24dp.xml b/android/src/main/res/drawable/ic_battery_90_black_24dp.xml new file mode 100644 index 0000000..d370a44 --- /dev/null +++ b/android/src/main/res/drawable/ic_battery_90_black_24dp.xml @@ -0,0 +1,13 @@ + + + + diff --git a/android/src/main/res/drawable/ic_bluetooth_black_24dp.xml b/android/src/main/res/drawable/ic_bluetooth_black_24dp.xml new file mode 100644 index 0000000..b66af5a --- /dev/null +++ b/android/src/main/res/drawable/ic_bluetooth_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_bluetooth_start.xml b/android/src/main/res/drawable/ic_bluetooth_start.xml new file mode 100644 index 0000000..401e444 --- /dev/null +++ b/android/src/main/res/drawable/ic_bluetooth_start.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/android/src/main/res/drawable/ic_bookmark_black_24dp.xml b/android/src/main/res/drawable/ic_bookmark_black_24dp.xml new file mode 100644 index 0000000..3c36255 --- /dev/null +++ b/android/src/main/res/drawable/ic_bookmark_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_bookmark_border_black_24dp.xml b/android/src/main/res/drawable/ic_bookmark_border_black_24dp.xml new file mode 100644 index 0000000..65fc813 --- /dev/null +++ b/android/src/main/res/drawable/ic_bookmark_border_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_change_folder_white.xml b/android/src/main/res/drawable/ic_change_folder_white.xml new file mode 100644 index 0000000..33449da --- /dev/null +++ b/android/src/main/res/drawable/ic_change_folder_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_check_black_24dp.xml b/android/src/main/res/drawable/ic_check_black_24dp.xml new file mode 100644 index 0000000..54f825f --- /dev/null +++ b/android/src/main/res/drawable/ic_check_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_code_white_48dp.xml b/android/src/main/res/drawable/ic_code_white_48dp.xml new file mode 100644 index 0000000..4c00ca8 --- /dev/null +++ b/android/src/main/res/drawable/ic_code_white_48dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_collapse_all.xml b/android/src/main/res/drawable/ic_collapse_all.xml new file mode 100644 index 0000000..0c3a390 --- /dev/null +++ b/android/src/main/res/drawable/ic_collapse_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_create_file_white.xml b/android/src/main/res/drawable/ic_create_file_white.xml new file mode 100644 index 0000000..dd3b845 --- /dev/null +++ b/android/src/main/res/drawable/ic_create_file_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_create_new_folder_white.xml b/android/src/main/res/drawable/ic_create_new_folder_white.xml new file mode 100644 index 0000000..a0d4b91 --- /dev/null +++ b/android/src/main/res/drawable/ic_create_new_folder_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_delete_black_24dp.xml b/android/src/main/res/drawable/ic_delete_black_24dp.xml new file mode 100644 index 0000000..85d559d --- /dev/null +++ b/android/src/main/res/drawable/ic_delete_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_delete_file_white.xml b/android/src/main/res/drawable/ic_delete_file_white.xml new file mode 100644 index 0000000..513c2e7 --- /dev/null +++ b/android/src/main/res/drawable/ic_delete_file_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_delete_folder_white_48dp.xml b/android/src/main/res/drawable/ic_delete_folder_white_48dp.xml new file mode 100644 index 0000000..de3b171 --- /dev/null +++ b/android/src/main/res/drawable/ic_delete_folder_white_48dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_desktop_windows_black_24dp.xml b/android/src/main/res/drawable/ic_desktop_windows_black_24dp.xml new file mode 100644 index 0000000..1931c61 --- /dev/null +++ b/android/src/main/res/drawable/ic_desktop_windows_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_dns_black_24dp.xml b/android/src/main/res/drawable/ic_dns_black_24dp.xml new file mode 100644 index 0000000..baf9b16 --- /dev/null +++ b/android/src/main/res/drawable/ic_dns_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_edit_group.xml b/android/src/main/res/drawable/ic_edit_group.xml new file mode 100644 index 0000000..41ea8b4 --- /dev/null +++ b/android/src/main/res/drawable/ic_edit_group.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_electronics.xml b/android/src/main/res/drawable/ic_electronics.xml new file mode 100644 index 0000000..7146154 --- /dev/null +++ b/android/src/main/res/drawable/ic_electronics.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_expand_all.xml b/android/src/main/res/drawable/ic_expand_all.xml new file mode 100644 index 0000000..3842734 --- /dev/null +++ b/android/src/main/res/drawable/ic_expand_all.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_file.xml b/android/src/main/res/drawable/ic_file.xml new file mode 100644 index 0000000..d34a190 --- /dev/null +++ b/android/src/main/res/drawable/ic_file.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_file_content_white.xml b/android/src/main/res/drawable/ic_file_content_white.xml new file mode 100644 index 0000000..cd2f9c5 --- /dev/null +++ b/android/src/main/res/drawable/ic_file_content_white.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_file_copy_white_48dp.xml b/android/src/main/res/drawable/ic_file_copy_white_48dp.xml new file mode 100644 index 0000000..9983dad --- /dev/null +++ b/android/src/main/res/drawable/ic_file_copy_white_48dp.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_file_download_black_24dp.xml b/android/src/main/res/drawable/ic_file_download_black_24dp.xml new file mode 100644 index 0000000..fe2d559 --- /dev/null +++ b/android/src/main/res/drawable/ic_file_download_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_file_download_white.xml b/android/src/main/res/drawable/ic_file_download_white.xml new file mode 100644 index 0000000..e5f0bc0 --- /dev/null +++ b/android/src/main/res/drawable/ic_file_download_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_file_edit_white_48dp.xml b/android/src/main/res/drawable/ic_file_edit_white_48dp.xml new file mode 100644 index 0000000..fc2e655 --- /dev/null +++ b/android/src/main/res/drawable/ic_file_edit_white_48dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_file_link_white_48dp.xml b/android/src/main/res/drawable/ic_file_link_white_48dp.xml new file mode 100644 index 0000000..4794c93 --- /dev/null +++ b/android/src/main/res/drawable/ic_file_link_white_48dp.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/android/src/main/res/drawable/ic_file_move_white.xml b/android/src/main/res/drawable/ic_file_move_white.xml new file mode 100644 index 0000000..c678610 --- /dev/null +++ b/android/src/main/res/drawable/ic_file_move_white.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_file_permission_white_48dp.xml b/android/src/main/res/drawable/ic_file_permission_white_48dp.xml new file mode 100644 index 0000000..e7fd86a --- /dev/null +++ b/android/src/main/res/drawable/ic_file_permission_white_48dp.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_fingerprint_black_24dp.xml b/android/src/main/res/drawable/ic_fingerprint_black_24dp.xml new file mode 100644 index 0000000..1b5062a --- /dev/null +++ b/android/src/main/res/drawable/ic_fingerprint_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_flash_on_black_24dp.xml b/android/src/main/res/drawable/ic_flash_on_black_24dp.xml new file mode 100644 index 0000000..29ceb33 --- /dev/null +++ b/android/src/main/res/drawable/ic_flash_on_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_folder_black_40dp.xml b/android/src/main/res/drawable/ic_folder_black_40dp.xml new file mode 100644 index 0000000..858fe0a --- /dev/null +++ b/android/src/main/res/drawable/ic_folder_black_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_folder_list_white.xml b/android/src/main/res/drawable/ic_folder_list_white.xml new file mode 100644 index 0000000..03d63a5 --- /dev/null +++ b/android/src/main/res/drawable/ic_folder_list_white.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_folder_path_white.xml b/android/src/main/res/drawable/ic_folder_path_white.xml new file mode 100644 index 0000000..5af9b6b --- /dev/null +++ b/android/src/main/res/drawable/ic_folder_path_white.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_healing_black_24dp.xml b/android/src/main/res/drawable/ic_healing_black_24dp.xml new file mode 100644 index 0000000..56a526f --- /dev/null +++ b/android/src/main/res/drawable/ic_healing_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icon_bitcoin.xml b/android/src/main/res/drawable/ic_icon_bitcoin.xml new file mode 100644 index 0000000..ad37068 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_bitcoin.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icon_controller.xml b/android/src/main/res/drawable/ic_icon_controller.xml new file mode 100644 index 0000000..81a59fa --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_controller.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icon_emacs.xml b/android/src/main/res/drawable/ic_icon_emacs.xml new file mode 100644 index 0000000..7604df7 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_emacs.xml @@ -0,0 +1,24 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_icon_fun.xml b/android/src/main/res/drawable/ic_icon_fun.xml new file mode 100644 index 0000000..1cc59b4 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_fun.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icon_git.xml b/android/src/main/res/drawable/ic_icon_git.xml new file mode 100644 index 0000000..2a5f570 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_git.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icon_json.xml b/android/src/main/res/drawable/ic_icon_json.xml new file mode 100644 index 0000000..fab1f87 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_json.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icon_mouse.xml b/android/src/main/res/drawable/ic_icon_mouse.xml new file mode 100644 index 0000000..6d6bfc8 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_mouse.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icon_skull.xml b/android/src/main/res/drawable/ic_icon_skull.xml new file mode 100644 index 0000000..37268ed --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_skull.xml @@ -0,0 +1,23 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icon_system_task.xml b/android/src/main/res/drawable/ic_icon_system_task.xml new file mode 100644 index 0000000..832ac9d --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_system_task.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icon_user.xml b/android/src/main/res/drawable/ic_icon_user.xml new file mode 100644 index 0000000..b80c921 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_user.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icon_vim.xml b/android/src/main/res/drawable/ic_icon_vim.xml new file mode 100644 index 0000000..c51b8e2 --- /dev/null +++ b/android/src/main/res/drawable/ic_icon_vim.xml @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_add_trash.xml b/android/src/main/res/drawable/ic_icons8_add_trash.xml new file mode 100644 index 0000000..da7d986 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_add_trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_add_user.xml b/android/src/main/res/drawable/ic_icons8_add_user.xml new file mode 100644 index 0000000..a3b5737 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_add_user.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_add_user_group.xml b/android/src/main/res/drawable/ic_icons8_add_user_group.xml new file mode 100644 index 0000000..31981fc --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_add_user_group.xml @@ -0,0 +1,25 @@ + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_arrow.xml b/android/src/main/res/drawable/ic_icons8_arrow.xml new file mode 100644 index 0000000..827a527 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_arrow.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_bot.xml b/android/src/main/res/drawable/ic_icons8_bot.xml new file mode 100644 index 0000000..e6f71f3 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_bot.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_calendar_1.xml b/android/src/main/res/drawable/ic_icons8_calendar_1.xml new file mode 100644 index 0000000..dfebe31 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_calendar_1.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_cancel.xml b/android/src/main/res/drawable/ic_icons8_cancel.xml new file mode 100644 index 0000000..23ed847 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_cancel.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_change_user_male.xml b/android/src/main/res/drawable/ic_icons8_change_user_male.xml new file mode 100644 index 0000000..cec2192 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_change_user_male.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_circled_pause.xml b/android/src/main/res/drawable/ic_icons8_circled_pause.xml new file mode 100644 index 0000000..40f58c1 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_circled_pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_circled_play.xml b/android/src/main/res/drawable/ic_icons8_circled_play.xml new file mode 100644 index 0000000..4641ea3 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_circled_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_clipboard.xml b/android/src/main/res/drawable/ic_icons8_clipboard.xml new file mode 100644 index 0000000..209d362 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_clipboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_coin_wallet.xml b/android/src/main/res/drawable/ic_icons8_coin_wallet.xml new file mode 100644 index 0000000..060d9bc --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_coin_wallet.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_compass.xml b/android/src/main/res/drawable/ic_icons8_compass.xml new file mode 100644 index 0000000..e2c4269 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_compass.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_connected.xml b/android/src/main/res/drawable/ic_icons8_connected.xml new file mode 100644 index 0000000..65ecd7a --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_connected.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_console.xml b/android/src/main/res/drawable/ic_icons8_console.xml new file mode 100644 index 0000000..d77790c --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_console.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_copy.xml b/android/src/main/res/drawable/ic_icons8_copy.xml new file mode 100644 index 0000000..5520aef --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_copy.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_delete_trash.xml b/android/src/main/res/drawable/ic_icons8_delete_trash.xml new file mode 100644 index 0000000..a2c336d --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_delete_trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_document.xml b/android/src/main/res/drawable/ic_icons8_document.xml new file mode 100644 index 0000000..bc691cd --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_document.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_downloads_folder.xml b/android/src/main/res/drawable/ic_icons8_downloads_folder.xml new file mode 100644 index 0000000..bd39bc6 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_downloads_folder.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_edit_user.xml b/android/src/main/res/drawable/ic_icons8_edit_user.xml new file mode 100644 index 0000000..58f1383 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_edit_user.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_exe.xml b/android/src/main/res/drawable/ic_icons8_exe.xml new file mode 100644 index 0000000..775df1e --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_exe.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_file_preview.xml b/android/src/main/res/drawable/ic_icons8_file_preview.xml new file mode 100644 index 0000000..53c82b8 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_file_preview.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_flow_chart.xml b/android/src/main/res/drawable/ic_icons8_flow_chart.xml new file mode 100644 index 0000000..e111a91 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_flow_chart.xml @@ -0,0 +1,36 @@ + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_github.xml b/android/src/main/res/drawable/ic_icons8_github.xml new file mode 100644 index 0000000..4fce8e1 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_github.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_golden_fever.xml b/android/src/main/res/drawable/ic_icons8_golden_fever.xml new file mode 100644 index 0000000..456430a --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_golden_fever.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_group_foreground_selected.xml b/android/src/main/res/drawable/ic_icons8_group_foreground_selected.xml new file mode 100644 index 0000000..da4b5c4 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_group_foreground_selected.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_hand_with_pen.xml b/android/src/main/res/drawable/ic_icons8_hand_with_pen.xml new file mode 100644 index 0000000..f19002f --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_hand_with_pen.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_hdd.xml b/android/src/main/res/drawable/ic_icons8_hdd.xml new file mode 100644 index 0000000..3923375 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_hdd.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_hide.xml b/android/src/main/res/drawable/ic_icons8_hide.xml new file mode 100644 index 0000000..61fcebc --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_hide.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_home.xml b/android/src/main/res/drawable/ic_icons8_home.xml new file mode 100644 index 0000000..eb200cc --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_home.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_keyboard.xml b/android/src/main/res/drawable/ic_icons8_keyboard.xml new file mode 100644 index 0000000..9274d56 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_keyboard.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_kitchen_scales.xml b/android/src/main/res/drawable/ic_icons8_kitchen_scales.xml new file mode 100644 index 0000000..d9a4253 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_kitchen_scales.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_linux.xml b/android/src/main/res/drawable/ic_icons8_linux.xml new file mode 100644 index 0000000..bded048 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_linux.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_merge.xml b/android/src/main/res/drawable/ic_icons8_merge.xml new file mode 100644 index 0000000..a0b283b --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_merge.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_moderator_male.xml b/android/src/main/res/drawable/ic_icons8_moderator_male.xml new file mode 100644 index 0000000..2e9bbf9 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_moderator_male.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_mother.xml b/android/src/main/res/drawable/ic_icons8_mother.xml new file mode 100644 index 0000000..78541bf --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_mother.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_network_card.xml b/android/src/main/res/drawable/ic_icons8_network_card.xml new file mode 100644 index 0000000..5c025ac --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_network_card.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_new.xml b/android/src/main/res/drawable/ic_icons8_new.xml new file mode 100644 index 0000000..09b39f3 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_new.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_numbered_list.xml b/android/src/main/res/drawable/ic_icons8_numbered_list.xml new file mode 100644 index 0000000..adbb3e6 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_numbered_list.xml @@ -0,0 +1,45 @@ + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_ping_pong.xml b/android/src/main/res/drawable/ic_icons8_ping_pong.xml new file mode 100644 index 0000000..2e1c8d7 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_ping_pong.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_plus.xml b/android/src/main/res/drawable/ic_icons8_plus.xml new file mode 100644 index 0000000..3ebe830 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_print.xml b/android/src/main/res/drawable/ic_icons8_print.xml new file mode 100644 index 0000000..b4bf1b7 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_print.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_print_file.xml b/android/src/main/res/drawable/ic_icons8_print_file.xml new file mode 100644 index 0000000..5221af2 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_print_file.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_redo.xml b/android/src/main/res/drawable/ic_icons8_redo.xml new file mode 100644 index 0000000..26df61b --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_redo.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_remove_user_male.xml b/android/src/main/res/drawable/ic_icons8_remove_user_male.xml new file mode 100644 index 0000000..bed659f --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_remove_user_male.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_restore_window.xml b/android/src/main/res/drawable/ic_icons8_restore_window.xml new file mode 100644 index 0000000..02f87b3 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_restore_window.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_reuse.xml b/android/src/main/res/drawable/ic_icons8_reuse.xml new file mode 100644 index 0000000..a1c06c5 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_reuse.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_root_server.xml b/android/src/main/res/drawable/ic_icons8_root_server.xml new file mode 100644 index 0000000..7a418b4 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_root_server.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_save.xml b/android/src/main/res/drawable/ic_icons8_save.xml new file mode 100644 index 0000000..66438f6 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_save.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_show_property.xml b/android/src/main/res/drawable/ic_icons8_show_property.xml new file mode 100644 index 0000000..449e013 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_show_property.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_synchronize.xml b/android/src/main/res/drawable/ic_icons8_synchronize.xml new file mode 100644 index 0000000..a198047 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_synchronize.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_talk.xml b/android/src/main/res/drawable/ic_icons8_talk.xml new file mode 100644 index 0000000..2e30f79 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_talk.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_teacher_hiring.xml b/android/src/main/res/drawable/ic_icons8_teacher_hiring.xml new file mode 100644 index 0000000..05f8ee7 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_teacher_hiring.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_text.xml b/android/src/main/res/drawable/ic_icons8_text.xml new file mode 100644 index 0000000..624f64e --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_text.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_treatment.xml b/android/src/main/res/drawable/ic_icons8_treatment.xml new file mode 100644 index 0000000..d391687 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_treatment.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_tv_off.xml b/android/src/main/res/drawable/ic_icons8_tv_off.xml new file mode 100644 index 0000000..76393f3 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_tv_off.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_tv_on.xml b/android/src/main/res/drawable/ic_icons8_tv_on.xml new file mode 100644 index 0000000..cc596e0 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_tv_on.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_icons8_undo.xml b/android/src/main/res/drawable/ic_icons8_undo.xml new file mode 100644 index 0000000..53418d7 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_undo.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_user_folder.xml b/android/src/main/res/drawable/ic_icons8_user_folder.xml new file mode 100644 index 0000000..a677f47 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_user_folder.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_user_male_circle.xml b/android/src/main/res/drawable/ic_icons8_user_male_circle.xml new file mode 100644 index 0000000..f437867 --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_user_male_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_icons8_visible.xml b/android/src/main/res/drawable/ic_icons8_visible.xml new file mode 100644 index 0000000..e5c7bfa --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_visible.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_icons8_work.xml b/android/src/main/res/drawable/ic_icons8_work.xml new file mode 100644 index 0000000..0a8d85e --- /dev/null +++ b/android/src/main/res/drawable/ic_icons8_work.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_idea.xml b/android/src/main/res/drawable/ic_idea.xml new file mode 100644 index 0000000..5cca8b4 --- /dev/null +++ b/android/src/main/res/drawable/ic_idea.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_info_40dp.xml b/android/src/main/res/drawable/ic_info_40dp.xml new file mode 100644 index 0000000..62c8922 --- /dev/null +++ b/android/src/main/res/drawable/ic_info_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_info_black_24dp.xml b/android/src/main/res/drawable/ic_info_black_24dp.xml new file mode 100644 index 0000000..62c8922 --- /dev/null +++ b/android/src/main/res/drawable/ic_info_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_ip_address.xml b/android/src/main/res/drawable/ic_ip_address.xml new file mode 100644 index 0000000..6a7ecc0 --- /dev/null +++ b/android/src/main/res/drawable/ic_ip_address.xml @@ -0,0 +1,12 @@ + + + + diff --git a/android/src/main/res/drawable/ic_keyboard_arrow_down_black_24dp.xml b/android/src/main/res/drawable/ic_keyboard_arrow_down_black_24dp.xml new file mode 100644 index 0000000..4dc68f4 --- /dev/null +++ b/android/src/main/res/drawable/ic_keyboard_arrow_down_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_keyboard_arrow_up_black_24dp.xml b/android/src/main/res/drawable/ic_keyboard_arrow_up_black_24dp.xml new file mode 100644 index 0000000..0d0881d --- /dev/null +++ b/android/src/main/res/drawable/ic_keyboard_arrow_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_keyboard_black_24dp.xml b/android/src/main/res/drawable/ic_keyboard_black_24dp.xml new file mode 100644 index 0000000..b00c708 --- /dev/null +++ b/android/src/main/res/drawable/ic_keyboard_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_list_groups_white_48dp.xml b/android/src/main/res/drawable/ic_list_groups_white_48dp.xml new file mode 100644 index 0000000..75bed83 --- /dev/null +++ b/android/src/main/res/drawable/ic_list_groups_white_48dp.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_list_interfaces_white_48dp.xml b/android/src/main/res/drawable/ic_list_interfaces_white_48dp.xml new file mode 100644 index 0000000..1269650 --- /dev/null +++ b/android/src/main/res/drawable/ic_list_interfaces_white_48dp.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/android/src/main/res/drawable/ic_list_sockets_white_48dp.xml b/android/src/main/res/drawable/ic_list_sockets_white_48dp.xml new file mode 100644 index 0000000..f933d9d --- /dev/null +++ b/android/src/main/res/drawable/ic_list_sockets_white_48dp.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_list_user_white_48dp.xml b/android/src/main/res/drawable/ic_list_user_white_48dp.xml new file mode 100644 index 0000000..cddff0b --- /dev/null +++ b/android/src/main/res/drawable/ic_list_user_white_48dp.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_loupe_black_24dp.xml b/android/src/main/res/drawable/ic_loupe_black_24dp.xml new file mode 100644 index 0000000..a75df65 --- /dev/null +++ b/android/src/main/res/drawable/ic_loupe_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_memory_slot.xml b/android/src/main/res/drawable/ic_memory_slot.xml new file mode 100644 index 0000000..1c64248 --- /dev/null +++ b/android/src/main/res/drawable/ic_memory_slot.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_network_card_40dp.xml b/android/src/main/res/drawable/ic_network_card_40dp.xml new file mode 100644 index 0000000..5c025ac --- /dev/null +++ b/android/src/main/res/drawable/ic_network_card_40dp.xml @@ -0,0 +1,33 @@ + + + + + + + + diff --git a/android/src/main/res/drawable/ic_org_genocide.xml b/android/src/main/res/drawable/ic_org_genocide.xml new file mode 100644 index 0000000..6e3bf11 --- /dev/null +++ b/android/src/main/res/drawable/ic_org_genocide.xml @@ -0,0 +1,2606 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_package_40.xml b/android/src/main/res/drawable/ic_package_40.xml new file mode 100644 index 0000000..02bc142 --- /dev/null +++ b/android/src/main/res/drawable/ic_package_40.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_power_settings_new_black_24dp.xml b/android/src/main/res/drawable/ic_power_settings_new_black_24dp.xml new file mode 100644 index 0000000..8065a58 --- /dev/null +++ b/android/src/main/res/drawable/ic_power_settings_new_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_public_black_24dp.xml b/android/src/main/res/drawable/ic_public_black_24dp.xml new file mode 100644 index 0000000..22788a0 --- /dev/null +++ b/android/src/main/res/drawable/ic_public_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_puzzle.xml b/android/src/main/res/drawable/ic_puzzle.xml new file mode 100644 index 0000000..dfc047e --- /dev/null +++ b/android/src/main/res/drawable/ic_puzzle.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_refresh_black_24dp.xml b/android/src/main/res/drawable/ic_refresh_black_24dp.xml new file mode 100644 index 0000000..9f9c433 --- /dev/null +++ b/android/src/main/res/drawable/ic_refresh_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_remote.xml b/android/src/main/res/drawable/ic_remote.xml new file mode 100644 index 0000000..7f661df --- /dev/null +++ b/android/src/main/res/drawable/ic_remote.xml @@ -0,0 +1,990 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_remove_user_from_group_white_48dp.xml b/android/src/main/res/drawable/ic_remove_user_from_group_white_48dp.xml new file mode 100644 index 0000000..3dd3ee4 --- /dev/null +++ b/android/src/main/res/drawable/ic_remove_user_from_group_white_48dp.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_remove_user_group.xml b/android/src/main/res/drawable/ic_remove_user_group.xml new file mode 100644 index 0000000..0d50345 --- /dev/null +++ b/android/src/main/res/drawable/ic_remove_user_group.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_report_black_24dp.xml b/android/src/main/res/drawable/ic_report_black_24dp.xml new file mode 100644 index 0000000..fc2d15a --- /dev/null +++ b/android/src/main/res/drawable/ic_report_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_search_40dp.xml b/android/src/main/res/drawable/ic_search_40dp.xml new file mode 100644 index 0000000..17e259b --- /dev/null +++ b/android/src/main/res/drawable/ic_search_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_search_black_24dp.xml b/android/src/main/res/drawable/ic_search_black_24dp.xml new file mode 100644 index 0000000..17e259b --- /dev/null +++ b/android/src/main/res/drawable/ic_search_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_search_executeable_man_white_48dp.xml b/android/src/main/res/drawable/ic_search_executeable_man_white_48dp.xml new file mode 100644 index 0000000..b816d62 --- /dev/null +++ b/android/src/main/res/drawable/ic_search_executeable_man_white_48dp.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_search_history_white_48dp.xml b/android/src/main/res/drawable/ic_search_history_white_48dp.xml new file mode 100644 index 0000000..8661239 --- /dev/null +++ b/android/src/main/res/drawable/ic_search_history_white_48dp.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_search_in_file_white_48dp.xml b/android/src/main/res/drawable/ic_search_in_file_white_48dp.xml new file mode 100644 index 0000000..b04442a --- /dev/null +++ b/android/src/main/res/drawable/ic_search_in_file_white_48dp.xml @@ -0,0 +1,14 @@ + + + + diff --git a/android/src/main/res/drawable/ic_search_list_white_48dp.xml b/android/src/main/res/drawable/ic_search_list_white_48dp.xml new file mode 100644 index 0000000..985ada3 --- /dev/null +++ b/android/src/main/res/drawable/ic_search_list_white_48dp.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_search_source_man_white_48dp.xml b/android/src/main/res/drawable/ic_search_source_man_white_48dp.xml new file mode 100644 index 0000000..b1d9d4a --- /dev/null +++ b/android/src/main/res/drawable/ic_search_source_man_white_48dp.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/android/src/main/res/drawable/ic_security_black_24dp.xml b/android/src/main/res/drawable/ic_security_black_24dp.xml new file mode 100644 index 0000000..00579c2 --- /dev/null +++ b/android/src/main/res/drawable/ic_security_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_settings_black_24dp.xml b/android/src/main/res/drawable/ic_settings_black_24dp.xml new file mode 100644 index 0000000..aaeae6b --- /dev/null +++ b/android/src/main/res/drawable/ic_settings_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_settings_black_40dp.xml b/android/src/main/res/drawable/ic_settings_black_40dp.xml new file mode 100644 index 0000000..aaeae6b --- /dev/null +++ b/android/src/main/res/drawable/ic_settings_black_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_share_black_24dp.xml b/android/src/main/res/drawable/ic_share_black_24dp.xml new file mode 100644 index 0000000..7f2829b --- /dev/null +++ b/android/src/main/res/drawable/ic_share_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_skull_40dp.xml b/android/src/main/res/drawable/ic_skull_40dp.xml new file mode 100644 index 0000000..d3c05b9 --- /dev/null +++ b/android/src/main/res/drawable/ic_skull_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_stop_bluetooth.xml b/android/src/main/res/drawable/ic_stop_bluetooth.xml new file mode 100644 index 0000000..1397a85 --- /dev/null +++ b/android/src/main/res/drawable/ic_stop_bluetooth.xml @@ -0,0 +1,29 @@ + + + + + + + diff --git a/android/src/main/res/drawable/ic_stop_wifi.xml b/android/src/main/res/drawable/ic_stop_wifi.xml new file mode 100644 index 0000000..47395d4 --- /dev/null +++ b/android/src/main/res/drawable/ic_stop_wifi.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/src/main/res/drawable/ic_storage_black_24dp.xml b/android/src/main/res/drawable/ic_storage_black_24dp.xml new file mode 100644 index 0000000..28cecff --- /dev/null +++ b/android/src/main/res/drawable/ic_storage_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_switch_video_white_48dp.xml b/android/src/main/res/drawable/ic_switch_video_white_48dp.xml new file mode 100644 index 0000000..56c53eb --- /dev/null +++ b/android/src/main/res/drawable/ic_switch_video_white_48dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/src/main/res/drawable/ic_timer_black_24dp.xml b/android/src/main/res/drawable/ic_timer_black_24dp.xml new file mode 100644 index 0000000..56d6c02 --- /dev/null +++ b/android/src/main/res/drawable/ic_timer_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_toolbar_search_black_24dp.xml b/android/src/main/res/drawable/ic_toolbar_search_black_24dp.xml new file mode 100644 index 0000000..2ba4cf3 --- /dev/null +++ b/android/src/main/res/drawable/ic_toolbar_search_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_usb_black_48dp.xml b/android/src/main/res/drawable/ic_usb_black_48dp.xml new file mode 100644 index 0000000..b8e73d2 --- /dev/null +++ b/android/src/main/res/drawable/ic_usb_black_48dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_user_male_circle_40dp.xml b/android/src/main/res/drawable/ic_user_male_circle_40dp.xml new file mode 100644 index 0000000..2f2ccf2 --- /dev/null +++ b/android/src/main/res/drawable/ic_user_male_circle_40dp.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_user_password.xml b/android/src/main/res/drawable/ic_user_password.xml new file mode 100644 index 0000000..c0b4038 --- /dev/null +++ b/android/src/main/res/drawable/ic_user_password.xml @@ -0,0 +1,34 @@ + + + + + + + + + \ No newline at end of file diff --git a/android/src/main/res/drawable/ic_video_trimming_40dp.xml b/android/src/main/res/drawable/ic_video_trimming_40dp.xml new file mode 100644 index 0000000..c62719a --- /dev/null +++ b/android/src/main/res/drawable/ic_video_trimming_40dp.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + diff --git a/android/src/main/res/drawable/ic_videogame_asset_black_24dp.xml b/android/src/main/res/drawable/ic_videogame_asset_black_24dp.xml new file mode 100644 index 0000000..461ff4c --- /dev/null +++ b/android/src/main/res/drawable/ic_videogame_asset_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_videogame_asset_black_40dp.xml b/android/src/main/res/drawable/ic_videogame_asset_black_40dp.xml new file mode 100644 index 0000000..461ff4c --- /dev/null +++ b/android/src/main/res/drawable/ic_videogame_asset_black_40dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_vip_lookup_white_48dp.xml b/android/src/main/res/drawable/ic_vip_lookup_white_48dp.xml new file mode 100644 index 0000000..0ff876c --- /dev/null +++ b/android/src/main/res/drawable/ic_vip_lookup_white_48dp.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/android/src/main/res/drawable/ic_volume_off_black_24dp.xml b/android/src/main/res/drawable/ic_volume_off_black_24dp.xml new file mode 100644 index 0000000..7ae7ab9 --- /dev/null +++ b/android/src/main/res/drawable/ic_volume_off_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_volume_up_black_24dp.xml b/android/src/main/res/drawable/ic_volume_up_black_24dp.xml new file mode 100644 index 0000000..0020cdc --- /dev/null +++ b/android/src/main/res/drawable/ic_volume_up_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_vpn_key_black_24dp.xml b/android/src/main/res/drawable/ic_vpn_key_black_24dp.xml new file mode 100644 index 0000000..3856a04 --- /dev/null +++ b/android/src/main/res/drawable/ic_vpn_key_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_webcam_white_48dp.xml b/android/src/main/res/drawable/ic_webcam_white_48dp.xml new file mode 100644 index 0000000..b704747 --- /dev/null +++ b/android/src/main/res/drawable/ic_webcam_white_48dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_wifi_black_24dp.xml b/android/src/main/res/drawable/ic_wifi_black_24dp.xml new file mode 100644 index 0000000..9d07e49 --- /dev/null +++ b/android/src/main/res/drawable/ic_wifi_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/src/main/res/drawable/ic_wifi_start.xml b/android/src/main/res/drawable/ic_wifi_start.xml new file mode 100644 index 0000000..a5925b7 --- /dev/null +++ b/android/src/main/res/drawable/ic_wifi_start.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/android/src/main/res/drawable/monochrome.xml b/android/src/main/res/drawable/monochrome.xml new file mode 100644 index 0000000..520a273 --- /dev/null +++ b/android/src/main/res/drawable/monochrome.xml @@ -0,0 +1,17 @@ + + + + + + + diff --git a/android/src/main/res/font/share_tech_mono.ttf b/android/src/main/res/font/share_tech_mono.ttf new file mode 100644 index 0000000..e8d9523 Binary files /dev/null and b/android/src/main/res/font/share_tech_mono.ttf differ diff --git a/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f6c06b0 --- /dev/null +++ b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..d1819e1 --- /dev/null +++ b/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher.png b/android/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..91a5d69 Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4443a63 Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..f4e7c8c Binary files /dev/null and b/android/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher.png b/android/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..e1e3674 Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..e330299 Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..4310871 Binary files /dev/null and b/android/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..20be3de Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..67986d4 Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dc27a40 Binary files /dev/null and b/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..51f8355 Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a2df765 Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..54c7192 Binary files /dev/null and b/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..1e07599 Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9b6045d Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..40e8c6b Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/src/main/res/mipmap-xxxhdpi/linode_horizontal.webp b/android/src/main/res/mipmap-xxxhdpi/linode_horizontal.webp new file mode 100644 index 0000000..b1fbd5a Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/linode_horizontal.webp differ diff --git a/android/src/main/res/mipmap-xxxhdpi/proton_free_horizontal.webp b/android/src/main/res/mipmap-xxxhdpi/proton_free_horizontal.webp new file mode 100644 index 0000000..209f219 Binary files /dev/null and b/android/src/main/res/mipmap-xxxhdpi/proton_free_horizontal.webp differ diff --git a/android/src/main/res/values/colors.xml b/android/src/main/res/values/colors.xml new file mode 100644 index 0000000..5a6fb95 --- /dev/null +++ b/android/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #f44336 + \ No newline at end of file diff --git a/android/src/main/res/values/strings.xml b/android/src/main/res/values/strings.xml new file mode 100644 index 0000000..4414bc1 --- /dev/null +++ b/android/src/main/res/values/strings.xml @@ -0,0 +1,18 @@ + + + + Linux Command Library + Commands + Basics + Tips + Info + Add bookmark + Remove bookmark + Collapse all + Expand all + Search + Back + Reset + Share + + \ No newline at end of file diff --git a/android/src/test/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModelTest.kt b/android/src/test/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModelTest.kt new file mode 100644 index 0000000..d37a561 --- /dev/null +++ b/android/src/test/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/basicgroups/BasicGroupsViewModelTest.kt @@ -0,0 +1,139 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.basicgroups + +import app.cash.turbine.test +import com.linuxcommandlibrary.shared.databaseHelper +import databases.BasicGroup +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class BasicGroupsViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(databaseHelper) // Assuming databaseHelper is an object + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkObject(databaseHelper) + } + + @Test + fun `init populates basicGroups and initializes empty collapsedMap`() = runTest { + val categoryId = 1L + val mockGroups = listOf( + BasicGroup(1L, "Group 1", categoryId, 0, "icon1"), + BasicGroup(2L, "Group 2", categoryId, 0, "icon2"), + ) + every { databaseHelper.getBasicGroupsByQuery(categoryId) } returns mockGroups + + val viewModel = BasicGroupsViewModel(categoryId) + + viewModel.uiState.test { + val state = awaitItem() + assertEquals(mockGroups.size, state.basicGroups.size) + assertEquals("Group 1", state.basicGroups[0].description) + assertTrue(state.collapsedMap.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `toggleCollapse updates collapsedMap correctly`() = runTest { + val categoryId = 1L + every { databaseHelper.getBasicGroupsByQuery(categoryId) } returns emptyList() + + val viewModel = BasicGroupsViewModel(categoryId) + val groupId = 123L + + // Initial state + assertFalse(viewModel.isGroupCollapsed(groupId)) + + // Toggle once + viewModel.toggleCollapse(groupId) + testDispatcher.scheduler.runCurrent() // Advance time for the update to process + assertTrue(viewModel.uiState.value.collapsedMap.getOrDefault(groupId, false)) + assertTrue(viewModel.isGroupCollapsed(groupId)) + + // Toggle again + viewModel.toggleCollapse(groupId) + testDispatcher.scheduler.runCurrent() // Advance time + assertFalse(viewModel.uiState.value.collapsedMap.getOrDefault(groupId, false)) + assertFalse(viewModel.isGroupCollapsed(groupId)) + } + + @Test + fun `toggleCollapse only affects the specified group`() = runTest { + val categoryId = 1L + every { databaseHelper.getBasicGroupsByQuery(categoryId) } returns emptyList() + + val viewModel = BasicGroupsViewModel(categoryId) + val groupId1 = 1L + val groupId2 = 2L + + // Collapse group 1 + viewModel.toggleCollapse(groupId1) + testDispatcher.scheduler.runCurrent() + + assertTrue(viewModel.uiState.value.collapsedMap.getOrDefault(groupId1, false)) + assertFalse(viewModel.uiState.value.collapsedMap.getOrDefault(groupId2, false)) + + // Collapse group 2 + viewModel.toggleCollapse(groupId2) + testDispatcher.scheduler.runCurrent() + + assertTrue(viewModel.uiState.value.collapsedMap.getOrDefault(groupId1, false)) // Should still be true + assertTrue(viewModel.uiState.value.collapsedMap.getOrDefault(groupId2, false)) + } + + @Test + fun `isGroupCollapsed returns correct state`() = runTest { + val categoryId = 1L + every { databaseHelper.getBasicGroupsByQuery(categoryId) } returns emptyList() + val viewModel = BasicGroupsViewModel(categoryId) + val groupId = 1L + + assertFalse(viewModel.isGroupCollapsed(groupId)) // Default + + viewModel.toggleCollapse(groupId) + testDispatcher.scheduler.runCurrent() + assertTrue(viewModel.isGroupCollapsed(groupId)) + + viewModel.toggleCollapse(groupId) + testDispatcher.scheduler.runCurrent() + assertFalse(viewModel.isGroupCollapsed(groupId)) + } + + @Test + fun `init with empty groups results in empty basicGroups list`() = runTest { + val categoryId = 1L + every { databaseHelper.getBasicGroupsByQuery(categoryId) } returns emptyList() + + val viewModel = BasicGroupsViewModel(categoryId) + + viewModel.uiState.test { + val state = awaitItem() + assertTrue(state.basicGroups.isEmpty()) + assertTrue(state.collapsedMap.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/android/src/test/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchViewModelTest.kt b/android/src/test/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchViewModelTest.kt new file mode 100644 index 0000000..c3d17e1 --- /dev/null +++ b/android/src/test/java/com/inspiredandroid/linuxcommandbibliotheca/ui/screens/search/SearchViewModelTest.kt @@ -0,0 +1,163 @@ +package com.inspiredandroid.linuxcommandbibliotheca.ui.screens.search + +import app.cash.turbine.test +import com.linuxcommandlibrary.shared.databaseHelper +import databases.BasicGroup +import databases.Command +import io.mockk.coEvery +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(databaseHelper) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkObject(databaseHelper) + } + + @Test + fun `init creates empty state`() = runTest { + val viewModel = SearchViewModel() + viewModel.uiState.test { + val state = awaitItem() + assertTrue(state.filteredCommands.isEmpty()) + assertTrue(state.filteredBasicGroups.isEmpty()) + assertTrue(state.collapsedMap.isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `search updates filteredCommands and filteredBasicGroups`() = runTest { + val searchText = "test" + val mockCommands = listOf(Command(1L, 0L, "cmd1", "desc1")) + val mockBasicGroups = listOf(BasicGroup(1L, "Group 1", 0L, 0, "icon1")) + + coEvery { databaseHelper.getCommandsByQuery(searchText) } returns mockCommands + coEvery { databaseHelper.getBasicGroupsByQuery(searchText) } returns mockBasicGroups + + val viewModel = SearchViewModel() + viewModel.search(searchText) + testDispatcher.scheduler.advanceUntilIdle() // Ensure coroutines complete + + viewModel.uiState.test { + val state = expectMostRecentItem() // Get the latest state + assertEquals(1, state.filteredCommands.size) + assertEquals("cmd1", state.filteredCommands[0].name) + assertEquals(1, state.filteredBasicGroups.size) + assertEquals("Group 1", state.filteredBasicGroups[0].description) + assertTrue(state.collapsedMap.isEmpty()) // Should not change + } + } + + @Test + fun `search with blank text clears results`() = runTest { + // Populate with some initial data + val initialSearchText = "test" + val mockCommands = listOf(Command(1L, 0L, "cmd1", "desc1")) + val mockBasicGroups = listOf(BasicGroup(1L, "Group 1", 0L, 0, "icon1")) + coEvery { databaseHelper.getCommandsByQuery(initialSearchText) } returns mockCommands + coEvery { databaseHelper.getBasicGroupsByQuery(initialSearchText) } returns mockBasicGroups + + val viewModel = SearchViewModel() + viewModel.search(initialSearchText) + testDispatcher.scheduler.advanceUntilIdle() + + // Ensure initial data is there + var latestState = viewModel.uiState.value + assertFalse(latestState.filteredCommands.isEmpty()) + assertFalse(latestState.filteredBasicGroups.isEmpty()) + + // Search with blank text + viewModel.search(" ") // Blank text + testDispatcher.scheduler.advanceUntilIdle() + + latestState = viewModel.uiState.value + assertTrue(latestState.filteredCommands.isEmpty()) + assertTrue(latestState.filteredBasicGroups.isEmpty()) + } + + @Test + fun `search cancels previous search`() = runTest { + val searchText1 = "search1" + val searchText2 = "search2" + + coEvery { databaseHelper.getCommandsByQuery(searchText1) } coAnswers { + delay(500) // Simulate long running search + listOf(Command(1L, 0L, "cmd1_from_search1", "desc1")) + } + coEvery { databaseHelper.getBasicGroupsByQuery(searchText1) } returns emptyList() + + coEvery { databaseHelper.getCommandsByQuery(searchText2) } returns listOf(Command(2L, 0L, "cmd2_from_search2", "desc2")) + coEvery { databaseHelper.getBasicGroupsByQuery(searchText2) } returns emptyList() + + val viewModel = SearchViewModel() + + viewModel.search(searchText1) + testDispatcher.scheduler.advanceTimeBy(100) // Start search1 but don't let it finish + + viewModel.search(searchText2) // This should cancel search1 + testDispatcher.scheduler.advanceUntilIdle() // Let search2 complete + + val state = viewModel.uiState.value + assertEquals(1, state.filteredCommands.size) + assertEquals("cmd2_from_search2", state.filteredCommands[0].name) // Only results from search2 + } + + @Test + fun `toggleCollapse updates collapsedMap correctly`() = runTest { + val viewModel = SearchViewModel() + val groupId = 123L + + assertFalse(viewModel.isGroupCollapsed(groupId)) + + viewModel.toggleCollapse(groupId) + testDispatcher.scheduler.runCurrent() + assertTrue(viewModel.uiState.value.collapsedMap.getOrDefault(groupId, false)) + assertTrue(viewModel.isGroupCollapsed(groupId)) + + viewModel.toggleCollapse(groupId) + testDispatcher.scheduler.runCurrent() + assertFalse(viewModel.uiState.value.collapsedMap.getOrDefault(groupId, false)) + assertFalse(viewModel.isGroupCollapsed(groupId)) + + // Ensure search results are not affected + assertTrue(viewModel.uiState.value.filteredCommands.isEmpty()) + assertTrue(viewModel.uiState.value.filteredBasicGroups.isEmpty()) + } + + @Test + fun `isGroupCollapsed returns correct state`() = runTest { + val viewModel = SearchViewModel() + val groupId = 1L + + assertFalse(viewModel.isGroupCollapsed(groupId)) // Default + + viewModel.toggleCollapse(groupId) + testDispatcher.scheduler.runCurrent() + assertTrue(viewModel.isGroupCollapsed(groupId)) + } +} diff --git a/art/assetlinks.json b/art/assetlinks.json new file mode 100644 index 0000000..1e5b4f7 --- /dev/null +++ b/art/assetlinks.json @@ -0,0 +1,14 @@ +[ + { + "relation": [ + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "com.inspiredandroid.linuxcommandbibliotheca", + "sha256_cert_fingerprints": [ + "01:76:7F:F9:25:17:A7:25:AF:AE:87:3F:40:E3:67:BD:A4:2F:0E:DB:A5:65:3A:D1:5C:AF:04:93:C6:43:5E:60" + ] + } + } +] \ No newline at end of file diff --git a/art/fdroid_badge.png b/art/fdroid_badge.png new file mode 100644 index 0000000..60521a3 Binary files /dev/null and b/art/fdroid_badge.png differ diff --git a/art/feature.png b/art/feature.png new file mode 100644 index 0000000..31ba40b Binary files /dev/null and b/art/feature.png differ diff --git a/art/funktions.png b/art/funktions.png new file mode 100644 index 0000000..70d0109 Binary files /dev/null and b/art/funktions.png differ diff --git a/art/funktions.xcf b/art/funktions.xcf new file mode 100644 index 0000000..e8611f3 Binary files /dev/null and b/art/funktions.xcf differ diff --git a/art/funktions_1024x576.png b/art/funktions_1024x576.png new file mode 100644 index 0000000..30d2038 Binary files /dev/null and b/art/funktions_1024x576.png differ diff --git a/art/funktions_huawei_1.png b/art/funktions_huawei_1.png new file mode 100644 index 0000000..2d537f8 Binary files /dev/null and b/art/funktions_huawei_1.png differ diff --git a/art/funktions_huawei_2.png b/art/funktions_huawei_2.png new file mode 100644 index 0000000..6be8e7b Binary files /dev/null and b/art/funktions_huawei_2.png differ diff --git a/art/funktions_huawei_3.png b/art/funktions_huawei_3.png new file mode 100644 index 0000000..c3a8a64 Binary files /dev/null and b/art/funktions_huawei_3.png differ diff --git a/art/funktions_huawei_4.png b/art/funktions_huawei_4.png new file mode 100644 index 0000000..47fe364 Binary files /dev/null and b/art/funktions_huawei_4.png differ diff --git a/art/funktions_huawei_5.jpg b/art/funktions_huawei_5.jpg new file mode 100644 index 0000000..5007eab Binary files /dev/null and b/art/funktions_huawei_5.jpg differ diff --git a/art/funktions_huawei_5.png b/art/funktions_huawei_5.png new file mode 100644 index 0000000..beb943c Binary files /dev/null and b/art/funktions_huawei_5.png differ diff --git a/art/ios_1024.png b/art/ios_1024.png new file mode 100644 index 0000000..1f2e6f6 Binary files /dev/null and b/art/ios_1024.png differ diff --git a/art/ios_512.png b/art/ios_512.png new file mode 100644 index 0000000..fab209a Binary files /dev/null and b/art/ios_512.png differ diff --git a/art/play_store_badge.png b/art/play_store_badge.png new file mode 100644 index 0000000..1b10bd6 Binary files /dev/null and b/art/play_store_badge.png differ diff --git a/art/screen-1-dark.png b/art/screen-1-dark.png new file mode 100644 index 0000000..741ff58 Binary files /dev/null and b/art/screen-1-dark.png differ diff --git a/art/screen-1-tablet-dark.png b/art/screen-1-tablet-dark.png new file mode 100644 index 0000000..8e8b040 Binary files /dev/null and b/art/screen-1-tablet-dark.png differ diff --git a/art/screen-1-tablet.png b/art/screen-1-tablet.png new file mode 100644 index 0000000..849ca35 Binary files /dev/null and b/art/screen-1-tablet.png differ diff --git a/art/screen-1.png b/art/screen-1.png new file mode 100644 index 0000000..77edb6e Binary files /dev/null and b/art/screen-1.png differ diff --git a/art/screen-2-dark.png b/art/screen-2-dark.png new file mode 100644 index 0000000..56a16be Binary files /dev/null and b/art/screen-2-dark.png differ diff --git a/art/screen-2-tablet-dark.png b/art/screen-2-tablet-dark.png new file mode 100644 index 0000000..0ddf464 Binary files /dev/null and b/art/screen-2-tablet-dark.png differ diff --git a/art/screen-2-tablet.png b/art/screen-2-tablet.png new file mode 100644 index 0000000..e7c2357 Binary files /dev/null and b/art/screen-2-tablet.png differ diff --git a/art/screen-2.png b/art/screen-2.png new file mode 100644 index 0000000..95ee774 Binary files /dev/null and b/art/screen-2.png differ diff --git a/art/screen-3-dark.png b/art/screen-3-dark.png new file mode 100644 index 0000000..854a966 Binary files /dev/null and b/art/screen-3-dark.png differ diff --git a/art/screen-3.png b/art/screen-3.png new file mode 100644 index 0000000..b312557 Binary files /dev/null and b/art/screen-3.png differ diff --git a/art/screen-4-dark.png b/art/screen-4-dark.png new file mode 100644 index 0000000..cde4e06 Binary files /dev/null and b/art/screen-4-dark.png differ diff --git a/art/screen-4.png b/art/screen-4.png new file mode 100644 index 0000000..c80559e Binary files /dev/null and b/art/screen-4.png differ diff --git a/art/screen-cli-1.png b/art/screen-cli-1.png new file mode 100644 index 0000000..4e8b49f Binary files /dev/null and b/art/screen-cli-1.png differ diff --git a/art/tux.xcf b/art/tux.xcf new file mode 100644 index 0000000..6de4fe0 Binary files /dev/null and b/art/tux.xcf differ diff --git a/art/web_badge.png b/art/web_badge.png new file mode 100644 index 0000000..426bccc Binary files /dev/null and b/art/web_badge.png differ diff --git a/art/web_hi_res_144.png b/art/web_hi_res_144.png new file mode 100644 index 0000000..7fa61a2 Binary files /dev/null and b/art/web_hi_res_144.png differ diff --git a/art/web_hi_res_512.png b/art/web_hi_res_512.png new file mode 100644 index 0000000..c88c437 Binary files /dev/null and b/art/web_hi_res_512.png differ diff --git a/art/web_hi_res_512.xcf b/art/web_hi_res_512.xcf new file mode 100644 index 0000000..5238d4e Binary files /dev/null and b/art/web_hi_res_512.xcf differ diff --git a/art/web_hi_res_512_backgroundless.png b/art/web_hi_res_512_backgroundless.png new file mode 100644 index 0000000..4574101 Binary files /dev/null and b/art/web_hi_res_512_backgroundless.png differ diff --git a/assets/database.db b/assets/database.db new file mode 100644 index 0000000..680852b Binary files /dev/null and b/assets/database.db differ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..3909b1f --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,46 @@ +import com.github.benmanes.gradle.versions.updates.DependencyUpdatesTask + +plugins { + alias(libs.plugins.ben.manes.versions) + alias(libs.plugins.spotless) + alias(libs.plugins.kotlin.multiplatform) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.sqldelight) apply false + alias(libs.plugins.compose.compiler) apply false +} + +group = "com.inspiredandroid" + +tasks.withType { + rejectVersionIf { + isNonStable(candidate.version) + } +} + +configure { + kotlin { + target("**/*.kt") + ktlint() + .editorConfigOverride( + mapOf( + "ktlint_standard_no-wildcard-imports" to "disabled", + "ktlint_standard_package-name" to "disabled", + "ktlint_standard_function-naming" to "disabled", + "ktlint_standard_discouraged-comment-location" to "disabled", + ), + ) + } + kotlinGradle { + target("**/*.gradle.kts") + ktlint() + } +} + +fun isNonStable(version: String): Boolean { + val stableKeyword = listOf("RELEASE", "FINAL", "GA").any { version.uppercase().contains(it) } + val regex = "^[0-9,.v-]+(-r)?$".toRegex() + val isStable = stableKeyword || regex.matches(version) + return isStable.not() +} diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts new file mode 100644 index 0000000..a01076c --- /dev/null +++ b/cli/build.gradle.kts @@ -0,0 +1,34 @@ +plugins { + kotlin("jvm") +} + +group = "com.linuxcommandlibrary" +version = parent!!.version + +dependencies { + implementation(project(":common")) + implementation(libs.sqldelight.sqlite.driver) +} + +kotlin { + compilerOptions { + sourceSets["main"].apply { + resources.srcDirs("../assets") + } + } +} + +val createJar = + tasks.register("createJar", Jar::class) { + archiveBaseName.set("MyApplication") + from(sourceSets["main"].output) + + archiveFileName.set("linuxcommandlibrary.jar") + manifest { + attributes["Main-Class"] = "com.linuxcommandlibrary.cli.ConsoleApplicationKt" + } + from(configurations.getByName("runtimeClasspath").map { if (it.isDirectory) it else zipTree(it) }) + + duplicatesStrategy = DuplicatesStrategy.INCLUDE + exclude("META-INF/*.RSA", "META-INF/*.SF", "META-INF/*.DSA") + } diff --git a/cli/src/main/kotlin/com/linuxcommandlibrary/cli/ConsoleApplication.kt b/cli/src/main/kotlin/com/linuxcommandlibrary/cli/ConsoleApplication.kt new file mode 100644 index 0000000..1eaf5cd --- /dev/null +++ b/cli/src/main/kotlin/com/linuxcommandlibrary/cli/ConsoleApplication.kt @@ -0,0 +1,187 @@ +package com.linuxcommandlibrary.cli + +import com.linuxcommandlibrary.shared.Version +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.initDatabase +import com.linuxcommandlibrary.shared.sortedSearch +import kotlin.system.exitProcess + +const val BOLD = "\u001b[1m" +const val RESET = "\u001b[0m" + +fun main() { + initDatabase() + + showIntro() + + showStartMenu() +} + +fun showIntro() { + println(" _ _ __ _ __ __ __ __") + println("| |__ | || \\| || | |\\ \\/ /") + println("|____||_||_|\\__| \\___/ /_/\\_\\") + println(" ____ ____ __ __ __ __ ____ __ _ ____") + println("/ (__`/ () \\| \\/ || \\/ | / () \\ | \\| || _) \\") + println("\\____)\\____/|_|\\/|_||_|\\/|_|/__/\\__\\|_|\\__||____/") + println(" _ _ _____ _____ ____ _____ __ __") + println("| |__ | || () )| () ) / () \\ | () )\\ \\/ /") + println("|____||_||_()_)|_|\\_\\/__/\\__\\|_|\\_\\ |__|") + println("Version: ${Version.appVersion}") +} + +fun showStartMenu() { + println() + println("1 Commands") + println("2 Basics") + println("3 Tips") + println() + println("0 Exit") + + when (readNumber()) { + 0 -> exitProcess(0) + 1 -> showSearch() + 2 -> showBasicCategories() + 3 -> showTips() + else -> { + println("Invalid input") + showStartMenu() + } + } +} + +fun showSearch() { + print("Search: ") + val input = readlnOrNull() ?: "" + val commands = databaseHelper.getCommandsByQuery(input).sortedSearch(input).take(10) + if (commands.isEmpty()) { + println("No results for \"$input\"") + showSearch() + return + } + commands.forEachIndexed { index, command -> + println("${index + 1} ${command.name}") + } + println() + println("0 Back") + + when (val choice = readNumber()) { + 0 -> showStartMenu() + in 1..commands.size -> { + val name = commands[choice - 1].name + showCommand(name) + } + + else -> { + println("Invalid input") + showSearch() + } + } +} + +fun showCommand(name: String) { + val commandId = databaseHelper.getCommand(name)?.id ?: return + + databaseHelper.getSections(commandId).forEach { + println(BOLD + it.title + RESET) + + println( + it.content.replace("
", "\n").replace("", BOLD).replace("", RESET) + .replace(Regex("s/<(.*?)>//g"), "").replace(" ", "").replace("&", ""), + ) + println() + } + + println("Press enter") + readlnOrNull() + showStartMenu() +} + +fun showBasicCategories() { + val categories = databaseHelper.getBasics() + categories.forEachIndexed { index, basicCategory -> + println("${index + 1} " + basicCategory.title) + } + println() + println("0 Back") + + when (val choice = readNumber()) { + 0 -> showStartMenu() + in 1..categories.size -> { + val id = categories[choice - 1].id + showBasicGroups(id) + } + + else -> { + println("Invalid input") + showBasicCategories() + } + } +} + +fun showBasicGroups(id: Long) { + databaseHelper.getBasicGroupsByQuery(id).forEach { group -> + println("$BOLD${group.description}$RESET") + databaseHelper.getBasicCommands(group.id).forEach { command -> + println("- " + command.command) + } + println() + } + + println("Press enter") + readlnOrNull() + showBasicCategories() +} + +fun showTips() { + val tips = databaseHelper.getTips() + tips.forEachIndexed { index, tip -> + println("${index + 1} ${tip.title}") + } + println() + println("0 Back") + + when (val choice = readNumber()) { + 0 -> showStartMenu() + in 1..tips.size -> { + val id = tips[choice - 1].id + showTipsDetail(id) + } + + else -> { + println("Invalid input") + showTips() + } + } +} + +fun showTipsDetail(id: Long) { + databaseHelper.getTipSections().filter { it.tip_id == id }.forEach { + when (it.type) { + 0L -> { + printTipData(it.data1) + } + + 1L -> { + printTipData(it.data1) + } + + 3L -> { + if (it.data1.isNotBlank()) { + printTipData(it.data1) + } + printTipData(it.data2) + } + } + } + + println("Press enter") + readlnOrNull() + showTips() +} + +fun printTipData(data: String) { + println(data.replace("\\n", "").replace("", BOLD).replace("", RESET)) +} + +fun readNumber(): Int = readlnOrNull()?.toIntOrNull() ?: -1 diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000..0552432 --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,94 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.library) + alias(libs.plugins.sqldelight) +} + +group = "com.linuxcommandlibrary" + +kotlin { + androidTarget() + jvm() + + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.runtime) + } + kotlin.srcDir(layout.buildDirectory.dir("generated/src/commonMain/kotlin")) + } + commonTest { + dependencies { + implementation(kotlin("test")) + } + } + androidMain { + dependencies { + implementation(libs.sqldelight.android.driver) + } + } + jvmMain { + dependencies { + implementation(libs.sqldelight.sqlite.driver) + } + } + } +} + +android { + compileSdk = 35 + sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml") + defaultConfig { + minSdk = 24 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + lint { + abortOnError = false + } + namespace = "com.linuxcommandlibrary.shared" +} + +sqldelight { + databases { + create("CommandDatabase") { + packageName.set("com.linuxcommandlibrary") + } + } +} + +class VersionGeneratorPlugin : Plugin { + override fun apply(project: Project) { + project.afterEvaluate { + val versionFile = + layout.buildDirectory + .file("generated/src/commonMain/kotlin/com/linuxcommandlibrary/shared/Version.kt") + .get() + .asFile + versionFile.parentFile?.mkdirs() + versionFile.writeText( + """ + package com.linuxcommandlibrary.shared + + object Version { + const val appVersion = "${libs.versions.appVersion.get()}" + } + """.trimIndent(), + ) + } + } +} + +apply() diff --git a/common/src/androidMain/AndroidManifest.xml b/common/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/common/src/androidMain/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/common/src/androidMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt b/common/src/androidMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt new file mode 100644 index 0000000..a01a42f --- /dev/null +++ b/common/src/androidMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt @@ -0,0 +1,68 @@ +package com.linuxcommandlibrary.shared + +import android.content.Context +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.android.AndroidSqliteDriver +import com.linuxcommandlibrary.CommandDatabase +import java.io.File +import java.io.InputStream +import java.io.OutputStream + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +actual var databaseHelper = DatabaseHelper() + +fun hasDatabase(context: Context): Boolean { + val file = File(context.dataDir, "databases/database.db") + return file.exists() +} + +fun copyDatabase(context: Context, onUpdateStatus: (Int) -> Unit = {}) { + val databaseFolder = File(context.dataDir, "databases") + if (!databaseFolder.exists()) { + databaseFolder.mkdir() + } + val file = File(databaseFolder, "database.db") + + val inputStream = context.assets.open("database.db") + inputStream.copyToWithStatus(file.outputStream(), onUpdateStatus) + inputStream.close() + + // Delete old realm database + val filesFolder = File(context.dataDir, "files") + if (filesFolder.exists()) { + File(filesFolder, "database.realm").delete() + } +} + +fun InputStream.copyToWithStatus(out: OutputStream, onUpdateStatus: (Int) -> Unit = {}): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = read(buffer) + val totalSize = this.available().toFloat() + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + bytes = read(buffer) + onUpdateStatus(bytesCopied.div(totalSize).times(100f).toInt()) + } + return bytesCopied +} + +fun initDatabase(context: Context) { + val driver: SqlDriver = AndroidSqliteDriver(CommandDatabase.Schema, context, "database.db") + databaseHelper.setupDriver(driver) +} diff --git a/common/src/cliMain/kotlin/com/linuxcommandlibrary/shared/DesktopApp.kt b/common/src/cliMain/kotlin/com/linuxcommandlibrary/shared/DesktopApp.kt new file mode 100644 index 0000000..2b62b8c --- /dev/null +++ b/common/src/cliMain/kotlin/com/linuxcommandlibrary/shared/DesktopApp.kt @@ -0,0 +1,25 @@ +package com.linuxcommandlibrary.shared + +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.runtime.Composable + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +@Preview +@Composable +fun AppPreview() { + // App() +} diff --git a/common/src/cliMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt b/common/src/cliMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt new file mode 100644 index 0000000..408310f --- /dev/null +++ b/common/src/cliMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt @@ -0,0 +1,33 @@ +package com.linuxcommandlibrary.shared + +import com.linuxcommandlibrary.CommandDatabase +import com.squareup.sqldelight.db.SqlDriver +import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver +import java.io.File + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +actual fun getPlatformName(): String = "Desktop" + +actual var databaseHelper = DatabaseHelper() + +fun initDatabase() { + val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite:assets/database.db") + if (!File("assets/database.db").exists()) { + CommandDatabase.Schema.create(driver) + } + databaseHelper.setupDriver(driver) +} diff --git a/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/App.kt b/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/App.kt new file mode 100644 index 0000000..7b98f1e --- /dev/null +++ b/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/App.kt @@ -0,0 +1,127 @@ +package com.linuxcommandlibrary.shared + +import databases.BasicCategory +import databases.Command +import databases.CommandSection +import java.util.Locale + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +sealed class CommandElement { + data class Text(val text: String) : CommandElement() + data class Man(val man: String) : CommandElement() + data class Url(val command: String, val url: String) : CommandElement() +} + +/** + * Search in name and description and return sorted by priority + */ +fun List.sortedSearch(phrase: String): List = this.sortedBy { + val name = it.name.lowercase() + val lowercasePhrase = phrase.lowercase() + when { + !name.contains(lowercasePhrase) -> 30 + name == lowercasePhrase -> 0 + name.startsWith(lowercasePhrase) -> 10 + else -> 20 + } +} + +/** + * Return a list of sealed Elements for visual representation + */ +fun String.getCommandList( + mans: String, + hasBrackets: Boolean = false, + checkExisting: Boolean = false, +): List { + var command = " $this" + val list = mutableListOf() + mans.split(",").filterNot { it.isEmpty() }.map { it.replace("(", "").replace(")", "") } + .forEach { + command = if (it.startsWith("url:")) { + val cmd = it.substring(4).split("|").first() + command.replace(cmd, " ü${it}ä") + } else { + if (hasBrackets) { + val escapedIt = Regex.escape(it) // Escapes special characters, e.g., "pbmto\\*\\*\\*" + val regex = "(?:[\\s,])($escapedIt)".toRegex() + command.replace(regex, " ü${it}ä") + } else { + command.replace(it, " ü${it}ä") + } + } + } + + var currentText = "" + var currentCommand = "" + var isCommand = false + command.trim().forEach { + if (it == 'ü') { + list.add(CommandElement.Text(currentText.replace("\n", ""))) + currentText = "" + isCommand = true + } else if (it == 'ä') { + if (currentCommand.isNotBlank()) { + when { + currentCommand.startsWith("url:") -> { + val url = currentCommand.split("|").last() + val cmd = currentCommand.substring(4).split("|").first() + list.add(CommandElement.Url(cmd, url)) + } + + checkExisting && databaseHelper.getCommand(currentCommand) == null -> { + list.add(CommandElement.Text(currentCommand)) + } + + else -> { + list.add(CommandElement.Man(currentCommand)) + } + } + } + currentCommand = "" + isCommand = false + } else { + if (isCommand) { + currentCommand += it + } else { + currentText += it + } + } + } + list.add(CommandElement.Text(currentText.replace("[cmd]", "[command]").replace("\n", ""))) + return list.toList() +} + +val onlyCharactersRegex = "[^a-z]".toRegex() + +/** + * Only allow characters in html file names to guarantee matching on the website and app deep linking + */ +fun BasicCategory.getHtmlFileName(): String = this.title.lowercase(Locale.US).replace(onlyCharactersRegex, "") + +/** + * Show TLDR and SYNOPSIS always on the top and SEE ALSO and AUTHOR on the bottom. Everything else in between + */ +fun CommandSection.getSortPriority(): Int = when (this.title) { + "TLDR" -> 0 + "SYNOPSIS" -> 10 + "SEE ALSO" -> 90 + "AUTHOR" -> 100 + else -> 50 +} + +fun String.isLetter(): Boolean = this.firstOrNull() in 'a'..'z' || this.firstOrNull() in 'A'..'Z' diff --git a/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/EmptyClass.kt b/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/EmptyClass.kt new file mode 100644 index 0000000..3d621fd --- /dev/null +++ b/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/EmptyClass.kt @@ -0,0 +1,3 @@ +package com.linuxcommandlibrary.shared + +class EmptyClass diff --git a/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt b/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt new file mode 100644 index 0000000..ab5f03b --- /dev/null +++ b/common/src/commonMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt @@ -0,0 +1,61 @@ +package com.linuxcommandlibrary.shared + +import app.cash.sqldelight.db.SqlDriver +import com.linuxcommandlibrary.CommandDatabase +import databases.BasicCategory +import databases.BasicCommand +import databases.BasicGroup +import databases.Command +import databases.CommandQueries +import databases.CommandSection +import databases.Tip +import databases.TipSection + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +expect var databaseHelper: DatabaseHelper + +class DatabaseHelper { + + private lateinit var sqlDriver: SqlDriver + private lateinit var commandQueries: CommandQueries + + fun setupDriver(driver: SqlDriver) { + sqlDriver = driver + // println("Setup driver: $sqlDriver") + commandQueries = CommandDatabase(sqlDriver).commandQueries + } + + fun getCommand(name: String): Command? = commandQueries.selectCommandByName(name).executeAsOneOrNull() + + fun getCommands(): List = commandQueries.selectCommands().executeAsList().sortedBy { !it.name.isLetter() } + + fun getCommandsByQuery(query: String): List = commandQueries.selectCommandsByQuery(query, query).executeAsList() + + fun getBasics(): List = commandQueries.selectBasicCategories().executeAsList() + + fun getBasicGroupsByQuery(categoryId: Long): List = commandQueries.selectBasicGroupByCategory(categoryId).executeAsList() + + fun getBasicCommands(groupId: Long): List = commandQueries.selectBasicCommandByGroupId(groupId).executeAsList() + + fun getBasicGroupsByQuery(query: String): List = commandQueries.selectBasicGroupsByQuery(query).executeAsList() + + fun getSections(commandId: Long): List = commandQueries.selectCommandSectionsByCommandId(commandId).executeAsList() + + fun getTips(): List = commandQueries.selectTips().executeAsList() + + fun getTipSections(): List = commandQueries.selectAllTipSections().executeAsList() +} diff --git a/common/src/commonMain/sqldelight/databases/Command.sq b/common/src/commonMain/sqldelight/databases/Command.sq new file mode 100644 index 0000000..62901f4 --- /dev/null +++ b/common/src/commonMain/sqldelight/databases/Command.sq @@ -0,0 +1,141 @@ +CREATE TABLE IF NOT EXISTS BasicGroup ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "position" INTEGER NOT NULL, + "description" TEXT NOT NULL, + "category_id" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS BasicCommand ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "command" TEXT NOT NULL, + "mans" TEXT NOT NULL, + "group_id" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS BasicCategory ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "position" INTEGER NOT NULL, + "title" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS Command ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "category" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS CommandSection ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "command_id" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS Tip ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "title" TEXT NOT NULL, + "position" INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS TipSection ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "tip_id" INTEGER NOT NULL, + "position" INTEGER NOT NULL, + "type" INTEGER NOT NULL, + "data1" TEXT NOT NULL, + "data2" TEXT NOT NULL, + "extra" TEXT NOT NULL +); + +selectBasicCategories: +SELECT * +FROM BasicCategory +ORDER BY position; + +selectBasicGroupByCategory: +SELECT * +FROM BasicGroup +WHERE category_id = ? +ORDER BY position DESC; + +selectBasicGroupsByQuery: +SELECT * +FROM BasicGroup +WHERE UPPER(description) LIKE '%' || UPPER(?) || '%' +AND category_id != 250 +AND category_id != 253 +AND category_id != 254 +AND category_id != 255 +AND category_id != 256 +AND category_id != 260 +ORDER BY position DESC; + +selectBasicCommandByGroupId: +SELECT * +FROM BasicCommand +WHERE group_id = ?; + +insertCommand: +INSERT INTO Command (category, name, description) +VALUES (?, ?, ?); + +insertBasicGroup: +INSERT INTO BasicGroup (position, description, category_id) +VALUES (?, ?, ?); + +insertBasicCommand: +INSERT INTO BasicCommand (command, mans, group_id) +VALUES (?, ?, ?); + +selectCommands: +SELECT * +FROM Command +ORDER BY name COLLATE NOCASE ASC; + +selectCommandsByQuery: +SELECT * +FROM Command +WHERE UPPER(name) LIKE '%' || UPPER(?) || '%' + OR UPPER(description) LIKE '%' || UPPER(?) || '%' +ORDER BY name COLLATE NOCASE ASC; + +selectCommandByName: +SELECT * +FROM Command +WHERE name = ?; + +deleteCommandSections: +DELETE FROM CommandSection +WHERE command_id = ? AND title != "TLDR"; + +insertCommandSection: +INSERT INTO CommandSection (title, content, command_id) +VALUES (?, ?, ?); + +selectCommandSectionsByCommandId: +SELECT * +FROM CommandSection +WHERE command_id = ? AND title != "NAME" +ORDER BY id; + +updateCommandTLDRSectionByCommandId: +UPDATE CommandSection +SET content = ? +WHERE command_id = ? AND title = "TLDR"; + +selectTips: +SELECT * +FROM Tip +ORDER BY position; + +selectTipSections: +SELECT * +FROM TipSection +WHERE tip_id = ? +ORDER BY position; + +selectAllTipSections: +SELECT * +FROM TipSection +ORDER BY position; \ No newline at end of file diff --git a/common/src/commonTest/kotlin/CommonTests.kt b/common/src/commonTest/kotlin/CommonTests.kt new file mode 100644 index 0000000..6498392 --- /dev/null +++ b/common/src/commonTest/kotlin/CommonTests.kt @@ -0,0 +1,63 @@ +import com.linuxcommandlibrary.shared.CommandElement +import com.linuxcommandlibrary.shared.getCommandList +import com.linuxcommandlibrary.shared.getHtmlFileName +import com.linuxcommandlibrary.shared.getSortPriority +import com.linuxcommandlibrary.shared.sortedSearch +import databases.BasicCategory +import databases.Command +import databases.CommandSection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class CommonTests { + + @Test + fun testCommandListElements() { + val command = "ps ax | grep firefox" + val elements = command.getCommandList("ps,grep") + assertTrue(elements.count { it is CommandElement.Man } == 2) + } + + @Test + fun testCommandListSearch() { + val commands = listOf( + Command(0, 0, "optipng", "convert"), + Command(0, 0, "thumbnail", "take png and do something"), + Command(0, 0, "Pngcheck", "print detailed"), + Command(0, 0, "png", "png"), + ) + + val filteredCommands = commands.sortedSearch("png") + + assert(filteredCommands.size == 4) + + assertEquals(filteredCommands[0].name, "png") + assertEquals(filteredCommands[1].name, "Pngcheck") + assertEquals(filteredCommands[2].name, "optipng") + assertEquals(filteredCommands[3].name, "thumbnail") + } + + @Test + fun testBasicCategory() { + val category = BasicCategory(0L, 0L, "Users & Groups 2") + assertEquals(category.getHtmlFileName(), "usersgroups") + } + + @Test + fun testSectionSorting() { + val sections = listOf( + CommandSection(0L, "SEE ALSO", "", 0L), + CommandSection(0L, "RANDOM", "", 0L), + CommandSection(0L, "TLDR", "", 0L), + CommandSection(0L, "AUTHOR", "", 0L), + CommandSection(0L, "SYNOPSIS", "", 0L), + ).sortedBy { it.getSortPriority() } + + assertEquals("TLDR", sections[0].title) + assertEquals("SYNOPSIS", sections[1].title) + assertEquals("RANDOM", sections[2].title) + assertEquals("SEE ALSO", sections[3].title) + assertEquals("AUTHOR", sections[4].title) + } +} diff --git a/common/src/jvmMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt b/common/src/jvmMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt new file mode 100644 index 0000000..932fd11 --- /dev/null +++ b/common/src/jvmMain/kotlin/com/linuxcommandlibrary/shared/Platform.kt @@ -0,0 +1,32 @@ +package com.linuxcommandlibrary.shared + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import com.linuxcommandlibrary.CommandDatabase +import java.io.File + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +actual var databaseHelper = DatabaseHelper() + +fun initDatabase() { + val databaseFile = EmptyClass::class.java.classLoader?.getResource("database.db")?.toURI() + val driver: SqlDriver = JdbcSqliteDriver("jdbc:sqlite::resource:$databaseFile") + if (!File("assets/database.db").exists()) { + CommandDatabase.Schema.create(driver) + } + databaseHelper.setupDriver(driver) +} diff --git a/desktop/build.gradle.kts b/desktop/build.gradle.kts new file mode 100644 index 0000000..267df86 --- /dev/null +++ b/desktop/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + kotlin("jvm") +} + +group = "com.linuxcommandlibrary" +version = "1.0" + +dependencies { + implementation(project(":common")) + implementation(libs.kotlinx.html.jvm) + implementation(libs.json) + implementation(libs.sqldelight.sqlite.driver) + implementation(libs.kotlinx.coroutines.core) +} + +kotlin { + compilerOptions { + sourceSets["main"].apply { + resources.srcDirs("../assets") + } + } +} diff --git a/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/FdroiInfoBuilder.kt b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/FdroiInfoBuilder.kt new file mode 100644 index 0000000..ed6d6bf --- /dev/null +++ b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/FdroiInfoBuilder.kt @@ -0,0 +1,60 @@ +package com.linuxcommandlibrary.desktop + +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.initDatabase +import java.io.File +import java.io.PrintStream + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +fun main() { + initDatabase() + + val builder = FdroidInfoBuilder() + builder.buildFullDescription() + builder.buildShortDescription() +} + +class FdroidInfoBuilder { + + fun buildFullDescription() { + val file = File("fastlane/metadata/android/en-US/full_description.txt") + val stream = PrintStream(file) + stream.appendLine("The app currently has ${databaseHelper.getCommands().size} manual pages, ${databaseHelper.getBasics().size} basic categories and a bunch of general terminal tips. It works 100% offline, doesn't need an internet connection and has no tracking software.") + stream.appendLine() + stream.appendLine("Categories") + stream.appendLine() + databaseHelper.getBasics().forEach { category -> + stream.appendLine("* ${category.title}") + } + stream.appendLine() + stream.appendLine("Tips") + stream.appendLine() + databaseHelper.getTips().forEach { tip -> + stream.appendLine("* ${tip.title}") + } + + stream.close() + } + + fun buildShortDescription() { + val file = File("fastlane/metadata/android/en-US/short_description.txt") + val stream = PrintStream(file) + stream.appendLine("${databaseHelper.getCommands().size} manual pages, ${databaseHelper.getBasics().size} basic categories and a bunch of general terminal tips.") + + stream.close() + } +} diff --git a/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/MarkdownBuilder.kt b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/MarkdownBuilder.kt new file mode 100644 index 0000000..7a4d9e0 --- /dev/null +++ b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/MarkdownBuilder.kt @@ -0,0 +1,114 @@ +package com.linuxcommandlibrary.desktop + +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.initDatabase +import java.io.File +import java.io.PrintStream + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +fun main() { + initDatabase() + + val markdownBuilder = MarkdownBuilder() + markdownBuilder.build() +} + +class MarkdownBuilder { + fun build() { + val file = File("README.md") + val stream = PrintStream(file) + stream.appendLine("## Linux Command Library (Mobile+CLI+Web)") + stream.appendLine() + stream.appendLine("![Icon](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandLibrary/master/art/web_hi_res_144.png)") + stream.appendLine() + stream.appendLine("The app currently has **${databaseHelper.getCommands().size}** manual pages, **${databaseHelper.getBasics().size}+** basic categories and a bunch of general terminal tips. It works 100% offline, doesn't need an internet connection and has no tracking software.") + stream.appendLine() + stream.appendLine("[![Play Store](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandBibliotheca/master/art/play_store_badge.png)](https://play.google.com/store/apps/details?id=com.inspiredandroid.linuxcommandbibliotheca)") + stream.appendLine("[![F-Droid](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandBibliotheca/master/art/fdroid_badge.png)](https://f-droid.org/en/packages/com.inspiredandroid.linuxcommandbibliotheca/)") + stream.appendLine("[![Web](https://raw.githubusercontent.com/SimonSchubert/LinuxCommandBibliotheca/master/art/web_badge.png)](https://linuxcommandlibrary.com)") + stream.appendLine() + stream.appendLine("### Mobile screenshots") + stream.appendLine() + stream.appendLine("

") + val mobileScreenshotFiles = + listOf("screen-1.png", "screen-2-dark.png", "screen-3.png", "screen-4-dark.png") + mobileScreenshotFiles.forEach { fileName -> + stream.appendLine("") + } + stream.appendLine("

") + val tabletScreenshotFiles = listOf("screen-1-tablet.png", "screen-2-tablet.png") + tabletScreenshotFiles.forEach { fileName -> + stream.appendLine("") + } + stream.appendLine() + stream.appendLine("### CLI screenshot") + stream.appendLine() + stream.appendLine("") + stream.appendLine() + stream.appendLine("Execute `gradle :cli:buildJar` to create jar file for Linux, Windows and Mac.") + stream.appendLine() + stream.appendLine("### Content") + stream.appendLine() + stream.appendLine("#### Categories") + stream.appendLine() + stream.appendLine( + databaseHelper.getBasics().joinToString { category -> + category.title + }, + ) + stream.appendLine() + stream.appendLine("#### Tips") + stream.appendLine() + stream.appendLine( + databaseHelper.getTips().joinToString { tip -> + tip.title + }, + ) + + stream.appendLine() + stream.appendLine("### CI/CD") + stream.appendLine() + stream.appendLine("[Github Action](.github/workflows/android.yml) to automatically create a new Github release with APK and JAR and upload an AAB to the Play Store.") + + stream.appendLine() + stream.appendLine("### Tests") + stream.appendLine() + stream.appendLine("Android Jetpack Compose screen tests: [ComposeTests.kt](android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeTests.kt)") + stream.appendLine() + stream.appendLine("Android Jetpack Compose deeplinking tests: [ComposeDeeplinkTests.kt](android/src/androidTest/java/com/inspiredandroid/linuxcommandbibliotheca/ComposeDeeplinkTests.kt)") + stream.appendLine() + stream.appendLine("Common code unit tests: [CommonTests.kt](common/src/commonTest/kotlin/CommonTests.kt)") + + stream.appendLine() + stream.appendLine("### Licensing") + stream.appendLine() + stream.appendLine("The source code is licensed under the Apache 2.0 license and the copyright of the man pages in the `database.db` file are copyrighted by their respective authors.") + + stream.appendLine() + stream.appendLine("### Thanks to") + stream.appendLine() + stream.appendLine("http://letsgokoyo.com - App Icon") + stream.appendLine() + stream.appendLine("https://www.commandlinefu.com - Lots of one-liners") + stream.appendLine() + stream.appendLine("https://icons8.com - Icons") + stream.appendLine() + stream.appendLine("https://tldr.sh - TLDR") + + stream.close() + } +} diff --git a/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/Minifier.kt b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/Minifier.kt new file mode 100644 index 0000000..ddbe02c --- /dev/null +++ b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/Minifier.kt @@ -0,0 +1,62 @@ +package com.linuxcommandlibrary.desktop + +import java.io.File +import java.nio.file.Files + +class Minifier { + + fun minifyScriptsAndSheets(isRelease: Boolean) { + val scriptsDir = File("html/scripts") + scriptsDir.mkdir() + val scripts = File("desktop/src/main/resources/scripts") + scripts.listFiles()?.forEach { + if (it.isFile) { + val file = File(scriptsDir, it.name) + file.delete() + if (isRelease) { + val minified = minifyJS(it.readText()) + file.writeText(minified) + } else { + Files.createLink(file.toPath(), it.toPath()) + } + } + } + val styleSheetsDir = File("html/stylesheets") + styleSheetsDir.mkdir() + val stylesheets = File("desktop/src/main/resources/stylesheets") + stylesheets.listFiles()?.forEach { + if (it.isFile) { + val file = File(styleSheetsDir, it.name) + file.delete() + if (isRelease) { + val minified = minifyCSS(it.readText()) + file.writeText(minified) + } else { + Files.createLink(file.toPath(), it.toPath()) + } + } + } + } + + private fun minifyCSS(css: String): String = css.replaceWhiteSpacesBeforeAndAfter(";") + .replaceWhiteSpacesBeforeAndAfter("}") + .replaceWhiteSpacesBeforeAndAfter("\\{") + .replaceWhiteSpacesBeforeAndAfter(":") + .replaceWhiteSpacesBeforeAndAfter(",") + + private fun minifyJS(js: String): String = js.replace("[\\n\\s].?//.*\\n".toRegex(), "") // will break if comment is after code + .replaceWhiteSpacesBeforeAndAfter(";") + .replaceWhiteSpacesBeforeAndAfter("}") + .replaceWhiteSpacesBeforeAndAfter("\\{") + .replaceWhiteSpacesBeforeAndAfter("=") + .replaceWhiteSpacesBeforeAndAfter("<") + .replaceWhiteSpacesBeforeAndAfter("-") + .replaceWhiteSpacesBeforeAndAfter(",") + .replaceWhiteSpacesBeforeAndAfter("\\+") + .replaceWhiteSpacesBeforeAndAfter("\\(") + .replaceWhiteSpacesBeforeAndAfter("\\)") + .replaceWhiteSpacesBeforeAndAfter("\\&") + .replaceWhiteSpacesBeforeAndAfter("\\>") + + private fun String.replaceWhiteSpacesBeforeAndAfter(value: String): String = replace("\\s*$value\\s*".toRegex(), value) +} diff --git a/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/WebsiteBuilder.kt b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/WebsiteBuilder.kt new file mode 100644 index 0000000..b1a7975 --- /dev/null +++ b/desktop/src/main/kotlin/com/linuxcommandlibrary/desktop/WebsiteBuilder.kt @@ -0,0 +1,1490 @@ +package com.linuxcommandlibrary.desktop + +import com.linuxcommandlibrary.shared.CommandElement +import com.linuxcommandlibrary.shared.databaseHelper +import com.linuxcommandlibrary.shared.getCommandList +import com.linuxcommandlibrary.shared.getHtmlFileName +import com.linuxcommandlibrary.shared.getSortPriority +import com.linuxcommandlibrary.shared.initDatabase +import databases.BasicCategory +import kotlinx.coroutines.async +import kotlinx.html.ATarget +import kotlinx.html.DIV +import kotlinx.html.FlowContent +import kotlinx.html.HEAD +import kotlinx.html.HTMLTag +import kotlinx.html.HtmlTagMarker +import kotlinx.html.InputType +import kotlinx.html.LINK +import kotlinx.html.META +import kotlinx.html.ScriptCrossorigin +import kotlinx.html.UL +import kotlinx.html.a +import kotlinx.html.attributesMapOf +import kotlinx.html.b +import kotlinx.html.body +import kotlinx.html.br +import kotlinx.html.button +import kotlinx.html.classes +import kotlinx.html.div +import kotlinx.html.footer +import kotlinx.html.h1 +import kotlinx.html.h2 +import kotlinx.html.head +import kotlinx.html.html +import kotlinx.html.i +import kotlinx.html.id +import kotlinx.html.img +import kotlinx.html.input +import kotlinx.html.lang +import kotlinx.html.li +import kotlinx.html.link +import kotlinx.html.nav +import kotlinx.html.noScript +import kotlinx.html.onClick +import kotlinx.html.onKeyUp +import kotlinx.html.p +import kotlinx.html.script +import kotlinx.html.span +import kotlinx.html.stream.appendHTML +import kotlinx.html.style +import kotlinx.html.styleLink +import kotlinx.html.table +import kotlinx.html.td +import kotlinx.html.title +import kotlinx.html.tr +import kotlinx.html.ul +import kotlinx.html.unsafe +import kotlinx.html.visit +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.io.PrintStream +import java.util.Locale + +/* Copyright 2022 Simon Schubert + * + * 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. +*/ + +fun main() { + initDatabase() + + val minifier = Minifier() + val websiteBuilder = WebsiteBuilder() + + val folder = File("html") + folder.mkdir() + + websiteBuilder.createCommandsHtmlFile(folder) + + websiteBuilder.createBasicsHtmlFile(folder) + websiteBuilder.createBasicHtmlFiles(File(folder, "basic")) + + websiteBuilder.createTipsHtmlFile(folder) + websiteBuilder.createManHtmlFiles(File(folder, "man")) + + websiteBuilder.create404HtmlFile() + websiteBuilder.createPrivacyPolicyHtmlFile(folder) + websiteBuilder.createContactHtmlFile(folder) + websiteBuilder.createTermsAndConditionsHtmlFile(folder) + + websiteBuilder.createSitemap(folder) + + minifier.minifyScriptsAndSheets(true) +} + +class WebsiteBuilder { + + private val cacheVersion = 11 + + private val h2Regex by lazy { + "(

)(.*?)(

)".toRegex() + } + private val quoteRegex by lazy { + "`(.*?)`".toRegex() + } + private val commandRegex by lazy { + "([^\\s,]+)\\(([0-9]|1L|1M|p)\\)".toRegex() + } + private val htmlTagRegex by lazy { + "<[^>]*>".toRegex() + } + + fun createCommandsHtmlFile(folder: File) { + println("Create index html") + + val file = File(folder, "commands.html") + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + stream.appendHTML().html { + lang = "en" + head { + commonMeta() + val title = "Commands | Linux Command Library" + uncommonMeta( + title = title, + description = "Handy cheat sheets with linux tips, terminal basics and thousands of man pages.", + url = "https://linuxcommandlibrary.com", + keywords = "linux,cmd,tips,man,commands", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + script(src = "/scripts/search.js?v=$cacheVersion") { + defer = true + } + + noScript { + style { + unsafe { +"#search-wrapper { display: none; }" } + } + } + } + body { + header(selectedIndex = 0) + + contentWrapper { + div { + id = "content" + style = "width: 100%; padding: 12px; align-self: start" + div { + id = "search-wrapper" + input { + type = InputType.text + id = "search" + onKeyUp = "search()" + placeholder = "Search for commands" + autoComplete = "false" + } + } + div { + id = "commandlist" + var currentFirstLetter = "" + databaseHelper.getCommands().forEach { + if (it.name.lowercase().first().toString() != currentFirstLetter) { + currentFirstLetter = it.name.lowercase().first().toString() + div { + classes = classes + "headline" + text(currentFirstLetter.uppercase()) + } + } + a("man/${it.name.lowercase()}") { + attributes["data-c"] = it.name.lowercase() + text(it.name) + } + it.name + } + } + div { + id = "no-results" + text("No results") + } + } + } + + footer() + } + } + stream.close() + } + + fun createBasicsHtmlFile(folder: File) { + println("Create basics html") + + val file = File(folder, "index.html") + file.delete() + val stream = PrintStream(file) + + val basicCategories = databaseHelper.getBasics() + + stream.appendLine("") + stream.appendHTML().html { + lang = "en" + head { + commonMeta() + + val title = "Basics | Cheat sheets | Linux Command Library" + uncommonMeta( + title = title, + description = "Handy cheat sheets with linux tips and terminal basics about System control, Users, Files, Package managers, Video and Audio, Hacking tools, Terminal games and many more categories.", + url = "https://linuxcommandlibrary.com/${folder.name}/${file.nameWithoutExtension}", + keywords = "linux,cmd,basics,terminal,console,cheat sheets,tips,${ + basicCategories.joinToString( + ",", + ) { it.title } + }", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + } + body { + header(selectedIndex = 1) + + contentWrapper { + div { + ul { + classes = setOf("grid-container") + + basicCategories.forEach { + li { + classes = setOf("grid-item") + a("basic/${it.getHtmlFileName()}") { + div { + i { + classes = setOf("invert-color") + style = + "background-image: url(\"images/${it.getIconResource()}\");" + } + h2 { + text(it.title) + } + } + } + } + } + } + } + } + + footer() + } + } + stream.close() + } + + fun createBasicHtmlFiles(folder: File) { + folder.mkdir() + + val basicCategories = databaseHelper.getBasics() + val totalCount = basicCategories.count() + + basicCategories.forEachIndexed { index, category -> + print("\rCreate basic category html ${index + 1}/$totalCount") + + val groups = databaseHelper.getBasicGroupsByQuery(category.id) + + val file = File(folder, "${category.getHtmlFileName()}.html") + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + stream.appendHTML().html { + lang = "en" + head { + val title = "${category.title} | Basic | Cheat sheet | Linux Command Library" + commonMeta() + uncommonMeta( + title = title, + description = category.getDescription(), + url = "https://linuxcommandlibrary.com/${folder.name}/${file.nameWithoutExtension}", + keywords = getKeywordsForBasic(category), + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + script(src = "/scripts/copy.js?v=$cacheVersion") { + defer = true + } + + noScript { + style { + unsafe { +".copy-button { display: none; }" } + } + } + + if (category.title == "One-liners") { + style { + unsafe { +".masonry{-webkit-column-width: 400px !important;-moz-column-width: 400px !important;column-width: 400px !important;}" } + } + } else { + script(type = "application/ld+json") { + val faqJson = JSONObject() + faqJson.put("@context", "https://schema.org") + faqJson.put("@type", "FAQPage") + val answerArray = JSONArray() + groups.forEach { group -> + val answerJson = JSONObject() + answerJson.put("@type", "Question") + answerJson.put("name", group.description) + val acceptedAnswerJson = JSONObject() + acceptedAnswerJson.put("@type", "Answer") + acceptedAnswerJson.put( + "text", + databaseHelper.getBasicCommands(group.id).first().command, + ) + answerJson.put("acceptedAnswer", acceptedAnswerJson) + answerArray.put(answerJson) + } + faqJson.put("mainEntity", answerArray) + } + } + } + body { + header(1) + + contentWrapper { + div { + h1 { + text(category.title) + } + div { + classes = setOf("masonry") + groups.forEach { group -> + div { + classes = setOf("code-group") + h2 { + a("/${folder.name}/${file.nameWithoutExtension}#${group.id}") { + id = group.id.toString() + text(group.description) + } + } + databaseHelper.getBasicCommands(group.id) + .forEach { command -> + if (listOf( + "VIM Texteditor", + "Emacs Texteditor", + "Nano Texteditor", + "Pico Texteditor", + "Micro Texteditor", + ).contains(category.title) + ) { + table { + command.command.split("\n").forEach { + tr { + it.split(" - ").forEach { + td { + text(it) + } + } + } + } + } + } else if (listOf( + "Terminal games", + "Fun", + ).contains(category.title) + ) { + code( + "$ ${ + command.command.replace( + "\\n", + "
", + ) + }", + command.mans, + true, + ) + } else { + code( + "$ ${ + command.command.replace( + "\\n", + "
", + ) + }", + command.mans, + ) + } + } + } + } + } + } + } + + tooltip() + footer() + } + } + stream.close() + } + println() + } + + fun createTipsHtmlFile(folder: File) { + println("Create tips html") + + folder.mkdir() + + val file = File(folder, "tips.html") + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + stream.appendHTML().html { + lang = "en" + head { + commonMeta() + val title = "Tips | Cheat sheets | Linux Command Library" + uncommonMeta( + title = title, + description = "Handy cheat sheets with linux tips and terminal basics.", + url = "https://linuxcommandlibrary.com/${file.nameWithoutExtension}", + keywords = "linux,cmd,useful,terminal,tips,cheat", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + script(src = "/scripts/copy.js?v=$cacheVersion") { + defer = true + } + + noScript { + style { + unsafe { +".copy-button { display: none; }" } + } + } + } + + body { + header(2) + + contentWrapper { + div { + classes = setOf("masonry") + + databaseHelper.getTips().forEach { tip -> + div { + classes = setOf("code-group") + + h2 { + a("/${file.nameWithoutExtension}#${tip.id}") { + id = tip.id.toString() + text(tip.title) + } + } + + var isTable = false + + fun closeTable() { + isTable = false + unsafe { + +"" + } + } + + fun startTable() { + isTable = true + unsafe { + +"" + } + } + + databaseHelper.getTipSections().filter { it.tip_id == tip.id }.forEach { + if (it.type != 3L && isTable) { + closeTable() + } + if (it.type == 3L && !isTable) { + startTable() + } + when (it.type) { + 0L -> { + span { + unsafe { + +it.data1.replace("\\n", "
") + } + } + br + } + + 1L -> { + code(it.data1.replace("\\n", "
"), it.extra) + } + + 3L -> { + unsafe { + +"" + } + unsafe { + +"" + } + } + } + } + if (isTable) { + closeTable() + } + } + } + } + } + tooltip() + footer() + } + } + stream.close() + } + + fun createManHtmlFiles(folder: File) { + folder.mkdir() + + val commands = databaseHelper.getCommands() + val totalCount = commands.count() + + commands.forEachIndexed { index, command -> + print("\rCreate mans html ${index + 1}/$totalCount") + + val file = File(folder, "${command.name.lowercase()}.html") + + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + stream.appendHTML().html { + lang = "en" + head { + commonMeta() + + val title = "${command.name} man | Linux Command Library" + uncommonMeta( + title = title, + description = "${command.name} linux command man page: ${command.description}", + url = "https://linuxcommandlibrary.com/${folder.name}/${file.nameWithoutExtension}", + keywords = "linux,man,page,command,manual,${command.name}", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + script(src = "/scripts/copy.js?v=$cacheVersion") { + defer = true + } + script(type = "application/ld+json") { + val applicationJson = JSONObject() + applicationJson.put("@context", "https://schema.org") + applicationJson.put("@type", "SoftwareApplication") + applicationJson.put("name", command.name) + applicationJson.put("operatingSystem", "LINUX") + unsafe { + +applicationJson.toString() + } + } + + noScript { + style { + unsafe { + +".toggle-all-button { display: none; }" + +".copy-button { display: none; }" + } + } + } + } + body { + header(selectedIndex = 0) + + contentWrapper { + div { + id = "content" + h1 { + text(command.name) + } + h2 { + classes = setOf("subtitle") + text(command.description) + } + + databaseHelper.getSections(command.id).sortedBy { it.getSortPriority() } + .forEach { section -> + h2 { + onClick = "togglePanel(this)" + classes = setOf("accordion-button", "active") + val sectionId = + section.title.lowercase(Locale.US).replace(" ", "-") + a("/man/${command.name.lowercase(Locale.US)}#$sectionId") { + id = sectionId + text(section.title) + } + } + div { + classes = setOf("panel") + when (section.title) { + "SEE ALSO" -> { + p { + val elements = + getSeeAlsoSectionElements(section.content) + elements.forEach { element -> + when (element) { + is CommandElement.Man -> { + a("/man/${element.man}") { + title = + "${element.man} man page" + text(element.man) + } + } + + is CommandElement.Text -> { + unsafe { + +element.text + } + } + + else -> {} + } + } + } + } + + "TLDR" -> { + p { + unsafe { + +sanitizeHtml(section.content.addAnchorAndCodeStyle(file.nameWithoutExtension)) + } + } + } + + else -> { + p { + unsafe { + +sanitizeHtml(section.content) + } + } + } + } + } + } + + button { + onClick = "toggleAll(this)" + classes = setOf("toggle-all-button") + text("COLLAPSE ALL") + } + } + } + + tooltip() + footer() + + script(src = "/scripts/man.js?v=$cacheVersion") { + defer = true + } + } + } + stream.close() + } + println() + } + + fun createTermsAndConditionsHtmlFile(folder: File) { + println("Create terms and conditions html") + + folder.mkdir() + + val file = File(folder, "terms-conditions.html") + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + + stream.appendHTML().html { + lang = "en" + head { + commonMeta(adSense = false) + val title = "Terms and Conditions | Linux Command Library" + uncommonMeta( + title = title, + description = "", + url = "https://linuxcommandlibrary.com/${file.nameWithoutExtension}", + keywords = "", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + } + + body { + header(-1) + + div { + id = "content" + + h1 { +"Terms and Conditions" } + + h2 { +"Educational Purpose Only" } + p { + +"The content on Linux Command Library is provided solely for educational and informational purposes. It is intended to assist users in learning about Linux commands." + } + + h2 { +"Disclaimer of Liability" } + p { + +"The commands on this website can be powerful and may cause data loss or system damage if misused. Users are responsible for verifying and safely using the commands. Linux Command Library is not liable for any damage or loss resulting from the use or misuse of this information." + } + + h2 { +"Copyright and Credits" } + p { + +"Man pages referenced on this site are copyrighted by their respective authors and used under fair use principles for educational reference. We gratefully acknowledge inspiration and contributions from " + a(href = "https://tldr.sh/") { +"TLDR" } + +" and " + a(href = "https://www.commandlinefu.com/") { +"commandlinefu.com" } + +"." + } + } + + footer(showAd = false) + } + } + stream.close() + } + + fun createPrivacyPolicyHtmlFile(folder: File) { + println("Create privacy html") + + folder.mkdir() + + val file = File(folder, "privacy-policy.html") + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + + stream.appendHTML().html { + lang = "en" + head { + commonMeta(adSense = false) + val title = "Privacy Policy | Linux Command Library" + uncommonMeta( + title = title, + description = "", + url = "https://linuxcommandlibrary.com/${file.nameWithoutExtension}", + keywords = "", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + } + + body { + header(-1) + + div { + id = "content" + + h1 { +"Privacy Policy" } + p { +"Last Updated: April 19, 2025" } + + h2 { +"1. No Data Collection or Tracking" } + p { +"Our website does not use cookies, web beacons, or any other tracking technologies to collect personal information about your browsing activities." } + p { +"We do not collect any personal data from users. There are no contact forms, email sign-ups, or other mechanisms that gather personal information on our website." } + + h2 { +"2. Future Use of Cookies (Google AdSense)" } + p { + +"If our website is approved for Google AdSense, we may use cookies to serve personalized advertisements. These cookies would be managed by Google and are subject to " + a(href = "https://policies.google.com/privacy") { +"Google’s Privacy Policy" } + +". If this occurs, we will update this Privacy Policy to reflect the change and provide details on how cookies are used for advertising purposes." + } + p { +"You will be able to manage your ad preferences through Google’s Ad Settings." } + + h2 { +"3. Your Rights (GDPR Compliance)" } + p { +"For EU Users: If you are located in the European Union, you have the right to access, correct, or delete any personal data we may hold about you. Since we do not collect personal data through tracking or forms, no such data is stored." } + p { +"Cookie Consent (Future): If we introduce cookies for advertising in the future, we will implement a cookie consent mechanism for EU users to ensure compliance with GDPR." } + + h2 { +"4. Contact Us" } + p { +"If you have any questions or concerns about this Privacy Policy, you can contact us at [your email address]." } + + h2 { +"5. Changes to This Policy" } + p { +"We may update this Privacy Policy from time to time, especially if we introduce new features or services that affect data handling (e.g., Google AdSense). Any changes will be posted on this page with an updated 'Last Updated' date." } + } + + footer(showAd = false) + } + } + stream.close() + } + + fun createContactHtmlFile(folder: File) { + println("Create contact html") + + folder.mkdir() + + val file = File(folder, "contact.html") + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + + stream.appendHTML().html { + lang = "en" + head { + commonMeta(adSense = false) + val title = "Contact | Linux Command Library" + uncommonMeta( + title = title, + description = "", + url = "https://linuxcommandlibrary.com/${file.nameWithoutExtension}", + keywords = "", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + } + + body { + header(-1) + + div { + id = "content" + + h1 { +"Contact Us" } + p { + +"Have questions, feedback, or issues about Linux Command Library?" + } + p { + +"Please reach out to us at " + a(href = "mailto:info@linuxcommandlibrary.com") { +"info@linuxcommandlibrary.com" } + +". We aim to respond within 2-3 business days." + } + p { + +"For more information about our site, please review our " + a(href = "/privacy-policy") { +"Privacy Policy" } + +" and " + a(href = "/terms-and-conditions") { +"Terms and Conditions" } + +"." + } + } + + footer(showAd = false) + } + } + stream.close() + } + + private fun sanitizeHtml(content: String): String = content.replace(Regex("(?i)<(html|head|title|body)[^>]*>.*?|<(html|head|title|body)[^>]*>"), "") + .replace(Regex("(?i)"), "") + + /** + * Find and link man pages if command exists in database. Example: ps(1), regex(7), signal(7) + */ + private fun getSeeAlsoSectionElements(content: String): List { + var text = content.replace(htmlTagRegex, "") + repeat(10) { + text = text.replace(" ($it)", "($it)") + } + val mans = text.getCommaSeparatedMans() + + return text.getCommandList( + mans, + hasBrackets = true, + checkExisting = true, + ) + } + + /** + * Return comma separated list of commands. Example: ps(1),man(1) -> ps,man + */ + private fun String.getCommaSeparatedMans(): String { + val matches = commandRegex.findAll(this) + return matches.mapNotNull { + it.groups[1]?.value + }.sortedByDescending { it.length }.joinToString(",") + } + + fun create404HtmlFile() { + println("Create 404 html") + + val file = File("html/error_404.html") + file.delete() + val stream = PrintStream(file) + + stream.appendLine("") + stream.appendHTML().html { + lang = "en" + head { + commonMeta(adSense = false) + val title = "404 command not found | Linux Command Library" + uncommonMeta( + title = title, + description = "Handy cheat sheets with linux tips, terminal basics and thousands of man pages.", + url = "https://linuxcommandlibrary.com", + keywords = "linux,cmd,tips,man,commands", + ) + + styleLink("/stylesheets/main.css?v=$cacheVersion") + } + body { + header(selectedIndex = -1) + + div { + style = + "display: flex;height: 500px;justify-content: center;align-items: center;flex-direction: column;" + h1 { + style = "margin: inherit;" + text("404 command not found") + } + + img { + style = "margin-top:12px;" + src = "/images/icons8-404.svg" + width = "100" + height = "100" + alt = "not found" + } + } + + footer(false) + } + } + } + + private fun UL.headerNav( + title: String, + href: String, + index: Int, + selectedIndex: Int, + ): FlowContent { + li { + a(href) { + if (selectedIndex == index) { + classes = setOf("selected") + } + text(title) + } + } + return this + } + + private fun FlowContent.header(selectedIndex: Int) { + div { + id = "top-border" + + div { + a("/") { + title = "logo" + id = "logo-icon-wrapper" + i { + style = "background-image: url('/images/logo.png');" + classes = setOf("logo-icon") + } + } + div { + classes = setOf("title") + span { + text("Linux") + } + span { + text("Command") + } + span { + text("Library") + } + } + + div { + a("https://github.com/SimonSchubert/LinuxCommandLibrary") { + target = ATarget.blank + rel = "noopener" + img { + src = "/images/logo-github.svg" + width = "25" + height = "25" + } + } + a("https://f-droid.org/en/packages/com.inspiredandroid.linuxcommandbibliotheca") { + target = ATarget.blank + rel = "noopener" + img { + src = "/images/icon-fdroid.svg" + width = "25" + height = "25" + } + } + a("https://play.google.com/store/apps/details?id=com.inspiredandroid.linuxcommandbibliotheca") { + target = ATarget.blank + rel = "noopener" + img { + src = "/images/icon-playstore.svg" + width = "25" + height = "25" + } + } + } + } + } + nav { + ul { + headerNav("Basics", "/", 1, selectedIndex) + headerNav("Tips", "/tips", 2, selectedIndex) + headerNav("Commands", "/commands", 0, selectedIndex) + } + } + } + + fun createSitemap(folder: File) { + val file = File(folder, "sitemap.xml") + file.delete() + + val stream = PrintStream(file) + stream.print("") + stream.print(getSitemapUrlNode("")) + stream.print(getSitemapUrlNode("tips")) + stream.print(getSitemapUrlNode("commands")) + databaseHelper.getBasics().forEach { + stream.print(getSitemapUrlNode("basic/${it.getHtmlFileName()}")) + } + databaseHelper.getCommands().forEach { + stream.print(getSitemapUrlNode("man/${it.name}")) + } + stream.print("") + stream.close() + } + + private fun getSitemapUrlNode(urlPart: String): String = "" + + "https://linuxcommandlibrary.com/$urlPart" + + "" + + private fun HEAD.commonMeta(adSense: Boolean = true) { + meta(charset = "utf-8") + meta(name = "viewport", content = "width=device-width, initial-scale=1") + link(rel = "apple-touch-icon", sizes = "180x180", href = "/apple-touch-icon.png") + link(rel = "icon", type = "image/png", sizes = "32x32", href = "/favicon-32x32.png") + link(rel = "icon", type = "image/png", sizes = "16x16", href = "/favicon-16x16.png") + link(rel = "manifest", href = "/site.webmanifest") + meta(name = "msapplication-TileColor", content = "#da532c") + meta(name = "theme-color", content = "#ffffff") + if (adSense) { + script { + src = + "https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-3003920357099437" + async = true + crossorigin = ScriptCrossorigin.anonymous + } + } + } + + private fun HEAD.uncommonMeta( + title: String, + description: String, + url: String, + keywords: String, + ) { + title(title) + + meta(name = "description", content = description) + meta(name = "keywords", content = keywords) + link(rel = "canonical", href = url) + + meta(property = "og:title", content = title) + meta(property = "og:type", content = "website") + meta(property = "og:url", content = url) + meta(property = "og:image", content = "https://linuxcommandlibrary.com/images/preview.jpg") + meta(property = "og:description", content = description) + meta(property = "og:site_name", content = "Linux Command Library") + meta(property = "og:locale", content = "en_US") + + meta(property = "twitter:card", content = "summary") + meta(property = "twitter:title", content = title) + meta(property = "twitter:description", content = description) + meta( + property = "twitter:image", + content = "https://linuxcommandlibrary.com/images/preview.jpg", + ) + } + + private inline fun HTMLTag.link( + href: String? = null, + rel: String? = null, + type: String? = null, + sizes: String?, + crossinline block: LINK.() -> Unit = {}, + ): Unit = LINK( + attributesMapOf("href", href, "rel", rel, "type", type, "sizes", sizes), + consumer, + ).visit(block) + + data class Ad(val imageUrl: String, val url: String, val backgroundColor: String) + val ads = listOf( + Ad("linode-vertical.webp", "/linode-2025", "#ea9230"), + Ad("digitalocean-vertical.webp", "/digitalocean-2025", "#173a62"), + Ad("proton-free-vertical.webp", "/proton-free-2025", "#01a4e8"), + Ad("proton-paid-vertical.webp", "/proton-paid-2025", "#f1c522"), + ) + + private fun FlowContent.contentWrapper(content: DIV.() -> Unit = {}): FlowContent { + val randomAds = ads.shuffled().take(2) + div { + id = "content-wrapper" + randomAds[0].let { ad -> + div { + classes = setOf("side-panel") + style = "background-color: ${ad.backgroundColor}" + a { + href = ad.url + img { + src = "/images/af/${ad.imageUrl}" + width = "200" + } + } + } + } + content() + randomAds[1].let { ad -> + div { + classes = setOf("side-panel") + style = "background-color: ${ad.backgroundColor}" + a { + href = ad.url + img { + src = "/images/af/${ad.imageUrl}" + width = "200" + } + } + } + } + } + return this + } + + private fun FlowContent.footer(showAd: Boolean = true): FlowContent { + if (showAd) { + div { + classes = setOf("bottom-panel") + + a("/linode-2025") { + target = ATarget.blank + img { + style = "max-width: calc(100% - 4px);" + src = "/images/af/linode-horizontal.webp" + attributes["loading"] = "lazy" + width = "600" + } + } + } + } + footer { + p { + a { + target = ATarget.self + rel = "noopener" + href = "/privacy-policy" + text("Privacy Policy") + } + text(" | ") + a { + target = ATarget.self + rel = "noopener" + href = "/terms-conditions" + text("Terms and Conditions") + } + text(" | ") + a { + target = ATarget.self + rel = "noopener" + href = "/contact" + text("Contact") + } + } + + a("https://play.google.com/store/apps/details?id=com.inspiredandroid.linuxcommandbibliotheca") { + style = "margin-right: 4px;" + target = ATarget.blank + rel = "noopener" + img { + src = "/images/google-play-download.svg" + alt = "Google Play Store" + classes = setOf("download-icon") + width = "169" + height = "50" + } + } + a("https://f-droid.org/en/packages/com.inspiredandroid.linuxcommandbibliotheca") { + style = "margin-left: 4px;" + target = ATarget.blank + rel = "noopener" + img { + src = "/images/f-droid-download.png" + alt = "F-Droid Store" + classes = setOf("download-icon") + width = "168" + height = "50" + } + } + div { + style = + "width: 100%; justify-content: center; padding-top: 6px; display: flex; align-items: center; gap: 6px;" + text("My other projects: ") + div { + classes = setOf("project") + a("https://adahub.io") { + target = ATarget.blank + rel = "noopener" + img { + src = "https://adahub.io/favicon.svg" + alt = "adahub.io" + width = "30" + height = "30" + } + } + br + text("Blockchain") + } + div { + classes = setOf("project") + a("https://simonschubert.github.io/YogaBase/") { + target = ATarget.blank + rel = "noopener" + img { + src = "https://simonschubert.github.io/YogaBase/favicon.svg" + alt = "" + width = "30" + height = "30" + } + } + br + text("Yoga") + } + div { + classes = setOf("project") + a("https://betabase.fun") { + target = ATarget.blank + rel = "noopener" + img { + src = "https://betabase.fun/images/icon.png" + alt = "betabase.fun" + width = "30" + height = "30" + } + } + br + text("Climbing") + } + } + } + return this + } + + private fun FlowContent.tooltip(): FlowContent { + div { + classes = setOf("tooltip") + b { + text("Copied to clipboard") + } + } + return this + } + + private fun FlowContent.code( + command: String, + mans: String, + isMonospace: Boolean = false, + ): FlowContent { + div { + classes = setOf("code-wrapper") + span { + classes = setOf("code") + if (isMonospace) { + style = "font-family: 'Courier New', Courier, monospace;font-size:14px;" + } + command.getCommandList(mans).forEach { element -> + when (element) { + is CommandElement.Man -> { + a("/man/${element.man}") { + title = "${element.man} man page" + text(element.man) + } + } + + is CommandElement.Text -> { + element.text.split("
").map { it.replace("$ ", "$ ") } + .forEachIndexed { index, s -> + if (index != 0) { + br + } + s.split(" ").forEachIndexed { index2, s2 -> + if (index2 != 0) { + unsafe { + +" " + } + } + text(s2) + } + } + } + + is CommandElement.Url -> { + a(element.url) { + target = ATarget.blank + rel = "noopener" + text(element.command) + } + } + } + } + } + div { + if (isMonospace) { + onClick = + "javascript:copy('$mans')" + } else { + onClick = + "javascript:copy('${ + command.split("
").first().drop(2).replace("'", "'") + .replace("\n", "").trim() + }')" + } + classes = setOf("copy-button") + img { + src = "/images/icon-copy.svg" + alt = "copy" + width = "24" + height = "24" + } + } + } + return this + } + + @HtmlTagMarker + inline fun HTMLTag.meta( + property: String? = null, + name: String? = null, + content: String? = null, + charset: String? = null, + httpEquiv: String? = null, + crossinline block: META.() -> Unit = {}, + ): Unit = META( + attributesMapOf( + "property", + property, + "name", + name, + "content", + content, + "charset", + charset, + "http-equiv", + httpEquiv, + ), + consumer, + ).visit(block) + + private fun BasicCategory.getIconResource(): String = when (title) { + "One-liners" -> "icon-hand_with_pen.svg" + "System information" -> "icon-system_task.svg" + "System control" -> "icon-settings.svg" + "Users & Groups" -> "icon-user.svg" + "Files & Folders" -> "icon-file.svg" + "Printing" -> "icon-print.svg" + "Network" -> "icon-network_card.svg" + "Search & Find" -> "icon-search.svg" + "GIT" -> "icon-git.svg" + "SSH" -> "icon-console.svg" + "Video & Audio" -> "icon-video_trimming.svg" + "Package manager" -> "icon-package.svg" + "Hacking tools" -> "icon-skull.svg" + "Terminal games" -> "icon-controller.svg" + "VIM" -> "icon-vim.svg" + "Emacs" -> "icon-emacs.svg" + "Nano" -> "icon-nano.svg" + "Pico" -> "icon-pico.svg" + "Crypto currencies" -> "icon-bitcoin.svg" + "Input" -> "icon-mouse.svg" + "JSON" -> "icon-json.svg" + "Fun" -> "icon-fun.svg" + "VIM Texteditor" -> "icon-text-edit.svg" + "Emacs Texteditor" -> "icon-text-edit.svg" + "Nano Texteditor" -> "icon-text-edit.svg" + "Pico Texteditor" -> "icon-text-edit.svg" + "Micro Texteditor" -> "icon-text-edit.svg" + else -> "" + } + + private fun BasicCategory.getDescription(): String = when (title) { + "One-liners" -> "Useful linux command line one liners" + "System information" -> "System and battery/cpu/memory/disk usage info on Linux " + "System control" -> "Lock, unlock, start/stop bluetooth/wifi, shutdown, reboot system" + "Users & Groups" -> "Create, remove, modify and list Linux groups and users" + "Files & Folders" -> "Create, delete, list, show and change Linux files and folders" + "Printing" -> "Print, view, start and cancel printing jobs on Linux" + "Network" -> "Configure, list, trace, sockets, wifi networks on Linux" + "Search & Find" -> "Search and find files by phrase, date and size on Linux" + "GIT" -> "Commit, push, create, delete and undo with git on Linux" + "SSH" -> "Connect, forward, push and pull files via SSH" + "Video & Audio" -> "Convert, volume, play, screenshot, webcam on Linux" + "Package manager" -> "Install, update, upgrade, remove packages on Linux" + "Hacking tools" -> "Hacking, forensics and exploitation tools for Linux" + "Terminal games" -> "Terminal games on Linux" + "VIM Texteditor" -> "Working with vim on the Linux command line" + "Emacs Texteditor" -> "Working with emacs on the Linux command line" + "Nano Texteditor" -> "Working with nano on the Linux command line" + "Pico Texteditor" -> "Working with pico on the Linux command line" + "Micro Texteditor" -> "Working with micro on the Linux command line" + "Crypto currencies" -> "Miners, wallets and trading bots for Linux" + "Input" -> "Type keys and move mouse via the Linux command line" + "JSON" -> "Print, select, modify, delete and create json files on cmd" + "Fun" -> "Fun on the linux command line" + else -> "" + } + + private fun getKeywordsForBasic(category: BasicCategory): String = when (category.title) { + "One-liners" -> "linux,list,useful,oneliners,commands,cmd" + "Input" -> "linux,move,click,mouse,type,text,xdotool,ydotool,read,copy,clipboard" + "System information" -> "linux,system,info,disk,bluetooth,cpu,memory,battery" + "System control" -> "linux,control,lock,unlock,reboot,shutdown,start,stop,wifi,bluetooth" + "Users & Groups" -> "linux,create,delete,user,group,list,info" + "Files & Folders" -> "linux,create,edit,delete,file,folder,permission,list" + "Printing" -> "linux,print,file,cancel,job,status,queue" + "JSON" -> "linux,json,pretty,print,select,put,delete,create" + "Network" -> "linux,network,wifi,password,ip,interfaces,sockets" + "Search & Find" -> "linux,find,search,pattern,files,path,phrase" + "GIT" -> "linux,create,clone,repository,tag,checkout,delete,commit" + "SSH" -> "linux,connect,ssh,push,pull,forwarding" + "Video & Audio" -> "linux,screenshot,webcam,sounds,video,convert,image" + "Package manager" -> "linux,install,file,repository,find,package,upgrade" + "Hacking tools" -> "linux,password,forensics,sniffing,spoofing,exploit,vulnerability" + "Crypto currencies" -> "linux,minters,wallets,coin,trading,bots" + "VIM Texteditor" -> "linux,insert,search,edit,replace,navigation" + "Emacs Texteditor" -> "linux,emacs,usage,buffers,navigation" + "Nano Texteditor" -> "linux,nano,info,navigation,edit,input,output" + "Pico Texteditor" -> "linux,pico,navigation,usage,input,output" + "Micro Texteditor" -> "linux,pico,navigation,usage,input,output" + "Terminal games" -> "linux,terminal,games,list,rogue" + else -> throw Exception("${category.title} not found") + } + + private fun String.addAnchorAndCodeStyle(fileName: String): String { + var content = this + var matches = quoteRegex.findAll(content) + matches.forEach { + val command = it.value.replace("`", "").replace("'", "'").replace(">", ">") + .replace("<", "<") + .replace("\"", """) + content = content.replace( + it.value, + "
$ $command
\"copy\"
", + ) + } + + matches = h2Regex.findAll(content) + matches.forEachIndexed { index, matchResult -> + val text = + matchResult.value.replace("

", "").replace("

", "").replace(">", ">") + .replace("<", "<") + content = + content.replace( + matchResult.value, + "

$text

", + ) + } + + return content + } +} diff --git a/desktop/src/main/resources/scripts/copy.js b/desktop/src/main/resources/scripts/copy.js new file mode 100644 index 0000000..7c6b142 --- /dev/null +++ b/desktop/src/main/resources/scripts/copy.js @@ -0,0 +1,42 @@ +var timeout; + +function copy(text) { + var inp = document.createElement('input'); + document.body.appendChild(inp); + inp.value = unEscapeHtml(text); + inp.select(); + document.execCommand('copy', false); + inp.remove(); + + clearTimeout(timeout); + hide(); + timeout = setTimeout(function() { + hide(); + }, 1500); + setTimeout(function() { + show(); + }, 100); +} + +function show() { + var element = document.getElementsByClassName("tooltip")[0]; + element.classList.remove("hidden"); + element.classList.add("visible"); +} + +function hide() { + var element = document.getElementsByClassName("tooltip")[0]; + element.classList.remove("visible"); + element.classList.add("hidden"); +} + +function unEscapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(///g, "index.html") + .replace(/\\/g, "\\\\"); +} \ No newline at end of file diff --git a/desktop/src/main/resources/scripts/man.js b/desktop/src/main/resources/scripts/man.js new file mode 100644 index 0000000..4ada968 --- /dev/null +++ b/desktop/src/main/resources/scripts/man.js @@ -0,0 +1,65 @@ +var accordionButtons = document.getElementsByClassName("accordion-button"); +var toggleAllButton = document.getElementsByClassName("toggle-all-button")[0]; + +function togglePanel(param) { + param.classList.toggle("active"); + updatePanel(param); + + if (allCollapsed()) { + toggleAllButton.classList.remove("active"); + toggleAllButton.classList.add("active"); + toggleAllButton.innerText = "EXPAND ALL"; + } else if (isAnyExpanded()) { + toggleAllButton.classList.remove("active"); + toggleAllButton.innerText = "COLLAPSE ALL"; + } +} + +function toggleAll(param) { + param.classList.toggle("active"); + + if (param.classList.contains("active")) { + param.innerText = "EXPAND ALL"; + updateList(true); + } else { + param.innerText = "COLLAPSE ALL"; + updateList(false); + } +} + +function updateList(collapse) { + for (var i = 0; i < accordionButtons.length; i++) { + accordionButtons[i].classList.remove("active"); + if (!collapse) { + accordionButtons[i].classList.add("active"); + } + updatePanel(accordionButtons[i]); + } +} + +function updatePanel(button) { + var panel = button.nextElementSibling; + if (button.classList.contains("active")) { + panel.style.display = "block"; + } else { + panel.style.display = "none"; + } +} + +function isAnyExpanded() { + for (var i = 0; i < accordionButtons.length; i++) { + if (accordionButtons[i].classList.contains("active")) { + return true; + } + } + return false; +} + +function allCollapsed() { + for (var i = 0; i < accordionButtons.length; i++) { + if (accordionButtons[i].classList.contains("active")) { + return false; + } + } + return true; +} \ No newline at end of file diff --git a/desktop/src/main/resources/scripts/search.js b/desktop/src/main/resources/scripts/search.js new file mode 100644 index 0000000..1723c5d --- /dev/null +++ b/desktop/src/main/resources/scripts/search.js @@ -0,0 +1,83 @@ +var input, filter, ul, li, a, i, headers, value, index, lastSearch = ""; + +window.onload = (event) => { + input = document.getElementById('search'); + ul = document.getElementById("commandlist"); + li = ul.getElementsByTagName('a'); + headers = ul.getElementsByTagName('div'); +}; + +document.addEventListener('keyup', (e) => { + if (e.keyCode == 38) { + focusPreviousTabStop(); + } else if (e.keyCode == 40) { + focusNextTabStop(); + } +}); + +function getVisibleTabs() { + var universe = document.querySelectorAll('#commandlist a'); + return Array.prototype.filter.call(universe, function(item) {return item.parentNode.style.display !== "none"}); +} + +function focusNextTabStop() { + var tabs = getVisibleTabs(); + var index = tabs.indexOf(document.activeElement); + return (tabs[index + 1] || tabs[0]).focus(); +} + +function focusPreviousTabStop() { + var tabs = getVisibleTabs(); + var index = tabs.indexOf(document.activeElement); + if(index == 0) { + input.focus(); + } else { + (tabs[index - 1] || tabs[0]).focus(); + } +} + +function search(){ + return Promise.resolve() + .then(function() { + setTimeout(function() { + filter = input.value.toLowerCase(); + if(lastSearch === filter) { + return + } + if(lastSearch === "") { + for (i = 0; i < headers.length; i++) { + headers[i].style.display = "none"; + } + } + if(filter === "") { + for (i = 0; i < headers.length; i++) { + headers[i].style.display = ""; + } + } + lastSearch = filter; + var numberOfResults = 0; + for (i = 0; i < li.length; i++) { + value = li[i].getAttribute('data-c'); + index = value.indexOf(filter); + if (index > -1) { + value = value.substring(0, index) + "" + value.substring(index, index + filter.length) + "" + value.substring(index + filter.length); + li[i].innerHTML = value; + numberOfResults++; + li[i].style.display = ""; + } else { + li[i].style.display = "none"; + } + } + + var noresults = document.getElementById("no-results"); + if(numberOfResults == 0) { + noresults.innerHTML = "No commands found for '" + filter + "'"; + noresults.style.display = "block"; + ul.style.display = "none"; + } else { + noresults.style.display = "none"; + ul.style.display = "block"; + } + }, 0); + }); +} \ No newline at end of file diff --git a/desktop/src/main/resources/stylesheets/main.css b/desktop/src/main/resources/stylesheets/main.css new file mode 100644 index 0000000..b3bfdf1 --- /dev/null +++ b/desktop/src/main/resources/stylesheets/main.css @@ -0,0 +1,555 @@ +body { + margin: 0px; + padding: 0px; + color: #151515; + font-family:sans-serif; + background-color: #f9f7f6; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +a { + outline-color: #F44336; +} + +nav { + display: flex; + background: #e45151; + height: 44px; + width: 100%; + display: flex; + justify-content: center; +} + +nav ul { + display: flex; + padding: 0; + margin: 0; + font-size: 22px; + text-transform: uppercase; + font-weight: bold; + margin: auto; +max-width: 900px; + width: 900px; +} + +nav li { + display: block; + margin-top: auto; + margin-bottom: auto; +} + +nav li a { + padding-left: 8px; + padding-right: 8px; + height: 44px; + line-height: 44px; + display: inline-block; +} + +nav li a:hover { + background-color: #f9f7f6; +} + +nav li .selected:hover { + background-color: #161616; +} + +nav li .selected { + color: #f9f7f6 !important; +} + +#top-border { + height: 60px; + width: 100%; + background: #161616; + display: flex; + justify-content: center; +} + +#top-border > div { + display: flex; + flex-wrap: wrap; + align-items: center; + height: 60px; + margin: auto; +max-width: 900px; + width: 900px; + padding-left: 8px; + padding-right: 8px; +} + +#filler { + flex-grow: 1; +} + +footer { + text-align: center; + padding: 8px; + background: #e45151; + color: #151515; +} + +#content, .grid-container, .masonry { + align-self: center; + width: auto; +} + +#content-wrapper { + display: flex; + flex-flow: row; + justify-content: center; +} + +.grid-container, .masonry { + max-width: 900px; +} +#content { + max-width: 900px; +} + +@media (max-width: 1000px) { + .side-panel { + display: none; + } +} +@media (min-width: 1000px) { + .bottom-panel { + display: none; + } + } + + .bottom-panel { + text-align: center; + background-color: #e4751a; + } + +.bottom-panel a { + display: block; + width: 100%; +} + +.side-panel a { + display: block; + height: 100%; +} + +.title { + font-size: 16px; + padding-left: 10px; + padding-right: 24px; + font-weight: bold; + color: #dbdbdb; + display: inline-flex; + flex: auto; + text-transform: uppercase; +} + +.title span::first-letter { + color: #e45151; +} +.title span { + margin-left: 8px; +} + +a:link, a:visited, a:hover, a:active { + color: #151515; + text-decoration: none; +} + +h1 { + text-align: center; + background: #f0f0f0; + width: max-content; + margin-left: auto; + margin-right: auto; + padding: 4px 8px; + margin-top: 16px; + margin-top: 16px; + margin-bottom: 0px; +} + +.download-icon { + margin-top: 8px; +} + +.logo-icon { + height: 48px; + width: 48px; + background-size: 48px 48px; + display: block; +} + +.server-button { + text-align: center; + margin-top: 12px; + margin-bottom: 12px; +} + +.server-button img { + border: black 2px solid; + max-width: calc(100% - 20px); + width: 500px; +} + +.server-button img:hover { + border-color: #F44336; +} + +.project img { + border-radius: 5px; +} + +.project { +font-size: 12px; + text-align: center; + } + + +.masonry { + -webkit-column-width: 300px; + -moz-column-width: 300px; + column-width: 300px; + -webkit-column-gap: 10px; + -moz-column-gap: 10px; + column-gap: 12px; + margin: 12px; +} + +.grid-container { + display: flex; + justify-content: center; + flex-wrap: wrap; + margin: 6px; + padding: 0; + list-style: none; + width: auto; +} + +.grid-item { + background-color: #eee; + color: #444; + width: 128px; + height: 128px; + text-align: center; + margin: 6px; + min-width: 128px; + flex: 1 1 128px; + max-width: 180px; +} + +.grid-item h2 { + padding: 0; + margin: 0; + margin-left: 14px; + margin-right: 14px; + font-size: 18px; + font-weight: bold; +} + +.grid-item:hover { + background-color: #e45151; +} + +.grid-item div { + width: 100%; + height: 100% +} + +.grid-item i { + width: 40px; + height: 40px; + display: block; + background-size: 40px 40px; + margin-left: auto; + margin-right: auto; + margin-bottom: 4px; + background-repeat: no-repeat; + background-position: bottom; + padding-top: 28px; +} + +.highlight { + color: #F44336; +} + +#search { + background-image: url(/images/icon-search.svg); + background-position: 10px 12px; + background-repeat: no-repeat; + font-size: 16px; + padding: 12px 20px 12px 40px; + border: 2px solid #ddd; + border-radius: 2px; + outline-color: #F44336; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; + width: 100%; +} + +#search-wrapper { + width: 100%; + display: inline-block; +} + +#no-results { + display: none; + padding: 24px; + text-align: center; + font-size: 20px; +} + +#commandlist { + list-style-type: none; + padding: 0; + margin: 0; + margin-top: 12px; + border: solid #ddd; + border-width: 0px 0px 1px 0px; +} + +#commandlist a, #commandlist div.headline { + border: solid #ddd; + background-color: #f6f6f6; + padding: 6px; + text-decoration: none; + font-size: 1.4em; + display: block; + border-width: 1px 1px 0 1px; +} + +#commandlist div.headline { + background-color: #e2e2e2; +} + +#commandlist a:hover:not(.headline) { + background-color: #F44336; +} + +#commandlist a:hover:not(.headline) span { + color: #000; +} + +.accordion-button { + background-color: #dfdfdf; + color: #444; + cursor: pointer; + padding: 12px; + font-weight: bold; + font-size: 1.3em; + margin: 0px; + font-family: Sans-serif; +} + +.toggle-all-button { + background-color: #f9f9f9; + color: #444; + cursor: pointer; + padding: 12px; + position: fixed; + bottom: 10px; + right: 10px; + width: 190px; + outline: none; + font-weight: bold; + font-size: 1.3em; + text-align: center; + border: 2px #f9f9f9 solid; +} + +.accordion-button:hover, .toggle-all-button:hover { + background-color: #e45151; +} + +.panel { + padding: 0 18px; + display: flow-root; + background-color: #eee; +} + +.panel h3 { + text-align: left; + margin: 0px; + margin-bottom: 6px; + font-size: 17px; +} + +.panel h3 a { + color: black !important; +} + +.panel a { + font-weight: bold; + color: #F44336; +} + +.subtitle { + text-align: center; + font-size: 20px; +} + +pre { + white-space: pre-wrap; + white-space: -moz-pre-wrap; + white-space: -pre-wrap; + white-space: -o-pre-wrap; + word-wrap: break-word; +} + +.code { + color: #fff; + background-color: #000; + padding: 2px; + display: inline-block; + white-space: normal; + word-break: break-word; + margin-bottom: 5px; + max-width: 550px; + padding: 4px; +} + +.code a:link, .code a:visited { + color: #e45151; + text-decoration: none; + target-new: none; +} + +.code-wrapper { + display: flex; +} + +.code-group { + background-color: #eee; + padding-left: 8px; + padding-right: 8px; + padding-bottom: 4px; + width: 100%; + box-sizing: border-box; + margin: 0; + -webkit-column-break-inside: avoid; + page-break-inside: avoid; + break-inside: avoid-column; + display: inline-grid; + margin-bottom: 15px; +} + +.code-group h2 { + padding-top: 6px; + margin-bottom: 8px; + margin-top: 2px; + font-size: 20px; +} + +.copy-button img { + margin-left: 4px; + margin-top: 2px; + cursor: pointer; +} + +.copy-button { + display: inline-block; + margin-top: auto; + margin-bottom: auto; +} + +.visible { + visibility: visible !important; + opacity: 1; + transition: opacity 0.3s linear; +} + +.hidden { + visibility: hidden !important; + opacity: 0; + transition: visibility 0s 0.3s, opacity 0.3s linear; +} + +.tooltip { + position: fixed; + background: #bbddab; + padding: 4px; + left: 50%; + border: #bbddab 2px solid; + border-radius: 2px; + font-size: 18px; + transform: translateY(-50%) translateX(-50%); + top: 50%; + visibility: hidden; +} + +td:first-child { + white-space: nowrap; + font-weight: bold; + vertical-align: top; +} + +table { + border-spacing: 4px 1px; +} + +@media screen and (max-width: 410px) { + #logo-icon-wrapper { + display: none !important; + } +} + +@media (prefers-color-scheme: dark) { + body { + background-color: #383838; + color: #bebebe; + } + nav a:link,nav a:visited,nav a:hover,nav a:active { + color: #151515; + text-decoration: none; + } + h1 { + background: #282828; + color: #ffffff; + } + a:link, a:visited, a:hover, a:active { + color: #ffffff; + } + .copy-button img, .invert-color { + filter: invert(100%) sepia(0%) saturate(2968%) hue-rotate(17deg) brightness(114%) contrast(87%); + } + .grid-item { + background-color: #1e1e1e; + border-color: #444; + } + #search { + border-color: #444; + background-color: #d3d2d2; + } + #commandlist { + border-color: #444; + } + #commandlist a { + background-color: #282828; + border-color: #444; + } + #commandlist div.headline { + background-color: #585858; + border-color: #444; + } + .panel h3 a { + color: #ffffff !important; + } + .accordion-button, .toggle-all-button { + background-color: #282828; + border-color: #444; + } + .panel { + background-color: #1e1e1e; + } + .toggle-all-button { + color: #ffffff; + } + .code-group { + background: #1e1e1e; + } + .tooltip { + color: #000000; + } +} \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/100.txt b/fastlane/metadata/android/en-US/changelogs/100.txt new file mode 100644 index 0000000..7aed46c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/100.txt @@ -0,0 +1,2 @@ +Add new commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/101.txt b/fastlane/metadata/android/en-US/changelogs/101.txt new file mode 100644 index 0000000..4565628 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/101.txt @@ -0,0 +1,2 @@ +Add persistent expand sections toggle +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/102.txt b/fastlane/metadata/android/en-US/changelogs/102.txt new file mode 100644 index 0000000..b4a34cb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/102.txt @@ -0,0 +1 @@ +Add commands \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/103.txt b/fastlane/metadata/android/en-US/changelogs/103.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/103.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/104.txt b/fastlane/metadata/android/en-US/changelogs/104.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/104.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/105.txt b/fastlane/metadata/android/en-US/changelogs/105.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/105.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/106.txt b/fastlane/metadata/android/en-US/changelogs/106.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/106.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/107.txt b/fastlane/metadata/android/en-US/changelogs/107.txt new file mode 100644 index 0000000..242cad9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/107.txt @@ -0,0 +1,3 @@ +Add commands +Upgrade sdks +Improve performance \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/108.txt b/fastlane/metadata/android/en-US/changelogs/108.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/108.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/111.txt b/fastlane/metadata/android/en-US/changelogs/111.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/111.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/112.txt b/fastlane/metadata/android/en-US/changelogs/112.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/112.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/113.txt b/fastlane/metadata/android/en-US/changelogs/113.txt new file mode 100644 index 0000000..b4a34cb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/113.txt @@ -0,0 +1 @@ +Add commands \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/114.txt b/fastlane/metadata/android/en-US/changelogs/114.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/114.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/115.txt b/fastlane/metadata/android/en-US/changelogs/115.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/115.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/116.txt b/fastlane/metadata/android/en-US/changelogs/116.txt new file mode 100644 index 0000000..498d551 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/116.txt @@ -0,0 +1,3 @@ +Fix search bar accessibility issues +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/117.txt b/fastlane/metadata/android/en-US/changelogs/117.txt new file mode 100644 index 0000000..b4a34cb --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/117.txt @@ -0,0 +1 @@ +Add commands \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/118.txt b/fastlane/metadata/android/en-US/changelogs/118.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/118.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/119.txt b/fastlane/metadata/android/en-US/changelogs/119.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/119.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/120.txt b/fastlane/metadata/android/en-US/changelogs/120.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/120.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/70.txt b/fastlane/metadata/android/en-US/changelogs/70.txt new file mode 100644 index 0000000..1e812e2 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/70.txt @@ -0,0 +1,4 @@ +Refactor app to Kotlin Compose +Add new commands +Add new basics +Add new tips \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/71.txt b/fastlane/metadata/android/en-US/changelogs/71.txt new file mode 100644 index 0000000..45dcfde --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/71.txt @@ -0,0 +1,2 @@ +Add command description search +Improve navigation performance \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/72.txt b/fastlane/metadata/android/en-US/changelogs/72.txt new file mode 100644 index 0000000..8e85b6f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/72.txt @@ -0,0 +1,2 @@ +Fix tips screen UI width bug +Improve tips screen performance \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/73.txt b/fastlane/metadata/android/en-US/changelogs/73.txt new file mode 100644 index 0000000..6cd4b6c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/73.txt @@ -0,0 +1,3 @@ +Add search priority sorting +Add new commands and update man pages +Upgrade to the latest Jetpack Compose and Kotlin \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/74.txt b/fastlane/metadata/android/en-US/changelogs/74.txt new file mode 100644 index 0000000..15068bf --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/74.txt @@ -0,0 +1,2 @@ +Adjust layouts, margins, colors and bottom navigation highlights +Upgrade to the latest Jetpack Compose \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/75.txt b/fastlane/metadata/android/en-US/changelogs/75.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/75.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/76.txt b/fastlane/metadata/android/en-US/changelogs/76.txt new file mode 100644 index 0000000..5a3206c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/76.txt @@ -0,0 +1 @@ +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/77.txt b/fastlane/metadata/android/en-US/changelogs/77.txt new file mode 100644 index 0000000..4ac4b51 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/77.txt @@ -0,0 +1,3 @@ +Add commands +Fix cursor tips section +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/79.txt b/fastlane/metadata/android/en-US/changelogs/79.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/79.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/80.txt b/fastlane/metadata/android/en-US/changelogs/80.txt new file mode 100644 index 0000000..c126083 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/80.txt @@ -0,0 +1,3 @@ +Add monochrome icon +Improve performance +Fix group and command section collapse state not persisting \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/82.txt b/fastlane/metadata/android/en-US/changelogs/82.txt new file mode 100644 index 0000000..ef62631 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/82.txt @@ -0,0 +1,2 @@ +Fix case sensitive search +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/83.txt b/fastlane/metadata/android/en-US/changelogs/83.txt new file mode 100644 index 0000000..5a3206c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/83.txt @@ -0,0 +1 @@ +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/84.txt b/fastlane/metadata/android/en-US/changelogs/84.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/84.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/85.txt b/fastlane/metadata/android/en-US/changelogs/85.txt new file mode 100644 index 0000000..929cf2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/85.txt @@ -0,0 +1,2 @@ +Add commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/89.txt b/fastlane/metadata/android/en-US/changelogs/89.txt new file mode 100644 index 0000000..2c38762 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/89.txt @@ -0,0 +1,2 @@ +Fix github issues +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/90.txt b/fastlane/metadata/android/en-US/changelogs/90.txt new file mode 100644 index 0000000..69db8e1 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/90.txt @@ -0,0 +1,2 @@ +Add commands +Migrate to android sdk 35 \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/92.txt b/fastlane/metadata/android/en-US/changelogs/92.txt new file mode 100644 index 0000000..5a3206c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/92.txt @@ -0,0 +1 @@ +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/94.txt b/fastlane/metadata/android/en-US/changelogs/94.txt new file mode 100644 index 0000000..27a5c62 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/94.txt @@ -0,0 +1,2 @@ +Add new commands and major update command info data +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/95.txt b/fastlane/metadata/android/en-US/changelogs/95.txt new file mode 100644 index 0000000..6a43036 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/95.txt @@ -0,0 +1,3 @@ +Add new commands +Improve dark mode +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/96.txt b/fastlane/metadata/android/en-US/changelogs/96.txt new file mode 100644 index 0000000..9d06025 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/96.txt @@ -0,0 +1,3 @@ +Add see also chips +Add new commands +Improve performance \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/97.txt b/fastlane/metadata/android/en-US/changelogs/97.txt new file mode 100644 index 0000000..60b5133 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/97.txt @@ -0,0 +1,2 @@ +Improve search with "basics results" +Add new commands \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/98.txt b/fastlane/metadata/android/en-US/changelogs/98.txt new file mode 100644 index 0000000..9c43127 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/98.txt @@ -0,0 +1,3 @@ +Fix formatting crash +Add new commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/99.txt b/fastlane/metadata/android/en-US/changelogs/99.txt new file mode 100644 index 0000000..7aed46c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/99.txt @@ -0,0 +1,2 @@ +Add new commands +Upgrade sdks \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..5fdb5ab --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,43 @@ +The app currently has 7680 manual pages, 22 basic categories and a bunch of general terminal tips. It works 100% offline, doesn't need an internet connection and has no tracking software. + +Categories + +* One-liners +* System information +* System control +* Users & Groups +* Files & Folders +* Input +* Printing +* JSON +* Network +* Search & Find +* GIT +* SSH +* Video & Audio +* Package manager +* Hacking tools +* Terminal games +* Crypto currencies +* VIM Texteditor +* Emacs Texteditor +* Nano Texteditor +* Pico Texteditor +* Micro Texteditor + +Tips + +* Clear and reset the terminal +* List of recent commands +* Close a frozen window/application +* Tab Completion +* Temporary aliases +* Permanent aliases +* Chain commands +* Command syntax +* Cursor navigation +* Redirection +* Special characters in commands +* View file permissions +* Modify file permissions +* Set file permissions via binary references diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png new file mode 100644 index 0000000..e8406fc Binary files /dev/null and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..e1cffc9 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png new file mode 100644 index 0000000..77edb6e Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/01.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png new file mode 100644 index 0000000..56a16be Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/02.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png new file mode 100644 index 0000000..b312557 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/03.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png new file mode 100644 index 0000000..cde4e06 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/04.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png new file mode 100644 index 0000000..849ca35 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/05.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/06.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.png new file mode 100644 index 0000000..0ddf464 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/06.png differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..fcac99e --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +7680 manual pages, 22 basic categories and a bunch of general terminal tips. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..dac1884 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,6 @@ +kotlin.code.style=official +android.useAndroidX=true +org.gradle.jvmargs=-Xmx4608m +kotlin.mpp.androidSourceSetLayoutVersion=2 +android.nonTransitiveRClass=false +android.nonFinalResIds=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..559b7ec --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,59 @@ +[versions] +agp = "8.13.1" +appVersion = "3.4.10" +androidVersionCode = "120" +kotlin = "2.2.21" +accompanistAppcompatTheme = "0.36.0" +activityCompose = "1.11.0" +foundation = "1.9.4" +json = "20250517" +koinCore = "4.1.1" +kotlinxCoroutinesCore = "1.10.2" +kotlinxHtmlJvm = "0.12.0" +lifecycleViewmodelCompose = "2.9.4" +material = "1.9.4" +materialIcons = "1.7.8" +navigationCompose = "2.9.6" +preference = "1.2.1" +sqldelight = "2.2.1" +uiToolingPreview = "1.9.4" +spotless = "8.0.0" +kotlinxCollectionsImmutable = "0.4.0" +benManesVersions = "0.53.0" + + +[libraries] +accompanist-appcompat-theme = { module = "com.google.accompanist:accompanist-appcompat-theme", version.ref = "accompanistAppcompatTheme" } +accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanistAppcompatTheme" } +androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } +androidx-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "foundation" } +androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" } +androidx-material = { module = "androidx.compose.material:material", version.ref = "material" } +androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core", version.ref = "materialIcons" } +androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } +androidx-preference = { module = "androidx.preference:preference", version.ref = "preference" } +androidx-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4", version.ref = "uiToolingPreview" } +androidx-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest", version.ref = "uiToolingPreview" } +androidx-ui-tooling = { module = "androidx.compose.ui:ui-tooling", version.ref = "uiToolingPreview" } +androidx-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "uiToolingPreview" } +json = { module = "org.json:json", version.ref = "json" } +koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinCore" } +koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koinCore" } +koin-core = { module = "io.insert-koin:koin-core", version.ref = "koinCore" } +kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-html-jvm = { module = "org.jetbrains.kotlinx:kotlinx-html-jvm", version.ref = "kotlinxHtmlJvm" } +runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" } +sqldelight-android-driver = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } +sqldelight-sqlite-driver = { module = "app.cash.sqldelight:sqlite-driver", version.ref = "sqldelight" } +kotlinx-collections-immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } + + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } +compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } +ben-manes-versions = { id = "com.github.ben-manes.versions", version.ref = "benManesVersions" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..63e0e83 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/proguard-rules.pro b/proguard-rules.pro new file mode 100644 index 0000000..3b46cd6 --- /dev/null +++ b/proguard-rules.pro @@ -0,0 +1,2 @@ +# VerifyError: Superclass androidx.core.app.f of androidx.activity.ComponentActivity is declared final / https://issuetracker.google.com/issues/237785592 +-keep class androidx.core.app.** { *; } \ No newline at end of file diff --git a/pull_screenshots.sh b/pull_screenshots.sh new file mode 100644 index 0000000..4a223e3 --- /dev/null +++ b/pull_screenshots.sh @@ -0,0 +1,14 @@ +#!/bin/bash +adb root +adb pull /data/data/com.inspiredandroid.linuxcommandbibliotheca/files/ . +echo "Move files from device to project" +mv files/*.png art +echo "Move files to art folder" +rmdir files +echo "Move files to fastlane folder" +cp art/screen-1.png fastlane/metadata/android/en-US/images/phoneScreenshots/01.png +cp art/screen-2-dark.png fastlane/metadata/android/en-US/images/phoneScreenshots/02.png +cp art/screen-3.png fastlane/metadata/android/en-US/images/phoneScreenshots/03.png +cp art/screen-4-dark.png fastlane/metadata/android/en-US/images/phoneScreenshots/04.png +cp art/screen-1-tablet.png fastlane/metadata/android/en-US/images/phoneScreenshots/05.png +cp art/screen-2-tablet-dark.png fastlane/metadata/android/en-US/images/phoneScreenshots/06.png \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..5461fdd --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) // Enforce centralized repositories + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Linux Command Library" + +include(":android", ":common", ":desktop", ":cli")
" + } + if (it.data1.isNotBlank()) { + b { + text(it.data1) + } + } + unsafe { + +"" + } + if (it.extra.isNotBlank()) { + code(it.data2.replace("\\n", "
"), it.extra) + } else { + text(it.data2) + } + unsafe { + +"