言語フィルタ画面のリファクタ。 /api/v1/instance/languages で言語リストを読む。

This commit is contained in:
tateisu 2024-03-20 04:36:09 +09:00
parent 50d0139a08
commit add0014304
27 changed files with 1097 additions and 650 deletions

View File

@ -169,6 +169,7 @@ dependencies {
implementation("androidx.appcompat:appcompat:${Vers.androidxAppcompat}")
implementation("androidx.browser:browser:1.8.0")
implementation("androidx.compose.material3:material3:1.2.1")
implementation("androidx.compose.material:material-icons-extended-android:${Vers.androidxComposeMaterialIcons}")
implementation("androidx.compose.runtime:runtime-livedata:${Vers.androidxComposeRuntime}")
implementation("androidx.compose.ui:ui-tooling-preview:${Vers.androidxComposeUi}")
implementation("androidx.compose.ui:ui:${Vers.androidxComposeUi}")

File diff suppressed because one or more lines are too long

View File

@ -298,7 +298,7 @@
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />
<activity
android:name=".ActLanguageFilter"
android:name=".ui.languageFilter.LanguageFilterActivity"
android:exported="false"
android:label="@string/language_filter"
android:windowSoftInputMode="adjustResize|stateAlwaysHidden" />

View File

@ -1,478 +0,0 @@
package jp.juggler.subwaytooter
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.PersistableBundle
import android.os.Process
import android.text.Editable
import android.text.TextWatcher
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.FileProvider
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.column.Column
import jp.juggler.subwaytooter.databinding.ActLanguageFilterBinding
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.pref.FILE_PROVIDER_AUTHORITY
import jp.juggler.util.*
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchProgress
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.*
import org.jetbrains.anko.textColor
import java.io.File
import java.io.FileOutputStream
import java.util.*
class ActLanguageFilter : AppCompatActivity(), View.OnClickListener {
private class MyItem(
val code: String,
var allow: Boolean,
)
companion object {
internal val log = LogCategory("ActLanguageFilter")
internal const val EXTRA_COLUMN_INDEX = "column_index"
private const val STATE_LANGUAGE_LIST = "language_list"
fun createIntent(activity: ActMain, idx: Int) =
Intent(activity, ActLanguageFilter::class.java).apply {
putExtra(EXTRA_COLUMN_INDEX, idx)
}
private val languageComparator = Comparator<MyItem> { a, b ->
when {
a.code == TootStatus.LANGUAGE_CODE_DEFAULT -> -1
b.code == TootStatus.LANGUAGE_CODE_DEFAULT -> 1
a.code == TootStatus.LANGUAGE_CODE_UNKNOWN -> -1
b.code == TootStatus.LANGUAGE_CODE_UNKNOWN -> 1
else -> a.code.compareTo(b.code)
}
}
private fun equalsLanguageList(a: JsonObject?, b: JsonObject?): Boolean {
fun JsonObject.encodeToString(): String {
val clone = this.toString().decodeJsonObject()
if (!clone.contains(TootStatus.LANGUAGE_CODE_DEFAULT)) {
clone[TootStatus.LANGUAGE_CODE_DEFAULT] = true
}
return clone.keys.sorted().joinToString(",") { "$it=${this[it]}" }
}
val a_sign = (a ?: JsonObject()).encodeToString()
val b_sign = (b ?: JsonObject()).encodeToString()
return a_sign == b_sign
}
}
private val languageNameMap by lazy {
HashMap<String, String>().apply {
// from https://github.com/google/cld3/blob/master/src/task_context_params.cc#L43
val languageNamesCld3 = arrayOf(
"eo", "co", "eu", "ta", "de", "mt", "ps", "te", "su", "uz", "zh-Latn", "ne",
"nl", "sw", "sq", "hmn", "ja", "no", "mn", "so", "ko", "kk", "sl", "ig",
"mr", "th", "zu", "ml", "hr", "bs", "lo", "sd", "cy", "hy", "uk", "pt",
"lv", "iw", "cs", "vi", "jv", "be", "km", "mk", "tr", "fy", "am", "zh",
"da", "sv", "fi", "ht", "af", "la", "id", "fil", "sm", "ca", "el", "ka",
"sr", "it", "sk", "ru", "ru-Latn", "bg", "ny", "fa", "haw", "gl", "et",
"ms", "gd", "bg-Latn", "ha", "is", "ur", "mi", "hi", "bn", "hi-Latn", "fr",
"yi", "hu", "xh", "my", "tg", "ro", "ar", "lb", "el-Latn", "st", "ceb",
"kn", "az", "si", "ky", "mg", "en", "gu", "es", "pl", "ja-Latn", "ga", "lt",
"sn", "yo", "pa", "ku"
)
for (src1 in languageNamesCld3) {
val src2 = src1.replace("-Latn", "")
val isLatn = src2 != src1
val locale = Locale(src2)
log.w("languageNameMap $src1 ${locale.language} ${locale.country} ${locale.displayName}")
put(
src1, if (isLatn) {
"${locale.displayName}(Latn)"
} else {
locale.displayName
}
)
}
put(TootStatus.LANGUAGE_CODE_DEFAULT, getString(R.string.language_code_default))
put(TootStatus.LANGUAGE_CODE_UNKNOWN, getString(R.string.language_code_unknown))
}
}
private fun getDesc(item: MyItem): String {
val code = item.code
return languageNameMap[code] ?: getString(R.string.custom)
}
private var columnIndex: Int = 0
internal lateinit var column: Column
internal lateinit var appState: AppState
internal var density: Float = 0f
private val views by lazy {
ActLanguageFilterBinding.inflate(layoutInflater)
}
private lateinit var adapter: MyAdapter
private val languageList = ArrayList<MyItem>()
private var loadingBusy: Boolean = false
private val arExport = ActivityResultHandler(log) {
}
private val arImport = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.checkMimeTypeAndGrant(contentResolver)
?.firstOrNull()?.uri?.let { import2(it) }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
backPressed { confirmBack() }
arExport.register(this)
arImport.register(this)
App1.setActivityTheme(this)
initUI()
appState = App1.getAppState(this)
density = appState.density
columnIndex = intent.int(EXTRA_COLUMN_INDEX) ?: 0
column = appState.column(columnIndex)!!
if (savedInstanceState != null) {
try {
val sv = savedInstanceState.getString(STATE_LANGUAGE_LIST, null)
if (sv != null) {
val list = sv.decodeJsonObject()
load(list)
return
}
} catch (ex: Throwable) {
log.e(ex, "restore failed.")
}
}
load(column.languageFilter)
}
override fun onSaveInstanceState(outState: Bundle, outPersistentState: PersistableBundle) {
super.onSaveInstanceState(outState, outPersistentState)
outState.putString(STATE_LANGUAGE_LIST, encodeLanguageList().toString())
}
private fun initUI() {
setContentView(views.root)
setSupportActionBar(views.toolbar)
setNavigationBack(views.toolbar)
fixHorizontalMargin(views.llContent)
arrayOf(
views.btnAdd,
views.btnSave,
views.btnMore,
).forEach {
it.setOnClickListener(this)
}
adapter = MyAdapter()
views.listView.adapter = adapter
views.listView.onItemClickListener = adapter
}
// UIのデータをJsonObjectにエンコード
private fun encodeLanguageList() = buildJsonObject {
for (item in languageList) {
put(item.code, item.allow)
}
}
private fun load(src: JsonObject?) {
loadingBusy = true
try {
languageList.clear()
if (src != null) {
for (key in src.keys) {
languageList.add(MyItem(key, src.boolean(key) ?: true))
}
}
if (languageList.none { it.code == TootStatus.LANGUAGE_CODE_DEFAULT }) {
languageList.add(MyItem(TootStatus.LANGUAGE_CODE_DEFAULT, true))
}
languageList.sortWith(languageComparator)
adapter.notifyDataSetChanged()
} finally {
loadingBusy = false
}
}
private fun save() {
column.languageFilter = encodeLanguageList()
}
private inner class MyAdapter : BaseAdapter(), AdapterView.OnItemClickListener {
override fun getCount(): Int = languageList.size
override fun getItemId(idx: Int): Long = 0L
override fun getItem(idx: Int): Any = languageList[idx]
override fun getView(idx: Int, viewArg: View?, parent: ViewGroup?): View {
val tv = (viewArg ?: layoutInflater.inflate(
R.layout.lv_language_filter,
parent,
false
)) as TextView
val item = languageList[idx]
tv.text =
"${item.code} ${getDesc(item)} : ${getString(if (item.allow) R.string.language_show else R.string.language_hide)}"
tv.textColor = attrColor(
when (item.allow) {
true -> R.attr.colorTextContent
false -> R.attr.colorRegexFilterError
}
)
return tv
}
override fun onItemClick(parent: AdapterView<*>?, viewArg: View?, idx: Int, id: Long) {
if (idx in languageList.indices) edit(languageList[idx])
}
}
override fun onClick(v: View) {
when (v.id) {
R.id.btnSave -> {
save()
val data = Intent()
data.putExtra(EXTRA_COLUMN_INDEX, columnIndex)
setResult(RESULT_OK, data)
finish()
}
R.id.btnAdd -> edit(null)
R.id.btnMore -> {
launchAndShowError {
actionsDialog {
action(getString(R.string.clear_all)) {
languageList.clear()
languageList.add(MyItem(TootStatus.LANGUAGE_CODE_DEFAULT, true))
adapter.notifyDataSetChanged()
}
action(getString(R.string.export)) { export() }
action(getString(R.string.import_)) { import() }
}
}
}
}
}
private fun edit(myItem: MyItem?) =
DlgLanguageFilter.open(this, myItem, object : DlgLanguageFilter.Callback {
override fun onOK(code: String, allow: Boolean) {
val it = languageList.iterator()
while (it.hasNext()) {
val item = it.next()
if (item.code == code) {
item.allow = allow
adapter.notifyDataSetChanged()
return
}
}
languageList.add(MyItem(code, allow))
languageList.sortWith(languageComparator)
adapter.notifyDataSetChanged()
return
}
override fun onDelete(code: String) {
val it = languageList.iterator()
while (it.hasNext()) {
val item = it.next()
if (item.code == code) it.remove()
}
adapter.notifyDataSetChanged()
}
})
private object DlgLanguageFilter {
interface Callback {
fun onOK(code: String, allow: Boolean)
fun onDelete(code: String)
}
@SuppressLint("InflateParams")
fun open(activity: ActLanguageFilter, item: MyItem?, callback: Callback) {
val view = activity.layoutInflater.inflate(R.layout.dlg_language_filter, null, false)
val etLanguage: EditText = view.findViewById(R.id.etLanguage)
val btnPresets: ImageButton = view.findViewById(R.id.btnPresets)
val tvLanguage: TextView = view.findViewById(R.id.tvLanguage)
val rbShow: RadioButton = view.findViewById(R.id.rbShow)
val rbHide: RadioButton = view.findViewById(R.id.rbHide)
when (item?.allow ?: true) {
true -> rbShow.isChecked = true
else -> rbHide.isChecked = true
}
fun updateDesc() {
val code = etLanguage.text.toString().trim()
val desc = activity.languageNameMap[code] ?: activity.getString(R.string.custom)
tvLanguage.text = desc
}
val languageList =
activity.languageNameMap.map { MyItem(it.key, true) }.sortedWith(languageComparator)
btnPresets.setOnClickListener {
activity.run {
launchAndShowError {
actionsDialog(getString(R.string.presets)) {
for (a in languageList) {
action("${a.code} ${activity.getDesc(a)}") {
etLanguage.setText(a.code)
updateDesc()
}
}
}
}
}
}
etLanguage.setText(item?.code ?: "")
updateDesc()
etLanguage.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(p0: Editable?) {
updateDesc()
}
override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {
}
})
if (item != null) {
etLanguage.isEnabledAlpha = false
btnPresets.setEnabledColor(
activity,
R.drawable.ic_edit,
activity.attrColor(R.attr.colorTextContent),
false
)
}
val builder = AlertDialog.Builder(activity)
.setView(view)
.setCancelable(true)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok) { _, _ ->
callback.onOK(etLanguage.text.toString().trim(), rbShow.isChecked)
}
if (item != null && item.code != TootStatus.LANGUAGE_CODE_DEFAULT) {
builder.setNeutralButton(R.string.delete) { _, _ ->
callback.onDelete(etLanguage.text.toString().trim())
}
}
builder.show()
}
}
@Suppress("BlockingMethodInNonBlockingContext")
private fun export() {
launchProgress(
"export language filter",
doInBackground = {
val data = JsonObject().apply {
for (item in languageList) {
put(item.code, item.allow)
}
}
.toString()
.encodeUTF8()
val cacheDir = this@ActLanguageFilter.cacheDir
cacheDir.mkdir()
val file = File(
cacheDir,
"SubwayTooter-language-filter.${Process.myPid()}.${Process.myTid()}.json"
)
FileOutputStream(file).use {
it.write(data)
}
file
},
afterProc = {
val uri = FileProvider.getUriForFile(
this@ActLanguageFilter,
FILE_PROVIDER_AUTHORITY,
it
)
val intent = Intent(Intent.ACTION_SEND)
intent.type = contentResolver.getType(uri)
intent.putExtra(Intent.EXTRA_SUBJECT, "SubwayTooter language filter data")
intent.putExtra(Intent.EXTRA_STREAM, uri)
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
arExport.launch(intent)
}
)
}
private fun import() {
arImport.launch(intentOpenDocument("*/*"))
}
@Suppress("BlockingMethodInNonBlockingContext")
private fun import2(uri: Uri) {
launchProgress(
"import language filter",
doInBackground = {
log.d("import2 type=${contentResolver.getType(uri)}")
try {
contentResolver.openInputStream(uri)!!.use {
it.readBytes().decodeUTF8().decodeJsonObject()
}
} catch (ex: Throwable) {
showToast(ex, "openInputStream failed.")
null
}
},
afterProc = { load(it) }
)
}
private fun confirmBack() {
if (equalsLanguageList(column.languageFilter, encodeLanguageList())) {
finish()
} else {
AlertDialog.Builder(this)
.setMessage(R.string.language_filter_quit_waring)
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.setNegativeButton(R.string.cancel, null)
.show()
}
}
}

View File

@ -90,6 +90,7 @@ import jp.juggler.subwaytooter.pref.PrefS
import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.span.MyClickableSpanHandler
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.ui.languageFilter.LanguageFilterActivity
import jp.juggler.subwaytooter.util.DecodeOptions.Companion.reloadEmojiScale
import jp.juggler.subwaytooter.util.EmojiDecoder
import jp.juggler.subwaytooter.util.openBrowser
@ -309,11 +310,10 @@ class ActMain : AppCompatActivity(),
}
val arLanguageFilter = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
appState.saveColumnList()
r.data?.int(ActLanguageFilter.EXTRA_COLUMN_INDEX)
?.let { appState.column(it) }
?.onLanguageFilterChanged()
LanguageFilterActivity.decodeResult(r)?.let { columnIndex ->
appState.saveColumnList()
appState.column(columnIndex) ?.onLanguageFilterChanged()
}
}
val arNickname = ActivityResultHandler(log) { r ->

View File

@ -3,7 +3,6 @@ package jp.juggler.subwaytooter.columnviewholder
import android.view.View
import android.widget.CompoundButton
import jp.juggler.subwaytooter.ActColumnCustomize
import jp.juggler.subwaytooter.ActLanguageFilter
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.action.accountResendConfirmMail
@ -12,7 +11,13 @@ import jp.juggler.subwaytooter.action.notificationDeleteAll
import jp.juggler.subwaytooter.actmain.closeColumn
import jp.juggler.subwaytooter.actmain.closeColumnAll
import jp.juggler.subwaytooter.api.entity.TootAnnouncement
import jp.juggler.subwaytooter.column.*
import jp.juggler.subwaytooter.column.Column
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.column.addColumnViewHolder
import jp.juggler.subwaytooter.column.fireShowContent
import jp.juggler.subwaytooter.column.isSearchColumn
import jp.juggler.subwaytooter.column.startLoading
import jp.juggler.subwaytooter.ui.languageFilter.LanguageFilterActivity.Companion.openLanguageFilterActivity
import jp.juggler.util.log.showToast
import jp.juggler.util.log.withCaption
import jp.juggler.util.ui.hideKeyboard
@ -252,9 +257,9 @@ fun ColumnViewHolder.onClickImpl(v: View?) {
btnLanguageFilter ->
activity.appState.columnIndex(column)?.let { colIdx ->
activity.arLanguageFilter.launch(
ActLanguageFilter.createIntent(activity, colIdx)
openLanguageFilterActivity(
activity.arLanguageFilter,
colIdx
)
}

View File

@ -1,12 +1,12 @@
package jp.juggler.subwaytooter.dialog
import android.annotation.SuppressLint
import android.app.Activity
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.view.View
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.databinding.DlgConfirmBinding
import jp.juggler.util.ui.dismissSafe
@ -16,57 +16,8 @@ import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object DlgConfirm {
// interface Callback {
// var isConfirmEnabled: Boolean
//
// fun onOK()
// }
// @SuppressLint("InflateParams")
// fun open(activity: Activity, message: String, callback: Callback): Dialog {
//
// if (!callback.isConfirmEnabled) {
// callback.onOK()
// return
// }
//
// val view = activity.layoutInflater.inflate(R.layout.dlg_confirm, null, false)
// val tvMessage = view.findViewById<TextView>(R.id.tvMessage)
// val cbSkipNext = view.findViewById<CheckBox>(R.id.cbSkipNext)
// tvMessage.text = message
//
// AlertDialog.Builder(activity)
// .setView(view)
// .setCancelable(true)
// .setNegativeButton(R.string.cancel, null)
// .setPositiveButton(R.string.ok) { _, _ ->
// if (cbSkipNext.isChecked) {
// callback.isConfirmEnabled = false
// }
// callback.onOK()
// }
// .show()
// }
// @SuppressLint("InflateParams")
// fun openSimple(activity: Activity, message: String, callback: () -> Unit) {
// val view = activity.layoutInflater.inflate(R.layout.dlg_confirm, null, false)
// val tvMessage = view.findViewById<TextView>(R.id.tvMessage)
// val cbSkipNext = view.findViewById<CheckBox>(R.id.cbSkipNext)
// tvMessage.text = message
// cbSkipNext.visibility = View.GONE
//
// AlertDialog.Builder(activity)
// .setView(view)
// .setCancelable(true)
// .setNegativeButton(R.string.cancel, null)
// .setPositiveButton(R.string.ok) { _, _ -> callback() }
// .show()
// }
@SuppressLint("InflateParams")
suspend inline fun AppCompatActivity.confirm(
suspend inline fun Activity.confirm(
message: String,
isConfirmEnabled: Boolean,
setConfirmEnabled: (newConfirmEnabled: Boolean) -> Unit,
@ -94,10 +45,10 @@ object DlgConfirm {
if (skipNext) setConfirmEnabled(false)
}
suspend fun AppCompatActivity.confirm(@StringRes messageId: Int, vararg args: Any?) =
suspend fun Activity.confirm(@StringRes messageId: Int, vararg args: Any?) =
confirm(getString(messageId, *args))
suspend fun AppCompatActivity.confirm(message: CharSequence, title: CharSequence? = null) {
suspend fun Activity.confirm(message: CharSequence, title: CharSequence? = null) {
suspendCancellableCoroutine { cont ->
try {
val views = DlgConfirmBinding.inflate(layoutInflater)
@ -124,10 +75,10 @@ object DlgConfirm {
}
}
suspend fun AppCompatActivity.okDialog(@StringRes messageId: Int, vararg args: Any?) =
suspend fun Activity.okDialog(@StringRes messageId: Int, vararg args: Any?) =
okDialog(getString(messageId, *args))
suspend fun AppCompatActivity.okDialog(message: CharSequence, title: CharSequence? = null) {
suspend fun Activity.okDialog(message: CharSequence, title: CharSequence? = null) {
suspendCancellableCoroutine { cont ->
try {
val views = DlgConfirmBinding.inflate(layoutInflater)

View File

@ -0,0 +1,452 @@
package jp.juggler.subwaytooter.ui.languageFilter
import android.app.Application
import android.content.Intent
import android.os.Bundle
import android.os.PersistableBundle
import android.view.Window
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResult
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeightIn
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.MoreVert
import androidx.compose.material.icons.outlined.Save
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.dialog.DlgConfirm.confirm
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.pref.FILE_PROVIDER_AUTHORITY
import jp.juggler.subwaytooter.util.StColorScheme
import jp.juggler.subwaytooter.util.collectOnLifeCycle
import jp.juggler.subwaytooter.util.dummyStColorTheme
import jp.juggler.subwaytooter.util.fireBackPressed
import jp.juggler.subwaytooter.util.getStColorTheme
import jp.juggler.subwaytooter.util.provideViewModel
import jp.juggler.util.backPressed
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.data.checkMimeTypeAndGrant
import jp.juggler.util.data.intentOpenDocument
import jp.juggler.util.int
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showError
import jp.juggler.util.ui.ActivityResultHandler
import jp.juggler.util.ui.isNotOk
import jp.juggler.util.ui.isOk
import jp.juggler.util.ui.launch
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class LanguageFilterActivity : ComponentActivity() {
companion object {
private val log = LogCategory("LanguageFilterActivity")
fun openLanguageFilterActivity(
launcher: ActivityResultHandler,
columnIndex: Int,
) {
Intent(
launcher.context
?: error("openLanguageFilterActivity: launcher is not registered."),
LanguageFilterActivity::class.java
).apply {
putExtra(LanguageFilterViewModel.EXTRA_COLUMN_INDEX, columnIndex)
}.launch(launcher)
}
fun decodeResult(r: ActivityResult): Int? =
when {
r.isOk -> r.data?.int(LanguageFilterViewModel.EXTRA_COLUMN_INDEX)
else -> null
}
}
private val viewModel by lazy {
provideViewModel(this) {
LanguageFilterViewModel(application)
}
}
private val stColorScheme by lazy {
getStColorTheme()
}
private val arImport = ActivityResultHandler(log) { r ->
if (r.isNotOk) return@ActivityResultHandler
r.data?.checkMimeTypeAndGrant(contentResolver)
?.firstOrNull()?.uri?.let {
viewModel.import2(it)
}
}
override fun onSaveInstanceState(outState: Bundle, outPersistentState: PersistableBundle) {
super.onSaveInstanceState(outState, outPersistentState)
viewModel.saveState(outState)
}
override fun onCreate(savedInstanceState: Bundle?) {
arImport.register(this)
super.onCreate(savedInstanceState)
App1.setActivityTheme(this)
backPressed {
launchAndShowError {
if (viewModel.isLanguageListChanged()) {
confirm(R.string.language_filter_quit_waring)
}
finish()
}
}
viewModel.restoreOrInitialize(
this,
savedInstanceState,
intent,
)
// ステータスバーの色にattr色を使っているので、テーマの指定は必要
requestWindowFeature(Window.FEATURE_NO_TITLE)
App1.setActivityTheme(this)
val stColorScheme = getStColorTheme()
setContent {
Screen(
viewModel = viewModel,
stColorScheme = stColorScheme,
languageListFlow = viewModel.languageList,
progressMessageFlow = viewModel.progressMessage,
saveAction = {
setResult(RESULT_OK, viewModel.save())
finish()
},
getDisplayName = { langDesc(it, viewModel.languageNameMap) },
)
}
collectOnLifeCycle(viewModel.error) {
it ?: return@collectOnLifeCycle
showError(it)
}
}
private suspend fun edit(myItem: LanguageFilterItem?) {
val result = dialogLanguageFilterEdit(
myItem,
viewModel.languageNameMap,
stColorScheme,
)
viewModel.handleEditResult(result)
}
/**
* 言語フィルタのエクスポート
* - 現在のデータをjsonエンコードする
* - 保存先アプリに渡す
* 保存自体はすぐ終わるのでプログレス表示やFlow経由の結果受取はない
*/
suspend fun export() {
val file = withContext(Dispatchers.IO) {
viewModel.createExportCacheFile()
}
val uri = FileProvider.getUriForFile(
this@LanguageFilterActivity,
FILE_PROVIDER_AUTHORITY,
file,
)
val intent = Intent(Intent.ACTION_SEND).apply {
type = contentResolver.getType(uri)
putExtra(Intent.EXTRA_SUBJECT, "SubwayTooter language filter data")
putExtra(Intent.EXTRA_STREAM, uri)
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
}
@Preview
@Composable
fun DefaultPreview() {
Screen(
viewModel = LanguageFilterViewModel(Application()),
stColorScheme = dummyStColorTheme(),
languageListFlow = MutableStateFlow(
listOf(
LanguageFilterItem(
code = TootStatus.LANGUAGE_CODE_DEFAULT,
allow = true,
),
LanguageFilterItem(
code = TootStatus.LANGUAGE_CODE_UNKNOWN,
allow = false,
),
LanguageFilterItem(
code = "xxx",
allow = true,
),
)
),
progressMessageFlow = MutableStateFlow(
StringResAndArgs(R.string.wait_previous_operation)
),
saveAction = {},
getDisplayName = { "言語の名前" },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun Screen(
viewModel: LanguageFilterViewModel,
stColorScheme: StColorScheme,
languageListFlow: StateFlow<List<LanguageFilterItem>>,
progressMessageFlow: StateFlow<StringResAndArgs?>,
saveAction: () -> Unit,
getDisplayName: (String) -> String,
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val progressMessageState = progressMessageFlow.collectAsState()
MaterialTheme(colorScheme = stColorScheme.materialColorScheme) {
CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) {
Scaffold(
snackbarHost = {
SnackbarHost(hostState = snackbarHostState)
},
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(),
title = {
Text(stringResource(R.string.language_filter))
},
navigationIcon = {
IconButton(
onClick = {
fireBackPressed()
}
) {
Icon(
// imageVector = AutoMirrored.Outlined.ArrowBack,
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.close)
)
}
},
actions = {
IconButton(
onClick = {
scope.launch {
try {
edit(null)
} catch (ex: Throwable) {
showError(ex)
}
}
},
) {
Icon(
Icons.Outlined.Add,
contentDescription = stringResource(R.string.add),
)
}
IconButton(
onClick = { saveAction() }
) {
Icon(
imageVector = Icons.Outlined.Save,
contentDescription = stringResource(R.string.close)
)
}
IconButton(
onClick = {
scope.launch {
try {
actionsDialog {
action(getString(R.string.clear_all)) {
viewModel.clearAllLanguage()
}
action(getString(R.string.export)) {
scope.launch {
try {
export()
} catch (ex: Throwable) {
showError(ex)
}
}
}
action(getString(R.string.import_)) {
arImport.launch(intentOpenDocument("*/*"))
}
}
} catch (ex: Throwable) {
showError(ex)
}
}
},
) {
Icon(
Icons.Outlined.MoreVert,
contentDescription = stringResource(R.string.more),
)
}
}
)
},
) { innerPadding ->
ScrollContent(
scope = scope,
innerPadding = innerPadding,
languageListFlow = languageListFlow,
getDisplayName = getDisplayName,
stColorScheme = stColorScheme,
)
val progressMessage = progressMessageState.value?.let {
stringResource(it.stringId, *it.args)
}
if (progressMessage != null) {
ProgressCircleAndText(stColorScheme, progressMessage)
}
}
}
}
}
@Composable
fun ProgressCircleAndText(
stColorScheme: StColorScheme,
progressMessage: String,
) {
Column(
modifier = Modifier.fillMaxSize().clickable {
// 奥の要素をクリックできなくする
}.background(
color = stColorScheme.colorProgressBackground,
),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(160.dp),
)
Spacer(modifier = Modifier.size(20.dp))
Text(progressMessage)
}
}
@Composable
fun ScrollContent(
stColorScheme: StColorScheme,
scope: CoroutineScope,
innerPadding: PaddingValues,
languageListFlow: StateFlow<List<LanguageFilterItem>>,
getDisplayName: (String) -> String,
) {
val stateLanguageList = languageListFlow.collectAsState()
LazyColumn(
modifier = Modifier.padding(innerPadding).fillMaxSize(),
) {
items(stateLanguageList.value) {
LanguageItemCard(
it,
scope,
getDisplayName,
)
HorizontalDivider(
color = stColorScheme.colorDivider,
thickness = 1.dp
)
}
}
}
@Composable
fun LanguageItemCard(
item: LanguageFilterItem,
scope: CoroutineScope,
getDisplayName: (String) -> String,
) {
Row(
modifier = Modifier.fillMaxWidth()
// .requiredHeightIn(min = 48.dp)
.clickable {
scope.launch {
try {
edit(item)
} catch (ex: Throwable) {
showError(ex)
}
}
},
) {
Text(
modifier = Modifier
.requiredHeightIn(min = 56.dp)
.wrapContentHeight()
.padding(
horizontal = 12.dp,
vertical = 6.dp,
),
text = "${
item.code
} ${
getDisplayName(item.code)
} : ${
stringResource(
when {
item.allow -> R.string.language_show
else -> R.string.language_hide
}
)
}",
color = when (item.allow) {
true -> MaterialTheme.colorScheme.onBackground
false -> MaterialTheme.colorScheme.error
}
)
}
}
}

View File

@ -0,0 +1,99 @@
package jp.juggler.subwaytooter.ui.languageFilter
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AlertDialog
import androidx.compose.ui.graphics.toArgb
import androidx.core.widget.addTextChangedListener
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.databinding.DlgLanguageFilterBinding
import jp.juggler.subwaytooter.dialog.actionsDialog
import jp.juggler.subwaytooter.util.StColorScheme
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.ui.dismissSafe
import jp.juggler.util.ui.isEnabledAlpha
import jp.juggler.util.ui.setEnabledColor
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resumeWithException
sealed interface LanguageFilterEditResult {
class Update(val code: String, val allow: Boolean) : LanguageFilterEditResult
class Delete(val code: String) : LanguageFilterEditResult
}
/**
* 言語コード1つを追加/編集/削除するダイアログ
*/
suspend fun ComponentActivity.dialogLanguageFilterEdit(
// 既存項目の編集時は非null
item: LanguageFilterItem?,
// 言語コード→表示名のマップ
nameMap: Map<String, LanguageInfo>,
// 色スキーマ
stColorScheme: StColorScheme,
): LanguageFilterEditResult = suspendCancellableCoroutine { cont ->
val views = DlgLanguageFilterBinding.inflate(layoutInflater, null, false)
views.apply {
fun updateDesc() {
val code = etLanguage.text.toString().trim()
tvLanguage.text = nameMap[code]?.displayName ?: getString(R.string.custom)
}
when (item?.allow ?: true) {
true -> rbShow.isChecked = true
else -> rbHide.isChecked = true
}
btnPresets.setOnClickListener {
launchAndShowError {
actionsDialog(getString(R.string.presets)) {
val languageList = nameMap.map {
LanguageFilterItem(it.key, true)
}.sortedWith(languageFilterItemComparator)
for (a in languageList) {
action("${a.code} ${langDesc(a.code, nameMap)}") {
etLanguage.setText(a.code)
updateDesc()
}
}
}
}
}
etLanguage.addTextChangedListener { updateDesc() }
etLanguage.setText(item?.code ?: "")
updateDesc()
// 編集時は言語コードを変更できない
etLanguage.isEnabledAlpha = item == null
btnPresets.setEnabledColor(
btnPresets.context,
R.drawable.ic_edit,
stColorScheme.colorTextContent.toArgb(),
item == null
)
fun getCode() = etLanguage.text.toString().trim()
fun isAllow() = rbShow.isChecked
AlertDialog.Builder(this@dialogLanguageFilterEdit).apply {
setView(views.root)
setCancelable(true)
setNegativeButton(R.string.cancel, null)
setPositiveButton(R.string.ok) { _, _ ->
if (cont.isActive) cont.resume(
LanguageFilterEditResult.Update(getCode(), isAllow())
) {}
}
if (item != null && item.code != TootStatus.LANGUAGE_CODE_DEFAULT) {
setNeutralButton(R.string.delete) { _, _ ->
if (cont.isActive) cont.resume(
LanguageFilterEditResult.Delete(item.code)
) {}
}
}
}.create().also { dialog ->
dialog.setOnDismissListener {
if (cont.isActive) cont.resumeWithException(CancellationException())
}
cont.invokeOnCancellation { dialog.dismissSafe() }
dialog.show()
}
}
}

View File

@ -0,0 +1,18 @@
package jp.juggler.subwaytooter.ui.languageFilter
import jp.juggler.subwaytooter.api.entity.TootStatus
data class LanguageFilterItem(
val code: String,
var allow: Boolean,
)
val languageFilterItemComparator = Comparator<LanguageFilterItem> { a, b ->
when {
a.code == TootStatus.LANGUAGE_CODE_DEFAULT -> -1
b.code == TootStatus.LANGUAGE_CODE_DEFAULT -> 1
a.code == TootStatus.LANGUAGE_CODE_UNKNOWN -> -1
b.code == TootStatus.LANGUAGE_CODE_UNKNOWN -> 1
else -> a.code.compareTo(b.code)
}
}

View File

@ -0,0 +1,258 @@
package jp.juggler.subwaytooter.ui.languageFilter
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Process
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.AppState
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.column.Column
import jp.juggler.util.data.JsonObject
import jp.juggler.util.data.buildJsonObject
import jp.juggler.util.data.decodeJsonObject
import jp.juggler.util.data.decodeUTF8
import jp.juggler.util.data.encodeUTF8
import jp.juggler.util.int
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
class LanguageFilterViewModel(
application: Application,
) : AndroidViewModel(application) {
companion object {
private val log = LogCategory("LanguageFilterViewModel")
private const val STATE_LANGUAGE_LIST = "language_list"
const val EXTRA_COLUMN_INDEX = "column_index"
}
private var mastodonLanguageJsonCache: String? = null
private val context: Context
get() = getApplication()
lateinit var appState: AppState
private var columnIndex: Int = 0
private lateinit var column: Column
// 編集中の言語リスト
private val _languageList = MutableStateFlow<List<LanguageFilterItem>>(emptyList())
val languageList = _languageList.asStateFlow()
// 言語コードと名前の対応表
var languageNameMap: Map<String, LanguageInfo> = emptyMap()
// エラーイベント
private val _error = Channel<Throwable?>(capacity = Channel.CONFLATED)
val error = _error.receiveAsFlow()
// ロード中表示の文字列
private val _progressMessage = MutableStateFlow<StringResAndArgs?>(null)
val progressMessage = _progressMessage.asStateFlow()
fun saveState(outState: Bundle) {
outState.putString(STATE_LANGUAGE_LIST, encodeLanguageList().toString())
}
fun restoreOrInitialize(
activityContext: Context,
savedInstanceState: Bundle?,
intent: Intent?,
) {
appState = App1.getAppState(context)
columnIndex = intent?.int(EXTRA_COLUMN_INDEX) ?: 0
column = appState.column(columnIndex) ?: error("missing column[$columnIndex]")
viewModelScope.launch {
try {
if (mastodonLanguageJsonCache == null) {
_progressMessage.value = StringResAndArgs(R.string.language_filter_loading)
mastodonLanguageJsonCache = try {
val accessInfo = column.accessInfo
if (accessInfo.isNA) {
"na"
} else if (accessInfo.isPseudo) {
loadMastodonLanguages(accessInfo.apiHost)
} else {
loadMastodonLanguages(accessInfo.apiHost, accessInfo.bearerAccessToken)
}
} catch (ex: Throwable) {
"error"
}
}
// 端末のconfiguration changeがありうるので、言語マップはActivityのonCreateのたびに再取得する
languageNameMap = withContext(Dispatchers.IO) {
getLanguageNames(context, mastodonLanguageJsonCache).apply {
val specDefault = LanguageInfo(
code = TootStatus.LANGUAGE_CODE_DEFAULT,
name = TootStatus.LANGUAGE_CODE_DEFAULT,
displayName = activityContext.getString(R.string.language_code_default),
)
val specUnknown = LanguageInfo(
code = TootStatus.LANGUAGE_CODE_UNKNOWN,
name = TootStatus.LANGUAGE_CODE_UNKNOWN,
displayName = activityContext.getString(R.string.language_code_unknown)
)
put(specDefault.code, specDefault)
put(specUnknown.code, specUnknown)
}
}
// 状態の復元
try {
savedInstanceState?.getString(STATE_LANGUAGE_LIST, null)
?.decodeJsonObject()
?.let { load(it) }
} catch (ex: Throwable) {
log.e(ex, "restore failed.")
}
// 未初期化なら初期データのロード
if (languageList.value.isEmpty()) {
load(column.languageFilter)
}
} catch (ex: Throwable) {
_error.send(ex)
} finally {
_progressMessage.value = null
}
}
}
private fun load(src: JsonObject?) {
_languageList.value = buildList<LanguageFilterItem> {
if (src != null) {
for (key in src.keys) {
add(LanguageFilterItem(key, src.boolean(key) ?: true))
}
}
if (none { it.code == TootStatus.LANGUAGE_CODE_DEFAULT }) {
add(LanguageFilterItem(TootStatus.LANGUAGE_CODE_DEFAULT, true))
}
log.i("load: list size=$size")
}.sortedWith(languageFilterItemComparator)
}
fun createExportCacheFile(): File {
val bytes = JsonObject().apply {
for (item in _languageList.value) {
put(item.code, item.allow)
}
}.toString().encodeUTF8()
val cacheDir = context.cacheDir
cacheDir.mkdirs()
val file = File(
cacheDir,
"SubwayTooter-language-filter.${Process.myPid()}.${Process.myTid()}.json"
)
FileOutputStream(file).use {
it.write(bytes)
}
return file
}
@Suppress("BlockingMethodInNonBlockingContext")
fun import2(uri: Uri) {
viewModelScope.launch {
try {
_progressMessage.value = StringResAndArgs(R.string.language_filter_importing)
val jsonObject = withContext(Dispatchers.IO) {
val resolver = context.contentResolver
log.i("import2 type=${resolver.getType(uri)}")
(resolver.openInputStream(uri) ?: error("openInputStream returns null"))
.use {
it.readBytes().decodeUTF8().decodeJsonObject()
}
}
load(jsonObject)
} catch (ex: Throwable) {
log.e(ex, "import2 failed.")
_error.send(ex)
} finally {
_progressMessage.value = null
}
}
}
fun save(): Intent {
column.languageFilter = encodeLanguageList()
return Intent().apply {
putExtra(EXTRA_COLUMN_INDEX, columnIndex)
}
}
// UIのデータをJsonObjectにエンコード
private fun encodeLanguageList() = buildJsonObject {
for (item in languageList.value) {
put(item.code, item.allow)
}
}
fun isLanguageListChanged(): Boolean {
fun JsonObject.encodeSorted(): String {
val clone = this.toString().decodeJsonObject()
if (!clone.contains(TootStatus.LANGUAGE_CODE_DEFAULT)) {
clone[TootStatus.LANGUAGE_CODE_DEFAULT] = true
}
return clone.keys.sorted().joinToString(",") { "$it=${this[it]}" }
}
val current = (encodeLanguageList()).encodeSorted()
val initial = (column.languageFilter ?: JsonObject()).encodeSorted()
return current != initial
}
fun handleEditResult(result: LanguageFilterEditResult) {
_languageList.value = when (result) {
is LanguageFilterEditResult.Delete ->
_languageList.value.filter { it.code != result.code }
is LanguageFilterEditResult.Update -> when (
val item = languageList.value.find { it.code == result.code }
) {
null -> (_languageList.value + listOf(
LanguageFilterItem(
result.code,
result.allow
)
))
.sortedWith(languageFilterItemComparator)
else -> {
item.allow = result.allow
_languageList.value
}
}
}
}
// 言語フィルタを初期状態に戻す
fun clearAllLanguage() {
viewModelScope.launch {
try {
_languageList.value = listOf(
LanguageFilterItem(TootStatus.LANGUAGE_CODE_DEFAULT, true)
)
} catch (ex: Throwable) {
log.e(ex, "clearAllLanguage failed.")
_error.send(ex)
} finally {
_progressMessage.value = null
}
}
}
}

View File

@ -0,0 +1,142 @@
package jp.juggler.subwaytooter.ui.languageFilter
import android.content.Context
import jp.juggler.subwaytooter.App1
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.util.data.decodeJsonArray
import jp.juggler.util.data.loadRawResource
import jp.juggler.util.data.notBlank
import jp.juggler.util.log.LogCategory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import ru.gildor.coroutines.okhttp.await
import java.util.Locale
private val log = LogCategory("GetLanguageNames")
data class LanguageInfo(
// code,name from https://mastodon.social/api/v1/instance/languages
val code: String,
val name: String,
// displayName from locale
var displayName: String = "",
) {
val locales = HashSet<Locale>()
}
fun Context.langDesc(code: String, nameMap: Map<String, LanguageInfo>) =
nameMap[code]?.displayName ?: getString(R.string.custom)
suspend fun loadMastodonLanguages(
apiHost: Host,
accessToken: String? = null,
) = withContext(Dispatchers.IO) {
val request = Request.Builder().apply {
url("https://${apiHost.ascii}/api/v1/instance/languages")
get()
accessToken?.notBlank()?.let {
header("Authorization", "Bearer $it")
}
}.build()
val response = App1.ok_http_client.newCall(request).await()
if (!response.isSuccessful) error("HTTP ${response.code}")
response.body.string()
}
private val languageStringIds = buildMap {
put("cnd", R.string.language_name_montenegrin)
}
fun mergeLanguageName(
context: Context,
jsonString: String,
) = HashMap<String, LanguageInfo>().also { dst ->
// read from mastodon languages
for (it in jsonString.decodeJsonArray().objectList()) {
val code = it.string("code")?.notBlank() ?: error("missing item.code")
val name = it.string("name")?.notBlank() ?: error("missing item.name")
dst[code] = LanguageInfo(code = code, name = name)
}
// using Locale.forLanguageTag to find Java Locale
for (item in dst.values) {
val locale = Locale.forLanguageTag(item.code)
log.i("code=${item.code} locale=$locale lang=${locale.language} displayName=${locale.displayName}")
// 2018年に、ISO 639-2およびISO 639-3にモンテネグロ語の言語コード(cnr)が追加された
// Android Javaには定義がないが、この場合にdisplayNameは言語コードと同じ値になる
if (locale.displayName == item.code || locale.displayName.isNullOrBlank()) continue
item.locales.add(locale)
}
// 今度はJavaのロケールを順に処理する
loop@ for (locale in Locale.getAvailableLocales().sortedBy { it.toLanguageTag() }) {
// 言語名が空やundはスキップ
if (locale.language.isEmpty() || locale.language == "und") continue
// 既に対応するmastodon言語コードが判明している
if (dst.values.any { c -> c.locales.any { it == locale } }) continue
// language名またはlanguage tagで検索
for (code in arrayOf(locale.language, locale.toLanguageTag())) {
val item = dst.values.find { it.code.equals(code, ignoreCase = true) }
if (item != null) {
item.locales.add(locale)
continue@loop
}
}
// log.w("Java locale not match to mastodon lang list. ${locale.displayLanguage}(${locale.language}, ${locale.toLanguageTag()})")
}
// 互換性があるなかで最も言語タグが短いロケールの表示名を使う
// fallback 1: 言語コード別の文字列リソースを参照する
// fallback 2: mastodon api のフォールバック用のname
// fallback 3: "?"
for (item in dst.values) {
val locale = item.locales.sortedBy { it.toLanguageTag() }.firstOrNull()
item.displayName = locale?.displayName?.notBlank()
?: languageStringIds[item.code]?.let { context.getString(it) }
?: item.name.notBlank()
?: "?"
}
return dst
}
/**
* - Mastodonの言語リストをロードするなければフォールバックのrawリソースを読む
* - 言語リストを
* 現在の表示言語にあう言語コード名前マップを返す
*/
fun getLanguageNames(
context: Context,
jsonString: String?,
): HashMap<String, LanguageInfo> {
if (jsonString?.contains("{") == true) {
try {
val map = mergeLanguageName(
context,
jsonString,
)
if (map.isEmpty()) error("map is empty. (1)")
return map
} catch (ex: Throwable) {
log.e(ex, "loadMastodonLanguages failed.")
}
}
// fallback
try {
val map = mergeLanguageName(
context,
context.loadRawResource(R.raw.languages_fallback)
.decodeToString()
)
if (map.isEmpty()) error("map is empty. (2)")
return map
} catch (ex: Throwable) {
log.e(ex, "loadRawResource failed.")
}
// error
return HashMap()
}

View File

@ -0,0 +1,20 @@
package jp.juggler.subwaytooter.ui.languageFilter
import android.content.Context
import androidx.annotation.StringRes
class StringResAndArgs(
@StringRes val stringId: Int,
// composeのstringResource ではnullを受け付けなくなった
vararg val args: Any,
) {
fun toCharSequence(context: Context) = when {
args.isNotEmpty() -> context.getString(stringId, *args)
else -> context.getText(stringId)
}
fun toString(context: Context) = when {
args.isNotEmpty() -> context.getString(stringId, *args)
else -> context.getString(stringId)
}
}

View File

@ -2,6 +2,7 @@ package jp.juggler.subwaytooter.util
import android.app.Activity
import android.content.Context
import androidx.activity.ComponentActivity
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
@ -14,6 +15,10 @@ import jp.juggler.util.ui.resColor
class StColorScheme(
val materialColorScheme: ColorScheme,
val colorTextLink: Color,
val colorTextContent: Color,
val colorTextError: Color,
val colorProgressBackground:Color,
val colorDivider: Color,
)
fun Context.createStColorSchemeLight(): StColorScheme {
@ -33,13 +38,16 @@ fun Context.createStColorSchemeLight(): StColorScheme {
secondary = colorTextLink,
onSecondary = Color.White,
surface = Color(resColor(R.color.Light_colorColumnSettingBackground)),
surface = Color(resColor(R.color.Light_colorReplyBackground)),
onSurface = colorTextContent,
onSurfaceVariant = colorTextContent,
onTertiary = colorTextContent,
onSurfaceVariant = Color(resColor(R.color.Light_colorTextHint)),
),
colorTextLink = colorTextLink,
colorTextContent = colorTextContent,
colorTextError = colorTextError,
colorProgressBackground = Color(0xC0000000),
colorDivider = Color(resColor(R.color.Light_colorTextDivider)),
)
}
@ -61,13 +69,16 @@ fun Context.createStColorSchemeDark(): StColorScheme {
secondary = colorTextLink,
onSecondary = colorTextContent,
surface = Color(resColor(R.color.Dark_colorColumnSettingBackground)),
surface = Color(resColor(R.color.Dark_colorColumnHeader)),
onSurface = colorTextContent,
onSurfaceVariant = colorTextContent,
onTertiary = colorTextContent,
onSurfaceVariant = Color(resColor(R.color.Dark_colorTextHint)),
),
colorTextLink = colorTextLink,
colorTextContent = colorTextContent,
colorTextError = colorTextError,
colorProgressBackground = Color(0xC0000000),
colorDivider = Color(resColor(R.color.Dark_colorSettingDivider)),
)
}
@ -89,13 +100,16 @@ fun Context.createStColorSchemeMastodonDark(): StColorScheme {
secondary = colorTextLink,
onSecondary = colorTextContent,
surface = Color(resColor(R.color.Mastodon_colorColumnSettingBackground)),
surface = Color(resColor(R.color.Mastodon_colorColumnHeader)),
onSurface = colorTextContent,
onSurfaceVariant = colorTextContent,
onTertiary = colorTextContent,
onSurfaceVariant = Color(resColor(R.color.Mastodon_colorTextHint)),
),
colorTextLink = colorTextLink,
colorTextContent = colorTextContent,
colorTextError = colorTextError,
colorProgressBackground = Color(0xC0000000),
colorDivider = Color(resColor(R.color.Mastodon_colorSettingDivider)),
)
}
@ -112,5 +126,12 @@ fun Activity.getStColorTheme(forceDark: Boolean = false): StColorScheme {
fun dummyStColorTheme() = StColorScheme(
materialColorScheme = darkColorScheme(),
colorTextLink = Color.Cyan,
colorTextContent = Color.White,
colorTextLink = Color(0xff0080ff),
colorTextError = Color.Red,
colorProgressBackground = Color(0x80808080),
colorDivider = Color(0x80808080),
)
fun ComponentActivity.fireBackPressed() =
onBackPressedDispatcher.onBackPressed()

View File

@ -1,80 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@drawable/action_bar_bg"
android:elevation="4dp"
app:navigationIcon="?attr/homeAsUpIndicator" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:id="@+id/llContent"
android:orientation="vertical">
<ListView
android:id="@+id/listView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fadeScrollbars="false"
tools:ignore="NestedWeights" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorStatusButtonsPopupBg"
android:paddingBottom="6dp"
android:paddingEnd="12dp"
android:paddingStart="12dp"
android:paddingTop="6dp">
<ImageButton
android:id="@+id/btnAdd"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:contentDescription="@string/add"
android:elevation="3dp"
android:src="@drawable/ic_add"
android:textAllCaps="false" />
<Button
android:id="@+id/btnSave"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:elevation="3dp"
android:text="@string/save"
android:textAllCaps="false" />
<ImageButton
android:id="@+id/btnMore"
android:layout_width="48dp"
android:layout_height="48dp"
android:contentDescription="@string/more"
android:elevation="3dp"
android:src="@drawable/ic_more" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="6dp"
android:paddingEnd="12dp"
android:paddingStart="12dp"
android:text="@string/language_filter_description"
android:textSize="12sp"
/>
</LinearLayout>
</LinearLayout>

View File

@ -2,6 +2,7 @@
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="300dp"
android:layout_height="wrap_content"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
<TextView
@ -33,6 +34,7 @@
android:elevation="3dp"
android:src="@drawable/ic_edit"
android:id="@+id/btnPresets"
app:tint="?attr/colorTextContent"
android:contentDescription="@string/presets"
/>
</LinearLayout>

View File

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView
android:id="@android:id/text1"
style="?android:attr/spinnerDropDownItemStyle"
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:background="@drawable/btn_bg_transparent_round6dp"
/>

File diff suppressed because one or more lines are too long

View File

@ -1293,4 +1293,7 @@
<string name="post_404_desc">(もしくは、フォロー限定投稿にフォロー外から返信しようとしました)</string>
<string name="rearrange">並べ替え</string>
<string name="attachment_rearrange_desc">ドラッグで並べ替え</string>
<string name="language_filter_importing">言語フィルタの読み込み中です…</string>
<string name="language_filter_loading">利用可能な言語の取得中…</string>
<string name="language_name_montenegrin">モンテネグロ語</string>
</resources>

View File

@ -1305,4 +1305,7 @@
<string name="post_404_desc">(Or, you reply to follower-only post from other account)</string>
<string name="rearrange">rearrange</string>
<string name="attachment_rearrange_desc">Drag to rearrange.</string>
<string name="language_filter_importing">Importing language filter…</string>
<string name="language_filter_loading">Loading available languages…</string>
<string name="language_name_montenegrin">Montenegrin</string>
</resources>

File diff suppressed because one or more lines are too long

View File

@ -105,6 +105,10 @@ dependencies {
// HttpUtils で使う
implementation("com.squareup.okhttp3:okhttp:${Vers.okhttpVersion}")
// ないとなぜかIDE上にエラーが出る
implementation("androidx.activity:activity-ktx:${Vers.androidxActivity}")
// ==========================================================================
// 単体テスト
testImplementation(kotlin("test"))

View File

@ -11,6 +11,7 @@ import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.annotation.AnimRes
import androidx.appcompat.app.AppCompatActivity
@ -138,6 +139,12 @@ fun AppCompatActivity.backPressed(block: () -> Unit) {
})
}
fun ComponentActivity.backPressed(block: () -> Unit) {
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() = block()
})
}
// 型推論できる文脈だと型名を書かずにすむ
inline fun <reified T> systemService(context: Context): T? =
/* ContextCompat. */ ContextCompat.getSystemService(context, T::class.java)

View File

@ -1,5 +1,6 @@
package jp.juggler.util.coroutine
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import jp.juggler.util.log.LogCategory
@ -81,6 +82,25 @@ fun AppCompatActivity.launchAndShowError(
}
}
}
fun ComponentActivity.launchAndShowError(
errorCaption: String? = null,
block: suspend CoroutineScope.() -> Unit,
): Job = lifecycleScope.launch {
try {
block()
} catch (ex: Throwable) {
when (ex) {
is CancellationException -> {
log.w(errorCaption ?: "launchAndShowError cancelled.")
}
else -> {
log.e(ex, errorCaption ?: "launchAndShowError failed.")
showError(ex, errorCaption)
}
}
}
}
/////////////////////////////////////////////////////////////////////////

View File

@ -242,9 +242,8 @@ fun getStreamSize(bClose: Boolean, inStream: InputStream): Long {
// }
//}
fun Context.loadRawResource(@RawRes resId: Int): ByteArray {
return resources.openRawResource(resId).use { it.readBytes() }
}
fun Context.loadRawResource(@RawRes resId: Int): ByteArray =
resources.openRawResource(resId).use { it.readBytes() }
fun intentOpenDocument(mimeType: String): Intent {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)

View File

@ -3,6 +3,7 @@ package jp.juggler.util.ui
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@ -18,7 +19,7 @@ class ActivityResultHandler(
private var launcher: ActivityResultLauncher<Intent>? = null
private var getContext: (() -> Context?)? = null
private val context
val context
get() = getContext?.invoke()
// startForActivityResultの代わりに呼び出す
@ -37,6 +38,13 @@ class ActivityResultHandler(
ActivityResultContracts.StartActivityForResult()
) { callback(it) }
}
// onCreate時に呼び出す
fun register(a: ComponentActivity) {
getContext = { a.applicationContext }
this.launcher = a.registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { callback(it) }
}
}
fun Intent.launch(ar: ActivityResultHandler) = ar.launch(this)

View File

@ -35,6 +35,7 @@ object Vers {
const val androidxArchCoreTesting = "2.2.0"
const val androidxComposeRuntime = "1.6.3"
const val androidxComposeUi = "1.6.3"
const val androidxComposeMaterialIcons = "1.6.3"
const val androidxCore = "1.12.0"
const val androidxEmoji2 = "1.4.0"
const val androidxLifecycle = "2.7.0"