app/src/main/kotlin/space/subread/anki/MainActivity.kt (14746 bytes)
1 package space.subread.anki 2 3 import android.annotation.SuppressLint 4 import android.app.Activity 5 import android.app.AlertDialog 6 import android.content.Intent 7 import android.content.pm.PackageManager 8 import android.graphics.Color 9 import android.graphics.Typeface 10 import android.os.Bundle 11 import android.text.InputType 12 import android.text.SpannableString 13 import android.text.Spanned 14 import android.text.style.UnderlineSpan 15 import android.view.View 16 import android.widget.Button 17 import android.widget.EditText 18 import android.widget.LinearLayout 19 import android.widget.ScrollView 20 import android.widget.TextView 21 import android.widget.Toast 22 import androidx.core.net.toUri 23 import space.subread.anki.core.Fields 24 import space.subread.anki.core.NoteType 25 import space.subread.anki.core.Source 26 import kotlin.concurrent.thread 27 28 /** 29 * The settings: AnkiDroid, the deck and the note type, the dictionary, the capture, the 30 * options, and a field to try the pop-up. Each part is on the screen from the start, so that 31 * the user sees what the app does before a button is pressed. 32 */ 33 class MainActivity : Activity() { 34 35 private lateinit var store: Store 36 private lateinit var content: LinearLayout 37 38 override fun onCreate(savedInstanceState: Bundle?) { 39 super.onCreate(savedInstanceState) 40 store = Store(this) 41 content = LinearLayout(this).apply { 42 orientation = LinearLayout.VERTICAL 43 setPadding(dp(20), dp(20), dp(20), dp(20)) 44 setBackgroundColor(Color.WHITE) 45 } 46 setContentView(ScrollView(this).apply { 47 fitsSystemWindows = true 48 setBackgroundColor(Color.WHITE) 49 addView(content) 50 }) 51 } 52 53 /** The user comes back from AnkiDroid or from a dialog of Android here, so the state is read again. */ 54 override fun onResume() { 55 super.onResume() 56 draw() 57 } 58 59 private fun draw() { 60 content.removeAllViews() 61 title(getString(R.string.app_name)) 62 note(getString(R.string.about)) 63 note(getString(R.string.privacy)) 64 65 val anki = AnkiClient.packageName(this) 66 val allowed = anki != null && AnkiClient.hasPermission(this) 67 when { 68 anki == null -> step(R.string.step_anki, getString(R.string.anki_missing), getString(R.string.anki_install)) { open(ANKIDROID_INSTALL) } 69 !allowed -> step(R.string.step_anki, getString(R.string.anki_no_permission), getString(R.string.anki_allow)) { 70 requestPermissions(arrayOf(AnkiClient.PERMISSION), PERMISSION) 71 } 72 else -> step(R.string.step_anki, getString(R.string.anki_ok), null, done = true) {} 73 } 74 75 step(R.string.step_deck, getString(R.string.deck_current, deckName()), if (allowed) getString(R.string.deck_choose) else null) { chooseDeck() } 76 note(modelName(), top = 12) 77 if (allowed) row(button(getString(R.string.model_choose)) { chooseModel() }, button(getString(R.string.fields)) { editFields() }) 78 note(getString(R.string.fields_why), top = 4) 79 80 val dictionary = DictionaryClient.installed(this) 81 step( 82 R.string.step_dictionary, 83 getString( 84 when { 85 !dictionary -> R.string.dictionary_missing 86 !DictionaryClient.answers(this) -> R.string.dictionary_old 87 else -> R.string.dictionary_ok 88 }, 89 ), 90 getString(if (dictionary) R.string.open_dictionary else R.string.install_dictionary), 91 done = DictionaryClient.answers(this), 92 ) { 93 if (dictionary) launch(DictionaryClient.PACKAGE) else open(DICTIONARY_INSTALL) 94 } 95 96 val capturing = CaptureService.instance != null 97 step( 98 R.string.step_capture, 99 getString(R.string.capture_why) + if (CaptureService.canCaptureAudio) "" else "\n\n" + getString(R.string.capture_no_audio), 100 getString(if (capturing) R.string.capture_stop else R.string.capture_start), 101 done = capturing, 102 ) { 103 if (capturing) CaptureService.stop(this) else startActivity(Intent(this, CaptureActivity::class.java)) 104 content.postDelayed({ draw() }, 500) 105 } 106 note(getString(R.string.capture_overlay), top = 12) 107 note(getString(R.string.pad), top = 12) 108 numberField(store.padMs) { store.padMs = it } 109 110 step(R.string.step_options, getString(if (store.addAtOnce) R.string.popup_off else R.string.popup_on), getString(R.string.toggle)) { 111 store.addAtOnce = !store.addAtOnce 112 draw() 113 } 114 note(getString(if (store.skipDuplicates) R.string.duplicates_skip else R.string.duplicates_add), top = 12) 115 content.addView(button(getString(R.string.toggle)) { store.skipDuplicates = !store.skipDuplicates; draw() }, narrow()) 116 note(getString(R.string.tags), top = 12) 117 textField(store.tags) { store.tags = it } 118 119 step(R.string.step_try, "", null) {} 120 val tryField = EditText(this).apply { 121 hint = getString(R.string.try_hint) 122 setTextColor(Color.BLACK) 123 } 124 content.addView(tryField, wide()) 125 content.addView(button(getString(R.string.try_look_up)) { 126 val text = tryField.text.toString().trim() 127 // The same intent as the text selection menu, so "Try it" opens the same pop-up. 128 if (text.isNotEmpty()) { 129 startActivity( 130 Intent(Intent.ACTION_PROCESS_TEXT).setType("text/plain").setClass(this, AddActivity::class.java) 131 .putExtra(Intent.EXTRA_PROCESS_TEXT, text), 132 ) 133 } 134 }, narrow()) 135 136 if (BuildConfig.DONATE_LINK) content.addView(button(getString(R.string.donate)) { open(KOFI) }, wide(top = 24)) 137 // The Google Play build (-PplayStore=true) has no Ko-fi link, and no More apps either. 138 if (BuildConfig.DONATE_LINK) moreApps() 139 } 140 141 /** The other sites and apps of the same author. A tap on an entry opens its page in the browser. */ 142 private fun moreApps() { 143 step(R.string.more_apps, "", null) {} 144 for (app in MORE_APPS) { 145 val name = getString(app.name) 146 content.addView(TextView(this).apply { 147 text = SpannableString("$name\n${getString(app.line)}").apply { 148 setSpan(UnderlineSpan(), 0, name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) 149 } 150 textSize = 15f 151 setTextColor(Color.BLACK) 152 setOnClickListener { open(app.url) } 153 }, wide(top = 12)) 154 } 155 } 156 157 private fun deckName(): String { 158 val id = store.deckId 159 if (id == Store.NONE) return AnkiClient.DECK_NAME 160 return AnkiClient(this).decks()[id] ?: AnkiClient.DECK_NAME 161 } 162 163 private fun modelName(): String { 164 val id = store.modelId 165 if (id == Store.NONE) return getString(R.string.model_own, NoteType.NAME) 166 val name = AnkiClient(this).models()[id] ?: return getString(R.string.model_own, NoteType.NAME) 167 return getString(R.string.model_current, name) 168 } 169 170 private fun chooseDeck() { 171 thread { 172 val decks = AnkiClient(this).decks().entries.sortedBy { it.value } 173 runOnUiThread { 174 val names = decks.map { it.value } + getString(R.string.deck_new) 175 AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which -> 176 val deck = decks.getOrNull(which) 177 if (deck != null) { 178 store.deckId = deck.key 179 draw() 180 } else { 181 newDeck() 182 } 183 }.show() 184 } 185 } 186 } 187 188 private fun newDeck() { 189 val field = EditText(this).apply { hint = getString(R.string.deck_new_name) } 190 AlertDialog.Builder(this).setView(field).setPositiveButton(R.string.save) { _, _ -> 191 val name = field.text.toString().trim() 192 if (name.isEmpty()) return@setPositiveButton 193 thread { 194 val id = runCatching { AnkiClient(this).run { decks().entries.firstOrNull { it.value == name }?.key ?: addDeck(name) } }.getOrNull() 195 runOnUiThread { 196 if (id != null) store.deckId = id else toast(getString(R.string.failed, name)) 197 draw() 198 } 199 } 200 }.setNegativeButton(R.string.cancel, null).show() 201 } 202 203 private fun chooseModel() { 204 thread { 205 val models = AnkiClient(this).models().entries.sortedBy { it.value } 206 runOnUiThread { 207 val names = listOf(getString(R.string.model_own_choice)) + models.map { it.value } 208 AlertDialog.Builder(this).setItems(names.toTypedArray()) { _, which -> 209 val model = models.getOrNull(which - 1) 210 store.modelId = model?.key ?: Store.NONE 211 // A new note type gets a fresh guess of its fields. 212 store.mapping = if (model == null) NoteType.MAPPING else emptyMap() 213 draw() 214 }.show() 215 } 216 } 217 } 218 219 /** One dialog with the fields; a tap on a field opens the list of what can fill it. */ 220 private fun editFields() { 221 thread { 222 val anki = AnkiClient(this) 223 val modelId = runCatching { anki.ensureModel(anki.ensureDeck()) }.getOrNull() 224 val fields = modelId?.let { anki.fields(it) }.orEmpty() 225 runOnUiThread { 226 if (modelId == null || fields.isEmpty()) { 227 toast(getString(R.string.anki_no_permission)) 228 return@runOnUiThread 229 } 230 val mapping = LinkedHashMap(anki.mapping(modelId, fields).let { m -> fields.associateWith { m[it] ?: Source.NONE } }) 231 showFields(fields, mapping) 232 } 233 } 234 } 235 236 private fun showFields(fields: List<String>, mapping: LinkedHashMap<String, Source>) { 237 val labels = fields.map { getString(R.string.field_source, it, mapping[it]!!.name) } 238 AlertDialog.Builder(this) 239 .setTitle(R.string.fields) 240 .setItems(labels.toTypedArray()) { _, which -> 241 val field = fields[which] 242 val sources = Source.entries 243 AlertDialog.Builder(this).setTitle(field).setItems(sources.map { it.name }.toTypedArray()) { _, i -> 244 mapping[field] = sources[i] 245 showFields(fields, mapping) 246 }.show() 247 } 248 .setPositiveButton(R.string.save) { _, _ -> 249 store.mapping = mapping 250 toast(getString(R.string.saved)) 251 } 252 .setNegativeButton(R.string.cancel, null) 253 .show() 254 } 255 256 override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { 257 super.onRequestPermissionsResult(requestCode, permissions, grantResults) 258 if (requestCode == PERMISSION && grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) { 259 toast(getString(R.string.anki_no_permission)) 260 } 261 draw() 262 } 263 264 private fun launch(packageName: String) { 265 packageManager.getLaunchIntentForPackage(packageName)?.let { runCatching { startActivity(it) } } 266 } 267 268 private fun open(link: String) { 269 runCatching { startActivity(Intent(Intent.ACTION_VIEW, link.toUri())) } 270 } 271 272 private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show() 273 274 // The screen is built in code: a few rows do not need a layout file each. 275 276 private fun title(value: String) = content.addView(TextView(this).apply { 277 text = value 278 textSize = 26f 279 setTypeface(typeface, Typeface.BOLD) 280 setTextColor(Color.BLACK) 281 }) 282 283 private fun note(value: String, top: Int = 8) = content.addView(TextView(this).apply { 284 text = value 285 textSize = 15f 286 setTextColor(Color.BLACK) 287 }, wide(top)) 288 289 @SuppressLint("SetTextI18n") 290 private fun step(name: Int, detail: String, action: String?, done: Boolean = false, onAction: () -> Unit) { 291 content.addView(View(this).apply { setBackgroundColor(Color.BLACK) }, LinearLayout.LayoutParams(-1, dp(1)).apply { topMargin = dp(16) }) 292 content.addView(TextView(this).apply { 293 text = getString(name) + if (done) " ✓" else "" 294 textSize = 18f 295 setTypeface(typeface, Typeface.BOLD) 296 setTextColor(Color.BLACK) 297 }, wide(top = 12)) 298 if (detail.isNotEmpty()) note(detail, top = 4) 299 if (action != null) content.addView(button(action, onAction), narrow()) 300 } 301 302 private fun textField(value: String, onSave: (String) -> Unit) { 303 val field = EditText(this).apply { 304 setText(value) 305 setTextColor(Color.BLACK) 306 } 307 row(field.also { it.layoutParams = LinearLayout.LayoutParams(0, -2, 1f) }, button(getString(R.string.save)) { 308 onSave(field.text.toString()) 309 toast(getString(R.string.saved)) 310 }) 311 } 312 313 // A count of milliseconds is typed as plain digits, in every locale. 314 @SuppressLint("SetTextI18n") 315 private fun numberField(value: Int, onSave: (Int) -> Unit) { 316 val field = EditText(this).apply { 317 setText(value.toString()) 318 inputType = InputType.TYPE_CLASS_NUMBER 319 setTextColor(Color.BLACK) 320 } 321 row(field.also { it.layoutParams = LinearLayout.LayoutParams(0, -2, 1f) }, button(getString(R.string.save)) { 322 field.text.toString().toIntOrNull()?.let(onSave) 323 toast(getString(R.string.saved)) 324 }) 325 } 326 327 private fun row(vararg views: View) = content.addView(LinearLayout(this).apply { views.forEach { addView(it) } }, wide(top = 4)) 328 329 private fun button(label: String, onClick: () -> Unit) = Button(this).apply { 330 text = label 331 isAllCaps = false 332 setOnClickListener { onClick() } 333 } 334 335 private fun wide(top: Int = 8) = LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(top) } 336 337 private fun narrow() = LinearLayout.LayoutParams(-2, -2) 338 339 private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt() 340 341 private companion object { 342 const val PERMISSION = 1 343 const val ANKIDROID_INSTALL = "https://play.google.com/store/apps/details?id=com.ichi2.anki" 344 const val DICTIONARY_INSTALL = "https://github.com/equwal/subread-dictionary/releases/latest" 345 const val KOFI = "https://ko-fi.com/truex" 346 } 347 }