├── .editorconfig
├── .github
└── workflows
│ └── build.yml
├── .gitignore
├── .idea
├── codeStyles
│ ├── Project.xml
│ └── codeStyleConfig.xml
└── icon.svg
├── LICENSE
├── README.md
├── app
├── build.gradle.kts
├── proguard-rules.pro
├── schemas
│ └── net.simno.dmach.db.PatchDatabase
│ │ ├── 3.json
│ │ ├── 4.json
│ │ └── 5.json
└── src
│ ├── androidTest
│ └── java
│ │ └── net
│ │ └── simno
│ │ └── dmach
│ │ └── db
│ │ ├── DbTests.kt
│ │ └── PatchTable.kt
│ ├── main
│ ├── AndroidManifest.xml
│ ├── java
│ │ └── net
│ │ │ └── simno
│ │ │ └── dmach
│ │ │ ├── App.kt
│ │ │ ├── Destination.kt
│ │ │ ├── MainActivity.kt
│ │ │ ├── StateViewModel.kt
│ │ │ ├── core
│ │ │ ├── CoreDialog.kt
│ │ │ ├── DarkButton.kt
│ │ │ ├── HapticClick.kt
│ │ │ ├── LightButton.kt
│ │ │ ├── OptionsDialog.kt
│ │ │ ├── PredictiveBackProgress.kt
│ │ │ └── Text.kt
│ │ │ ├── data
│ │ │ ├── Channel.kt
│ │ │ ├── DefaultPatch.kt
│ │ │ ├── Pan.kt
│ │ │ ├── Patch.kt
│ │ │ ├── Position.kt
│ │ │ ├── Setting.kt
│ │ │ ├── Steps.kt
│ │ │ ├── Swing.kt
│ │ │ └── Tempo.kt
│ │ │ ├── db
│ │ │ ├── DbModule.kt
│ │ │ ├── PatchDao.kt
│ │ │ ├── PatchDatabase.kt
│ │ │ ├── PatchEntity.kt
│ │ │ └── PatchRepository.kt
│ │ │ ├── machine
│ │ │ ├── MachineModule.kt
│ │ │ ├── MachineScreen.kt
│ │ │ ├── MachineViewModel.kt
│ │ │ ├── state
│ │ │ │ ├── Action.kt
│ │ │ │ ├── MachineProcessor.kt
│ │ │ │ ├── MachineStateReducer.kt
│ │ │ │ ├── Randomizer.kt
│ │ │ │ ├── Result.kt
│ │ │ │ └── ViewState.kt
│ │ │ └── ui
│ │ │ │ ├── ChaosPad.kt
│ │ │ │ ├── ConfigCheckbox.kt
│ │ │ │ ├── ConfigDialog.kt
│ │ │ │ ├── ConfigValue.kt
│ │ │ │ ├── ExportDialog.kt
│ │ │ │ ├── IconButton.kt
│ │ │ │ ├── Machine.kt
│ │ │ │ ├── PanFader.kt
│ │ │ │ ├── StepSequencer.kt
│ │ │ │ └── TextButton.kt
│ │ │ ├── patch
│ │ │ ├── PatchModule.kt
│ │ │ ├── PatchScreen.kt
│ │ │ ├── PatchViewModel.kt
│ │ │ ├── state
│ │ │ │ ├── Action.kt
│ │ │ │ ├── PatchProcessor.kt
│ │ │ │ ├── PatchStateReducer.kt
│ │ │ │ ├── Result.kt
│ │ │ │ └── ViewState.kt
│ │ │ └── ui
│ │ │ │ └── Patch.kt
│ │ │ ├── playback
│ │ │ ├── AudioFocus.kt
│ │ │ ├── PlaybackController.kt
│ │ │ ├── PlaybackModule.kt
│ │ │ ├── PlaybackService.kt
│ │ │ ├── PureData.kt
│ │ │ └── WaveExporter.kt
│ │ │ ├── settings
│ │ │ ├── Settings.kt
│ │ │ ├── SettingsModule.kt
│ │ │ └── SettingsRepository.kt
│ │ │ ├── theme
│ │ │ ├── Color.kt
│ │ │ ├── Dimension.kt
│ │ │ ├── Shape.kt
│ │ │ ├── Theme.kt
│ │ │ └── Type.kt
│ │ │ └── util
│ │ │ ├── DpUtils.kt
│ │ │ └── Logging.kt
│ └── res
│ │ ├── drawable
│ │ ├── ic_launcher_foreground.xml
│ │ └── ic_stat_playback.xml
│ │ ├── mipmap
│ │ └── ic_launcher.xml
│ │ ├── raw
│ │ └── dmach.zip
│ │ ├── values
│ │ ├── ic_launcher_background.xml
│ │ ├── strings.xml
│ │ └── styles.xml
│ │ └── xml
│ │ └── file_provider_paths.xml
│ └── test
│ └── java
│ └── net
│ └── simno
│ └── dmach
│ ├── db
│ └── TestPatchDao.kt
│ ├── machine
│ └── state
│ │ └── MachineProcessorTests.kt
│ ├── patch
│ └── state
│ │ └── PatchProcessorTests.kt
│ └── playback
│ └── AudioFocusTests.kt
├── build.gradle.kts
├── gradle.properties
├── gradle
├── libs.versions.toml
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── gradlew
├── gradlew.bat
├── lint.xml
├── screenshots
├── dmach-1.webp
├── dmach-2.webp
├── dmach-3.webp
└── dmach-4.webp
└── settings.gradle.kts
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 4
6 | end_of_line = lf
7 | charset = utf-8
8 | trim_trailing_whitespace = true
9 | insert_final_newline = true
10 |
11 | [*.{json,yml}]
12 | indent_size = 2
13 |
14 | [*.{kt,kts}]
15 | ktlint_code_style = android_studio
16 | max_line_length = 120
17 | compose_allowed_composition_locals = LocalDimensions
18 | ktlint_function_naming_ignore_when_annotated_with = Composable
19 | ktlint_standard_function-signature = disabled
20 | ij_kotlin_line_break_after_multiline_when_entry = false
21 | ktlint_class_signature_rule_force_multiline_when_parameter_count_greater_or_equal_than = 1
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 |
3 | on: [ push ]
4 |
5 | concurrency:
6 | group: build-${{ github.ref }}
7 | cancel-in-progress: true
8 |
9 | jobs:
10 | build:
11 | runs-on: ubuntu-latest
12 | if: github.repository == 'simonnorberg/dmach'
13 | timeout-minutes: 60
14 |
15 | strategy:
16 | matrix:
17 | api-level: [ 28, 34 ]
18 |
19 | steps:
20 | - name: Checkout
21 | uses: actions/checkout@v4.2.2
22 |
23 | - name: Set up JDK
24 | uses: actions/setup-java@v4.7.1
25 | with:
26 | distribution: 'zulu'
27 | java-version: 21
28 |
29 | - name: Enable KVM
30 | run: |
31 | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
32 | sudo udevadm control --reload-rules
33 | sudo udevadm trigger --name-match=kvm
34 |
35 | - name: Build and check
36 | run: ./gradlew ktlintCheck assemble bundle check
37 |
38 | - name: Run tests
39 | uses: reactivecircus/android-emulator-runner@v2.34.0
40 | with:
41 | api-level: ${{ matrix.api-level }}
42 | arch: x86_64
43 | script: ./gradlew connectedCheck
44 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | .gradle
3 | local.properties
4 | .idea/*
5 | !.idea/codeStyles/
6 | !.idea/icon.svg
7 | .DS_Store
8 | build
9 | captures
10 | .externalNativeBuild
11 | .cxx
12 | .kotlin
13 |
--------------------------------------------------------------------------------
/.idea/codeStyles/Project.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | xmlns:android
71 |
72 | ^$
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 | xmlns:.*
82 |
83 | ^$
84 |
85 |
86 | BY_NAME
87 |
88 |
89 |
90 |
91 |
92 |
93 | .*:id
94 |
95 | http://schemas.android.com/apk/res/android
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | .*:name
105 |
106 | http://schemas.android.com/apk/res/android
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 | name
116 |
117 | ^$
118 |
119 |
120 |
121 |
122 |
123 |
124 |
125 |
126 | style
127 |
128 | ^$
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 | .*
138 |
139 | ^$
140 |
141 |
142 | BY_NAME
143 |
144 |
145 |
146 |
147 |
148 |
149 | .*
150 |
151 | http://schemas.android.com/apk/res/android
152 |
153 |
154 | ANDROID_ATTRIBUTE_ORDER
155 |
156 |
157 |
158 |
159 |
160 |
161 | .*
162 |
163 | .*
164 |
165 |
166 | BY_NAME
167 |
168 |
169 |
170 |
171 |
172 |
173 |
174 |
175 |
176 |
177 |
178 |
179 |
180 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # DMach [](https://github.com/simonnorberg/dmach/actions)
2 |
3 | DMach is a drum machine for Android with 6 channels, a 16 step sequencer and real-time sound synthesis.
4 |
5 |
6 |
7 |
8 |
9 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | plugins {
2 | alias(libs.plugins.android.application)
3 | alias(libs.plugins.cachefix)
4 | alias(libs.plugins.hilt.android)
5 | alias(libs.plugins.kotlin.android)
6 | alias(libs.plugins.kotlin.compose)
7 | alias(libs.plugins.kotlin.serialization)
8 | alias(libs.plugins.ksp)
9 | }
10 |
11 | hilt {
12 | enableAggregatingTask = true
13 | }
14 |
15 | ksp {
16 | arg("room.schemaLocation", "$projectDir/schemas")
17 | arg("room.incremental", "true")
18 | arg("room.generateKotlin", "true")
19 | }
20 |
21 | android {
22 | namespace = "net.simno.dmach"
23 | compileSdk = libs.versions.compileSdk.get().toInt()
24 | ndkVersion = libs.versions.ndk.get()
25 | defaultConfig {
26 | applicationId = "net.simno.dmach"
27 | minSdk = libs.versions.minSdk.get().toInt()
28 | targetSdk = libs.versions.targetSdk.get().toInt()
29 | versionCode = 30013
30 | versionName = "3.8"
31 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
32 | ndk.abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64"))
33 | }
34 | buildTypes {
35 | release {
36 | isMinifyEnabled = true
37 | isShrinkResources = true
38 | proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
39 | }
40 | }
41 | buildFeatures {
42 | buildConfig = true
43 | compose = true
44 | }
45 | packaging {
46 | resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
47 | jniLibs.useLegacyPackaging = true
48 | }
49 | testOptions {
50 | unitTests.isIncludeAndroidResources = true
51 | }
52 | sourceSets.getByName("androidTest") {
53 | assets.srcDirs("$projectDir/schemas", "$projectDir/databases")
54 | }
55 | lint {
56 | warningsAsErrors = true
57 | abortOnError = true
58 | }
59 | }
60 |
61 | dependencies {
62 | implementation(platform(libs.coroutines.bom))
63 | implementation(libs.coroutines.android)
64 | implementation(libs.coroutines.core)
65 | implementation(libs.kotlin.collections)
66 | implementation(libs.kotlin.serialization)
67 |
68 | implementation(platform(libs.compose.bom))
69 | implementation(libs.compose.material)
70 | implementation(libs.compose.material.icons)
71 | implementation(libs.compose.ui)
72 | implementation(libs.compose.ui.tooling.preview)
73 | debugImplementation(libs.compose.ui.tooling)
74 | debugImplementation(libs.compose.ui.testmanifest)
75 | lintChecks(libs.compose.lint)
76 | ktlintRuleset(libs.ktlint.compose)
77 |
78 | implementation(libs.androidx.activity)
79 | implementation(libs.androidx.core)
80 | implementation(libs.androidx.datastore)
81 | ksp(libs.androidx.hilt.compiler)
82 | implementation(libs.androidx.hilt.navigation)
83 | implementation(libs.androidx.media)
84 | implementation(libs.androidx.navigation)
85 |
86 | implementation(libs.androidx.lifecycle.runtime.compose)
87 | implementation(libs.androidx.lifecycle.runtime.ktx)
88 | implementation(libs.androidx.lifecycle.viewmodel.compose)
89 | implementation(libs.androidx.lifecycle.viewmodel.ktx)
90 |
91 | implementation(libs.androidx.paging.common)
92 | implementation(libs.androidx.paging.runtime)
93 | implementation(libs.androidx.paging.compose)
94 |
95 | implementation(libs.androidx.room.runtime)
96 | implementation(libs.androidx.room.ktx)
97 | implementation(libs.androidx.room.paging)
98 | ksp(libs.androidx.room.compiler)
99 |
100 | implementation(libs.hilt.android)
101 | ksp(libs.hilt.compiler)
102 |
103 | implementation(libs.dmach.externals)
104 | implementation(libs.kortholt)
105 |
106 | implementation(libs.leakcanary.plumber)
107 |
108 | androidTestImplementation(libs.androidx.room.testing)
109 | androidTestImplementation(libs.androidx.test.espresso)
110 | androidTestImplementation(libs.androidx.test.junit)
111 | androidTestImplementation(libs.androidx.test.truth)
112 |
113 | testImplementation(libs.androidx.paging.common)
114 | testImplementation(libs.androidx.test.core)
115 | testImplementation(libs.androidx.test.runner)
116 | testImplementation(libs.androidx.test.junit)
117 | testImplementation(libs.androidx.test.truth)
118 | testImplementation(libs.mockito.core)
119 | testImplementation(libs.mockito.kotlin)
120 | testImplementation(libs.robolectric)
121 | }
122 |
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Kotlin
2 | -keep class kotlin.** { *; }
3 | -keep class kotlin.Metadata { *; }
4 | -dontwarn kotlin.**
5 | -keepclassmembers class **$WhenMappings {
6 | ;
7 | }
8 | -keepclassmembers class kotlin.Metadata {
9 | public ;
10 | }
11 | -assumenosideeffects class kotlin.jvm.internal.Intrinsics {
12 | static void checkParameterIsNotNull(java.lang.Object, java.lang.String);
13 | }
14 |
15 |
16 | # Coroutines
17 | # https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/jvm/resources/META-INF/proguard/coroutines.pro
18 | # ServiceLoader support
19 | -keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
20 | -keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
21 |
22 | # Most of volatile fields are updated with AFU and should not be mangled
23 | -keepclassmembers class kotlinx.coroutines.** {
24 | volatile ;
25 | }
26 |
27 | # Same story for the standard library's SafeContinuation that also uses AtomicReferenceFieldUpdater
28 | -keepclassmembers class kotlin.coroutines.SafeContinuation {
29 | volatile ;
30 | }
31 |
32 |
33 | # Kotlin serialization
34 | # https://github.com/Kotlin/kotlinx.serialization#android
35 | # Keep `Companion` object fields of serializable classes.
36 | # This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
37 | -if @kotlinx.serialization.Serializable class **
38 | -keepclassmembers class <1> {
39 | static <1>$Companion Companion;
40 | }
41 |
42 | # Keep `serializer()` on companion objects (both default and named) of serializable classes.
43 | -if @kotlinx.serialization.Serializable class ** {
44 | static **$* *;
45 | }
46 | -keepclassmembers class <2>$<3> {
47 | kotlinx.serialization.KSerializer serializer(...);
48 | }
49 |
50 | # Keep `INSTANCE.serializer()` of serializable objects.
51 | -if @kotlinx.serialization.Serializable class ** {
52 | public static ** INSTANCE;
53 | }
54 | -keepclassmembers class <1> {
55 | public static <1> INSTANCE;
56 | kotlinx.serialization.KSerializer serializer(...);
57 | }
58 |
59 | # @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
60 | -keepattributes RuntimeVisibleAnnotations,AnnotationDefault
61 |
--------------------------------------------------------------------------------
/app/schemas/net.simno.dmach.db.PatchDatabase/3.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 3,
5 | "identityHash": "c2783958f9f3a53874acc7a4cc74fdda",
6 | "entities": [
7 | {
8 | "tableName": "patch",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `sequence` TEXT NOT NULL, `channels` TEXT NOT NULL, `selected` INTEGER NOT NULL, `tempo` INTEGER NOT NULL, `swing` INTEGER NOT NULL, `active` INTEGER NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "_id",
13 | "columnName": "_id",
14 | "affinity": "INTEGER",
15 | "notNull": false
16 | },
17 | {
18 | "fieldPath": "title",
19 | "columnName": "title",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "sequence",
25 | "columnName": "sequence",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "channels",
31 | "columnName": "channels",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "selected",
37 | "columnName": "selected",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "tempo",
43 | "columnName": "tempo",
44 | "affinity": "INTEGER",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "swing",
49 | "columnName": "swing",
50 | "affinity": "INTEGER",
51 | "notNull": true
52 | },
53 | {
54 | "fieldPath": "active",
55 | "columnName": "active",
56 | "affinity": "INTEGER",
57 | "notNull": true
58 | }
59 | ],
60 | "primaryKey": {
61 | "columnNames": [
62 | "_id"
63 | ],
64 | "autoGenerate": true
65 | },
66 | "indices": [
67 | {
68 | "name": "index_patch_title",
69 | "unique": true,
70 | "columnNames": [
71 | "title"
72 | ],
73 | "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_title` ON `${TABLE_NAME}` (`title`)"
74 | }
75 | ],
76 | "foreignKeys": []
77 | }
78 | ],
79 | "views": [],
80 | "setupQueries": [
81 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
82 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c2783958f9f3a53874acc7a4cc74fdda')"
83 | ]
84 | }
85 | }
--------------------------------------------------------------------------------
/app/schemas/net.simno.dmach.db.PatchDatabase/4.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 4,
5 | "identityHash": "91d024659f34ecd633b46f9f4a9cc1e6",
6 | "entities": [
7 | {
8 | "tableName": "patch",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `sequence` TEXT NOT NULL, `channels` TEXT NOT NULL, `selected` INTEGER NOT NULL, `tempo` INTEGER NOT NULL, `swing` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `active` INTEGER NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "_id",
13 | "columnName": "_id",
14 | "affinity": "INTEGER",
15 | "notNull": false
16 | },
17 | {
18 | "fieldPath": "title",
19 | "columnName": "title",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "sequence",
25 | "columnName": "sequence",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "channels",
31 | "columnName": "channels",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "selected",
37 | "columnName": "selected",
38 | "affinity": "INTEGER",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "tempo",
43 | "columnName": "tempo",
44 | "affinity": "INTEGER",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "swing",
49 | "columnName": "swing",
50 | "affinity": "INTEGER",
51 | "notNull": true
52 | },
53 | {
54 | "fieldPath": "steps",
55 | "columnName": "steps",
56 | "affinity": "INTEGER",
57 | "notNull": true
58 | },
59 | {
60 | "fieldPath": "active",
61 | "columnName": "active",
62 | "affinity": "INTEGER",
63 | "notNull": true
64 | }
65 | ],
66 | "primaryKey": {
67 | "autoGenerate": true,
68 | "columnNames": [
69 | "_id"
70 | ]
71 | },
72 | "indices": [
73 | {
74 | "name": "index_patch_title",
75 | "unique": true,
76 | "columnNames": [
77 | "title"
78 | ],
79 | "orders": [],
80 | "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_title` ON `${TABLE_NAME}` (`title`)"
81 | }
82 | ],
83 | "foreignKeys": []
84 | }
85 | ],
86 | "views": [],
87 | "setupQueries": [
88 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
89 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '91d024659f34ecd633b46f9f4a9cc1e6')"
90 | ]
91 | }
92 | }
--------------------------------------------------------------------------------
/app/schemas/net.simno.dmach.db.PatchDatabase/5.json:
--------------------------------------------------------------------------------
1 | {
2 | "formatVersion": 1,
3 | "database": {
4 | "version": 5,
5 | "identityHash": "d7464484e9457b6c5837b91fc673c599",
6 | "entities": [
7 | {
8 | "tableName": "patch",
9 | "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `title` TEXT NOT NULL, `sequence` TEXT NOT NULL, `muted` TEXT NOT NULL, `channels` TEXT NOT NULL, `selected` INTEGER NOT NULL, `tempo` INTEGER NOT NULL, `swing` INTEGER NOT NULL, `steps` INTEGER NOT NULL, `active` INTEGER NOT NULL)",
10 | "fields": [
11 | {
12 | "fieldPath": "id",
13 | "columnName": "_id",
14 | "affinity": "INTEGER",
15 | "notNull": false
16 | },
17 | {
18 | "fieldPath": "title",
19 | "columnName": "title",
20 | "affinity": "TEXT",
21 | "notNull": true
22 | },
23 | {
24 | "fieldPath": "sequence",
25 | "columnName": "sequence",
26 | "affinity": "TEXT",
27 | "notNull": true
28 | },
29 | {
30 | "fieldPath": "muted",
31 | "columnName": "muted",
32 | "affinity": "TEXT",
33 | "notNull": true
34 | },
35 | {
36 | "fieldPath": "channels",
37 | "columnName": "channels",
38 | "affinity": "TEXT",
39 | "notNull": true
40 | },
41 | {
42 | "fieldPath": "selected",
43 | "columnName": "selected",
44 | "affinity": "INTEGER",
45 | "notNull": true
46 | },
47 | {
48 | "fieldPath": "tempo",
49 | "columnName": "tempo",
50 | "affinity": "INTEGER",
51 | "notNull": true
52 | },
53 | {
54 | "fieldPath": "swing",
55 | "columnName": "swing",
56 | "affinity": "INTEGER",
57 | "notNull": true
58 | },
59 | {
60 | "fieldPath": "steps",
61 | "columnName": "steps",
62 | "affinity": "INTEGER",
63 | "notNull": true
64 | },
65 | {
66 | "fieldPath": "active",
67 | "columnName": "active",
68 | "affinity": "INTEGER",
69 | "notNull": true
70 | }
71 | ],
72 | "primaryKey": {
73 | "autoGenerate": true,
74 | "columnNames": [
75 | "_id"
76 | ]
77 | },
78 | "indices": [
79 | {
80 | "name": "index_patch_title",
81 | "unique": true,
82 | "columnNames": [
83 | "title"
84 | ],
85 | "orders": [],
86 | "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_patch_title` ON `${TABLE_NAME}` (`title`)"
87 | }
88 | ],
89 | "foreignKeys": []
90 | }
91 | ],
92 | "views": [],
93 | "setupQueries": [
94 | "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
95 | "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd7464484e9457b6c5837b91fc673c599')"
96 | ]
97 | }
98 | }
--------------------------------------------------------------------------------
/app/src/androidTest/java/net/simno/dmach/db/DbTests.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | import android.content.Context
4 | import android.database.sqlite.SQLiteDatabase
5 | import androidx.core.content.contentValuesOf
6 | import androidx.room.testing.MigrationTestHelper
7 | import androidx.sqlite.db.SupportSQLiteDatabase
8 | import androidx.sqlite.db.SupportSQLiteOpenHelper
9 | import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
10 | import androidx.test.core.app.ApplicationProvider
11 | import androidx.test.ext.junit.runners.AndroidJUnit4
12 | import androidx.test.platform.app.InstrumentationRegistry
13 | import com.google.common.truth.Truth.assertThat
14 | import kotlinx.coroutines.flow.first
15 | import kotlinx.coroutines.runBlocking
16 | import net.simno.dmach.data.defaultPatch
17 | import net.simno.dmach.db.PatchRepository.Companion.toEntity
18 | import org.junit.After
19 | import org.junit.Before
20 | import org.junit.Rule
21 | import org.junit.Test
22 | import org.junit.runner.RunWith
23 |
24 | @RunWith(AndroidJUnit4::class)
25 | class DbTests {
26 |
27 | @Rule
28 | @JvmField
29 | val migrationTestHelper = MigrationTestHelper(
30 | InstrumentationRegistry.getInstrumentation(),
31 | PatchDatabase::class.java,
32 | emptyList(),
33 | FrameworkSQLiteOpenHelperFactory()
34 | )
35 |
36 | @Before
37 | fun setup() {
38 | deleteDatabase()
39 | }
40 |
41 | @After
42 | fun tearDown() {
43 | deleteDatabase()
44 | }
45 |
46 | private fun deleteDatabase() {
47 | val context = ApplicationProvider.getApplicationContext() as Context
48 | context.deleteDatabase(PatchDatabase.NAME)
49 | }
50 |
51 | private fun createSqliteOpenHelper(): SupportSQLiteOpenHelper {
52 | val callback = object : SupportSQLiteOpenHelper.Callback(PatchTable.VERSION) {
53 | override fun onCreate(db: SupportSQLiteDatabase) {
54 | db.execSQL(PatchTable.CREATE_TABLE)
55 | }
56 |
57 | override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
58 | }
59 |
60 | override fun onDowngrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) {
61 | }
62 | }
63 | val configuration = SupportSQLiteOpenHelper.Configuration
64 | .builder(ApplicationProvider.getApplicationContext())
65 | .name(PatchDatabase.NAME)
66 | .callback(callback)
67 | .build()
68 | return FrameworkSQLiteOpenHelperFactory().create(configuration)
69 | }
70 |
71 | @Test
72 | fun dbMigration() {
73 | val sqliteOpenHelper = createSqliteOpenHelper()
74 |
75 | val patch = defaultPatch()
76 | val entity = runBlocking { patch.toEntity(patch.title) }
77 |
78 | val values = contentValuesOf(
79 | PatchTable.TITLE to entity.title,
80 | PatchTable.SEQUENCE to entity.sequence,
81 | PatchTable.CHANNELS to entity.channels,
82 | PatchTable.SELECTED to entity.selected,
83 | PatchTable.TEMPO to entity.tempo,
84 | PatchTable.SWING to entity.swing
85 | )
86 |
87 | val db = sqliteOpenHelper.writableDatabase
88 | db.insert(PatchTable.TABLE_NAME, SQLiteDatabase.CONFLICT_REPLACE, values)
89 | db.close()
90 |
91 | migrationTestHelper.runMigrationsAndValidate(
92 | PatchDatabase.NAME,
93 | 5,
94 | true,
95 | PatchDatabase.MIGRATION_2_3,
96 | PatchDatabase.MIGRATION_3_4,
97 | PatchDatabase.MIGRATION_4_5
98 | )
99 |
100 | val patchDatabase = DbModule.providePatchDatabase(ApplicationProvider.getApplicationContext())
101 | migrationTestHelper.closeWhenFinished(patchDatabase)
102 |
103 | val migratedPatch = runBlocking { PatchRepository(patchDatabase.patchDao()).activePatch().first() }
104 | assertThat(migratedPatch).isEqualTo(patch)
105 | }
106 |
107 | @Test
108 | fun dbDefaultPatch() {
109 | val patchDatabase = DbModule.providePatchDatabase(ApplicationProvider.getApplicationContext())
110 | migrationTestHelper.closeWhenFinished(patchDatabase)
111 |
112 | val defaultPatch = runBlocking { PatchRepository(patchDatabase.patchDao()).activePatch().first() }
113 | assertThat(defaultPatch).isEqualTo(defaultPatch())
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/net/simno/dmach/db/PatchTable.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | object PatchTable {
4 | const val VERSION = 2
5 | const val TABLE_NAME = "patch"
6 | private const val ID = "_id"
7 | const val TITLE = "title"
8 | const val SEQUENCE = "sequence"
9 | const val CHANNELS = "channels"
10 | const val SELECTED = "selected"
11 | const val TEMPO = "tempo"
12 | const val SWING = "swing"
13 |
14 | const val CREATE_TABLE = "create table " + TABLE_NAME +
15 | "(" +
16 | ID + " integer primary key autoincrement, " +
17 | TITLE + " text unique not null, " +
18 | SEQUENCE + " text not null, " +
19 | CHANNELS + " text not null, " +
20 | SELECTED + " integer not null, " +
21 | TEMPO + " integer not null, " +
22 | SWING + " integer not null " +
23 | ");"
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
8 |
15 |
16 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
34 |
35 |
40 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/App.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach
2 |
3 | import android.app.Application
4 | import dagger.hilt.android.HiltAndroidApp
5 |
6 | @HiltAndroidApp
7 | class App : Application()
8 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/Destination.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | sealed class Destination {
7 | @Serializable
8 | data object Machine : Destination()
9 |
10 | @Serializable
11 | data object Patch : Destination()
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach
2 |
3 | import android.os.Bundle
4 | import android.view.WindowManager
5 | import androidx.activity.ComponentActivity
6 | import androidx.activity.compose.setContent
7 | import androidx.activity.enableEdgeToEdge
8 | import androidx.compose.animation.core.tween
9 | import androidx.compose.animation.fadeIn
10 | import androidx.compose.animation.fadeOut
11 | import androidx.compose.foundation.background
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.Spacer
14 | import androidx.compose.foundation.layout.WindowInsets
15 | import androidx.compose.foundation.layout.asPaddingValues
16 | import androidx.compose.foundation.layout.calculateEndPadding
17 | import androidx.compose.foundation.layout.calculateStartPadding
18 | import androidx.compose.foundation.layout.displayCutout
19 | import androidx.compose.foundation.layout.fillMaxHeight
20 | import androidx.compose.foundation.layout.fillMaxSize
21 | import androidx.compose.foundation.layout.width
22 | import androidx.compose.material3.Surface
23 | import androidx.compose.ui.Alignment
24 | import androidx.compose.ui.Modifier
25 | import androidx.compose.ui.graphics.Color
26 | import androidx.compose.ui.unit.LayoutDirection
27 | import androidx.navigation.compose.NavHost
28 | import androidx.navigation.compose.composable
29 | import androidx.navigation.compose.rememberNavController
30 | import dagger.hilt.android.AndroidEntryPoint
31 | import javax.inject.Inject
32 | import net.simno.dmach.machine.MachineScreen
33 | import net.simno.dmach.patch.PatchScreen
34 | import net.simno.dmach.playback.PlaybackController
35 | import net.simno.dmach.theme.AppTheme
36 |
37 | @AndroidEntryPoint
38 | class MainActivity : ComponentActivity() {
39 |
40 | @Inject
41 | lateinit var playbackController: PlaybackController
42 |
43 | override fun onCreate(savedInstanceState: Bundle?) {
44 | enableEdgeToEdge()
45 | super.onCreate(savedInstanceState)
46 | window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
47 |
48 | lifecycle.addObserver(playbackController)
49 |
50 | setContent {
51 | AppTheme {
52 | Surface {
53 | Box(
54 | modifier = Modifier.fillMaxSize()
55 | ) {
56 | val navController = rememberNavController()
57 | NavHost(
58 | navController = navController,
59 | startDestination = Destination.Machine,
60 | enterTransition = { fadeIn(animationSpec = tween(200)) },
61 | exitTransition = { fadeOut(animationSpec = tween(200)) }
62 | ) {
63 | composable {
64 | MachineScreen(navigateToPatch = { navController.navigate(Destination.Patch) })
65 | }
66 | composable {
67 | PatchScreen(navigateUp = navController::navigateUp)
68 | }
69 | }
70 | val displayCutout = WindowInsets.displayCutout.asPaddingValues()
71 | Spacer(
72 | modifier = Modifier
73 | .fillMaxHeight()
74 | .width(displayCutout.calculateStartPadding(LayoutDirection.Ltr))
75 | .background(Color.Black)
76 | .align(Alignment.TopStart)
77 | )
78 | Spacer(
79 | modifier = Modifier
80 | .fillMaxHeight()
81 | .width(displayCutout.calculateEndPadding(LayoutDirection.Ltr))
82 | .background(Color.Black)
83 | .align(Alignment.TopEnd)
84 | )
85 | }
86 | }
87 | }
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/StateViewModel.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach
2 |
3 | import androidx.lifecycle.ViewModel
4 | import androidx.lifecycle.viewModelScope
5 | import kotlinx.coroutines.Dispatchers
6 | import kotlinx.coroutines.channels.Channel
7 | import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
8 | import kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlinx.coroutines.flow.SharingStarted
11 | import kotlinx.coroutines.flow.StateFlow
12 | import kotlinx.coroutines.flow.buffer
13 | import kotlinx.coroutines.flow.catch
14 | import kotlinx.coroutines.flow.emitAll
15 | import kotlinx.coroutines.flow.flow
16 | import kotlinx.coroutines.flow.flowOf
17 | import kotlinx.coroutines.flow.flowOn
18 | import kotlinx.coroutines.flow.receiveAsFlow
19 | import kotlinx.coroutines.flow.scan
20 | import kotlinx.coroutines.flow.shareIn
21 | import kotlinx.coroutines.flow.stateIn
22 |
23 | abstract class StateViewModel(
24 | processor: (Flow) -> Flow,
25 | reducer: (ViewState, Result) -> ViewState,
26 | onError: (Throwable) -> Result,
27 | startViewState: ViewState,
28 | vararg startActions: Action
29 | ) : ViewModel() {
30 |
31 | private val actionsChannel = Channel(BUFFERED)
32 |
33 | private val actions: Flow = flow {
34 | emitAll(flowOf(*startActions))
35 | emitAll(actionsChannel.receiveAsFlow())
36 | }
37 |
38 | val viewState: StateFlow = actions
39 | .buffer(RENDEZVOUS)
40 | .shareIn(viewModelScope, SharingStarted.Lazily)
41 | .let(processor)
42 | .catch { emit(onError(it)) }
43 | .scan(startViewState) { previousState, result -> reducer(previousState, result) }
44 | .flowOn(Dispatchers.Default)
45 | .stateIn(viewModelScope, SharingStarted.Lazily, startViewState)
46 |
47 | fun onAction(action: Action) {
48 | actionsChannel.trySend(action)
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/core/CoreDialog.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.core
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Box
5 | import androidx.compose.foundation.layout.BoxScope
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.padding
8 | import androidx.compose.foundation.layout.wrapContentHeight
9 | import androidx.compose.material3.BasicAlertDialog
10 | import androidx.compose.material3.MaterialTheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableFloatStateOf
14 | import androidx.compose.runtime.mutableStateOf
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.runtime.setValue
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.draw.scale
19 | import androidx.compose.ui.window.DialogProperties
20 | import net.simno.dmach.theme.AppTheme
21 |
22 | @Composable
23 | fun CoreDialog(
24 | onDismiss: () -> Unit,
25 | modifier: Modifier = Modifier,
26 | properties: DialogProperties = DialogProperties(),
27 | content: @Composable BoxScope.() -> Unit
28 | ) {
29 | BasicAlertDialog(
30 | onDismissRequest = onDismiss,
31 | properties = properties
32 | ) {
33 | var backProgress by remember { mutableFloatStateOf(0f) }
34 | var inPredictiveBack by remember { mutableStateOf(false) }
35 | PredictiveBackProgress(
36 | onProgress = { backProgress = it },
37 | onInPredictiveBack = { inPredictiveBack = it },
38 | onBack = onDismiss
39 | )
40 | Box(
41 | modifier = modifier
42 | .scale((1f - backProgress).coerceAtLeast(0.85f))
43 | .background(
44 | color = MaterialTheme.colorScheme.primary,
45 | shape = MaterialTheme.shapes.medium
46 | )
47 | .fillMaxWidth()
48 | .wrapContentHeight()
49 | .padding(AppTheme.dimens.paddingSmall),
50 | content = content
51 | )
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/core/DarkButton.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.core
2 |
3 | import androidx.compose.material3.ButtonDefaults
4 | import androidx.compose.material3.ElevatedButton
5 | import androidx.compose.material3.MaterialTheme
6 | import androidx.compose.runtime.Composable
7 | import androidx.compose.ui.Modifier
8 | import androidx.lifecycle.compose.dropUnlessResumed
9 |
10 | @Composable
11 | fun DarkButton(
12 | text: String,
13 | onClick: () -> Unit,
14 | modifier: Modifier = Modifier
15 | ) {
16 | ElevatedButton(
17 | onClick = dropUnlessResumed(block = onClick),
18 | modifier = modifier,
19 | colors = ButtonDefaults.elevatedButtonColors(
20 | containerColor = MaterialTheme.colorScheme.primary
21 | )
22 | ) {
23 | LightMediumLabel(text)
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/core/HapticClick.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.core
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType
5 | import androidx.compose.ui.platform.LocalHapticFeedback
6 |
7 | @Composable
8 | fun hapticClick(block: (() -> Unit)?): () -> Unit {
9 | val haptic = LocalHapticFeedback.current
10 | return {
11 | block?.let {
12 | haptic.performHapticFeedback(HapticFeedbackType.LongPress)
13 | it()
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/core/LightButton.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.core
2 |
3 | import androidx.compose.material3.ElevatedButton
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.Modifier
6 | import androidx.lifecycle.compose.dropUnlessResumed
7 |
8 | @Composable
9 | fun LightButton(
10 | text: String,
11 | onClick: () -> Unit,
12 | modifier: Modifier = Modifier
13 | ) {
14 | ElevatedButton(
15 | onClick = dropUnlessResumed(block = onClick),
16 | modifier = modifier
17 | ) {
18 | DarkMediumLabel(text)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/core/OptionsDialog.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.core
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Column
6 | import androidx.compose.foundation.layout.Row
7 | import androidx.compose.foundation.layout.Spacer
8 | import androidx.compose.foundation.layout.fillMaxWidth
9 | import androidx.compose.foundation.layout.padding
10 | import androidx.compose.foundation.layout.size
11 | import androidx.compose.foundation.layout.wrapContentHeight
12 | import androidx.compose.material3.CircularProgressIndicator
13 | import androidx.compose.material3.MaterialTheme
14 | import androidx.compose.runtime.Composable
15 | import androidx.compose.ui.Alignment
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.unit.dp
18 | import androidx.compose.ui.window.DialogProperties
19 | import net.simno.dmach.theme.AppTheme
20 |
21 | @Composable
22 | fun OptionsDialog(
23 | text: String,
24 | option1Text: String,
25 | option2Text: String,
26 | onDismiss: () -> Unit,
27 | onOption1: () -> Unit,
28 | onOption2: () -> Unit,
29 | modifier: Modifier = Modifier,
30 | enabled: Boolean = true,
31 | properties: DialogProperties = DialogProperties()
32 | ) {
33 | val surface = MaterialTheme.colorScheme.surface
34 | val onPrimary = MaterialTheme.colorScheme.onPrimary
35 | val shapeMedium = MaterialTheme.shapes.medium
36 | val paddingLarge = AppTheme.dimens.paddingLarge
37 |
38 | CoreDialog(
39 | onDismiss = onDismiss,
40 | properties = properties
41 | ) {
42 | Column(
43 | modifier = modifier
44 | .background(
45 | color = onPrimary,
46 | shape = shapeMedium
47 | )
48 | .fillMaxWidth()
49 | .wrapContentHeight()
50 | .padding(paddingLarge),
51 | verticalArrangement = Arrangement.spacedBy(24.dp)
52 | ) {
53 | LightMediumText(text = text)
54 | if (enabled) {
55 | Row(
56 | modifier = Modifier.fillMaxWidth(),
57 | horizontalArrangement = Arrangement.End
58 | ) {
59 | LightButton(
60 | text = option1Text,
61 | onClick = onOption1
62 | )
63 | Spacer(modifier = Modifier.size(paddingLarge))
64 | LightButton(
65 | text = option2Text,
66 | onClick = onOption2
67 | )
68 | }
69 | } else {
70 | CircularProgressIndicator(
71 | modifier = Modifier.align(Alignment.CenterHorizontally),
72 | color = surface
73 | )
74 | }
75 | }
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/core/PredictiveBackProgress.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.core
2 |
3 | import androidx.activity.compose.PredictiveBackHandler
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.rememberUpdatedState
7 | import kotlin.coroutines.cancellation.CancellationException
8 |
9 | @Composable
10 | fun PredictiveBackProgress(
11 | onProgress: (Float) -> Unit,
12 | onInPredictiveBack: (Boolean) -> Unit,
13 | onBack: () -> Unit,
14 | enabled: Boolean = true
15 | ) {
16 | val currentOnProgress by rememberUpdatedState(onProgress)
17 | val currentOnInPredictiveBack by rememberUpdatedState(onInPredictiveBack)
18 | val currentOnBack by rememberUpdatedState(onBack)
19 |
20 | PredictiveBackHandler(enabled = enabled) { backEvents ->
21 | currentOnProgress(0f)
22 | try {
23 | backEvents.collect { event ->
24 | currentOnInPredictiveBack(true)
25 | currentOnProgress(event.progress)
26 | }
27 | currentOnInPredictiveBack(false)
28 | currentOnBack()
29 | } catch (e: CancellationException) {
30 | currentOnInPredictiveBack(false)
31 | }
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/core/Text.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.core
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.material3.Text
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.ui.Modifier
7 | import androidx.compose.ui.text.style.TextAlign
8 | import androidx.compose.ui.text.style.TextOverflow
9 |
10 | @Composable
11 | fun LightLargeText(
12 | text: String,
13 | modifier: Modifier = Modifier
14 | ) {
15 | Text(
16 | text = text,
17 | modifier = modifier,
18 | color = MaterialTheme.colorScheme.surface,
19 | maxLines = 1,
20 | style = MaterialTheme.typography.bodyLarge
21 | )
22 | }
23 |
24 | @Composable
25 | fun DarkLargeText(
26 | text: String,
27 | modifier: Modifier = Modifier,
28 | textAlign: TextAlign? = null
29 | ) {
30 | Text(
31 | text = text,
32 | modifier = modifier,
33 | color = MaterialTheme.colorScheme.primary,
34 | textAlign = textAlign,
35 | maxLines = 1,
36 | style = MaterialTheme.typography.bodyLarge
37 | )
38 | }
39 |
40 | @Composable
41 | fun LightMediumText(
42 | text: String,
43 | modifier: Modifier = Modifier
44 | ) {
45 | Text(
46 | text = text,
47 | modifier = modifier,
48 | color = MaterialTheme.colorScheme.surface,
49 | maxLines = 1,
50 | style = MaterialTheme.typography.bodyMedium
51 | )
52 | }
53 |
54 | @Composable
55 | fun DarkMediumText(
56 | text: String,
57 | modifier: Modifier = Modifier,
58 | textAlign: TextAlign? = null
59 | ) {
60 | Text(
61 | text = text,
62 | modifier = modifier,
63 | color = MaterialTheme.colorScheme.primary,
64 | textAlign = textAlign,
65 | maxLines = 1,
66 | style = MaterialTheme.typography.bodyMedium
67 | )
68 | }
69 |
70 | @Composable
71 | fun LightSmallText(
72 | text: String,
73 | modifier: Modifier = Modifier
74 | ) {
75 | Text(
76 | text = text,
77 | modifier = modifier,
78 | color = MaterialTheme.colorScheme.surface,
79 | style = MaterialTheme.typography.bodySmall
80 | )
81 | }
82 |
83 | @Composable
84 | fun DarkSmallText(
85 | text: String,
86 | modifier: Modifier = Modifier
87 | ) {
88 | Text(
89 | text = text,
90 | modifier = modifier,
91 | color = MaterialTheme.colorScheme.primary.copy(alpha = 0.666f),
92 | style = MaterialTheme.typography.bodySmall
93 | )
94 | }
95 |
96 | @Composable
97 | fun LightMediumLabel(
98 | text: String,
99 | modifier: Modifier = Modifier
100 | ) {
101 | Text(
102 | text = text,
103 | modifier = modifier,
104 | color = MaterialTheme.colorScheme.surface,
105 | maxLines = 1,
106 | style = MaterialTheme.typography.labelMedium
107 | )
108 | }
109 |
110 | @Composable
111 | fun DarkMediumLabel(
112 | text: String,
113 | modifier: Modifier = Modifier,
114 | textAlign: TextAlign? = null
115 | ) {
116 | Text(
117 | text = text,
118 | modifier = modifier,
119 | color = MaterialTheme.colorScheme.primary,
120 | textAlign = textAlign,
121 | maxLines = 1,
122 | style = MaterialTheme.typography.labelMedium
123 | )
124 | }
125 |
126 | @Composable
127 | fun DarkSmallLabel(
128 | text: String,
129 | modifier: Modifier = Modifier
130 | ) {
131 | Text(
132 | text = text,
133 | modifier = modifier,
134 | color = MaterialTheme.colorScheme.primary,
135 | overflow = TextOverflow.Ellipsis,
136 | maxLines = 1,
137 | style = MaterialTheme.typography.labelSmall
138 | )
139 | }
140 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Channel.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | import kotlinx.serialization.Serializable
4 | import kotlinx.serialization.Transient
5 |
6 | @Serializable
7 | data class Channel(
8 | val name: String,
9 | val settings: List,
10 | val selectedSetting: Int,
11 | val pan: Pan
12 | ) {
13 | @Transient
14 | val setting: Setting = settings.getOrElse(selectedSetting) { Setting.EMPTY }
15 |
16 | companion object {
17 | const val NONE_ID = -1
18 |
19 | val NONE: Channel = Channel("none", emptyList(), 0, Pan(0.5f))
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/DefaultPatch.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | import kotlinx.collections.immutable.persistentListOf
4 | import kotlinx.collections.immutable.persistentSetOf
5 |
6 | fun defaultPatch(): Patch {
7 | val bd = Channel(
8 | name = "bd",
9 | settings = listOf(
10 | Setting(hText = "Pitch A", vText = "Gain", hIndex = 0, vIndex = 7, x = .4f, y = .49f),
11 | Setting(hText = "Low-pass", vText = "Square", hIndex = 5, vIndex = 3, x = .7f, y = 0f),
12 | Setting(hText = "Pitch B", vText = "Curve Time", hIndex = 1, vIndex = 2, x = .4f, y = .4f),
13 | Setting(hText = "Decay", vText = "Noise Level", hIndex = 6, vIndex = 4, x = .49f, y = .7f)
14 | ),
15 | selectedSetting = 0,
16 | pan = Pan(.5f)
17 | )
18 | val sd = Channel(
19 | name = "sd",
20 | settings = listOf(
21 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 9, x = .49f, y = .45f),
22 | Setting(hText = "Low-pass", vText = "Noise", hIndex = 7, vIndex = 1, x = .6f, y = .8f),
23 | Setting(hText = "X-fade", vText = "Attack", hIndex = 8, vIndex = 6, x = .35f, y = .55f),
24 | Setting(hText = "Decay", vText = "Body Decay", hIndex = 4, vIndex = 5, x = .55f, y = .42f),
25 | Setting(hText = "Band-pass", vText = "Band-pass Q", hIndex = 2, vIndex = 3, x = .7f, y = .6f)
26 | ),
27 | selectedSetting = 0,
28 | pan = Pan(.5f)
29 | )
30 | val cp = Channel(
31 | name = "cp",
32 | settings = listOf(
33 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 7, x = .55f, y = .3f),
34 | Setting(hText = "Delay 1", vText = "Delay 2", hIndex = 4, vIndex = 5, x = .3f, y = .3f),
35 | Setting(hText = "Decay", vText = "Filter Q", hIndex = 6, vIndex = 1, x = .59f, y = .2f),
36 | Setting(hText = "Filter 1", vText = "Filter 2", hIndex = 2, vIndex = 3, x = .9f, y = .15f)
37 | ),
38 | selectedSetting = 0,
39 | pan = Pan(.5f)
40 | )
41 | val tt = Channel(
42 | name = "tt",
43 | settings = listOf(
44 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 1, x = .49f, y = .49f)
45 | ),
46 | selectedSetting = 0,
47 | pan = Pan(.5f)
48 | )
49 | val cb = Channel(
50 | name = "cb",
51 | settings = listOf(
52 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 5, x = .3f, y = .49f),
53 | Setting(hText = "Decay 1", vText = "Decay 2", hIndex = 1, vIndex = 2, x = .1f, y = .75f),
54 | Setting(hText = "Vcf", vText = "Vcf Q", hIndex = 3, vIndex = 4, x = .3f, y = 0f)
55 | ),
56 | selectedSetting = 0,
57 | pan = Pan(.5f)
58 | )
59 | val hh = Channel(
60 | name = "hh",
61 | settings = listOf(
62 | Setting(hText = "Pitch", vText = "Gain", hIndex = 0, vIndex = 11, x = .45f, y = .4f),
63 | Setting(hText = "Low-pass", vText = "Snap", hIndex = 10, vIndex = 5, x = .8f, y = .1f),
64 | Setting(hText = "Noise Pitch", vText = "Noise", hIndex = 4, vIndex = 3, x = .55f, y = .6f),
65 | Setting(hText = "Ratio B", vText = "Ratio A", hIndex = 2, vIndex = 1, x = .9f, y = 1f),
66 | Setting(hText = "Release", vText = "Attack", hIndex = 7, vIndex = 6, x = .55f, y = .4f),
67 | Setting(hText = "Filter", vText = "Filter Q", hIndex = 8, vIndex = 9, x = .7f, y = .6f)
68 | ),
69 | selectedSetting = 0,
70 | pan = Pan(.5f)
71 | )
72 | return Patch(
73 | title = "untitled",
74 | sequence = Patch.EMPTY_SEQUENCE,
75 | mutedChannels = persistentSetOf(),
76 | channels = persistentListOf(bd, sd, cp, tt, cb, hh),
77 | selectedChannel = Channel.NONE_ID,
78 | tempo = Tempo(120),
79 | swing = Swing(0),
80 | steps = Steps(16)
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Pan.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | import kotlinx.serialization.Serializable
4 |
5 | @Serializable
6 | @JvmInline
7 | value class Pan(
8 | val value: Float
9 | )
10 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Patch.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | import kotlinx.collections.immutable.PersistentList
4 | import kotlinx.collections.immutable.PersistentSet
5 | import kotlinx.collections.immutable.toPersistentList
6 |
7 | data class Patch(
8 | val title: String,
9 | val sequence: PersistentList,
10 | val mutedChannels: PersistentSet,
11 | val channels: PersistentList,
12 | val selectedChannel: Int,
13 | val tempo: Tempo,
14 | val swing: Swing,
15 | val steps: Steps
16 | ) {
17 | val channel: Channel = channels.getOrElse(selectedChannel) { Channel.NONE }
18 |
19 | companion object {
20 | const val STEPS = 16
21 | const val CHANNELS = 6
22 | val MASKS = intArrayOf(1, 2, 4)
23 | val MUTED_MASKS = MASKS.map { 7 - it }.toIntArray()
24 | val EMPTY_SEQUENCE = (0..31).map { 0 }.toPersistentList()
25 | }
26 | }
27 |
28 | fun Patch.mutedSequence(): List = sequence.mapIndexed { index, step ->
29 | val offset = if (index < Patch.STEPS) 0 else Patch.MUTED_MASKS.size
30 | Patch.MUTED_MASKS.foldIndexed(step) { maskIndex, maskedStep, mask ->
31 | when {
32 | mutedChannels.contains(maskIndex + offset) -> maskedStep and mask
33 | else -> maskedStep
34 | }
35 | }
36 | }
37 |
38 | fun Patch.withPan(pan: Pan): Patch = copy(
39 | channels = channels.map { ch ->
40 | if (ch == channel) ch.copy(pan = pan) else ch
41 | }.toPersistentList()
42 | )
43 |
44 | fun Patch.withSelectedSetting(selectedSetting: Int): Patch = copy(
45 | channels = channels.map { ch ->
46 | if (ch == channel) ch.copy(selectedSetting = selectedSetting) else ch
47 | }.toPersistentList()
48 | )
49 |
50 | fun Patch.withPosition(position: Position): Patch {
51 | val setting = channel.setting
52 | return copy(
53 | channels = channels.map { ch ->
54 | if (ch == channel) {
55 | ch.copy(
56 | settings = ch.settings.map { s ->
57 | if (s == setting) s.copy(x = position.x, y = position.y) else s
58 | }.toPersistentList()
59 | )
60 | } else {
61 | ch
62 | }
63 | }.toPersistentList()
64 | )
65 | }
66 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Position.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | data class Position(
4 | val x: Float,
5 | val y: Float
6 | )
7 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Setting.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | import kotlinx.serialization.Serializable
4 | import kotlinx.serialization.Transient
5 |
6 | @Serializable
7 | data class Setting(
8 | val hText: String,
9 | val vText: String,
10 | val hIndex: Int,
11 | val vIndex: Int,
12 | val x: Float,
13 | val y: Float
14 | ) {
15 | @Transient
16 | val position: Position = Position(x, y)
17 |
18 | companion object {
19 | val EMPTY: Setting = Setting("", "", 0, 0, 0.5f, 0.5f)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Steps.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | @JvmInline
4 | value class Steps(
5 | val value: Int
6 | )
7 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Swing.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | @JvmInline
4 | value class Swing(
5 | val value: Int
6 | )
7 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/data/Tempo.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.data
2 |
3 | @JvmInline
4 | value class Tempo(
5 | val value: Int
6 | )
7 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/db/DbModule.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | import android.content.Context
4 | import androidx.room.Room
5 | import dagger.Module
6 | import dagger.Provides
7 | import dagger.hilt.InstallIn
8 | import dagger.hilt.android.qualifiers.ApplicationContext
9 | import dagger.hilt.components.SingletonComponent
10 | import javax.inject.Singleton
11 | import kotlinx.coroutines.runBlocking
12 | import net.simno.dmach.data.defaultPatch
13 | import net.simno.dmach.db.PatchRepository.Companion.toEntity
14 |
15 | @Module
16 | @InstallIn(SingletonComponent::class)
17 | object DbModule {
18 | @Provides
19 | @Singleton
20 | fun providePatchDatabase(@ApplicationContext context: Context): PatchDatabase {
21 | val db = Room
22 | .databaseBuilder(context, PatchDatabase::class.java, PatchDatabase.NAME)
23 | .addMigrations(
24 | PatchDatabase.MIGRATION_2_3,
25 | PatchDatabase.MIGRATION_3_4,
26 | PatchDatabase.MIGRATION_4_5
27 | )
28 | .fallbackToDestructiveMigration(dropAllTables = true)
29 | .build()
30 |
31 | val dao = db.patchDao()
32 | runBlocking {
33 | if (dao.count() == 0) {
34 | val defaultPatch = defaultPatch()
35 | dao.insertPatch(defaultPatch.toEntity(defaultPatch.title))
36 | }
37 | }
38 |
39 | return db
40 | }
41 |
42 | @Provides
43 | @Singleton
44 | fun providePatchRepository(
45 | patchDatabase: PatchDatabase
46 | ): PatchRepository = PatchRepository(patchDatabase.patchDao())
47 | }
48 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/db/PatchDao.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.room.Dao
5 | import androidx.room.Insert
6 | import androidx.room.OnConflictStrategy
7 | import androidx.room.Query
8 | import androidx.room.Transaction
9 | import kotlinx.coroutines.flow.Flow
10 |
11 | @Dao
12 | interface PatchDao {
13 |
14 | @Query("SELECT COUNT(*) FROM patch")
15 | suspend fun count(): Int
16 |
17 | @Query("SELECT * FROM patch WHERE active = 1 LIMIT 1")
18 | fun getActivePatch(): Flow
19 |
20 | @Query("SELECT * FROM patch ORDER BY title")
21 | fun getAllPatches(): PagingSource
22 |
23 | @Query("DELETE FROM patch WHERE title = :title")
24 | suspend fun deletePatch(title: String): Int
25 |
26 | @Query("UPDATE patch SET active = 0 WHERE active = 1")
27 | suspend fun internalResetActive()
28 |
29 | @Query("UPDATE patch SET active = 1 WHERE title = :title")
30 | suspend fun internalSetActive(title: String): Int
31 |
32 | @Insert(onConflict = OnConflictStrategy.ABORT)
33 | suspend fun internalInsertPatch(patch: PatchEntity): Long
34 |
35 | @Insert(onConflict = OnConflictStrategy.REPLACE)
36 | suspend fun internalReplacePatch(patch: PatchEntity): Long
37 |
38 | @Transaction
39 | suspend fun selectPatch(title: String): Int {
40 | internalResetActive()
41 | return internalSetActive(title)
42 | }
43 |
44 | @Transaction
45 | suspend fun insertPatch(patch: PatchEntity): Long {
46 | internalResetActive()
47 | return internalInsertPatch(patch)
48 | }
49 |
50 | @Transaction
51 | suspend fun replacePatch(patch: PatchEntity): Long {
52 | internalResetActive()
53 | return internalReplacePatch(patch)
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/db/PatchDatabase.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | import androidx.room.Database
4 | import androidx.room.RoomDatabase
5 | import androidx.room.migration.Migration
6 | import androidx.sqlite.db.SupportSQLiteDatabase
7 |
8 | @Database(
9 | entities = [PatchEntity::class],
10 | version = 5,
11 | exportSchema = true
12 | )
13 | abstract class PatchDatabase : RoomDatabase() {
14 |
15 | abstract fun patchDao(): PatchDao
16 |
17 | companion object {
18 | const val NAME = "dmach.db"
19 |
20 | val MIGRATION_2_3: Migration = object : Migration(2, 3) {
21 | override fun migrate(db: SupportSQLiteDatabase) {
22 | db.execSQL("ALTER TABLE patch ADD active INTEGER NOT NULL DEFAULT 1;")
23 | db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_patch_title ON patch (title)")
24 | }
25 | }
26 |
27 | val MIGRATION_3_4: Migration = object : Migration(3, 4) {
28 | override fun migrate(db: SupportSQLiteDatabase) {
29 | db.execSQL("ALTER TABLE patch ADD steps INTEGER NOT NULL DEFAULT 16;")
30 | }
31 | }
32 |
33 | val MIGRATION_4_5: Migration = object : Migration(4, 5) {
34 | override fun migrate(db: SupportSQLiteDatabase) {
35 | db.execSQL("ALTER TABLE patch ADD muted TEXT NOT NULL DEFAULT '[]';")
36 | }
37 | }
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/db/PatchEntity.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | import androidx.room.ColumnInfo
4 | import androidx.room.Entity
5 | import androidx.room.Index
6 | import androidx.room.PrimaryKey
7 |
8 | @Entity(
9 | tableName = "patch",
10 | indices = [
11 | Index("title", unique = true)
12 | ]
13 | )
14 | data class PatchEntity(
15 | @PrimaryKey(autoGenerate = true)
16 | @ColumnInfo(name = "_id") val id: Int?,
17 | @ColumnInfo(name = "title") val title: String,
18 | @ColumnInfo(name = "sequence") val sequence: String,
19 | @ColumnInfo(name = "muted") val muted: String,
20 | @ColumnInfo(name = "channels") val channels: String,
21 | @ColumnInfo(name = "selected") val selected: Int,
22 | @ColumnInfo(name = "tempo") val tempo: Int,
23 | @ColumnInfo(name = "swing") val swing: Int,
24 | @ColumnInfo(name = "steps") val steps: Int,
25 | @ColumnInfo(name = "active") val active: Boolean
26 | )
27 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/db/PatchRepository.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | import android.database.sqlite.SQLiteConstraintException
4 | import androidx.paging.PagingSource
5 | import kotlinx.collections.immutable.toPersistentList
6 | import kotlinx.collections.immutable.toPersistentSet
7 | import kotlinx.coroutines.Dispatchers.Default
8 | import kotlinx.coroutines.Dispatchers.IO
9 | import kotlinx.coroutines.flow.Flow
10 | import kotlinx.coroutines.flow.MutableSharedFlow
11 | import kotlinx.coroutines.flow.MutableStateFlow
12 | import kotlinx.coroutines.flow.filterNotNull
13 | import kotlinx.coroutines.flow.map
14 | import kotlinx.coroutines.flow.onEach
15 | import kotlinx.coroutines.withContext
16 | import kotlinx.serialization.builtins.ListSerializer
17 | import kotlinx.serialization.builtins.SetSerializer
18 | import kotlinx.serialization.builtins.serializer
19 | import kotlinx.serialization.json.Json
20 | import net.simno.dmach.data.Channel
21 | import net.simno.dmach.data.Patch
22 | import net.simno.dmach.data.Steps
23 | import net.simno.dmach.data.Swing
24 | import net.simno.dmach.data.Tempo
25 |
26 | class PatchRepository(
27 | private val patchDao: PatchDao
28 | ) {
29 | private val unsavedPatch = MutableSharedFlow(replay = 1)
30 | private val deleteTitle = MutableStateFlow("")
31 | private val saveTitle = MutableStateFlow("")
32 |
33 | fun patches(): PagingSource = patchDao.getAllPatches()
34 |
35 | suspend fun acceptPatch(patch: Patch) = withContext(IO) {
36 | unsavedPatch.tryEmit(patch)
37 | }
38 |
39 | suspend fun acceptDeleteTitle(title: String) = withContext(IO) {
40 | deleteTitle.emit(title)
41 | }
42 |
43 | suspend fun unsavedPatch(): Patch = withContext(IO) {
44 | unsavedPatch.replayCache.first()
45 | }
46 |
47 | fun activePatch(): Flow = patchDao.getActivePatch()
48 | .filterNotNull()
49 | .map { it.toPatch() }
50 | .onEach { acceptPatch(it) }
51 |
52 | suspend fun selectPatch(title: String): Int = withContext(IO) {
53 | patchDao.selectPatch(title)
54 | }
55 |
56 | suspend fun deletePatch(): Int = withContext(IO) {
57 | patchDao.deletePatch(deleteTitle.value)
58 | }
59 |
60 | suspend fun insertPatch(title: String): Boolean = withContext(IO) {
61 | val patch = unsavedPatch().toEntity(title)
62 | val inserted = try {
63 | patchDao.insertPatch(patch) != 0L
64 | } catch (ignored: SQLiteConstraintException) {
65 | false
66 | }
67 | if (!inserted) {
68 | saveTitle.emit(title)
69 | }
70 | inserted
71 | }
72 |
73 | suspend fun replacePatch(): Long = withContext(IO) {
74 | val patch = unsavedPatch().toEntity(saveTitle.value)
75 | patchDao.replacePatch(patch)
76 | }
77 |
78 | companion object {
79 | suspend fun PatchEntity.toPatch(): Patch = withContext(Default) {
80 | Patch(
81 | title = title,
82 | sequence = Json.decodeFromString(ListSerializer(Int.serializer()), sequence).toPersistentList(),
83 | mutedChannels = Json.decodeFromString(SetSerializer(Int.serializer()), muted).toPersistentSet(),
84 | channels = Json.decodeFromString(ListSerializer(Channel.serializer()), channels).toPersistentList(),
85 | selectedChannel = selected,
86 | tempo = Tempo(tempo),
87 | swing = Swing(swing),
88 | steps = Steps(steps)
89 | )
90 | }
91 |
92 | suspend fun Patch.toEntity(title: String): PatchEntity = withContext(Default) {
93 | PatchEntity(
94 | id = null,
95 | title = title,
96 | sequence = Json.encodeToString(ListSerializer(Int.serializer()), sequence),
97 | muted = Json.encodeToString(SetSerializer(Int.serializer()), mutedChannels),
98 | channels = Json.encodeToString(ListSerializer(Channel.serializer()), channels),
99 | selected = selectedChannel,
100 | tempo = tempo.value,
101 | swing = swing.value,
102 | steps = steps.value,
103 | active = true
104 | )
105 | }
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/MachineModule.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.android.components.ViewModelComponent
7 | import dagger.hilt.android.scopes.ViewModelScoped
8 | import net.simno.dmach.db.PatchRepository
9 | import net.simno.dmach.machine.state.MachineProcessor
10 | import net.simno.dmach.playback.AudioFocus
11 | import net.simno.dmach.playback.PlaybackController
12 | import net.simno.dmach.playback.PureData
13 | import net.simno.dmach.playback.WaveExporter
14 | import net.simno.dmach.settings.SettingsRepository
15 |
16 | @Module
17 | @InstallIn(ViewModelComponent::class)
18 | object MachineModule {
19 | @Provides
20 | @ViewModelScoped
21 | fun provideMachineProcesssor(
22 | playbackController: PlaybackController,
23 | pureData: PureData,
24 | waveExporter: WaveExporter,
25 | audioFocus: AudioFocus,
26 | patchRepository: PatchRepository,
27 | settingsRepository: SettingsRepository
28 | ): MachineProcessor = MachineProcessor(
29 | playbackController,
30 | pureData,
31 | waveExporter,
32 | audioFocus,
33 | patchRepository,
34 | settingsRepository
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/MachineScreen.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.getValue
5 | import androidx.hilt.navigation.compose.hiltViewModel
6 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
7 | import net.simno.dmach.machine.ui.Machine
8 |
9 | @Composable
10 | fun MachineScreen(
11 | navigateToPatch: () -> Unit,
12 | viewModel: MachineViewModel = hiltViewModel()
13 | ) {
14 | val state by viewModel.viewState.collectAsStateWithLifecycle()
15 |
16 | Machine(
17 | state = state,
18 | onAction = viewModel::onAction,
19 | onClickPatch = navigateToPatch
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/MachineViewModel.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine
2 |
3 | import dagger.hilt.android.lifecycle.HiltViewModel
4 | import javax.inject.Inject
5 | import net.simno.dmach.StateViewModel
6 | import net.simno.dmach.machine.state.Action
7 | import net.simno.dmach.machine.state.ErrorResult
8 | import net.simno.dmach.machine.state.LoadAction
9 | import net.simno.dmach.machine.state.MachineProcessor
10 | import net.simno.dmach.machine.state.MachineStateReducer
11 | import net.simno.dmach.machine.state.PlaybackAction
12 | import net.simno.dmach.machine.state.Result
13 | import net.simno.dmach.machine.state.SettingsAction
14 | import net.simno.dmach.machine.state.ViewState
15 |
16 | @HiltViewModel
17 | class MachineViewModel @Inject constructor(
18 | processor: MachineProcessor
19 | ) : StateViewModel(
20 | processor = processor,
21 | reducer = MachineStateReducer,
22 | onError = { ErrorResult(it) },
23 | startViewState = ViewState(),
24 | LoadAction,
25 | PlaybackAction,
26 | SettingsAction
27 | )
28 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/state/Action.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.state
2 |
3 | import kotlinx.collections.immutable.PersistentList
4 | import net.simno.dmach.data.Pan
5 | import net.simno.dmach.data.Position
6 | import net.simno.dmach.data.Steps
7 | import net.simno.dmach.data.Swing
8 | import net.simno.dmach.data.Tempo
9 | import net.simno.dmach.settings.Settings
10 |
11 | sealed class Action
12 |
13 | data class DebugAction(
14 | val debug: Boolean
15 | ) : Action()
16 |
17 | data object LoadAction : Action()
18 |
19 | data object ResumeAction : Action()
20 |
21 | data object PlaybackAction : Action()
22 |
23 | data class PlayPauseAction(
24 | val play: Boolean
25 | ) : Action()
26 |
27 | data object SettingsAction : Action()
28 |
29 | data class ChangeSettingsAction(
30 | val settings: Settings
31 | ) : Action()
32 |
33 | data object ConfigAction : Action()
34 |
35 | data object ExportAction : Action()
36 |
37 | data class ExportFileAction(
38 | val title: String,
39 | val tempo: Tempo,
40 | val steps: Steps
41 | ) : Action()
42 |
43 | data object DismissAction : Action()
44 |
45 | data class ChangeSequenceAction(
46 | val sequenceId: Int,
47 | val sequence: PersistentList
48 | ) : Action()
49 |
50 | data class MuteChannelAction(
51 | val channel: Int,
52 | val isMuted: Boolean
53 | ) : Action()
54 |
55 | data class SelectChannelAction(
56 | val channel: Int,
57 | val isSelected: Boolean
58 | ) : Action()
59 |
60 | data class SelectSettingAction(
61 | val setting: Int
62 | ) : Action()
63 |
64 | data class ChangePositionAction(
65 | val position: Position
66 | ) : Action()
67 |
68 | data class ChangePanAction(
69 | val pan: Pan
70 | ) : Action()
71 |
72 | data class ChangeTempoAction(
73 | val tempo: Tempo
74 | ) : Action()
75 |
76 | data class ChangeSwingAction(
77 | val swing: Swing
78 | ) : Action()
79 |
80 | data class ChangeStepsAction(
81 | val steps: Steps
82 | ) : Action()
83 |
84 | sealed class ChangePatchAction(
85 | open val settings: Settings
86 | ) : Action() {
87 | data class Reset(
88 | override val settings: Settings
89 | ) : ChangePatchAction(settings)
90 |
91 | data class Randomize(
92 | override val settings: Settings
93 | ) : ChangePatchAction(settings)
94 | }
95 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/state/MachineStateReducer.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.state
2 |
3 | import net.simno.dmach.util.logError
4 |
5 | object MachineStateReducer : (ViewState, Result) -> ViewState {
6 | override fun invoke(previousState: ViewState, result: Result) = when (result) {
7 | is ErrorResult -> {
8 | logError("MachineStateReducer", "ErrorResult", result.error)
9 | previousState
10 | }
11 | is DebugResult -> previousState.copy(
12 | debug = result.debug
13 | )
14 | is LoadResult -> previousState.copy(
15 | title = result.title,
16 | sequenceId = result.sequenceId,
17 | sequence = result.sequence,
18 | mutedChannels = result.mutedChannels,
19 | selectedChannel = result.selectedChannel,
20 | selectedSetting = result.selectedSetting,
21 | settingsSize = result.settingsSize,
22 | settingId = result.settingId,
23 | hText = result.hText,
24 | vText = result.vText,
25 | position = result.position,
26 | panId = result.panId,
27 | pan = result.pan,
28 | tempo = result.tempo,
29 | swing = result.swing,
30 | steps = result.steps
31 | )
32 | is ResumeResult -> previousState.copy(
33 | settingId = result.settingId,
34 | position = result.position,
35 | panId = result.panId,
36 | pan = result.pan
37 | )
38 | is PlaybackResult -> previousState.copy(
39 | isPlaying = result.isPlaying,
40 | position = null,
41 | pan = null
42 | )
43 | PlayPauseResult -> previousState.copy(
44 | position = null,
45 | pan = null
46 | )
47 | is SettingsResult -> previousState.copy(
48 | settings = result.settings,
49 | position = null,
50 | pan = null
51 | )
52 | ChangeSettingsResult -> previousState.copy(
53 | position = null,
54 | pan = null
55 | )
56 | is ConfigResult -> previousState.copy(
57 | showConfig = true,
58 | configId = result.configId,
59 | position = null,
60 | pan = null
61 | )
62 | ExportResult -> previousState.copy(
63 | showExport = true,
64 | startExport = true,
65 | waveFile = null,
66 | position = null,
67 | pan = null
68 | )
69 | is ExportFileResult -> previousState.copy(
70 | showExport = result.waveFile != null,
71 | startExport = false,
72 | waveFile = result.waveFile,
73 | position = null,
74 | pan = null
75 | )
76 | DismissResult -> previousState.copy(
77 | showConfig = false,
78 | showExport = false,
79 | startExport = false,
80 | waveFile = null,
81 | position = null,
82 | pan = null
83 | )
84 | is ChangeSequenceResult -> previousState.copy(
85 | sequenceId = result.sequenceId,
86 | sequence = result.sequence,
87 | position = null,
88 | pan = null
89 | )
90 | is MuteChannelResult -> previousState.copy(
91 | mutedChannels = result.mutedChannels
92 | )
93 | is SelectChannelResult -> previousState.copy(
94 | selectedChannel = result.selectedChannel,
95 | selectedSetting = result.selectedSetting,
96 | settingId = result.settingId,
97 | settingsSize = result.settingsSize,
98 | hText = result.hText,
99 | vText = result.vText,
100 | position = result.position,
101 | panId = result.panId,
102 | pan = result.pan
103 | )
104 | is SelectSettingResult -> previousState.copy(
105 | selectedSetting = result.selectedSetting,
106 | settingId = result.settingId,
107 | hText = result.hText,
108 | vText = result.vText,
109 | position = result.position
110 | )
111 | ChangePositionResult -> previousState.copy(
112 | position = null,
113 | pan = null
114 | )
115 | ChangePanResult -> previousState.copy(
116 | position = null,
117 | pan = null
118 | )
119 | is ChangeTempoResult -> previousState.copy(
120 | position = null,
121 | pan = null,
122 | tempo = result.tempo
123 | )
124 | is ChangeSwingResult -> previousState.copy(
125 | position = null,
126 | pan = null,
127 | swing = result.swing
128 | )
129 | is ChangeStepsResult -> previousState.copy(
130 | position = null,
131 | pan = null,
132 | sequenceId = result.sequenceId,
133 | steps = result.steps
134 | )
135 | is ChangePatchResult -> previousState.copy(
136 | sequenceId = result.sequenceId,
137 | sequence = result.sequence,
138 | panId = result.panId,
139 | pan = result.pan,
140 | settingId = result.settingId,
141 | position = result.position
142 | )
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/state/Randomizer.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.state
2 |
3 | import kotlin.random.Random
4 | import kotlinx.collections.immutable.PersistentList
5 | import kotlinx.collections.immutable.toPersistentList
6 | import net.simno.dmach.data.Patch
7 |
8 | interface Randomizer {
9 | fun nextSequence(): PersistentList
10 | fun nextFloat(): Float
11 | fun nextInt(): Int
12 |
13 | companion object {
14 | val DEFAULT = object : Randomizer {
15 | override fun nextSequence(): PersistentList = Patch.EMPTY_SEQUENCE
16 | .map { Random.nextInt(12) }
17 | .map { if (it < 8) it else 0 }
18 | .toPersistentList()
19 |
20 | override fun nextFloat(): Float = Random.nextDouble(0.0, 1.0).toFloat()
21 |
22 | override fun nextInt(): Int = Random.nextInt()
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/state/Result.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.state
2 |
3 | import java.io.File
4 | import kotlinx.collections.immutable.PersistentList
5 | import kotlinx.collections.immutable.PersistentSet
6 | import net.simno.dmach.data.Pan
7 | import net.simno.dmach.data.Position
8 | import net.simno.dmach.data.Steps
9 | import net.simno.dmach.data.Swing
10 | import net.simno.dmach.data.Tempo
11 | import net.simno.dmach.settings.Settings
12 |
13 | sealed class Result
14 |
15 | data class ErrorResult(
16 | val error: Throwable
17 | ) : Result()
18 |
19 | data class DebugResult(
20 | val debug: Boolean
21 | ) : Result()
22 |
23 | data class LoadResult(
24 | val title: String,
25 | val sequenceId: Int,
26 | val sequence: PersistentList,
27 | val mutedChannels: PersistentSet,
28 | val selectedChannel: Int,
29 | val selectedSetting: Int,
30 | val settingId: Int,
31 | val settingsSize: Int,
32 | val hText: String,
33 | val vText: String,
34 | val position: Position,
35 | val panId: Int,
36 | val pan: Pan,
37 | val tempo: Tempo,
38 | val swing: Swing,
39 | val steps: Steps
40 | ) : Result()
41 |
42 | data class ResumeResult(
43 | val settingId: Int,
44 | val position: Position,
45 | val panId: Int,
46 | val pan: Pan
47 | ) : Result()
48 |
49 | data class PlaybackResult(
50 | val isPlaying: Boolean
51 | ) : Result()
52 |
53 | data object PlayPauseResult : Result()
54 |
55 | data class SettingsResult(
56 | val settings: Settings
57 | ) : Result()
58 |
59 | data object ChangeSettingsResult : Result()
60 |
61 | data class ConfigResult(
62 | val configId: Int
63 | ) : Result()
64 |
65 | data object ExportResult : Result()
66 |
67 | data class ExportFileResult(
68 | val waveFile: File?
69 | ) : Result()
70 |
71 | data object DismissResult : Result()
72 |
73 | data class ChangeSequenceResult(
74 | val sequenceId: Int,
75 | val sequence: PersistentList
76 | ) : Result()
77 |
78 | data class MuteChannelResult(
79 | val mutedChannels: PersistentSet
80 | ) : Result()
81 |
82 | data class SelectChannelResult(
83 | val selectedChannel: Int,
84 | val selectedSetting: Int,
85 | val settingId: Int,
86 | val settingsSize: Int,
87 | val hText: String,
88 | val vText: String,
89 | val position: Position,
90 | val panId: Int,
91 | val pan: Pan
92 | ) : Result()
93 |
94 | data class SelectSettingResult(
95 | val selectedSetting: Int,
96 | val settingId: Int,
97 | val hText: String,
98 | val vText: String,
99 | val position: Position
100 | ) : Result()
101 |
102 | data object ChangePositionResult : Result()
103 |
104 | data object ChangePanResult : Result()
105 |
106 | data class ChangeTempoResult(
107 | val tempo: Tempo
108 | ) : Result()
109 |
110 | data class ChangeSwingResult(
111 | val swing: Swing
112 | ) : Result()
113 |
114 | data class ChangeStepsResult(
115 | val steps: Steps,
116 | val sequenceId: Int
117 | ) : Result()
118 |
119 | data class ChangePatchResult(
120 | val sequenceId: Int,
121 | val sequence: PersistentList,
122 | val panId: Int,
123 | val pan: Pan,
124 | val settingId: Int,
125 | val position: Position
126 | ) : Result()
127 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/state/ViewState.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.state
2 |
3 | import java.io.File
4 | import kotlinx.collections.immutable.PersistentList
5 | import kotlinx.collections.immutable.PersistentSet
6 | import kotlinx.collections.immutable.persistentSetOf
7 | import net.simno.dmach.data.Channel
8 | import net.simno.dmach.data.Pan
9 | import net.simno.dmach.data.Patch
10 | import net.simno.dmach.data.Position
11 | import net.simno.dmach.data.Steps
12 | import net.simno.dmach.data.Swing
13 | import net.simno.dmach.data.Tempo
14 | import net.simno.dmach.settings.Settings
15 |
16 | data class ViewState(
17 | val debug: Boolean = false,
18 | val title: String = "",
19 | val isPlaying: Boolean = false,
20 | val showConfig: Boolean = false,
21 | val configId: Int = 0,
22 | val showExport: Boolean = false,
23 | val startExport: Boolean = false,
24 | val waveFile: File? = null,
25 | val settings: Settings = Settings(),
26 | val sequenceId: Int = 0,
27 | val sequence: PersistentList = Patch.EMPTY_SEQUENCE,
28 | val mutedChannels: PersistentSet = persistentSetOf(),
29 | val selectedChannel: Int = Channel.NONE_ID,
30 | val selectedSetting: Int = Channel.NONE.selectedSetting,
31 | val settingId: Int = 0,
32 | val settingsSize: Int = Channel.NONE.settings.size,
33 | val hText: String = Channel.NONE.setting.hText,
34 | val vText: String = Channel.NONE.setting.vText,
35 | val position: Position? = Channel.NONE.setting.position,
36 | val panId: Int = 0,
37 | val pan: Pan? = Channel.NONE.pan,
38 | val tempo: Tempo = Tempo(120),
39 | val swing: Swing = Swing(0),
40 | val steps: Steps = Steps(16)
41 | )
42 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/ChaosPad.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.gestures.awaitEachGesture
5 | import androidx.compose.foundation.gestures.awaitFirstDown
6 | import androidx.compose.foundation.layout.Box
7 | import androidx.compose.foundation.layout.fillMaxSize
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.material3.MaterialTheme
10 | import androidx.compose.runtime.Composable
11 | import androidx.compose.runtime.LaunchedEffect
12 | import androidx.compose.runtime.getValue
13 | import androidx.compose.runtime.mutableStateOf
14 | import androidx.compose.runtime.remember
15 | import androidx.compose.runtime.rememberUpdatedState
16 | import androidx.compose.runtime.setValue
17 | import androidx.compose.ui.Alignment
18 | import androidx.compose.ui.Modifier
19 | import androidx.compose.ui.draw.clipToBounds
20 | import androidx.compose.ui.draw.drawBehind
21 | import androidx.compose.ui.draw.rotate
22 | import androidx.compose.ui.geometry.Offset
23 | import androidx.compose.ui.graphics.drawscope.Stroke
24 | import androidx.compose.ui.input.pointer.PointerInputChange
25 | import androidx.compose.ui.input.pointer.changedToDown
26 | import androidx.compose.ui.input.pointer.pointerInput
27 | import androidx.compose.ui.input.pointer.positionChanged
28 | import androidx.compose.ui.layout.layout
29 | import androidx.compose.ui.layout.onSizeChanged
30 | import androidx.compose.ui.unit.IntSize
31 | import androidx.compose.ui.unit.dp
32 | import java.util.Locale
33 | import net.simno.dmach.core.DarkLargeText
34 | import net.simno.dmach.core.DarkSmallText
35 | import net.simno.dmach.data.Position
36 | import net.simno.dmach.theme.AppTheme
37 | import net.simno.dmach.util.toPx
38 |
39 | @Composable
40 | fun ChaosPad(
41 | settingId: Int,
42 | position: Position?,
43 | horizontalText: String,
44 | verticalText: String,
45 | debug: Boolean,
46 | onPositionChange: (Position) -> Unit,
47 | modifier: Modifier = Modifier
48 | ) {
49 | val secondary = MaterialTheme.colorScheme.secondary
50 | val shapeSmall = MaterialTheme.shapes.small
51 | val paddingSmall = AppTheme.dimens.paddingSmall
52 |
53 | var debugPosition by remember { mutableStateOf(position) }
54 |
55 | LaunchedEffect(position) {
56 | position?.let {
57 | debugPosition = it
58 | }
59 | }
60 |
61 | Box(
62 | modifier = modifier
63 | .fillMaxSize()
64 | .background(
65 | color = secondary,
66 | shape = shapeSmall
67 | )
68 | ) {
69 | DarkLargeText(
70 | text = verticalText.uppercase(),
71 | modifier = Modifier
72 | .layout { measurable, constraints ->
73 | val placeable = measurable.measure(constraints)
74 | layout(placeable.height, placeable.width) {
75 | placeable.place(
76 | x = -(placeable.width / 2 - placeable.height / 2),
77 | y = -(placeable.height / 2 - placeable.width / 2)
78 | )
79 | }
80 | }
81 | .rotate(-90f)
82 | .align(Alignment.CenterStart)
83 | )
84 | DarkLargeText(
85 | text = horizontalText.uppercase(),
86 | modifier = Modifier
87 | .align(Alignment.BottomCenter)
88 | .padding(bottom = paddingSmall)
89 | )
90 | if (debug) {
91 | DarkSmallText(
92 | text = debugPosition?.let { String.format(Locale.US, "%.2f", it.y) }.orEmpty(),
93 | modifier = Modifier
94 | .layout { measurable, constraints ->
95 | val placeable = measurable.measure(constraints)
96 | layout(placeable.height, placeable.width) {
97 | placeable.place(
98 | x = -(placeable.width / 2 - placeable.height / 2),
99 | y = -(placeable.height / 2 - placeable.width / 2)
100 | )
101 | }
102 | }
103 | .rotate(-90f)
104 | .align(Alignment.TopStart)
105 | .padding(end = 4.dp)
106 | )
107 | DarkSmallText(
108 | text = debugPosition?.let { String.format(Locale.US, "%.2f", it.x) }.orEmpty(),
109 | modifier = Modifier
110 | .align(Alignment.BottomEnd)
111 | .padding(end = 4.dp)
112 | )
113 | }
114 | Circle(
115 | settingId = settingId,
116 | position = position,
117 | onPositionChange = {
118 | debugPosition = it
119 | onPositionChange(it)
120 | }
121 | )
122 | }
123 | }
124 |
125 | @Composable
126 | private fun Circle(
127 | settingId: Int,
128 | position: Position?,
129 | onPositionChange: (Position) -> Unit,
130 | modifier: Modifier = Modifier
131 | ) {
132 | val currentOnPositionChange by rememberUpdatedState(onPositionChange)
133 |
134 | val surface = MaterialTheme.colorScheme.surface
135 | val radius = AppTheme.dimens.circleRadius.toPx()
136 | val strokeWidth = AppTheme.dimens.paddingSmall.toPx()
137 |
138 | var size by remember { mutableStateOf(IntSize.Zero) }
139 |
140 | val stroke = remember(strokeWidth) { Stroke(width = strokeWidth) }
141 | val minX = remember(strokeWidth, radius) { strokeWidth / 2f + radius }
142 | val minY = remember(strokeWidth, radius) { strokeWidth / 2f + radius }
143 | val maxX = remember(minX, size.width) { size.width - minX }
144 | val maxY = remember(minY, size.height) { size.height - minY }
145 |
146 | var circlePosition by remember(settingId, maxX, maxY) {
147 | mutableStateOf(
148 | position?.let {
149 | // Convert position value [0.0-1.0] to pixels.
150 | val newX = it.x * ((maxX - minX) + minX)
151 | val newY = (1 - it.y) * ((maxY - minY) + minY)
152 | Position(newX, newY)
153 | }
154 | )
155 | }
156 |
157 | val circleOffset = remember(circlePosition) {
158 | circlePosition?.let {
159 | val newX = it.x
160 | .coerceAtLeast(minX)
161 | .coerceAtMost(maxX)
162 | val newY = it.y
163 | .coerceAtLeast(minY)
164 | .coerceAtMost(maxY)
165 | Offset(newX, newY)
166 | }
167 | }
168 |
169 | Box(
170 | modifier = modifier
171 | .fillMaxSize()
172 | .clipToBounds()
173 | .onSizeChanged { size = it }
174 | .pointerInput(settingId, size) {
175 | fun notifyPosition(x: Float, y: Float) {
176 | // Convert pixels to a position value [0.0-1.0]
177 | val posX = ((x - minX) / (maxX - minX)).coerceIn(0f, 1f)
178 | val posY = 1 - ((y - minY) / (maxY - minY)).coerceIn(0f, 1f)
179 | currentOnPositionChange(Position(posX, posY))
180 | }
181 |
182 | fun onPointerDownOrMove(pointer: PointerInputChange) {
183 | circlePosition = Position(pointer.position.x, pointer.position.y)
184 | notifyPosition(pointer.position.x, pointer.position.y)
185 | }
186 |
187 | awaitEachGesture {
188 | val firstPointer = awaitFirstDown()
189 | if (firstPointer.changedToDown()) {
190 | firstPointer.consume()
191 | }
192 | onPointerDownOrMove(firstPointer)
193 |
194 | do {
195 | val event = awaitPointerEvent()
196 | event.changes.forEach { pointer ->
197 | if (pointer.positionChanged()) {
198 | pointer.consume()
199 | }
200 | onPointerDownOrMove(pointer)
201 | }
202 | } while (event.changes.any { it.pressed })
203 | }
204 | }
205 | .drawBehind {
206 | circleOffset?.let {
207 | drawCircle(
208 | color = surface,
209 | radius = radius,
210 | style = stroke,
211 | center = it,
212 | alpha = 0.94f
213 | )
214 | }
215 | }
216 | )
217 | }
218 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/ConfigCheckbox.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.layout.Arrangement
5 | import androidx.compose.foundation.layout.Row
6 | import androidx.compose.foundation.layout.fillMaxWidth
7 | import androidx.compose.foundation.layout.height
8 | import androidx.compose.foundation.layout.padding
9 | import androidx.compose.material3.Checkbox
10 | import androidx.compose.material3.CheckboxDefaults
11 | import androidx.compose.material3.MaterialTheme
12 | import androidx.compose.runtime.Composable
13 | import androidx.compose.ui.Alignment
14 | import androidx.compose.ui.Modifier
15 | import net.simno.dmach.core.LightSmallText
16 | import net.simno.dmach.theme.AppTheme
17 |
18 | @Composable
19 | fun ConfigCheckbox(
20 | text: String,
21 | checked: Boolean,
22 | onCheckedChange: (Boolean) -> Unit,
23 | modifier: Modifier = Modifier
24 | ) {
25 | val surface = MaterialTheme.colorScheme.surface
26 | val onPrimary = MaterialTheme.colorScheme.onPrimary
27 | val shapeMedium = MaterialTheme.shapes.medium
28 | val paddingLarge = AppTheme.dimens.paddingLarge
29 | val paddingSmall = AppTheme.dimens.paddingSmall
30 | val configHeightSmall = AppTheme.dimens.configHeightSmall
31 |
32 | Row(
33 | modifier = modifier
34 | .background(
35 | color = onPrimary,
36 | shape = shapeMedium
37 | )
38 | .fillMaxWidth()
39 | .height(configHeightSmall)
40 | .padding(
41 | start = paddingLarge,
42 | top = paddingSmall,
43 | end = paddingSmall,
44 | bottom = paddingSmall
45 | ),
46 | verticalAlignment = Alignment.CenterVertically,
47 | horizontalArrangement = Arrangement.SpaceBetween
48 | ) {
49 | LightSmallText(text = text)
50 | Checkbox(
51 | checked = checked,
52 | onCheckedChange = onCheckedChange,
53 | colors = CheckboxDefaults.colors(
54 | checkedColor = surface,
55 | uncheckedColor = surface,
56 | checkmarkColor = onPrimary
57 | )
58 | )
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/ConfigDialog.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.clickable
5 | import androidx.compose.foundation.layout.Arrangement
6 | import androidx.compose.foundation.layout.Column
7 | import androidx.compose.foundation.layout.IntrinsicSize
8 | import androidx.compose.foundation.layout.Row
9 | import androidx.compose.foundation.layout.fillMaxWidth
10 | import androidx.compose.foundation.layout.height
11 | import androidx.compose.foundation.layout.padding
12 | import androidx.compose.foundation.layout.size
13 | import androidx.compose.foundation.layout.width
14 | import androidx.compose.material.icons.Icons
15 | import androidx.compose.material.icons.filled.ChevronLeft
16 | import androidx.compose.material.icons.filled.ChevronRight
17 | import androidx.compose.material3.Icon
18 | import androidx.compose.material3.MaterialTheme
19 | import androidx.compose.runtime.Composable
20 | import androidx.compose.runtime.getValue
21 | import androidx.compose.runtime.mutableFloatStateOf
22 | import androidx.compose.runtime.mutableStateOf
23 | import androidx.compose.runtime.remember
24 | import androidx.compose.runtime.setValue
25 | import androidx.compose.ui.Alignment
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.draw.scale
28 | import androidx.compose.ui.res.stringResource
29 | import androidx.compose.ui.unit.dp
30 | import androidx.compose.ui.window.Dialog
31 | import androidx.compose.ui.window.DialogProperties
32 | import net.simno.dmach.R
33 | import net.simno.dmach.core.LightMediumLabel
34 | import net.simno.dmach.core.PredictiveBackProgress
35 | import net.simno.dmach.data.Steps
36 | import net.simno.dmach.data.Swing
37 | import net.simno.dmach.data.Tempo
38 | import net.simno.dmach.settings.Settings
39 | import net.simno.dmach.theme.AppTheme
40 |
41 | @Composable
42 | fun ConfigDialog(
43 | configId: Int,
44 | tempo: Tempo,
45 | swing: Swing,
46 | steps: Steps,
47 | settings: Settings,
48 | onTempoChange: (Tempo) -> Unit,
49 | onSwingChange: (Swing) -> Unit,
50 | onStepsChange: (Steps) -> Unit,
51 | onSettingsChange: (Settings) -> Unit,
52 | modifier: Modifier = Modifier,
53 | onDismiss: () -> Unit
54 | ) {
55 | val surface = MaterialTheme.colorScheme.surface
56 | val primary = MaterialTheme.colorScheme.primary
57 | val onPrimary = MaterialTheme.colorScheme.onPrimary
58 | val shapeMedium = MaterialTheme.shapes.medium
59 | val paddingSmall = AppTheme.dimens.paddingSmall
60 | val paddingLarge = AppTheme.dimens.paddingLarge
61 | val configHeightSmall = AppTheme.dimens.configHeightSmall
62 | var showSettings by remember { mutableStateOf(false) }
63 |
64 | Dialog(
65 | properties = DialogProperties(usePlatformDefaultWidth = showSettings),
66 | onDismissRequest = onDismiss
67 | ) {
68 | var backProgress by remember { mutableFloatStateOf(0f) }
69 | var inPredictiveBack by remember { mutableStateOf(false) }
70 | PredictiveBackProgress(
71 | onProgress = { backProgress = it },
72 | onInPredictiveBack = { inPredictiveBack = it },
73 | onBack = onDismiss
74 | )
75 |
76 | Column(
77 | modifier = modifier
78 | .scale((1f - backProgress).coerceAtLeast(0.85f))
79 | .background(
80 | color = primary,
81 | shape = shapeMedium
82 | )
83 | .width(IntrinsicSize.Max)
84 | .padding(paddingSmall),
85 | verticalArrangement = Arrangement.spacedBy(paddingSmall)
86 | ) {
87 | if (showSettings) {
88 | Row(
89 | modifier = Modifier
90 | .background(
91 | color = onPrimary,
92 | shape = shapeMedium
93 | )
94 | .fillMaxWidth()
95 | .height(configHeightSmall)
96 | .clickable(onClick = { showSettings = false })
97 | .padding(horizontal = paddingLarge),
98 | verticalAlignment = Alignment.CenterVertically,
99 | horizontalArrangement = Arrangement.spacedBy(16.dp)
100 | ) {
101 | Icon(
102 | imageVector = Icons.Filled.ChevronLeft,
103 | contentDescription = null,
104 | tint = surface,
105 | modifier = Modifier.size(24.dp)
106 | )
107 | LightMediumLabel(stringResource(R.string.back))
108 | }
109 | ConfigCheckbox(
110 | text = stringResource(R.string.audiofocus),
111 | checked = settings.ignoreAudioFocus,
112 | onCheckedChange = { checked ->
113 | onSettingsChange(settings.copy(ignoreAudioFocus = checked))
114 | }
115 | )
116 | ConfigCheckbox(
117 | text = stringResource(R.string.sequencer_setting),
118 | checked = settings.sequenceEnabled,
119 | onCheckedChange = { checked ->
120 | onSettingsChange(settings.copy(sequenceEnabled = checked))
121 | }
122 | )
123 | ConfigCheckbox(
124 | text = stringResource(R.string.sound_setting),
125 | checked = settings.soundEnabled,
126 | onCheckedChange = { checked ->
127 | onSettingsChange(settings.copy(soundEnabled = checked))
128 | }
129 | )
130 | ConfigCheckbox(
131 | text = stringResource(R.string.pan_setting),
132 | checked = settings.panEnabled,
133 | onCheckedChange = { checked ->
134 | onSettingsChange(settings.copy(panEnabled = checked))
135 | }
136 | )
137 | } else {
138 | ConfigValue(
139 | configId = configId,
140 | label = R.string.bpm,
141 | configValue = tempo.value,
142 | minValue = 1,
143 | maxValue = 1000,
144 | onValueChange = { value -> onTempoChange(Tempo(value)) }
145 | )
146 | ConfigValue(
147 | configId = configId,
148 | label = R.string.swing,
149 | configValue = swing.value,
150 | minValue = 0,
151 | maxValue = 50,
152 | onValueChange = { value -> onSwingChange(Swing(value)) }
153 | )
154 | ConfigValue(
155 | configId = configId,
156 | label = R.string.steps,
157 | configValue = steps.value,
158 | minValue = 8,
159 | maxValue = 16,
160 | onValueChange = { value -> onStepsChange(Steps(value)) }
161 | )
162 | Row(
163 | modifier = Modifier
164 | .background(
165 | color = onPrimary,
166 | shape = shapeMedium
167 | )
168 | .fillMaxWidth()
169 | .height(configHeightSmall)
170 | .clickable(onClick = { showSettings = true })
171 | .padding(horizontal = paddingLarge),
172 | verticalAlignment = Alignment.CenterVertically,
173 | horizontalArrangement = Arrangement.SpaceBetween
174 | ) {
175 | LightMediumLabel(stringResource(R.string.more_settings))
176 | Icon(
177 | imageVector = Icons.Filled.ChevronRight,
178 | contentDescription = null,
179 | tint = surface,
180 | modifier = Modifier.size(24.dp)
181 | )
182 | }
183 | }
184 | }
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/ConfigValue.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.gestures.awaitEachGesture
6 | import androidx.compose.foundation.gestures.awaitFirstDown
7 | import androidx.compose.foundation.layout.Arrangement
8 | import androidx.compose.foundation.layout.Column
9 | import androidx.compose.foundation.layout.Row
10 | import androidx.compose.foundation.layout.Spacer
11 | import androidx.compose.foundation.layout.defaultMinSize
12 | import androidx.compose.foundation.layout.fillMaxWidth
13 | import androidx.compose.foundation.layout.height
14 | import androidx.compose.foundation.layout.padding
15 | import androidx.compose.foundation.layout.size
16 | import androidx.compose.material.icons.Icons
17 | import androidx.compose.material.icons.filled.Add
18 | import androidx.compose.material.icons.filled.Remove
19 | import androidx.compose.material3.Icon
20 | import androidx.compose.material3.MaterialTheme
21 | import androidx.compose.runtime.Composable
22 | import androidx.compose.runtime.getValue
23 | import androidx.compose.runtime.mutableIntStateOf
24 | import androidx.compose.runtime.remember
25 | import androidx.compose.runtime.rememberUpdatedState
26 | import androidx.compose.runtime.setValue
27 | import androidx.compose.ui.Alignment
28 | import androidx.compose.ui.Modifier
29 | import androidx.compose.ui.input.pointer.changedToDown
30 | import androidx.compose.ui.input.pointer.pointerInput
31 | import androidx.compose.ui.input.pointer.positionChanged
32 | import androidx.compose.ui.res.stringResource
33 | import androidx.compose.ui.unit.dp
34 | import kotlinx.coroutines.CoroutineStart
35 | import kotlinx.coroutines.coroutineScope
36 | import kotlinx.coroutines.delay
37 | import kotlinx.coroutines.isActive
38 | import kotlinx.coroutines.launch
39 | import net.simno.dmach.core.LightLargeText
40 | import net.simno.dmach.core.LightMediumLabel
41 | import net.simno.dmach.theme.AppTheme
42 |
43 | @Composable
44 | fun ConfigValue(
45 | configId: Int,
46 | @StringRes label: Int,
47 | configValue: Int,
48 | minValue: Int,
49 | maxValue: Int,
50 | onValueChange: (Int) -> Unit,
51 | modifier: Modifier = Modifier
52 | ) {
53 | val surface = MaterialTheme.colorScheme.surface
54 | val onPrimary = MaterialTheme.colorScheme.onPrimary
55 | val shapeMedium = MaterialTheme.shapes.medium
56 | val paddingLarge = AppTheme.dimens.paddingLarge
57 | val configHeight = AppTheme.dimens.configHeight
58 | val currentOnValueChange by rememberUpdatedState(onValueChange)
59 | var value by remember { mutableIntStateOf(configValue) }
60 |
61 | Row(
62 | modifier = modifier
63 | .background(
64 | color = onPrimary,
65 | shape = shapeMedium
66 | )
67 | .fillMaxWidth()
68 | .height(configHeight)
69 | .padding(horizontal = paddingLarge)
70 | .pointerInput(configId) {
71 | val width = size.width.toFloat()
72 | val center = width / 2f
73 | value = configValue
74 | .coerceAtLeast(minValue)
75 | .coerceAtMost(maxValue)
76 | var delay = 500L
77 | var change = 1
78 |
79 | fun calculateChangeAndDelay(x: Float) {
80 | val validX = x.coerceIn(0f, width)
81 |
82 | change = if (validX > center) 1 else -1
83 |
84 | val delayMultiplier = if (validX <= center) {
85 | validX / center
86 | } else {
87 | 1 - ((validX - center) / (width - center))
88 | }
89 |
90 | delay = (((MAX_DELAY - MIN_DELAY) * delayMultiplier) + MIN_DELAY).toLong()
91 | }
92 |
93 | coroutineScope {
94 | awaitEachGesture {
95 | val job = launch(start = CoroutineStart.LAZY) {
96 | runCatching {
97 | while (isActive) {
98 | val newValue = value + change
99 | if (newValue in minValue..maxValue) {
100 | value = newValue
101 | currentOnValueChange(newValue)
102 | }
103 | delay(delay)
104 | }
105 | }
106 | }
107 |
108 | val firstPointer = awaitFirstDown()
109 | if (firstPointer.changedToDown()) {
110 | firstPointer.consume()
111 | }
112 | calculateChangeAndDelay(firstPointer.position.x)
113 | job.start()
114 |
115 | do {
116 | val event = awaitPointerEvent()
117 | event.changes.forEach { pointer ->
118 | if (pointer.positionChanged()) {
119 | pointer.consume()
120 | }
121 | calculateChangeAndDelay(pointer.position.x)
122 | }
123 | } while (event.changes.any { it.pressed })
124 |
125 | job.cancel()
126 | }
127 | }
128 | },
129 | horizontalArrangement = Arrangement.SpaceBetween
130 | ) {
131 | Icon(
132 | imageVector = Icons.Filled.Remove,
133 | contentDescription = null,
134 | tint = surface,
135 | modifier = Modifier
136 | .align(Alignment.CenterVertically)
137 | .size(36.dp)
138 | )
139 | Spacer(modifier = Modifier.size(96.dp))
140 | Icon(
141 | imageVector = Icons.Filled.Remove,
142 | contentDescription = null,
143 | tint = surface,
144 | modifier = Modifier
145 | .align(Alignment.CenterVertically)
146 | .size(18.dp)
147 | )
148 | Column(
149 | modifier = Modifier
150 | .align(Alignment.CenterVertically)
151 | .defaultMinSize(minWidth = 64.dp)
152 | ) {
153 | LightMediumLabel(
154 | text = stringResource(label).uppercase(),
155 | modifier = Modifier.align(Alignment.CenterHorizontally)
156 | )
157 | LightLargeText(
158 | text = "$value",
159 | modifier = Modifier.align(Alignment.CenterHorizontally)
160 | )
161 | }
162 | Icon(
163 | imageVector = Icons.Filled.Add,
164 | contentDescription = null,
165 | tint = surface,
166 | modifier = Modifier
167 | .align(Alignment.CenterVertically)
168 | .size(18.dp)
169 | )
170 | Spacer(modifier = Modifier.size(96.dp))
171 | Icon(
172 | imageVector = Icons.Filled.Add,
173 | contentDescription = null,
174 | tint = surface,
175 | modifier = Modifier
176 | .align(Alignment.CenterVertically)
177 | .size(36.dp)
178 | )
179 | }
180 | }
181 |
182 | private const val MAX_DELAY = 400f
183 | private const val MIN_DELAY = 32f
184 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/ExportDialog.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import android.content.Intent
4 | import androidx.activity.compose.rememberLauncherForActivityResult
5 | import androidx.activity.result.contract.ActivityResultContracts.CreateDocument
6 | import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
7 | import androidx.compose.runtime.Composable
8 | import androidx.compose.runtime.rememberCoroutineScope
9 | import androidx.compose.ui.platform.LocalContext
10 | import androidx.compose.ui.res.stringResource
11 | import androidx.compose.ui.window.DialogProperties
12 | import androidx.core.content.FileProvider
13 | import java.io.File
14 | import kotlinx.coroutines.Dispatchers
15 | import kotlinx.coroutines.launch
16 | import net.simno.dmach.BuildConfig
17 | import net.simno.dmach.R
18 | import net.simno.dmach.core.OptionsDialog
19 |
20 | private const val WAV_MIME_TYPE = "audio/x-wav"
21 |
22 | @Composable
23 | fun ExportDialog(
24 | enabled: Boolean,
25 | waveFile: File?,
26 | onDismiss: () -> Unit
27 | ) {
28 | val context = LocalContext.current
29 | val scope = rememberCoroutineScope()
30 |
31 | val shareLauncher = rememberLauncherForActivityResult(StartActivityForResult()) {
32 | }
33 |
34 | val saveLauncher = rememberLauncherForActivityResult(CreateDocument(WAV_MIME_TYPE)) { uri ->
35 | if (uri != null && waveFile != null) {
36 | scope.launch(Dispatchers.IO) {
37 | runCatching {
38 | context.contentResolver.openOutputStream(uri)?.use { stream ->
39 | stream.write(waveFile.readBytes())
40 | }
41 | }
42 | }
43 | }
44 | }
45 |
46 | OptionsDialog(
47 | text = stringResource(R.string.export_wave_file),
48 | option1Text = stringResource(R.string.save),
49 | option2Text = stringResource(R.string.share),
50 | onDismiss = {
51 | runCatching { waveFile?.delete() }
52 | onDismiss()
53 | },
54 | onOption1 = {
55 | waveFile?.name?.let { name -> saveLauncher.launch(name) }
56 | },
57 | onOption2 = {
58 | waveFile?.let { file ->
59 | val uri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID, file)
60 | val target = Intent(Intent.ACTION_SEND).apply {
61 | setDataAndType(uri, WAV_MIME_TYPE)
62 | putExtra(Intent.EXTRA_STREAM, uri)
63 | putExtra(Intent.EXTRA_SUBJECT, file.name)
64 | addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
65 | }
66 | val intent = Intent.createChooser(target, context.getString(R.string.share))
67 | shareLauncher.launch(intent)
68 | }
69 | },
70 | enabled = enabled,
71 | properties = DialogProperties(
72 | dismissOnBackPress = enabled,
73 | dismissOnClickOutside = enabled
74 | )
75 | )
76 | }
77 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/IconButton.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import androidx.annotation.StringRes
4 | import androidx.compose.foundation.background
5 | import androidx.compose.foundation.combinedClickable
6 | import androidx.compose.foundation.interaction.MutableInteractionSource
7 | import androidx.compose.foundation.interaction.collectIsPressedAsState
8 | import androidx.compose.foundation.layout.Box
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.padding
11 | import androidx.compose.material3.Icon
12 | import androidx.compose.material3.MaterialTheme
13 | import androidx.compose.runtime.Composable
14 | import androidx.compose.runtime.getValue
15 | import androidx.compose.runtime.remember
16 | import androidx.compose.ui.Alignment
17 | import androidx.compose.ui.Modifier
18 | import androidx.compose.ui.graphics.vector.ImageVector
19 | import androidx.compose.ui.res.stringResource
20 | import androidx.compose.ui.semantics.Role
21 | import androidx.compose.ui.unit.Dp
22 | import androidx.lifecycle.compose.dropUnlessResumed
23 | import net.simno.dmach.core.hapticClick
24 | import net.simno.dmach.theme.AppTheme
25 |
26 | @Composable
27 | fun IconButton(
28 | icon: ImageVector,
29 | @StringRes description: Int,
30 | selected: Boolean,
31 | onClick: () -> Unit,
32 | modifier: Modifier = Modifier,
33 | iconPadding: Dp = AppTheme.dimens.paddingLarge,
34 | onLongClick: (() -> Unit)? = null
35 | ) {
36 | val interactionSource = remember { MutableInteractionSource() }
37 | val pressed by interactionSource.collectIsPressedAsState()
38 | val background = when {
39 | pressed -> MaterialTheme.colorScheme.onSecondary
40 | selected -> MaterialTheme.colorScheme.onSecondary
41 | else -> MaterialTheme.colorScheme.primary
42 | }
43 | Box(
44 | modifier = modifier
45 | .fillMaxSize()
46 | .background(
47 | color = background,
48 | shape = MaterialTheme.shapes.medium
49 | )
50 | .combinedClickable(
51 | onClick = dropUnlessResumed(block = onClick),
52 | onLongClick = hapticClick(block = onLongClick),
53 | enabled = true,
54 | role = Role.Button,
55 | interactionSource = interactionSource,
56 | indication = null
57 | ),
58 | contentAlignment = Alignment.Center
59 | ) {
60 | Icon(
61 | imageVector = icon,
62 | contentDescription = stringResource(description),
63 | tint = MaterialTheme.colorScheme.surface,
64 | modifier = Modifier
65 | .fillMaxSize()
66 | .padding(iconPadding)
67 | )
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/PanFader.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import android.animation.ValueAnimator
4 | import android.view.animation.DecelerateInterpolator
5 | import androidx.compose.foundation.gestures.awaitEachGesture
6 | import androidx.compose.foundation.gestures.awaitFirstDown
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.foundation.layout.fillMaxHeight
9 | import androidx.compose.foundation.layout.fillMaxSize
10 | import androidx.compose.foundation.layout.fillMaxWidth
11 | import androidx.compose.foundation.layout.height
12 | import androidx.compose.foundation.layout.padding
13 | import androidx.compose.foundation.layout.width
14 | import androidx.compose.foundation.layout.wrapContentSize
15 | import androidx.compose.material3.MaterialTheme
16 | import androidx.compose.runtime.Composable
17 | import androidx.compose.runtime.LaunchedEffect
18 | import androidx.compose.runtime.getValue
19 | import androidx.compose.runtime.mutableStateOf
20 | import androidx.compose.runtime.remember
21 | import androidx.compose.runtime.rememberUpdatedState
22 | import androidx.compose.runtime.setValue
23 | import androidx.compose.ui.Alignment
24 | import androidx.compose.ui.Modifier
25 | import androidx.compose.ui.draw.clipToBounds
26 | import androidx.compose.ui.draw.drawBehind
27 | import androidx.compose.ui.geometry.CornerRadius
28 | import androidx.compose.ui.geometry.Offset
29 | import androidx.compose.ui.geometry.Size
30 | import androidx.compose.ui.graphics.drawscope.Stroke
31 | import androidx.compose.ui.hapticfeedback.HapticFeedbackType
32 | import androidx.compose.ui.input.pointer.changedToDown
33 | import androidx.compose.ui.input.pointer.pointerInput
34 | import androidx.compose.ui.input.pointer.positionChanged
35 | import androidx.compose.ui.layout.onSizeChanged
36 | import androidx.compose.ui.platform.LocalDensity
37 | import androidx.compose.ui.platform.LocalHapticFeedback
38 | import androidx.compose.ui.res.stringResource
39 | import androidx.compose.ui.text.style.TextAlign
40 | import androidx.compose.ui.unit.IntSize
41 | import androidx.compose.ui.unit.dp
42 | import java.util.Locale
43 | import net.simno.dmach.R
44 | import net.simno.dmach.core.DarkLargeText
45 | import net.simno.dmach.core.DarkSmallText
46 | import net.simno.dmach.data.Pan
47 | import net.simno.dmach.theme.AppTheme
48 | import net.simno.dmach.util.toPx
49 |
50 | @Composable
51 | fun PanFader(
52 | panId: Int,
53 | pan: Pan?,
54 | debug: Boolean,
55 | onPanChange: (Pan) -> Unit,
56 | modifier: Modifier = Modifier
57 | ) {
58 | val rectHeight = AppTheme.dimens.rectHeight
59 | val buttonMedium = AppTheme.dimens.buttonMedium
60 |
61 | var debugPan by remember { mutableStateOf(pan) }
62 |
63 | LaunchedEffect(pan) {
64 | pan?.let {
65 | debugPan = it
66 | }
67 | }
68 |
69 | Box(
70 | modifier = modifier
71 | .width(buttonMedium)
72 | .fillMaxHeight()
73 | ) {
74 | Box(
75 | modifier = Modifier
76 | .fillMaxWidth()
77 | .height(rectHeight)
78 | .align(Alignment.TopCenter)
79 | .padding(top = 2.dp)
80 | ) {
81 | DarkLargeText(
82 | text = stringResource(R.string.pan_right),
83 | textAlign = TextAlign.Center,
84 | modifier = Modifier
85 | .wrapContentSize()
86 | .align(Alignment.Center)
87 | )
88 | }
89 | Box(
90 | modifier = Modifier
91 | .fillMaxWidth()
92 | .height(rectHeight)
93 | .align(Alignment.BottomCenter)
94 | .padding(bottom = 2.dp)
95 | ) {
96 | DarkLargeText(
97 | text = stringResource(R.string.pan_left),
98 | textAlign = TextAlign.Center,
99 | modifier = Modifier
100 | .wrapContentSize()
101 | .align(Alignment.Center)
102 | )
103 | }
104 | if (debug) {
105 | DarkSmallText(
106 | text = debugPan?.let { String.format(Locale.US, "%.2f", it.value) }.orEmpty(),
107 | modifier = Modifier.align(Alignment.Center)
108 | )
109 | }
110 | Fader(
111 | panId = panId,
112 | pan = pan,
113 | onPanChange = {
114 | debugPan = it
115 | onPanChange(it)
116 | }
117 | )
118 | }
119 | }
120 |
121 | @Composable
122 | private fun Fader(
123 | panId: Int,
124 | pan: Pan?,
125 | onPanChange: (Pan) -> Unit,
126 | modifier: Modifier = Modifier
127 | ) {
128 | val currentOnPanChange by rememberUpdatedState(onPanChange)
129 |
130 | val haptic = LocalHapticFeedback.current
131 | val density = LocalDensity.current
132 | val secondary = MaterialTheme.colorScheme.secondary
133 | val cornerSize = MaterialTheme.shapes.small.topStart
134 | val rectHeight = AppTheme.dimens.rectHeight.toPx()
135 | val strokeWidth = AppTheme.dimens.paddingSmall.toPx()
136 |
137 | var size by remember { mutableStateOf(IntSize.Zero) }
138 |
139 | val rectSize = remember(strokeWidth, size.width, rectHeight) {
140 | Size(size.width.toFloat() - strokeWidth, rectHeight)
141 | }
142 | val offset = remember(rectHeight) { rectHeight / 2f }
143 | val cornerRadius = remember(rectSize) { cornerSize.toPx(rectSize, density).let { CornerRadius(it, it) } }
144 | val stroke = remember(strokeWidth) { Stroke(width = strokeWidth) }
145 | val minX = remember(strokeWidth) { strokeWidth / 2f }
146 | val minY = remember(strokeWidth, offset) { offset + (strokeWidth / 2f) }
147 | val maxY = remember(minY, size.height) { size.height - minY }
148 |
149 | var panPosition by remember(panId, maxY) {
150 | mutableStateOf(
151 | pan?.let {
152 | // Convert position value [0.0-1.0] to pixels.
153 | if (pan.value == 0.5f) {
154 | size.height / 2f
155 | } else {
156 | (1 - pan.value) * ((maxY - minY) + minY)
157 | }
158 | }
159 | )
160 | }
161 |
162 | val panOffset = remember(panPosition, offset, minX) {
163 | panPosition?.let {
164 | val newY = it
165 | .coerceAtLeast(minY)
166 | .coerceAtMost(maxY)
167 | Offset(minX, newY - offset)
168 | }
169 | }
170 |
171 | Box(
172 | modifier = modifier
173 | .fillMaxSize()
174 | .clipToBounds()
175 | .onSizeChanged { size = it }
176 | .pointerInput(panId, size) {
177 | var centerAnimator: ValueAnimator? = null
178 | var isCentered = true
179 | val center = size.height / 2f
180 | val left = center + (offset / 2f)
181 | val right = center - (offset / 2f)
182 |
183 | fun notifyPosition(y: Float, notifyCenter: Boolean = false) {
184 | // Convert pixels to a position value [0.0-1.0]
185 | val pos = if (notifyCenter) {
186 | // Pixel conversion is not exact. Set y to 0.5 if we know it is centered.
187 | 0.5f
188 | } else {
189 | 1 - ((y - minY) / (maxY - minY)).coerceIn(0f, 1f)
190 | }
191 | currentOnPanChange(Pan(pos))
192 | }
193 |
194 | fun animateToCenter(y: Float, center: Float) {
195 | centerAnimator?.cancel()
196 | centerAnimator = ValueAnimator
197 | .ofFloat(y, center)
198 | .apply {
199 | duration = 100L
200 | interpolator = DecelerateInterpolator()
201 | addUpdateListener { animation ->
202 | (animation.animatedValue as Float).let {
203 | panPosition = it
204 | notifyPosition(it, notifyCenter = it == center)
205 | }
206 | }
207 | start()
208 | }
209 | haptic.performHapticFeedback(HapticFeedbackType.LongPress)
210 | }
211 |
212 | fun onPointerDownOrMove(y: Float) {
213 | if (y < left && y > right) {
214 | if (!isCentered) {
215 | isCentered = true
216 | animateToCenter(y, center)
217 | }
218 | } else {
219 | isCentered = false
220 | panPosition = y
221 | notifyPosition(y)
222 | }
223 | }
224 |
225 | awaitEachGesture {
226 | val firstPointer = awaitFirstDown()
227 | if (firstPointer.changedToDown()) {
228 | firstPointer.consume()
229 | }
230 | onPointerDownOrMove(firstPointer.position.y)
231 |
232 | do {
233 | val event = awaitPointerEvent()
234 | event.changes.forEach { pointer ->
235 | if (pointer.positionChanged()) {
236 | pointer.consume()
237 | }
238 | onPointerDownOrMove(pointer.position.y)
239 | }
240 | } while (event.changes.any { it.pressed })
241 | }
242 | }
243 | .drawBehind {
244 | panOffset?.let {
245 | drawRoundRect(
246 | color = secondary,
247 | topLeft = it,
248 | size = rectSize,
249 | cornerRadius = cornerRadius,
250 | style = stroke,
251 | alpha = 0.94f
252 | )
253 | }
254 | }
255 | )
256 | }
257 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/StepSequencer.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import androidx.compose.foundation.gestures.awaitEachGesture
4 | import androidx.compose.foundation.gestures.awaitFirstDown
5 | import androidx.compose.foundation.layout.Box
6 | import androidx.compose.foundation.layout.fillMaxSize
7 | import androidx.compose.material3.MaterialTheme
8 | import androidx.compose.runtime.Composable
9 | import androidx.compose.runtime.getValue
10 | import androidx.compose.runtime.mutableIntStateOf
11 | import androidx.compose.runtime.mutableStateListOf
12 | import androidx.compose.runtime.mutableStateOf
13 | import androidx.compose.runtime.remember
14 | import androidx.compose.runtime.rememberUpdatedState
15 | import androidx.compose.runtime.setValue
16 | import androidx.compose.ui.Modifier
17 | import androidx.compose.ui.draw.clipToBounds
18 | import androidx.compose.ui.draw.drawBehind
19 | import androidx.compose.ui.geometry.CornerRadius
20 | import androidx.compose.ui.geometry.Offset
21 | import androidx.compose.ui.geometry.Size
22 | import androidx.compose.ui.graphics.Color
23 | import androidx.compose.ui.graphics.drawscope.Fill
24 | import androidx.compose.ui.input.pointer.changedToDown
25 | import androidx.compose.ui.input.pointer.pointerInput
26 | import androidx.compose.ui.input.pointer.positionChanged
27 | import androidx.compose.ui.layout.onSizeChanged
28 | import androidx.compose.ui.platform.LocalDensity
29 | import androidx.compose.ui.unit.IntSize
30 | import kotlinx.collections.immutable.PersistentList
31 | import kotlinx.collections.immutable.toPersistentList
32 | import net.simno.dmach.data.Patch.Companion.CHANNELS
33 | import net.simno.dmach.data.Patch.Companion.MASKS
34 | import net.simno.dmach.data.Patch.Companion.STEPS
35 | import net.simno.dmach.data.Steps
36 | import net.simno.dmach.theme.AppTheme
37 | import net.simno.dmach.util.toPx
38 |
39 | @Composable
40 | fun StepSequencer(
41 | sequenceId: Int,
42 | sequence: PersistentList,
43 | sequenceLength: Steps,
44 | onSequenceChange: (PersistentList) -> Unit,
45 | modifier: Modifier = Modifier
46 | ) {
47 | val currentOnSequenceChange by rememberUpdatedState(onSequenceChange)
48 |
49 | val density = LocalDensity.current
50 | val tertiary = MaterialTheme.colorScheme.tertiary
51 | val onSurface = MaterialTheme.colorScheme.onSurface
52 | val onSurfaceVariant = MaterialTheme.colorScheme.onSurfaceVariant
53 | val cornerSize = MaterialTheme.shapes.small.topStart
54 | val margin = AppTheme.dimens.paddingSmall.toPx()
55 |
56 | var size by remember { mutableStateOf(IntSize.Zero) }
57 |
58 | var stepChanges by remember { mutableIntStateOf(0) }
59 | val steps = remember(sequenceId) { mutableStateListOf(*sequence.toTypedArray()) }
60 |
61 | val stepSize = remember(size, margin, sequenceLength) {
62 | Size(
63 | width = (size.width - (sequenceLength.value - 1f) * margin) / sequenceLength.value,
64 | height = (size.height - (CHANNELS - 1f) * margin) / CHANNELS
65 | )
66 | }
67 |
68 | val cornerRadius = remember(stepSize) { cornerSize.toPx(stepSize, density).let { CornerRadius(it, it) } }
69 |
70 | val drawableSteps = remember(steps, stepChanges, stepSize) {
71 | steps
72 | .mapIndexed { stepIndex, step ->
73 | MASKS.mapIndexed { maskIndex, mask ->
74 | val left = (stepIndex % STEPS) * (stepSize.width + margin)
75 | val top = (maskIndex + ((stepIndex / STEPS) * MASKS.size)) * (stepSize.height + margin)
76 | val color = when {
77 | step and mask > 0 -> tertiary
78 | stepIndex % 8 < 4 -> onSurface
79 | else -> onSurfaceVariant
80 | }
81 | DrawableStep(
82 | color = color,
83 | offset = Offset(left, top)
84 | )
85 | }
86 | }
87 | .flatten()
88 | }
89 |
90 | Box(
91 | modifier = modifier
92 | .fillMaxSize()
93 | .onSizeChanged { size = it }
94 | .clipToBounds()
95 | .pointerInput(sequenceId, size) {
96 | fun onStepChange(stepChange: StepChange) {
97 | steps[stepChange.index] = stepChange.changedStep
98 | stepChanges++
99 | currentOnSequenceChange(steps.toPersistentList())
100 | }
101 |
102 | awaitEachGesture {
103 | val firstPointer = awaitFirstDown()
104 | if (firstPointer.changedToDown()) {
105 | firstPointer.consume()
106 | }
107 |
108 | val firstStepChange = firstPointer.position
109 | .takeIf { it.isValid(size) }
110 | ?.let { position ->
111 | StepChange(steps, stepSize, margin, position)
112 | }
113 | firstStepChange?.let(::onStepChange)
114 |
115 | do {
116 | val event = awaitPointerEvent()
117 | event.changes.forEach { pointer ->
118 | if (pointer.positionChanged()) {
119 | pointer.consume()
120 | }
121 | pointer.position
122 | .takeIf { it.isValid(size) }
123 | ?.let { position ->
124 | val stepChange = StepChange(steps, stepSize, margin, position)
125 | if (stepChange.isChecked == firstStepChange?.isChecked) {
126 | onStepChange(stepChange)
127 | }
128 | }
129 | }
130 | } while (event.changes.any { it.pressed })
131 | }
132 | }
133 | .drawBehind {
134 | drawableSteps.forEach {
135 | drawRoundRect(
136 | color = it.color,
137 | topLeft = it.offset,
138 | size = stepSize,
139 | cornerRadius = cornerRadius,
140 | alpha = 1.0f,
141 | style = Fill
142 | )
143 | }
144 | }
145 | )
146 | }
147 |
148 | private fun Offset.isValid(size: IntSize): Boolean =
149 | x.isFinite() &&
150 | y.isFinite() &&
151 | x >= 0 &&
152 | y >= 0 &&
153 | x <= size.width &&
154 | y <= size.height
155 |
156 | private data class StepChange(
157 | val steps: List,
158 | val stepSize: Size,
159 | val margin: Float,
160 | val position: Offset
161 | ) {
162 | private val step = (position.x / (stepSize.width + margin)).toInt().coerceIn(0, STEPS - 1)
163 | private val channel = (position.y / (stepSize.height + margin)).toInt().coerceIn(0, CHANNELS - 1)
164 | private val mask = MASKS[channel % MASKS.size]
165 | val index = ((channel / MASKS.size) * STEPS) + step
166 | val isChecked = (steps[index] and mask) > 0
167 | val changedStep = steps[index] xor mask
168 | }
169 |
170 | private data class DrawableStep(
171 | val color: Color,
172 | val offset: Offset
173 | )
174 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/machine/ui/TextButton.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.machine.ui
2 |
3 | import androidx.compose.foundation.background
4 | import androidx.compose.foundation.combinedClickable
5 | import androidx.compose.foundation.interaction.MutableInteractionSource
6 | import androidx.compose.foundation.interaction.collectIsPressedAsState
7 | import androidx.compose.foundation.layout.Box
8 | import androidx.compose.material3.MaterialTheme
9 | import androidx.compose.runtime.Composable
10 | import androidx.compose.runtime.getValue
11 | import androidx.compose.runtime.remember
12 | import androidx.compose.ui.Alignment
13 | import androidx.compose.ui.Modifier
14 | import androidx.compose.ui.semantics.Role
15 | import androidx.lifecycle.compose.dropUnlessResumed
16 | import net.simno.dmach.core.LightMediumText
17 | import net.simno.dmach.core.hapticClick
18 |
19 | @Composable
20 | fun TextButton(
21 | text: String,
22 | selected: Boolean,
23 | onClick: () -> Unit,
24 | modifier: Modifier = Modifier,
25 | enabled: Boolean = true,
26 | muted: Boolean = false,
27 | radioButton: Boolean = false,
28 | onLongClick: (() -> Unit)? = null
29 | ) {
30 | val interactionSource = remember { MutableInteractionSource() }
31 | val pressed by interactionSource.collectIsPressedAsState()
32 | val background = when {
33 | !enabled -> MaterialTheme.colorScheme.surface
34 | selected && radioButton -> MaterialTheme.colorScheme.secondary
35 | pressed -> MaterialTheme.colorScheme.onSecondary
36 | selected -> MaterialTheme.colorScheme.secondary
37 | muted -> MaterialTheme.colorScheme.onSurfaceVariant
38 | else -> MaterialTheme.colorScheme.primary
39 | }
40 | Box(
41 | modifier = modifier
42 | .background(
43 | color = background,
44 | shape = MaterialTheme.shapes.small
45 | )
46 | .combinedClickable(
47 | onClick = dropUnlessResumed(block = onClick),
48 | onLongClick = hapticClick(block = onLongClick),
49 | enabled = enabled,
50 | role = Role.Button,
51 | interactionSource = interactionSource,
52 | indication = null
53 | ),
54 | contentAlignment = Alignment.Center
55 | ) {
56 | LightMediumText(text = text.uppercase())
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/PatchModule.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch
2 |
3 | import dagger.Module
4 | import dagger.Provides
5 | import dagger.hilt.InstallIn
6 | import dagger.hilt.android.components.ViewModelComponent
7 | import dagger.hilt.android.scopes.ViewModelScoped
8 | import net.simno.dmach.db.PatchRepository
9 | import net.simno.dmach.patch.state.PatchProcessor
10 |
11 | @Module
12 | @InstallIn(ViewModelComponent::class)
13 | object PatchModule {
14 | @Provides
15 | @ViewModelScoped
16 | fun providePatchProcesssor(patchRepository: PatchRepository): PatchProcessor = PatchProcessor(patchRepository)
17 | }
18 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/PatchScreen.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.LaunchedEffect
5 | import androidx.compose.runtime.getValue
6 | import androidx.compose.runtime.rememberUpdatedState
7 | import androidx.hilt.navigation.compose.hiltViewModel
8 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
9 | import androidx.paging.compose.collectAsLazyPagingItems
10 | import net.simno.dmach.patch.ui.Patch
11 |
12 | @Composable
13 | fun PatchScreen(
14 | navigateUp: () -> Unit,
15 | viewModel: PatchViewModel = hiltViewModel()
16 | ) {
17 | val currentNavigateUp by rememberUpdatedState(navigateUp)
18 | val state by viewModel.viewState.collectAsStateWithLifecycle()
19 | val patches = viewModel.patches.collectAsLazyPagingItems()
20 |
21 | LaunchedEffect(state.finish) {
22 | if (state.finish) {
23 | currentNavigateUp()
24 | }
25 | }
26 | Patch(
27 | state = state,
28 | patches = patches,
29 | onAction = viewModel::onAction
30 | )
31 | }
32 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/PatchViewModel.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch
2 |
3 | import androidx.lifecycle.viewModelScope
4 | import androidx.paging.Pager
5 | import androidx.paging.PagingConfig
6 | import androidx.paging.PagingData
7 | import androidx.paging.cachedIn
8 | import androidx.paging.map
9 | import dagger.hilt.android.lifecycle.HiltViewModel
10 | import javax.inject.Inject
11 | import kotlinx.coroutines.flow.Flow
12 | import kotlinx.coroutines.flow.map
13 | import net.simno.dmach.StateViewModel
14 | import net.simno.dmach.data.Patch
15 | import net.simno.dmach.db.PatchRepository
16 | import net.simno.dmach.db.PatchRepository.Companion.toPatch
17 | import net.simno.dmach.patch.state.Action
18 | import net.simno.dmach.patch.state.ErrorResult
19 | import net.simno.dmach.patch.state.LoadAction
20 | import net.simno.dmach.patch.state.PatchProcessor
21 | import net.simno.dmach.patch.state.PatchStateReducer
22 | import net.simno.dmach.patch.state.Result
23 | import net.simno.dmach.patch.state.ViewState
24 |
25 | @HiltViewModel
26 | class PatchViewModel @Inject constructor(
27 | processor: PatchProcessor,
28 | private val repository: PatchRepository
29 | ) : StateViewModel(
30 | processor = processor,
31 | reducer = PatchStateReducer,
32 | onError = { ErrorResult(it) },
33 | startViewState = ViewState(),
34 | LoadAction
35 | ) {
36 | val patches: Flow> = Pager(PagingConfig(pageSize = 50)) { repository.patches() }
37 | .flow
38 | .map { pagingData -> pagingData.map { entity -> entity.toPatch() } }
39 | .cachedIn(viewModelScope)
40 | }
41 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/state/Action.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch.state
2 |
3 | sealed class Action
4 |
5 | data object LoadAction : Action()
6 |
7 | data object DismissAction : Action()
8 |
9 | data object ConfirmOverwriteAction : Action()
10 |
11 | data object ConfirmDeleteAction : Action()
12 |
13 | data class SavePatchAction(
14 | val title: String
15 | ) : Action()
16 |
17 | data class DeletePatchAction(
18 | val title: String
19 | ) : Action()
20 |
21 | data class SelectPatchAction(
22 | val title: String
23 | ) : Action()
24 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/state/PatchProcessor.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch.state
2 |
3 | import kotlinx.coroutines.flow.Flow
4 | import kotlinx.coroutines.flow.filterIsInstance
5 | import kotlinx.coroutines.flow.map
6 | import kotlinx.coroutines.flow.merge
7 | import net.simno.dmach.db.PatchRepository
8 |
9 | class PatchProcessor(
10 | private val patchRepository: PatchRepository
11 | ) : (Flow) -> Flow {
12 |
13 | override fun invoke(actions: Flow): Flow = merge(
14 | actions.filterIsInstance().let(load),
15 | actions.filterIsInstance().let(dismiss),
16 | actions.filterIsInstance().let(confirmOverwrite),
17 | actions.filterIsInstance().let(confirmDelete),
18 | actions.filterIsInstance().let(savePatch),
19 | actions.filterIsInstance().let(deletePatch),
20 | actions.filterIsInstance().let(selectPatch)
21 | )
22 |
23 | private val load: (Flow) -> Flow = { actions ->
24 | actions
25 | .computeResult {
26 | val patch = patchRepository.unsavedPatch()
27 | LoadResult(
28 | title = patch.title
29 | )
30 | }
31 | }
32 |
33 | private val dismiss: (Flow) -> Flow = { actions ->
34 | actions
35 | .computeResult {
36 | DismissResult
37 | }
38 | }
39 |
40 | private val confirmOverwrite: (Flow) -> Flow = { actions ->
41 | actions
42 | .computeResult {
43 | patchRepository.replacePatch()
44 | ConfirmOverwriteResult
45 | }
46 | }
47 |
48 | private val confirmDelete: (Flow) -> Flow = { actions ->
49 | actions
50 | .computeResult {
51 | patchRepository.deletePatch()
52 | ConfirmDeleteResult
53 | }
54 | }
55 |
56 | private val savePatch: (Flow) -> Flow = { actions ->
57 | actions
58 | .computeResult { action ->
59 | val saved = patchRepository.insertPatch(action.title)
60 | SavePatchResult(!saved, action.title)
61 | }
62 | }
63 |
64 | private val deletePatch: (Flow) -> Flow = { actions ->
65 | actions
66 | .computeResult { action ->
67 | patchRepository.acceptDeleteTitle(action.title)
68 | DeletePatchResult(action.title)
69 | }
70 | }
71 |
72 | private val selectPatch: (Flow) -> Flow = { actions ->
73 | actions
74 | .computeResult { action ->
75 | patchRepository.selectPatch(action.title)
76 | SelectPatchResult
77 | }
78 | }
79 |
80 | private fun Flow.computeResult(mapper: suspend (T) -> R): Flow = map(mapper)
81 | }
82 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/state/PatchStateReducer.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch.state
2 |
3 | import net.simno.dmach.util.logError
4 |
5 | object PatchStateReducer : (ViewState, Result) -> ViewState {
6 | override fun invoke(previousState: ViewState, result: Result) = when (result) {
7 | is ErrorResult -> {
8 | logError("PatchStateReducer", "ErrorResult", result.error)
9 | previousState
10 | }
11 | is LoadResult -> previousState.copy(
12 | title = result.title
13 | )
14 | DismissResult -> previousState.copy(
15 | showDelete = false,
16 | showOverwrite = false
17 | )
18 | ConfirmOverwriteResult -> previousState.copy(
19 | finish = true,
20 | showOverwrite = false
21 | )
22 | ConfirmDeleteResult -> previousState.copy(
23 | showDelete = false
24 | )
25 | is SavePatchResult -> previousState.copy(
26 | finish = !result.showOverwrite,
27 | showOverwrite = result.showOverwrite,
28 | title = result.title
29 | )
30 | is DeletePatchResult -> previousState.copy(
31 | showDelete = true,
32 | deleteTitle = result.deleteTitle
33 | )
34 | SelectPatchResult -> previousState.copy(
35 | finish = true
36 | )
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/state/Result.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch.state
2 |
3 | sealed class Result
4 |
5 | data class ErrorResult(
6 | val error: Throwable
7 | ) : Result()
8 |
9 | data class LoadResult(
10 | val title: String
11 | ) : Result()
12 |
13 | data object DismissResult : Result()
14 |
15 | data object ConfirmOverwriteResult : Result()
16 |
17 | data object ConfirmDeleteResult : Result()
18 |
19 | data class SavePatchResult(
20 | val showOverwrite: Boolean,
21 | val title: String
22 | ) : Result()
23 |
24 | data class DeletePatchResult(
25 | val deleteTitle: String
26 | ) : Result()
27 |
28 | data object SelectPatchResult : Result()
29 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/patch/state/ViewState.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch.state
2 |
3 | data class ViewState(
4 | val finish: Boolean = false,
5 | val showDelete: Boolean = false,
6 | val showOverwrite: Boolean = false,
7 | val deleteTitle: String = "",
8 | val title: String = ""
9 | )
10 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/playback/AudioFocus.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.playback
2 |
3 | import android.media.AudioAttributes
4 | import android.media.AudioFocusRequest
5 | import android.media.AudioManager
6 | import kotlinx.coroutines.flow.Flow
7 | import kotlinx.coroutines.flow.MutableStateFlow
8 | import kotlinx.coroutines.flow.combine
9 | import kotlinx.coroutines.flow.distinctUntilChanged
10 | import net.simno.dmach.settings.SettingsRepository
11 |
12 | class AudioFocus(
13 | private val audioManager: AudioManager,
14 | settingsRepository: SettingsRepository
15 | ) : AudioManager.OnAudioFocusChangeListener {
16 |
17 | private enum class Focus {
18 | USER_GAIN,
19 | USER_LOSS,
20 | SYSTEM_GAIN,
21 | SYSTEM_LOSS,
22 | NONE
23 | }
24 |
25 | private val audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
26 | .setAudioAttributes(
27 | AudioAttributes.Builder()
28 | .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
29 | .setUsage(AudioAttributes.USAGE_GAME)
30 | .build()
31 | )
32 | .setWillPauseWhenDucked(true)
33 | .setAcceptsDelayedFocusGain(true)
34 | .setOnAudioFocusChangeListener(this)
35 | .build()
36 |
37 | private var playbackDelayed = false
38 | private var resumeOnFocusGain = false
39 |
40 | private val focusLock = Any()
41 | private val focus = MutableStateFlow(Focus.NONE)
42 |
43 | val audioFocus: Flow = combine(
44 | focus,
45 | settingsRepository.settings
46 | ) { focus, settings ->
47 | if (settings.ignoreAudioFocus) {
48 | when (focus) {
49 | Focus.USER_GAIN, Focus.SYSTEM_GAIN -> true
50 | else -> false
51 | }
52 | } else {
53 | when (focus) {
54 | Focus.USER_GAIN -> {
55 | val result = audioManager.requestAudioFocus(audioFocusRequest)
56 | synchronized(focusLock) {
57 | when (result) {
58 | AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> true
59 | AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
60 | playbackDelayed = true
61 | false
62 | }
63 | else -> false
64 | }
65 | }
66 | }
67 | Focus.USER_LOSS -> {
68 | audioManager.abandonAudioFocusRequest(audioFocusRequest)
69 | false
70 | }
71 | Focus.SYSTEM_GAIN -> true
72 | Focus.SYSTEM_LOSS -> false
73 | Focus.NONE -> false
74 | }
75 | }
76 | }.distinctUntilChanged()
77 |
78 | override fun onAudioFocusChange(focusChange: Int) {
79 | when (focusChange) {
80 | AudioManager.AUDIOFOCUS_GAIN -> {
81 | if (playbackDelayed || resumeOnFocusGain) {
82 | synchronized(focusLock) {
83 | playbackDelayed = false
84 | resumeOnFocusGain = false
85 | }
86 | setFocus(Focus.SYSTEM_GAIN)
87 | }
88 | }
89 | AudioManager.AUDIOFOCUS_LOSS -> {
90 | synchronized(focusLock) {
91 | playbackDelayed = false
92 | resumeOnFocusGain = false
93 | }
94 | setFocus(Focus.SYSTEM_LOSS)
95 | }
96 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
97 | AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
98 | synchronized(focusLock) {
99 | playbackDelayed = false
100 | resumeOnFocusGain = true
101 | }
102 | setFocus(Focus.SYSTEM_LOSS)
103 | }
104 | }
105 | }
106 |
107 | fun requestAudioFocus() {
108 | setFocus(Focus.USER_GAIN)
109 | }
110 |
111 | fun abandonAudioFocus() {
112 | setFocus(Focus.USER_LOSS)
113 | }
114 |
115 | private fun setFocus(request: Focus) {
116 | focus.tryEmit(request)
117 | }
118 | }
119 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/playback/PlaybackController.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.playback
2 |
3 | import android.content.Context
4 | import androidx.core.content.ContextCompat
5 | import androidx.lifecycle.DefaultLifecycleObserver
6 | import androidx.lifecycle.LifecycleOwner
7 | import java.util.concurrent.atomic.AtomicBoolean
8 | import kotlinx.coroutines.runBlocking
9 | import net.simno.dmach.R
10 | import net.simno.dmach.data.Tempo
11 | import net.simno.kortholt.Kortholt
12 |
13 | class PlaybackController(
14 | private val context: Context,
15 | private val kortholt: Kortholt.Player,
16 | private val pureData: PureData,
17 | private val waveExporter: WaveExporter
18 | ) : DefaultLifecycleObserver {
19 |
20 | private val isPlaying = AtomicBoolean(false)
21 | private var title: String? = null
22 | private var tempo: String? = null
23 |
24 | suspend fun openPatch() {
25 | kortholt.openPatch(R.raw.dmach, "dmach.pd", extractZip = true)
26 | }
27 |
28 | suspend fun startPlayback() {
29 | if (isPlaying.compareAndSet(false, true)) {
30 | startService()
31 | kortholt.startStream()
32 | pureData.startPlayback()
33 | }
34 | }
35 |
36 | fun stopPlayback() {
37 | isPlaying.set(false)
38 | pureData.stopPlayback()
39 | }
40 |
41 | fun updateInfo(title: String, tempo: Tempo) {
42 | this.title = title
43 | this.tempo = "${tempo.value} BPM"
44 | if (isPlaying.get()) {
45 | // Call startService again if we are playing to update the notification.
46 | startService()
47 | }
48 | }
49 |
50 | override fun onPause(owner: LifecycleOwner) {
51 | if (isPlaying.compareAndSet(false, false)) {
52 | stopService()
53 | if (!waveExporter.isExporting()) {
54 | runBlocking { kortholt.stopStream() }
55 | }
56 | }
57 | }
58 |
59 | override fun onDestroy(owner: LifecycleOwner) {
60 | isPlaying.set(false)
61 | stopService()
62 | pureData.stopPlayback()
63 | runBlocking {
64 | kortholt.stopStream()
65 | kortholt.closePatch()
66 | }
67 | }
68 |
69 | private fun startService() {
70 | ContextCompat.startForegroundService(context, PlaybackService.intent(context, title, tempo))
71 | }
72 |
73 | private fun stopService() {
74 | context.stopService(PlaybackService.intent(context))
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/playback/PlaybackModule.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.playback
2 |
3 | import android.content.Context
4 | import android.media.AudioManager
5 | import androidx.core.content.getSystemService
6 | import dagger.Module
7 | import dagger.Provides
8 | import dagger.hilt.InstallIn
9 | import dagger.hilt.android.qualifiers.ApplicationContext
10 | import dagger.hilt.components.SingletonComponent
11 | import javax.inject.Singleton
12 | import net.simno.dmach.settings.SettingsRepository
13 | import net.simno.kortholt.Kortholt
14 |
15 | @Module
16 | @InstallIn(SingletonComponent::class)
17 | object PlaybackModule {
18 |
19 | @Provides
20 | fun provideAudioManager(
21 | @ApplicationContext context: Context
22 | ): AudioManager = context.getSystemService()!!
23 |
24 | @Provides
25 | @Singleton
26 | fun provideAudioFocus(
27 | audioManager: AudioManager,
28 | settingsRepository: SettingsRepository
29 | ): AudioFocus = AudioFocus(audioManager, settingsRepository)
30 |
31 | @Provides
32 | @Singleton
33 | fun provideKortholtPlayer(
34 | @ApplicationContext context: Context
35 | ): Kortholt.Player = Kortholt.Player.Builder(context)
36 | .build()
37 | .also { Kortholt.setPlayer(it) }
38 |
39 | @Provides
40 | @Singleton
41 | fun provideWaveExporter(
42 | @ApplicationContext context: Context,
43 | kortholt: Kortholt.Player
44 | ): WaveExporter = WaveExporter(context, kortholt)
45 |
46 | @Provides
47 | @Singleton
48 | fun providePureData(
49 | kortholt: Kortholt.Player
50 | ): PureData = PureData(kortholt)
51 |
52 | @Provides
53 | @Singleton
54 | fun providePlaybackController(
55 | @ApplicationContext context: Context,
56 | kortholt: Kortholt.Player,
57 | pureData: PureData,
58 | waveExporter: WaveExporter
59 | ): PlaybackController = PlaybackController(context, kortholt, pureData, waveExporter)
60 | }
61 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/playback/PlaybackService.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.playback
2 |
3 | import android.app.Notification
4 | import android.app.NotificationManager
5 | import android.app.PendingIntent
6 | import android.app.Service
7 | import android.content.Context
8 | import android.content.Intent
9 | import android.media.MediaMetadata
10 | import android.os.IBinder
11 | import android.support.v4.media.MediaMetadataCompat
12 | import android.support.v4.media.session.MediaSessionCompat
13 | import androidx.core.app.NotificationChannelCompat
14 | import androidx.core.app.NotificationCompat
15 | import androidx.core.app.NotificationManagerCompat
16 | import androidx.core.content.ContextCompat
17 | import androidx.core.graphics.drawable.toBitmap
18 | import net.simno.dmach.MainActivity
19 | import net.simno.dmach.R
20 |
21 | class PlaybackService : Service() {
22 |
23 | private val notificationImage
24 | get() = ContextCompat.getDrawable(this, R.drawable.ic_launcher_foreground)?.toBitmap()
25 |
26 | private var mediaSession: MediaSessionCompat? = null
27 |
28 | override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29 | createNotificationChannel()
30 | val contentTitle = intent?.getStringExtra(TITLE) ?: getString(R.string.app_name)
31 | val contentText = intent?.getStringExtra(TEMPO).orEmpty()
32 | startForeground(NOTIFICATION_ID, createNotification(contentTitle, contentText))
33 | return START_STICKY
34 | }
35 |
36 | override fun onBind(intent: Intent?): IBinder? = null
37 |
38 | override fun onDestroy() {
39 | stopForeground(STOP_FOREGROUND_REMOVE)
40 | mediaSession?.release()
41 | super.onDestroy()
42 | }
43 |
44 | private fun createNotificationChannel() {
45 | val channel = NotificationChannelCompat.Builder(CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW)
46 | .setName(CHANNEL_NAME)
47 | .build()
48 | NotificationManagerCompat.from(applicationContext).createNotificationChannel(channel)
49 | }
50 |
51 | private fun createNotification(contentTitle: String, contentText: String): Notification {
52 | val metadata = MediaMetadataCompat.Builder()
53 | .putString(MediaMetadata.METADATA_KEY_TITLE, contentTitle)
54 | .putString(MediaMetadata.METADATA_KEY_ARTIST, contentText)
55 | .putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, notificationImage)
56 | .build()
57 |
58 | mediaSession?.release()
59 |
60 | val session = MediaSessionCompat(this, CHANNEL_NAME)
61 | session.setMetadata(metadata)
62 |
63 | val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle()
64 | .setMediaSession(session.sessionToken)
65 |
66 | mediaSession = session
67 |
68 | val intent = Intent(this, MainActivity::class.java)
69 | val contentIntent = PendingIntent.getActivity(applicationContext, 0, intent, PendingIntent.FLAG_IMMUTABLE)
70 | return NotificationCompat.Builder(this, CHANNEL_NAME)
71 | .setContentTitle(contentTitle)
72 | .setContentText(contentText)
73 | .setContentIntent(contentIntent)
74 | .setSmallIcon(R.drawable.ic_stat_playback)
75 | .setStyle(mediaStyle)
76 | .setOngoing(true)
77 | .setOnlyAlertOnce(true)
78 | .build()
79 | }
80 |
81 | companion object {
82 | private const val TITLE = "TITLE"
83 | private const val TEMPO = "TEMPO"
84 | private const val CHANNEL_NAME = "Playback"
85 | private const val NOTIFICATION_ID = 1337
86 |
87 | fun intent(
88 | context: Context,
89 | title: String? = null,
90 | tempo: String? = null
91 | ): Intent = Intent(context, PlaybackService::class.java).apply {
92 | putExtra(TITLE, title)
93 | putExtra(TEMPO, tempo)
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/playback/PureData.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.playback
2 |
3 | import androidx.annotation.Size
4 | import net.simno.dmach.data.Pan
5 | import net.simno.dmach.data.Patch
6 | import net.simno.dmach.data.Setting
7 | import net.simno.dmach.data.Steps
8 | import net.simno.dmach.data.Swing
9 | import net.simno.dmach.data.Tempo
10 | import net.simno.dmach.util.logSequence
11 | import net.simno.kortholt.Kortholt
12 |
13 | class PureData(
14 | private val kortholt: Kortholt.Player
15 | ) {
16 |
17 | fun startPlayback() {
18 | kortholt.sendBang("play")
19 | }
20 |
21 | fun stopPlayback() {
22 | kortholt.sendBang("stop")
23 | }
24 |
25 | fun changeSequence(@Size(32) sequence: List) {
26 | logSequence("PureData", sequence)
27 | for (step in 0 until Patch.STEPS) {
28 | kortholt.sendList("step", 0, step, sequence[step])
29 | kortholt.sendList("step", 1, step, sequence[step + Patch.STEPS])
30 | }
31 | }
32 |
33 | fun changeSetting(channel: String, setting: Setting) {
34 | kortholt.sendList(channel, setting.hIndex, setting.x)
35 | kortholt.sendList(channel, setting.vIndex, setting.y)
36 | }
37 |
38 | fun changePan(channel: String, pan: Pan) {
39 | kortholt.sendFloat(channel + "p", pan.value)
40 | }
41 |
42 | fun changeTempo(tempo: Tempo) {
43 | kortholt.sendFloat("tempo", tempo.value.toFloat())
44 | }
45 |
46 | fun changeSwing(swing: Swing) {
47 | kortholt.sendFloat("swing", swing.value / 100f)
48 | }
49 |
50 | fun changeSteps(steps: Steps) {
51 | kortholt.sendFloat("steps", steps.value.coerceIn(8, 16).toFloat())
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/playback/WaveExporter.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.playback
2 |
3 | import android.content.Context
4 | import java.io.File
5 | import java.util.concurrent.atomic.AtomicBoolean
6 | import kotlin.time.DurationUnit
7 | import kotlin.time.toDuration
8 | import kotlinx.coroutines.Dispatchers
9 | import kotlinx.coroutines.withContext
10 | import net.simno.dmach.data.Steps
11 | import net.simno.dmach.data.Tempo
12 | import net.simno.kortholt.ExperimentalWaveFile
13 | import net.simno.kortholt.Kortholt
14 |
15 | @OptIn(ExperimentalWaveFile::class)
16 | class WaveExporter(
17 | private val context: Context,
18 | private val kortholt: Kortholt.Player
19 | ) {
20 | private val isExporting = AtomicBoolean(false)
21 |
22 | fun isExporting(): Boolean = isExporting.get()
23 |
24 | suspend fun saveWaveFile(
25 | title: String,
26 | tempo: Tempo,
27 | steps: Steps
28 | ): File? = withContext(Dispatchers.IO) {
29 | isExporting.set(true)
30 | val result = runCatching {
31 | val fileName = "${title}_${tempo.value}_BPM.wav"
32 | val dir = File(context.filesDir, "wav")
33 | dir.mkdirs()
34 | val outputFile = File(dir, fileName)
35 |
36 | val milliSecondsPerBeat = (60 * 1000) / tempo.value.toDouble()
37 | val duration = (steps.value * milliSecondsPerBeat).toDuration(DurationUnit.MILLISECONDS)
38 |
39 | kortholt.saveWaveFile(
40 | outputFile = outputFile,
41 | duration = duration,
42 | startBang = "play",
43 | stopBang = "stop"
44 | )
45 | outputFile
46 | }
47 | isExporting.set(false)
48 | result.getOrNull()
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/settings/Settings.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.settings
2 |
3 | data class Settings(
4 | val ignoreAudioFocus: Boolean = false,
5 | val sequenceEnabled: Boolean = false,
6 | val soundEnabled: Boolean = false,
7 | val panEnabled: Boolean = false
8 | ) {
9 | val isAnyEnabled: Boolean = sequenceEnabled || soundEnabled || panEnabled
10 | }
11 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/settings/SettingsModule.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.settings
2 |
3 | import android.content.Context
4 | import androidx.datastore.core.DataStore
5 | import androidx.datastore.preferences.core.Preferences
6 | import androidx.datastore.preferences.preferencesDataStore
7 | import dagger.Module
8 | import dagger.Provides
9 | import dagger.hilt.InstallIn
10 | import dagger.hilt.android.qualifiers.ApplicationContext
11 | import dagger.hilt.components.SingletonComponent
12 | import javax.inject.Singleton
13 |
14 | @Module
15 | @InstallIn(SingletonComponent::class)
16 | object SettingsModule {
17 | private val Context.dataStore: DataStore by preferencesDataStore(name = "settings")
18 |
19 | @Provides
20 | @Singleton
21 | fun provideSettingsRepository(
22 | @ApplicationContext context: Context
23 | ): SettingsRepository = SettingsRepository(context.dataStore)
24 | }
25 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/settings/SettingsRepository.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.settings
2 |
3 | import androidx.datastore.core.DataStore
4 | import androidx.datastore.preferences.core.Preferences
5 | import androidx.datastore.preferences.core.booleanPreferencesKey
6 | import androidx.datastore.preferences.core.edit
7 | import kotlinx.coroutines.flow.Flow
8 | import kotlinx.coroutines.flow.map
9 |
10 | class SettingsRepository(
11 | private val preferences: DataStore
12 | ) {
13 | val settings: Flow = preferences.data.map { data ->
14 | Settings(
15 | ignoreAudioFocus = data[IGNORE_AUDIO_FOCUS] == true,
16 | sequenceEnabled = data[SEQUENCE_ENABLED] != false,
17 | soundEnabled = data[SOUND_ENABLED] != false,
18 | panEnabled = data[PAN_ENABLED] != false
19 | )
20 | }
21 |
22 | suspend fun updateSettings(settings: Settings) {
23 | preferences.edit { prefs ->
24 | prefs[IGNORE_AUDIO_FOCUS] = settings.ignoreAudioFocus
25 | prefs[SEQUENCE_ENABLED] = settings.sequenceEnabled
26 | prefs[SOUND_ENABLED] = settings.soundEnabled
27 | prefs[PAN_ENABLED] = settings.panEnabled
28 | }
29 | }
30 |
31 | companion object {
32 | private val IGNORE_AUDIO_FOCUS = booleanPreferencesKey("IGNORE_AUDIO_FOCUS")
33 | private val SEQUENCE_ENABLED = booleanPreferencesKey("SEQUENCE_ENABLED")
34 | private val SOUND_ENABLED = booleanPreferencesKey("SOUND_ENABLED")
35 | private val PAN_ENABLED = booleanPreferencesKey("PAN_ENABLED")
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Surface = Color(0xfff2f2c6)
6 | val OnSurface = Color(0xffc1bf87)
7 | val OnSurfaceVariant = Color(0xff94926f)
8 |
9 | val Primary = Color(0xff302e2c)
10 | val OnPrimary = Color(0xff3e3b39)
11 |
12 | val Secondary = Color(0xffe9950a)
13 | val OnSecondary = Secondary.copy(alpha = 0.5f)
14 |
15 | val Tertiary = Color(0xffb02b2f)
16 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/theme/Dimension.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.theme
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.CompositionLocalProvider
5 | import androidx.compose.runtime.ProvidableCompositionLocal
6 | import androidx.compose.runtime.remember
7 | import androidx.compose.runtime.staticCompositionLocalOf
8 | import androidx.compose.ui.unit.Dp
9 | import androidx.compose.ui.unit.TextUnit
10 | import androidx.compose.ui.unit.dp
11 | import androidx.compose.ui.unit.sp
12 |
13 | sealed class Dimensions(
14 | val circleRadius: Dp,
15 | val rectHeight: Dp,
16 | val configHeightSmall: Dp,
17 | val configHeight: Dp,
18 | val paddingSmall: Dp,
19 | val paddingMedium: Dp,
20 | val paddingLarge: Dp,
21 | val buttonSmall: Dp,
22 | val buttonMedium: Dp,
23 | val buttonLarge: Dp,
24 | val textSmall: TextUnit,
25 | val textMedium: TextUnit,
26 | val textLarge: TextUnit,
27 | val labelSmall: TextUnit,
28 | val labelMedium: TextUnit
29 | ) {
30 | data object Default : Dimensions(
31 | circleRadius = 18.dp,
32 | rectHeight = 44.dp,
33 | configHeightSmall = 48.dp,
34 | configHeight = 64.dp,
35 | paddingSmall = 4.dp,
36 | paddingMedium = 8.dp,
37 | paddingLarge = 12.dp,
38 | buttonSmall = 56.dp,
39 | buttonMedium = 64.dp,
40 | buttonLarge = 72.dp,
41 | textSmall = 16.sp,
42 | textMedium = 18.sp,
43 | textLarge = 24.sp,
44 | labelSmall = 10.sp,
45 | labelMedium = 14.sp
46 | )
47 |
48 | data object ShortestWidth600 : Dimensions(
49 | circleRadius = 24.dp,
50 | rectHeight = 64.dp,
51 | configHeightSmall = 72.dp,
52 | configHeight = 96.dp,
53 | paddingSmall = 5.dp,
54 | paddingMedium = 10.dp,
55 | paddingLarge = 15.dp,
56 | buttonSmall = 88.dp,
57 | buttonMedium = 96.dp,
58 | buttonLarge = 104.dp,
59 | textSmall = 22.sp,
60 | textMedium = 24.sp,
61 | textLarge = 30.sp,
62 | labelSmall = 12.sp,
63 | labelMedium = 20.sp
64 | )
65 | }
66 |
67 | @Composable
68 | fun ProvideDimensions(
69 | dimensions: Dimensions,
70 | content: @Composable () -> Unit
71 | ) {
72 | val dimensionSet = remember { dimensions }
73 | CompositionLocalProvider(LocalDimensions provides dimensionSet, content = content)
74 | }
75 |
76 | internal val LocalDimensions: ProvidableCompositionLocal = staticCompositionLocalOf {
77 | Dimensions.Default
78 | }
79 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/theme/Shape.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.theme
2 |
3 | import androidx.compose.foundation.shape.RoundedCornerShape
4 | import androidx.compose.material3.Shapes
5 | import androidx.compose.ui.unit.dp
6 |
7 | val Shapes = Shapes(
8 | small = RoundedCornerShape(size = 2.dp),
9 | medium = RoundedCornerShape(size = 3.dp),
10 | large = RoundedCornerShape(size = 0.dp)
11 | )
12 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.theme
2 |
3 | import androidx.compose.material3.MaterialTheme
4 | import androidx.compose.material3.lightColorScheme
5 | import androidx.compose.runtime.Composable
6 | import androidx.compose.runtime.ReadOnlyComposable
7 | import androidx.compose.ui.platform.LocalConfiguration
8 |
9 | @Composable
10 | fun AppTheme(
11 | content: @Composable () -> Unit
12 | ) {
13 | val configuration = LocalConfiguration.current
14 | val dimensions = if (configuration.smallestScreenWidthDp < 600) {
15 | Dimensions.Default
16 | } else {
17 | Dimensions.ShortestWidth600
18 | }
19 |
20 | ProvideDimensions(dimensions = dimensions) {
21 | MaterialTheme(
22 | colorScheme = lightColorScheme(
23 | background = Surface,
24 | surface = Surface,
25 | onSurface = OnSurface,
26 | onSurfaceVariant = OnSurfaceVariant,
27 | primary = Primary,
28 | onPrimary = OnPrimary,
29 | secondary = Secondary,
30 | onSecondary = OnSecondary,
31 | tertiary = Tertiary
32 | ),
33 | shapes = Shapes,
34 | typography = Typography,
35 | content = content
36 | )
37 | }
38 | }
39 |
40 | object AppTheme {
41 | val dimens: Dimensions
42 | @ReadOnlyComposable
43 | @Composable
44 | get() = LocalDimensions.current
45 | }
46 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.runtime.Composable
5 | import androidx.compose.ui.text.TextStyle
6 | import androidx.compose.ui.text.font.FontFamily
7 | import androidx.compose.ui.text.font.FontWeight
8 |
9 | val Typography: Typography
10 | @Composable
11 | get() = Typography(
12 | bodyLarge = TextStyle(
13 | fontFamily = FontFamily.SansSerif,
14 | fontWeight = FontWeight.Light,
15 | fontSize = AppTheme.dimens.textLarge
16 | ),
17 | bodyMedium = TextStyle(
18 | fontFamily = FontFamily.SansSerif,
19 | fontWeight = FontWeight.Light,
20 | fontSize = AppTheme.dimens.textMedium
21 | ),
22 | bodySmall = TextStyle(
23 | fontFamily = FontFamily.SansSerif,
24 | fontWeight = FontWeight.Light,
25 | fontSize = AppTheme.dimens.textSmall
26 | ),
27 | labelMedium = TextStyle(
28 | fontFamily = FontFamily.SansSerif,
29 | fontWeight = FontWeight.Bold,
30 | fontSize = AppTheme.dimens.labelMedium
31 | ),
32 | labelSmall = TextStyle(
33 | fontFamily = FontFamily.SansSerif,
34 | fontWeight = FontWeight.Light,
35 | fontSize = AppTheme.dimens.labelSmall
36 | )
37 | )
38 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/util/DpUtils.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.util
2 |
3 | import androidx.compose.runtime.Composable
4 | import androidx.compose.runtime.remember
5 | import androidx.compose.ui.platform.LocalDensity
6 | import androidx.compose.ui.unit.Dp
7 |
8 | @Composable
9 | fun Dp.toPx(): Float {
10 | val density = LocalDensity.current
11 | return remember(density) { with(density) { toPx() } }
12 | }
13 |
--------------------------------------------------------------------------------
/app/src/main/java/net/simno/dmach/util/Logging.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.util
2 |
3 | import android.util.Log
4 | import net.simno.dmach.BuildConfig
5 | import net.simno.dmach.data.Patch.Companion.MASKS
6 | import net.simno.dmach.data.Patch.Companion.STEPS
7 |
8 | fun logSequence(tag: String, sequence: List) {
9 | if (BuildConfig.DEBUG) {
10 | val fold = { index: Int, acc: String, step: Int ->
11 | acc + (if (step > 0) "◼" else "◻") + (if ((index + 1) % 4 == 0) "|" else "")
12 | }
13 | Log.d(tag, " ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲ ̲")
14 | Log.d(tag, sequence.take(STEPS).map { it and MASKS[0] }.foldIndexed("|BD|", fold))
15 | Log.d(tag, sequence.take(STEPS).map { it and MASKS[1] }.foldIndexed("|SD|", fold))
16 | Log.d(tag, sequence.take(STEPS).map { it and MASKS[2] }.foldIndexed("|CP|", fold))
17 | Log.d(tag, sequence.takeLast(STEPS).map { it and MASKS[0] }.foldIndexed("|TT|", fold))
18 | Log.d(tag, sequence.takeLast(STEPS).map { it and MASKS[1] }.foldIndexed("|CB|", fold))
19 | Log.d(tag, sequence.takeLast(STEPS).map { it and MASKS[2] }.foldIndexed("|HH|", fold))
20 | Log.d(tag, "‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾‾")
21 | }
22 | }
23 |
24 | fun logError(tag: String, message: String, error: Throwable) {
25 | if (BuildConfig.DEBUG) {
26 | Log.e(tag, message, error)
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
13 |
16 |
19 |
20 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_stat_playback.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/res/raw/dmach.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/app/src/main/res/raw/dmach.zip
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #f2f2c6
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | DMach
5 |
6 | BPM
7 | Swing
8 | Steps
9 | More settings
10 | Back
11 | Play over other apps
12 | Reset/Randomize sequencer
13 | Reset/Randomize sound
14 | Reset/Randomize pan
15 | Nothing reset
16 | Nothing randomized
17 |
18 | Delete
19 | Cancel
20 | Overwrite
21 | Delete %1$s?
22 | %1$s already exists
23 |
24 | L
25 | R
26 |
27 | Save patch
28 | Name
29 |
30 | Export wave file (experimental)
31 | Save
32 | Share
33 |
34 | play
35 | configure
36 | reset
37 | randomize
38 | export
39 |
40 |
41 |
--------------------------------------------------------------------------------
/app/src/main/res/values/styles.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/xml/file_provider_paths.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/test/java/net/simno/dmach/db/TestPatchDao.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.db
2 |
3 | import androidx.paging.PagingSource
4 | import androidx.paging.PagingState
5 | import kotlinx.coroutines.flow.Flow
6 | import kotlinx.coroutines.flow.flow
7 | import net.simno.dmach.data.Steps
8 | import net.simno.dmach.data.Swing
9 | import net.simno.dmach.data.Tempo
10 | import net.simno.dmach.data.defaultPatch
11 | import net.simno.dmach.db.PatchRepository.Companion.toEntity
12 | import net.simno.dmach.machine.state.Randomizer
13 |
14 | class TestPatchDao : PatchDao {
15 | var deleteTitle = ""
16 |
17 | val patch = defaultPatch().copy(
18 | title = "test",
19 | sequence = Randomizer.DEFAULT.nextSequence(),
20 | selectedChannel = 1,
21 | tempo = Tempo(123),
22 | swing = Swing(10),
23 | steps = Steps(16)
24 | )
25 |
26 | private val patches = listOf(patch, patch)
27 |
28 | private val dataSource = object : PagingSource() {
29 | override suspend fun load(params: LoadParams): LoadResult =
30 | LoadResult.Page(data = patches.map { it.toEntity(it.title) }, null, null)
31 |
32 | override fun getRefreshKey(state: PagingState): Int? = null
33 | }
34 |
35 | override suspend fun count(): Int = patches.size
36 |
37 | override fun getActivePatch(): Flow = flow { emit(patch.toEntity(patch.title)) }
38 |
39 | override fun getAllPatches(): PagingSource = dataSource
40 |
41 | override suspend fun deletePatch(title: String): Int {
42 | deleteTitle = title
43 | return 1
44 | }
45 |
46 | override suspend fun internalResetActive() = Unit
47 |
48 | override suspend fun internalSetActive(title: String): Int = 1
49 |
50 | override suspend fun internalInsertPatch(patch: PatchEntity): Long = 1
51 |
52 | override suspend fun internalReplacePatch(patch: PatchEntity): Long = 1
53 | }
54 |
--------------------------------------------------------------------------------
/app/src/test/java/net/simno/dmach/patch/state/PatchProcessorTests.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.patch.state
2 |
3 | import com.google.common.truth.Truth.assertThat
4 | import kotlinx.coroutines.DelicateCoroutinesApi
5 | import kotlinx.coroutines.GlobalScope
6 | import kotlinx.coroutines.channels.Channel.Factory.RENDEZVOUS
7 | import kotlinx.coroutines.delay
8 | import kotlinx.coroutines.flow.SharingStarted
9 | import kotlinx.coroutines.flow.asFlow
10 | import kotlinx.coroutines.flow.buffer
11 | import kotlinx.coroutines.flow.flowOf
12 | import kotlinx.coroutines.flow.onEach
13 | import kotlinx.coroutines.flow.shareIn
14 | import kotlinx.coroutines.flow.take
15 | import kotlinx.coroutines.flow.toList
16 | import kotlinx.coroutines.runBlocking
17 | import net.simno.dmach.db.PatchRepository
18 | import net.simno.dmach.db.TestPatchDao
19 | import net.simno.dmach.machine.state.MachineProcessor
20 | import net.simno.dmach.playback.AudioFocus
21 | import net.simno.dmach.playback.PlaybackController
22 | import net.simno.dmach.playback.PureData
23 | import net.simno.dmach.playback.WaveExporter
24 | import net.simno.dmach.settings.SettingsRepository
25 | import org.junit.Before
26 | import org.junit.Test
27 | import org.mockito.Mockito.mock
28 |
29 | @DelicateCoroutinesApi
30 | class PatchProcessorTests {
31 |
32 | private lateinit var repository: PatchRepository
33 | private lateinit var testDao: TestPatchDao
34 | private lateinit var patchProcessor: PatchProcessor
35 |
36 | private suspend fun processAction(action: Action): Result = processActions(action).first()
37 |
38 | private suspend fun processActions(
39 | vararg actions: Action
40 | ): List = actions.asFlow()
41 | .onEach { delay(10L) }
42 | .buffer(RENDEZVOUS)
43 | .shareIn(GlobalScope, SharingStarted.Lazily)
44 | .let(patchProcessor)
45 | .take(actions.size)
46 | .toList()
47 |
48 | @Before
49 | fun setup() {
50 | testDao = TestPatchDao()
51 | repository = PatchRepository(testDao)
52 | patchProcessor = PatchProcessor(repository)
53 | setupRepository()
54 | }
55 |
56 | private fun setupRepository() = runBlocking {
57 | flowOf(net.simno.dmach.machine.state.LoadAction)
58 | .onEach { delay(10L) }
59 | .buffer(RENDEZVOUS)
60 | .shareIn(GlobalScope, SharingStarted.Lazily)
61 | .let(
62 | MachineProcessor(
63 | playbackController = mock(PlaybackController::class.java),
64 | pureData = mock(PureData::class.java),
65 | waveExporter = mock(WaveExporter::class.java),
66 | audioFocus = mock(AudioFocus::class.java),
67 | patchRepository = repository,
68 | settingsRepository = mock(SettingsRepository::class.java)
69 | )
70 | )
71 | .take(1)
72 | .toList()
73 | }
74 |
75 | @Test
76 | fun load() = runBlocking {
77 | val actual = processAction(LoadAction) as LoadResult
78 | assertThat(actual.title).isEqualTo(testDao.patch.title)
79 | }
80 |
81 | @Test
82 | fun dismiss() = runBlocking {
83 | val actual = processAction(DismissAction)
84 | val expected = DismissResult
85 | assertThat(actual).isEqualTo(expected)
86 | }
87 |
88 | @Test
89 | fun confirmOverwrite() = runBlocking {
90 | val actual = processAction(ConfirmOverwriteAction)
91 | val expected = ConfirmOverwriteResult
92 | assertThat(actual).isEqualTo(expected)
93 | }
94 |
95 | @Test
96 | fun confirmDelete() = runBlocking {
97 | val actual = processAction(ConfirmDeleteAction)
98 | val expected = ConfirmDeleteResult
99 | assertThat(actual).isEqualTo(expected)
100 | }
101 |
102 | @Test
103 | fun savePatch() = runBlocking {
104 | val title = "1337"
105 | val actual = processAction(SavePatchAction(title))
106 | val expected = SavePatchResult(false, title)
107 | assertThat(actual).isEqualTo(expected)
108 | }
109 |
110 | @Test
111 | fun deletePatch() = runBlocking {
112 | val title = "1337"
113 | val actual = processAction(DeletePatchAction(title))
114 | val expected = DeletePatchResult(title)
115 | processAction(ConfirmDeleteAction)
116 | assertThat(actual).isEqualTo(expected)
117 | assertThat(testDao.deleteTitle).isEqualTo(title)
118 | }
119 |
120 | @Test
121 | fun selectPatch() = runBlocking {
122 | val title = "1337"
123 | val actual = processAction(SelectPatchAction(title))
124 | val expected = SelectPatchResult
125 | assertThat(actual).isEqualTo(expected)
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/app/src/test/java/net/simno/dmach/playback/AudioFocusTests.kt:
--------------------------------------------------------------------------------
1 | package net.simno.dmach.playback
2 |
3 | import android.app.Application
4 | import android.media.AudioManager
5 | import android.os.Build
6 | import androidx.core.content.getSystemService
7 | import androidx.test.core.app.ApplicationProvider
8 | import androidx.test.ext.junit.runners.AndroidJUnit4
9 | import com.google.common.truth.Truth.assertThat
10 | import kotlinx.coroutines.async
11 | import kotlinx.coroutines.awaitAll
12 | import kotlinx.coroutines.delay
13 | import kotlinx.coroutines.flow.flowOf
14 | import kotlinx.coroutines.flow.take
15 | import kotlinx.coroutines.flow.toList
16 | import kotlinx.coroutines.runBlocking
17 | import net.simno.dmach.settings.Settings
18 | import net.simno.dmach.settings.SettingsRepository
19 | import org.junit.Before
20 | import org.junit.Test
21 | import org.junit.runner.RunWith
22 | import org.mockito.Mock
23 | import org.mockito.MockitoAnnotations
24 | import org.mockito.kotlin.doReturn
25 | import org.mockito.kotlin.whenever
26 | import org.robolectric.Shadows
27 | import org.robolectric.annotation.Config
28 |
29 | @RunWith(AndroidJUnit4::class)
30 | @Config(sdk = [Build.VERSION_CODES.P])
31 | class AudioFocusTests {
32 |
33 | private val audioManager = ApplicationProvider.getApplicationContext()
34 | .getSystemService()!!
35 |
36 | @Mock
37 | private lateinit var settingsRepository: SettingsRepository
38 |
39 | @Before
40 | fun setup() {
41 | MockitoAnnotations.openMocks(this)
42 | }
43 |
44 | @Test
45 | fun requestAudioFocusGranted() = runBlocking {
46 | val shadowAudioManager = Shadows.shadowOf(audioManager)
47 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
48 |
49 | whenever(settingsRepository.settings)
50 | .doReturn(flowOf(Settings(ignoreAudioFocus = false)))
51 |
52 | val audioFocus = AudioFocus(audioManager, settingsRepository)
53 |
54 | audioFocus.requestAudioFocus()
55 | val expected = listOf(true)
56 | val actual = audioFocus.audioFocus.take(expected.size).toList()
57 | assertThat(actual).isEqualTo(expected)
58 | }
59 |
60 | @Test
61 | fun requestAudioFocusDelayed() = runBlocking {
62 | val shadowAudioManager = Shadows.shadowOf(audioManager)
63 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_DELAYED)
64 |
65 | whenever(settingsRepository.settings)
66 | .doReturn(flowOf(Settings(ignoreAudioFocus = false)))
67 |
68 | val audioFocus = AudioFocus(audioManager, settingsRepository)
69 |
70 | audioFocus.requestAudioFocus()
71 | val expected = listOf(false)
72 | val actual = audioFocus.audioFocus.take(expected.size).toList()
73 | assertThat(actual).isEqualTo(expected)
74 | }
75 |
76 | @Test
77 | fun requestAudioFocusFailed() = runBlocking {
78 | val shadowAudioManager = Shadows.shadowOf(audioManager)
79 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_FAILED)
80 |
81 | whenever(settingsRepository.settings)
82 | .doReturn(flowOf(Settings(ignoreAudioFocus = false)))
83 |
84 | val audioFocus = AudioFocus(audioManager, settingsRepository)
85 |
86 | audioFocus.requestAudioFocus()
87 | val expected = listOf(false)
88 | val actual = audioFocus.audioFocus.take(expected.size).toList()
89 | assertThat(actual).isEqualTo(expected)
90 | }
91 |
92 | @Suppress("UNCHECKED_CAST")
93 | @Test
94 | fun abandonAudioFocus() = runBlocking {
95 | val shadowAudioManager = Shadows.shadowOf(audioManager)
96 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
97 |
98 | whenever(settingsRepository.settings)
99 | .doReturn(flowOf(Settings(ignoreAudioFocus = false)))
100 |
101 | val audioFocus = AudioFocus(audioManager, settingsRepository)
102 |
103 | val expected = listOf(true, false)
104 | val actual = listOf(
105 | async {
106 | audioFocus.audioFocus.take(expected.size).toList()
107 | },
108 | async {
109 | audioFocus.requestAudioFocus()
110 | delay(10L)
111 | audioFocus.abandonAudioFocus()
112 | }
113 | ).awaitAll().first() as List
114 |
115 | assertThat(actual).isEqualTo(expected)
116 | }
117 |
118 | @Test
119 | fun ignoreAudioFocus() = runBlocking {
120 | val shadowAudioManager = Shadows.shadowOf(audioManager)
121 | shadowAudioManager.setNextFocusRequestResponse(AudioManager.AUDIOFOCUS_REQUEST_FAILED)
122 |
123 | whenever(settingsRepository.settings)
124 | .doReturn(flowOf(Settings(ignoreAudioFocus = true)))
125 |
126 | val audioFocus = AudioFocus(audioManager, settingsRepository)
127 |
128 | audioFocus.requestAudioFocus()
129 | val expected = listOf(true)
130 | val actual = audioFocus.audioFocus.take(expected.size).toList()
131 | assertThat(actual).isEqualTo(expected)
132 |
133 | assertThat(shadowAudioManager.lastAudioFocusRequest).isNull()
134 | }
135 | }
136 |
--------------------------------------------------------------------------------
/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
2 |
3 | plugins {
4 | alias(libs.plugins.android.application) apply false
5 | alias(libs.plugins.cachefix) apply false
6 | alias(libs.plugins.hilt.android) apply false
7 | alias(libs.plugins.kotlin.android) apply false
8 | alias(libs.plugins.kotlin.compose) apply false
9 | alias(libs.plugins.kotlin.serialization) apply false
10 | alias(libs.plugins.ksp) apply false
11 | alias(libs.plugins.ktlint.gradle)
12 | alias(libs.plugins.gradle.versions)
13 | }
14 |
15 | allprojects {
16 | plugins.withType().configureEach {
17 | extensions.configure {
18 | toolchain {
19 | languageVersion.set(
20 | JavaLanguageVersion.of(rootProject.libs.versions.javaVersion.get().toInt())
21 | )
22 | }
23 | }
24 | }
25 | tasks.withType().configureEach {
26 | compilerOptions {
27 | allWarningsAsErrors = true
28 | freeCompilerArgs.addAll(
29 | "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi",
30 | "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api",
31 | "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi"
32 | )
33 | }
34 | }
35 | apply(plugin = rootProject.libs.plugins.ktlint.gradle.get().pluginId)
36 | ktlint {
37 | version.set(rootProject.libs.versions.ktlint.asProvider())
38 | android.set(true)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
13 |
14 | # When configured, Gradle will run in incubating parallel mode.
15 | # This option should only be used with decoupled projects. More details, visit
16 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
17 | org.gradle.parallel=true
18 |
19 | org.gradle.configureondemand=true
20 | org.gradle.caching=true
21 |
22 | # AndroidX package structure to make it clearer which packages are bundled with the
23 | # Android operating system, and which are packaged with your app's APK
24 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
25 | android.useAndroidX=true
26 |
27 | # Kotlin code style for this project: "official" or "obsolete":
28 | kotlin.code.style=official
29 |
--------------------------------------------------------------------------------
/gradle/libs.versions.toml:
--------------------------------------------------------------------------------
1 | [versions]
2 | agp = "8.10.1"
3 | compileSdk = "36"
4 | minSdk = "28"
5 | targetSdk = "36"
6 | ndk = "28.1.13356709"
7 | androidx-activity = "1.10.1"
8 | androidx-core = "1.16.0"
9 | androidx-datastore = "1.1.7"
10 | androidx-hilt = "1.2.0"
11 | androidx-lifecycle = "2.9.1"
12 | androidx-media = "1.7.0"
13 | androidx-navigation = "2.9.0"
14 | androidx-paging = "3.3.6"
15 | androidx-room = "2.7.1"
16 | androidx-test-core = "1.6.1"
17 | androidx-test-espresso = "3.6.1"
18 | androidx-test-junit = "1.2.1"
19 | androidx-test-runner = "1.6.2"
20 | androidx-test-truth = "1.6.0"
21 | cachefix = "3.0.1"
22 | compose-bom = "2025.06.00"
23 | compose-lint = "1.4.2"
24 | coroutines = "1.10.2"
25 | dmach-externals = "3.4.0"
26 | dagger = "2.56.2"
27 | gradle-versions = "0.52.0"
28 | javaVersion = "21"
29 | kortholt = "3.4.0"
30 | kotlin = "2.1.21"
31 | kotlin-collections = "0.4.0"
32 | kotlin-serialization = "1.8.1"
33 | ktlint = "1.6.0"
34 | ktlint-compose = "0.4.22"
35 | ktlint-gradle = "12.2.0"
36 | ksp = "2.1.21-2.0.1"
37 | leakcanary = "2.14"
38 | mockito-core = "5.18.0"
39 | mockito-kotlin = "5.4.0"
40 | robolectric = "4.14.1"
41 |
42 | [plugins]
43 | android-application = { id = "com.android.application", version.ref = "agp" }
44 | hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "dagger" }
45 | kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
46 | kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
47 | kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
48 | ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
49 | ktlint-gradle = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint-gradle" }
50 | cachefix = { id = "org.gradle.android.cache-fix", version.ref = "cachefix" }
51 | gradle-versions = { id = "com.github.ben-manes.versions", version.ref = "gradle-versions" }
52 |
53 | [libraries]
54 | androidx-activity = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
55 | androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
56 | androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "androidx-datastore" }
57 | androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidx-hilt" }
58 | androidx-hilt-navigation = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidx-hilt" }
59 | androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
60 | androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "androidx-lifecycle" }
61 | androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
62 | androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" }
63 | androidx-media = { module = "androidx.media:media", version.ref = "androidx-media" }
64 | androidx-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
65 | androidx-paging-common = { module = "androidx.paging:paging-common-ktx", version.ref = "androidx-paging" }
66 | androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "androidx-paging" }
67 | androidx-paging-runtime = { module = "androidx.paging:paging-runtime-ktx", version.ref = "androidx-paging" }
68 | androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
69 | androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }
70 | androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" }
71 | androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
72 | androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "androidx-room" }
73 | androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test-core" }
74 | androidx-test-espresso = { module = "androidx.test.espresso:espresso-core", version.ref = "androidx-test-espresso" }
75 | androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
76 | androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" }
77 | androidx-test-truth = { module = "androidx.test.ext:truth", version.ref = "androidx-test-truth" }
78 | compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
79 | compose-lint = { module = "com.slack.lint.compose:compose-lint-checks", version.ref = "compose-lint" }
80 | compose-material = { module = "androidx.compose.material3:material3" }
81 | compose-material-icons = { module = "androidx.compose.material:material-icons-extended" }
82 | compose-ui = { module = "androidx.compose.ui:ui" }
83 | compose-ui-testmanifest = { module = "androidx.compose.ui:ui-test-manifest" }
84 | compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
85 | compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
86 | coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android" }
87 | coroutines-bom = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-bom", version.ref = "coroutines" }
88 | coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" }
89 | dmach-externals = { module = "com.github.simonnorberg:dmach-externals", version.ref = "dmach-externals" }
90 | hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" }
91 | hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" }
92 | kortholt = { module = "net.simno.kortholt:kortholt", version.ref = "kortholt" }
93 | kotlin-collections = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version.ref = "kotlin-collections" }
94 | kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
95 | ktlint-compose = { module = "io.nlopez.compose.rules:ktlint", version.ref = "ktlint-compose" }
96 | leakcanary-plumber = { module = "com.squareup.leakcanary:plumber-android", version.ref = "leakcanary" }
97 | mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito-core" }
98 | mockito-kotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockito-kotlin" }
99 | robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
100 |
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | distributionBase=GRADLE_USER_HOME
2 | distributionPath=wrapper/dists
3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-all.zip
4 | networkTimeout=10000
5 | validateDistributionUrl=true
6 | zipStoreBase=GRADLE_USER_HOME
7 | zipStorePath=wrapper/dists
8 |
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #
4 | # Copyright © 2015-2021 the original authors.
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # https://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # SPDX-License-Identifier: Apache-2.0
19 | #
20 |
21 | ##############################################################################
22 | #
23 | # Gradle start up script for POSIX generated by Gradle.
24 | #
25 | # Important for running:
26 | #
27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
28 | # noncompliant, but you have some other compliant shell such as ksh or
29 | # bash, then to run this script, type that shell name before the whole
30 | # command line, like:
31 | #
32 | # ksh Gradle
33 | #
34 | # Busybox and similar reduced shells will NOT work, because this script
35 | # requires all of these POSIX shell features:
36 | # * functions;
37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»;
39 | # * compound commands having a testable exit status, especially «case»;
40 | # * various built-in commands including «command», «set», and «ulimit».
41 | #
42 | # Important for patching:
43 | #
44 | # (2) This script targets any POSIX shell, so it avoids extensions provided
45 | # by Bash, Ksh, etc; in particular arrays are avoided.
46 | #
47 | # The "traditional" practice of packing multiple parameters into a
48 | # space-separated string is a well documented source of bugs and security
49 | # problems, so this is (mostly) avoided, by progressively accumulating
50 | # options in "$@", and eventually passing that to Java.
51 | #
52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
54 | # see the in-line comments for details.
55 | #
56 | # There are tweaks for specific operating systems such as AIX, CygWin,
57 | # Darwin, MinGW, and NonStop.
58 | #
59 | # (3) This script is generated from the Groovy template
60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
61 | # within the Gradle project.
62 | #
63 | # You can find Gradle at https://github.com/gradle/gradle/.
64 | #
65 | ##############################################################################
66 |
67 | # Attempt to set APP_HOME
68 |
69 | # Resolve links: $0 may be a link
70 | app_path=$0
71 |
72 | # Need this for daisy-chained symlinks.
73 | while
74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
75 | [ -h "$app_path" ]
76 | do
77 | ls=$( ls -ld "$app_path" )
78 | link=${ls#*' -> '}
79 | case $link in #(
80 | /*) app_path=$link ;; #(
81 | *) app_path=$APP_HOME$link ;;
82 | esac
83 | done
84 |
85 | # This is normally unused
86 | # shellcheck disable=SC2034
87 | APP_BASE_NAME=${0##*/}
88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
90 |
91 | # Use the maximum available, or set MAX_FD != -1 to use that value.
92 | MAX_FD=maximum
93 |
94 | warn () {
95 | echo "$*"
96 | } >&2
97 |
98 | die () {
99 | echo
100 | echo "$*"
101 | echo
102 | exit 1
103 | } >&2
104 |
105 | # OS specific support (must be 'true' or 'false').
106 | cygwin=false
107 | msys=false
108 | darwin=false
109 | nonstop=false
110 | case "$( uname )" in #(
111 | CYGWIN* ) cygwin=true ;; #(
112 | Darwin* ) darwin=true ;; #(
113 | MSYS* | MINGW* ) msys=true ;; #(
114 | NONSTOP* ) nonstop=true ;;
115 | esac
116 |
117 | CLASSPATH="\\\"\\\""
118 |
119 |
120 | # Determine the Java command to use to start the JVM.
121 | if [ -n "$JAVA_HOME" ] ; then
122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
123 | # IBM's JDK on AIX uses strange locations for the executables
124 | JAVACMD=$JAVA_HOME/jre/sh/java
125 | else
126 | JAVACMD=$JAVA_HOME/bin/java
127 | fi
128 | if [ ! -x "$JAVACMD" ] ; then
129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
130 |
131 | Please set the JAVA_HOME variable in your environment to match the
132 | location of your Java installation."
133 | fi
134 | else
135 | JAVACMD=java
136 | if ! command -v java >/dev/null 2>&1
137 | then
138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
139 |
140 | Please set the JAVA_HOME variable in your environment to match the
141 | location of your Java installation."
142 | fi
143 | fi
144 |
145 | # Increase the maximum file descriptors if we can.
146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
147 | case $MAX_FD in #(
148 | max*)
149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
150 | # shellcheck disable=SC2039,SC3045
151 | MAX_FD=$( ulimit -H -n ) ||
152 | warn "Could not query maximum file descriptor limit"
153 | esac
154 | case $MAX_FD in #(
155 | '' | soft) :;; #(
156 | *)
157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
158 | # shellcheck disable=SC2039,SC3045
159 | ulimit -n "$MAX_FD" ||
160 | warn "Could not set maximum file descriptor limit to $MAX_FD"
161 | esac
162 | fi
163 |
164 | # Collect all arguments for the java command, stacking in reverse order:
165 | # * args from the command line
166 | # * the main class name
167 | # * -classpath
168 | # * -D...appname settings
169 | # * --module-path (only if needed)
170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
171 |
172 | # For Cygwin or MSYS, switch paths to Windows format before running java
173 | if "$cygwin" || "$msys" ; then
174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
176 |
177 | JAVACMD=$( cygpath --unix "$JAVACMD" )
178 |
179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
180 | for arg do
181 | if
182 | case $arg in #(
183 | -*) false ;; # don't mess with options #(
184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
185 | [ -e "$t" ] ;; #(
186 | *) false ;;
187 | esac
188 | then
189 | arg=$( cygpath --path --ignore --mixed "$arg" )
190 | fi
191 | # Roll the args list around exactly as many times as the number of
192 | # args, so each arg winds up back in the position where it started, but
193 | # possibly modified.
194 | #
195 | # NB: a `for` loop captures its iteration list before it begins, so
196 | # changing the positional parameters here affects neither the number of
197 | # iterations, nor the values presented in `arg`.
198 | shift # remove old arg
199 | set -- "$@" "$arg" # push replacement arg
200 | done
201 | fi
202 |
203 |
204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
206 |
207 | # Collect all arguments for the java command:
208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
209 | # and any embedded shellness will be escaped.
210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
211 | # treated as '${Hostname}' itself on the command line.
212 |
213 | set -- \
214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \
215 | -classpath "$CLASSPATH" \
216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
217 | "$@"
218 |
219 | # Stop when "xargs" is not available.
220 | if ! command -v xargs >/dev/null 2>&1
221 | then
222 | die "xargs is not available"
223 | fi
224 |
225 | # Use "xargs" to parse quoted args.
226 | #
227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed.
228 | #
229 | # In Bash we could simply go:
230 | #
231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) &&
232 | # set -- "${ARGS[@]}" "$@"
233 | #
234 | # but POSIX shell has neither arrays nor command substitution, so instead we
235 | # post-process each arg (as a line of input to sed) to backslash-escape any
236 | # character that might be a shell metacharacter, then use eval to reverse
237 | # that process (while maintaining the separation between arguments), and wrap
238 | # the whole thing up as a single "set" statement.
239 | #
240 | # This will of course break if any of these variables contains a newline or
241 | # an unmatched quote.
242 | #
243 |
244 | eval "set -- $(
245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
246 | xargs -n1 |
247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
248 | tr '\n' ' '
249 | )" '"$@"'
250 |
251 | exec "$JAVACMD" "$@"
252 |
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 | @rem SPDX-License-Identifier: Apache-2.0
17 | @rem
18 |
19 | @if "%DEBUG%"=="" @echo off
20 | @rem ##########################################################################
21 | @rem
22 | @rem Gradle startup script for Windows
23 | @rem
24 | @rem ##########################################################################
25 |
26 | @rem Set local scope for the variables with windows NT shell
27 | if "%OS%"=="Windows_NT" setlocal
28 |
29 | set DIRNAME=%~dp0
30 | if "%DIRNAME%"=="" set DIRNAME=.
31 | @rem This is normally unused
32 | set APP_BASE_NAME=%~n0
33 | set APP_HOME=%DIRNAME%
34 |
35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
37 |
38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
40 |
41 | @rem Find java.exe
42 | if defined JAVA_HOME goto findJavaFromJavaHome
43 |
44 | set JAVA_EXE=java.exe
45 | %JAVA_EXE% -version >NUL 2>&1
46 | if %ERRORLEVEL% equ 0 goto execute
47 |
48 | echo. 1>&2
49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
50 | echo. 1>&2
51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
52 | echo location of your Java installation. 1>&2
53 |
54 | goto fail
55 |
56 | :findJavaFromJavaHome
57 | set JAVA_HOME=%JAVA_HOME:"=%
58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
59 |
60 | if exist "%JAVA_EXE%" goto execute
61 |
62 | echo. 1>&2
63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
64 | echo. 1>&2
65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2
66 | echo location of your Java installation. 1>&2
67 |
68 | goto fail
69 |
70 | :execute
71 | @rem Setup the command line
72 |
73 | set CLASSPATH=
74 |
75 |
76 | @rem Execute Gradle
77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
78 |
79 | :end
80 | @rem End local scope for the variables with windows NT shell
81 | if %ERRORLEVEL% equ 0 goto mainEnd
82 |
83 | :fail
84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
85 | rem the _cmd.exe /c_ return code!
86 | set EXIT_CODE=%ERRORLEVEL%
87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
89 | exit /b %EXIT_CODE%
90 |
91 | :mainEnd
92 | if "%OS%"=="Windows_NT" endlocal
93 |
94 | :omega
95 |
--------------------------------------------------------------------------------
/lint.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/screenshots/dmach-1.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-1.webp
--------------------------------------------------------------------------------
/screenshots/dmach-2.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-2.webp
--------------------------------------------------------------------------------
/screenshots/dmach-3.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-3.webp
--------------------------------------------------------------------------------
/screenshots/dmach-4.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/simonnorberg/dmach/434479ae789ba58b7cbef9e08b6886a9d8c93c20/screenshots/dmach-4.webp
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 | repositories {
3 | google()
4 | mavenCentral()
5 | gradlePluginPortal()
6 | }
7 | }
8 |
9 | dependencyResolutionManagement {
10 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
11 | repositories {
12 | google()
13 | mavenCentral()
14 | maven {
15 | url = uri("https://jitpack.io")
16 | content {
17 | includeModule("com.github.simonnorberg", "dmach-externals")
18 | }
19 | }
20 | }
21 | }
22 |
23 | rootProject.name = "dmach"
24 | include(":app")
25 |
--------------------------------------------------------------------------------