commit 7a5d6bbaf88ac9a72e8a5fdfa4ba7b35f0665576 Author: Lain Iwakura Date: Sat Aug 9 17:38:38 2025 +0300 первая версия которая СОБИРАЕТСЯ братья!! diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9e9ba09 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,607 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..d843f34 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..62b4a1f --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.serialization) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.mkfunproj.mobilemkch" + compileSdk = 35 + + defaultConfig { + applicationId = "com.mkfunproj.mobilemkch" + minSdk = 31 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.6.11" + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.appcompat) + implementation(libs.material) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.ui.tooling.preview) + implementation(libs.compose.material3) + implementation(libs.compose.foundation) + implementation(libs.compose.material) + implementation(libs.compose.material.icons.extended) + implementation(libs.activity.compose) + implementation(libs.navigation.compose) + implementation(libs.lifecycle.runtime.ktx) + implementation(libs.lifecycle.viewmodel.ktx) + implementation(libs.lifecycle.viewmodel.compose) + implementation(libs.retrofit) + implementation(libs.retrofit.converter.scalars) + implementation(libs.okhttp) + implementation(libs.okhttp.logging) + implementation(libs.serialization.json) + implementation(libs.coil.compose) + implementation(libs.datastore.preferences) + implementation(libs.work.runtime.ktx) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/mkfunproj/mobilemkch/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/mkfunproj/mobilemkch/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..6613480 --- /dev/null +++ b/app/src/androidTest/java/com/mkfunproj/mobilemkch/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.mkfunproj.mobilemkch + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.mkfunproj.mobilemkch", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..05b9f7b --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/MainActivity.kt b/app/src/main/java/com/mkfunproj/mobilemkch/MainActivity.kt new file mode 100644 index 0000000..5df7966 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/MainActivity.kt @@ -0,0 +1,37 @@ +package com.mkfunproj.mobilemkch + +import android.os.Bundle +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.getValue +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Modifier +import com.mkfunproj.mobilemkch.ui.AppRoot +import com.mkfunproj.mobilemkch.ui.theme.MobileMkchTheme +import com.mkfunproj.mobilemkch.workers.ThreadsCheckWorker +import java.util.concurrent.TimeUnit + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork("mkch_threads_check", ExistingPeriodicWorkPolicy.UPDATE, request) + setContent { + val settingsStore = com.mkfunproj.mobilemkch.data.SettingsStore(this) + val settings by settingsStore.data.collectAsState(initial = com.mkfunproj.mobilemkch.data.SettingsData()) + MobileMkchTheme(darkTheme = settings.theme == "dark" || settings.theme == "oled", oled = settings.theme == "oled") { + Surface(color = MaterialTheme.colorScheme.background) { + AppRoot() + } + } + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/ApiClient.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/ApiClient.kt new file mode 100644 index 0000000..fc62c00 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/ApiClient.kt @@ -0,0 +1,199 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import kotlinx.serialization.json.Json +import kotlinx.serialization.encodeToString +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.scalars.ScalarsConverterFactory +import java.io.File +import java.util.concurrent.TimeUnit + +class ApiClient(context: Context) { + private val baseUrl = "https://mkch.pooziqo.xyz" + private val apiUrl = "$baseUrl/api/" + private val userAgent = "MobileMkch/1.0-android-alpha" + + private val json = Json { ignoreUnknownKeys = true } + private val prefs = context.getSharedPreferences("api_prefs", Context.MODE_PRIVATE) + + private val userAgentInterceptor = Interceptor { chain -> + val request = chain.request().newBuilder() + .header("Accept", "application/json") + .header("Accept-Language", java.util.Locale.getDefault().toLanguageTag()) + .header("User-Agent", userAgent) + .build() + chain.proceed(request) + } + + private val httpClient: OkHttpClient = OkHttpClient.Builder() + .addInterceptor(userAgentInterceptor) + .addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }) + .cookieJar(PersistentCookieJar(context)) + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(40, TimeUnit.SECONDS) + .build() + + private val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(apiUrl) + .addConverterFactory(ScalarsConverterFactory.create()) + .client(httpClient) + .build() + + private fun parseBoards(body: String): List = json.decodeFromString(body) + private fun parseThreads(body: String): List = json.decodeFromString(body) + private fun parseThreadDetail(body: String): ThreadDetail = json.decodeFromString(body) + private fun parseComments(body: String): List = json.decodeFromString(body) + + private fun get(url: String): String { + val req = Request.Builder().url(url).get().build() + httpClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("HTTP ${'$'}{resp.code}") + return resp.body?.string() ?: throw RuntimeException("empty body") + } + } + + fun getBoards(): List = parseBoards(get("${apiUrl}boards/")) + + fun getThreads(boardCode: String): List = parseThreads(get("${apiUrl}board/$boardCode")) + + fun getThreadDetail(boardCode: String, threadId: Int): ThreadDetail = + parseThreadDetail(get("${apiUrl}board/$boardCode/thread/$threadId")) + + fun getComments(boardCode: String, threadId: Int): List = + parseComments(get("${apiUrl}board/$boardCode/thread/$threadId/comments")) + + fun getFullThread(boardCode: String, threadId: Int): Pair> { + val detail = getThreadDetail(boardCode, threadId) + val comments = getComments(boardCode, threadId) + return detail to comments + } + + fun authenticate(authKey: String) { + if (authKey.isEmpty()) return + val url = "$baseUrl/key/auth/" + val formHtml = get(url) + val csrf = extractCsrf(formHtml) + val body = "csrfmiddlewaretoken=${'$'}csrf&key=${'$'}authKey" + .toRequestBody("application/x-www-form-urlencoded".toMediaType()) + val req = Request.Builder().url(url) + .post(body) + .header("Referer", url) + .build() + httpClient.newCall(req).execute().use { resp -> + if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("auth failed ${'$'}{resp.code}") + } + } + + fun loginWithPasscode(passcode: String) { + if (passcode.isEmpty()) return + val url = "$baseUrl/passcode/enter/" + val formHtml = get(url) + val csrf = extractCsrf(formHtml) + val body = "csrfmiddlewaretoken=${'$'}csrf&passcode=${'$'}passcode" + .toRequestBody("application/x-www-form-urlencoded".toMediaType()) + val req = Request.Builder().url(url) + .post(body) + .header("Referer", url) + .build() + httpClient.newCall(req).execute().use { resp -> + if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("passcode failed ${'$'}{resp.code}") + } + } + + fun createThread(boardCode: String, title: String, text: String, files: List = emptyList(), passcode: String = "", authKey: String = "") { + if (authKey.isNotEmpty()) authenticate(authKey) + if (passcode.isNotEmpty()) loginWithPasscode(passcode) + val formUrl = "$baseUrl/boards/board/$boardCode/new" + val csrf = fetchCsrf(formUrl) + val req = if (files.isEmpty()) { + val body = "csrfmiddlewaretoken=$csrf&title=$title&text=$text" + .toRequestBody("application/x-www-form-urlencoded".toMediaType()) + Request.Builder().url(formUrl).post(body) + } else { + val builder = MultipartBody.Builder().setType(MultipartBody.FORM) + .addFormDataPart("csrfmiddlewaretoken", csrf) + .addFormDataPart("title", title) + .addFormDataPart("text", text) + files.forEachIndexed { idx, file -> + val rb: RequestBody = file.asRequestBody("image/jpeg".toMediaType()) + builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb) + } + Request.Builder().url(formUrl).post(builder.build()) + } + .header("Referer", formUrl) + .build() + httpClient.newCall(req).execute().use { resp -> + if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("createThread failed ${resp.code}") + } + } + + fun addComment(boardCode: String, threadId: Int, text: String, files: List = emptyList(), passcode: String = "", authKey: String = "") { + if (authKey.isNotEmpty()) authenticate(authKey) + if (passcode.isNotEmpty()) loginWithPasscode(passcode) + val formUrl = "$baseUrl/boards/board/$boardCode/thread/$threadId/comment" + val csrf = fetchCsrf(formUrl) + val req = if (files.isEmpty()) { + val body = "csrfmiddlewaretoken=$csrf&text=$text" + .toRequestBody("application/x-www-form-urlencoded".toMediaType()) + Request.Builder().url(formUrl).post(body) + } else { + val builder = MultipartBody.Builder().setType(MultipartBody.FORM) + .addFormDataPart("csrfmiddlewaretoken", csrf) + .addFormDataPart("text", text) + files.forEachIndexed { idx, file -> + val rb: RequestBody = file.asRequestBody("image/jpeg".toMediaType()) + builder.addFormDataPart("files", "photo_${idx + 1}.jpg", rb) + } + Request.Builder().url(formUrl).post(builder.build()) + } + .header("Referer", formUrl) + .build() + httpClient.newCall(req).execute().use { resp -> + if (!(resp.code == 200 || resp.code == 302)) throw RuntimeException("addComment failed ${resp.code}") + } + } + + private fun fetchCsrf(url: String): String { + val req = Request.Builder().url(url).get().build() + httpClient.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) throw RuntimeException("HTTP ${resp.code}") + val html = resp.body?.string().orEmpty() + val regex = Regex("name=['\"]csrfmiddlewaretoken['\"]\\s+value=['\"]([^'\"]+)['\"]") + val match = regex.find(html) ?: throw RuntimeException("csrf not found") + return match.groupValues[1] + } + } + + fun checkNewThreads(boardCode: String): List { + val current = getThreads(boardCode) + val key = "savedThreads_${'$'}boardCode" + val savedRaw = prefs.getString(key, null) + return if (savedRaw != null) { + val saved = runCatching { json.decodeFromString>(savedRaw) }.getOrElse { emptyList() } + val savedIds = saved.map { it.id }.toSet() + val newOnes = current.filter { it.id !in savedIds } + prefs.edit().putString(key, json.encodeToString(current)).apply() + newOnes + } else { + prefs.edit().putString(key, json.encodeToString(current)).apply() + emptyList() + } + } + + private fun extractCsrf(html: String): String { + val regex = Regex("name=['\"]csrfmiddlewaretoken['\"]\\s+value=['\"]([^'\"]+)['\"]") + val match = regex.find(html) ?: throw RuntimeException("csrf not found") + return match.groupValues[1] + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/Cache.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/Cache.kt new file mode 100644 index 0000000..7119a4a --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/Cache.kt @@ -0,0 +1,52 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + +class Cache(private val context: Context) { + private val json = Json + private val dir: File by lazy { File(context.cacheDir, "mkch_cache").apply { mkdirs() } } + + suspend fun set(key: String, value: T, encoder: (T) -> String, ttlSeconds: Long) = withContext(Dispatchers.IO) { + val file = File(dir, safeName(key)) + val wrapper = CacheWrapper(System.currentTimeMillis(), ttlSeconds, encoder(value)) + file.writeText(json.encodeToString(wrapper)) + } + + suspend fun get(key: String, decoder: (String) -> T): T? = withContext(Dispatchers.IO) { + val file = File(dir, safeName(key)) + if (!file.exists()) return@withContext null + val wrapper = runCatching { json.decodeFromString(CacheWrapper.serializer(), file.readText()) }.getOrNull() + ?: return@withContext null + val ageSec = (System.currentTimeMillis() - wrapper.timestampMs) / 1000 + if (ageSec > wrapper.ttlSeconds) return@withContext null + return@withContext decoder(wrapper.payload) + } + + suspend fun getStale(key: String, decoder: (String) -> T): T? = withContext(Dispatchers.IO) { + val file = File(dir, safeName(key)) + if (!file.exists()) return@withContext null + val wrapper = runCatching { json.decodeFromString(CacheWrapper.serializer(), file.readText()) }.getOrNull() + ?: return@withContext null + return@withContext decoder(wrapper.payload) + } + + suspend fun delete(key: String) = withContext(Dispatchers.IO) { + File(dir, safeName(key)).delete() + } + + private fun safeName(key: String) = key.hashCode().toString(16) + ".json" +} + +@kotlinx.serialization.Serializable +private data class CacheWrapper( + val timestampMs: Long, + val ttlSeconds: Long, + val payload: String +) + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/Cookies.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/Cookies.kt new file mode 100644 index 0000000..bbeeef7 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/Cookies.kt @@ -0,0 +1,27 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl + +class PersistentCookieJar(context: Context) : CookieJar { + private val prefs = context.getSharedPreferences("cookies", Context.MODE_PRIVATE) + + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val key = url.host + val existing = loadForRequest(url).toMutableList() + existing.removeAll { e -> cookies.any { it.name == e.name } } + existing.addAll(cookies) + val serialized = existing.joinToString(";") { it.toString() } + prefs.edit().putString(key, serialized).apply() + } + + override fun loadForRequest(url: HttpUrl): List { + val key = url.host + val stored = prefs.getString(key, null) ?: return emptyList() + return stored.split(";").mapNotNull { Cookie.parse(url, it) } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/FavoritesStore.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/FavoritesStore.kt new file mode 100644 index 0000000..1cd4358 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/FavoritesStore.kt @@ -0,0 +1,43 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val Context.favDataStore by preferencesDataStore(name = "favorites") + +class FavoritesStore(private val context: Context) { + private object Keys { val list = stringPreferencesKey("list") } + + val flow: Flow> = context.favDataStore.data.map { prefs -> + val raw = prefs[Keys.list] ?: "[]" + runCatching { Json.decodeFromString>(raw) }.getOrElse { emptyList() } + } + + suspend fun add(item: FavoriteThread) { + context.favDataStore.edit { prefs -> + val current = prefs[Keys.list]?.let { runCatching { Json.decodeFromString>(it) }.getOrNull() } ?: emptyList() + if (current.any { it.id == item.id && it.board == item.board }) return@edit + val next = current + item + prefs[Keys.list] = Json.encodeToString(next) + } + } + + suspend fun remove(threadId: Int, board: String) { + context.favDataStore.edit { prefs -> + val current = prefs[Keys.list]?.let { runCatching { Json.decodeFromString>(it) }.getOrNull() } ?: emptyList() + val next = current.filterNot { it.id == threadId && it.board == board } + prefs[Keys.list] = Json.encodeToString(next) + } + } + + suspend fun clear() { context.favDataStore.edit { it.remove(Keys.list) } } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/Models.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/Models.kt new file mode 100644 index 0000000..5a751a2 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/Models.kt @@ -0,0 +1,50 @@ +package com.mkfunproj.mobilemkch.data + +import kotlinx.serialization.Serializable + +@Serializable +data class Board( + val code: String, + val description: String +) + +@Serializable +data class ThreadItem( + val id: Int, + val title: String, + val text: String, + val creation: String, + val board: String, + val rating: Int? = null, + val pinned: Boolean? = null, + val files: List = emptyList() +) + +@Serializable +data class ThreadDetail( + val id: Int, + val creation: String, + val title: String, + val text: String, + val board: String, + val files: List = emptyList() +) + +@Serializable +data class Comment( + val id: Int, + val text: String, + val creation: String, + val files: List = emptyList() +) + +@Serializable +data class FavoriteThread( + val id: Int, + val title: String, + val board: String, + val boardDescription: String, + val addedDate: Long +) + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/NetworkMonitor.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/NetworkMonitor.kt new file mode 100644 index 0000000..2cc227d --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/NetworkMonitor.kt @@ -0,0 +1,29 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +class NetworkMonitor(context: Context) { + private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + private val _connected = MutableStateFlow(isCurrentlyConnected()) + val isConnected: StateFlow = _connected + + init { + cm.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { _connected.value = true } + override fun onLost(network: Network) { _connected.value = isCurrentlyConnected() } + }) + } + + private fun isCurrentlyConnected(): Boolean { + val n = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(n) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/Repository.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/Repository.kt new file mode 100644 index 0000000..5a688c9 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/Repository.kt @@ -0,0 +1,61 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class Repository(context: Context) { + private val api = ApiClient(context) + private val cache = Cache(context) + private val json = Json + + suspend fun getBoards(offline: Boolean): List = withContext(Dispatchers.IO) { + if (offline) { + cache.getStale("boards") { json.decodeFromString(it) } ?: emptyList() + } else { + val cached = cache.get("boards") { json.decodeFromString>(it) } + if (cached != null) return@withContext cached + val boards = api.getBoards() + cache.set("boards", boards, { json.encodeToString(it) }, ttlSeconds = 600) + boards + } + } + + suspend fun getThreads(board: String, offline: Boolean): List = withContext(Dispatchers.IO) { + if (offline) { + cache.getStale("threads_$board") { json.decodeFromString(it) } ?: emptyList() + } else { + val cached = cache.get("threads_$board") { json.decodeFromString>(it) } + if (cached != null) return@withContext cached + val list = api.getThreads(board) + cache.set("threads_$board", list, { json.encodeToString(it) }, ttlSeconds = 300) + list + } + } + + suspend fun getThread(board: String, id: Int, offline: Boolean): ThreadDetail? = withContext(Dispatchers.IO) { + if (offline) { + cache.getStale("thread_$id") { json.decodeFromString(it) } + } else { + cache.get("thread_$id") { json.decodeFromString(it) } ?: api.getThreadDetail(board, id).also { + cache.set("thread_$id", it, { json.encodeToString(it) }, ttlSeconds = 180) + } + } + } + + suspend fun getComments(board: String, id: Int, offline: Boolean): List = withContext(Dispatchers.IO) { + if (offline) { + cache.getStale("comments_$id") { json.decodeFromString(it) } ?: emptyList() + } else { + val cached = cache.get("comments_$id") { json.decodeFromString>(it) } + if (cached != null) return@withContext cached + val list = api.getComments(board, id) + cache.set("comments_$id", list, { json.encodeToString(it) }, ttlSeconds = 180) + list + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/SettingsStore.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/SettingsStore.kt new file mode 100644 index 0000000..c8f1b88 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/SettingsStore.kt @@ -0,0 +1,87 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Context.dataStore by preferencesDataStore(name = "settings") + +data class SettingsData( + val theme: String = "dark", + val lastBoard: String = "", + val autoRefresh: Boolean = true, + val showFiles: Boolean = true, + val compactMode: Boolean = false, + val pageSize: Int = 10, + val enablePagination: Boolean = false, + val enableUnstableFeatures: Boolean = false, + val passcode: String = "", + val key: String = "", + val notificationsEnabled: Boolean = false, + val notificationInterval: Int = 900, + val offlineMode: Boolean = false +) + +class SettingsStore(private val context: Context) { + private object Keys { + val theme = stringPreferencesKey("theme") + val lastBoard = stringPreferencesKey("lastBoard") + val autoRefresh = booleanPreferencesKey("autoRefresh") + val showFiles = booleanPreferencesKey("showFiles") + val compactMode = booleanPreferencesKey("compactMode") + val pageSize = intPreferencesKey("pageSize") + val enablePagination = booleanPreferencesKey("enablePagination") + val enableUnstable = booleanPreferencesKey("enableUnstableFeatures") + val passcode = stringPreferencesKey("passcode") + val key = stringPreferencesKey("key") + val notificationsEnabled = booleanPreferencesKey("notificationsEnabled") + val notificationInterval = intPreferencesKey("notificationInterval") + val offlineMode = booleanPreferencesKey("offlineMode") + } + + val data: Flow = context.dataStore.data.map { p -> p.toSettings() } + + suspend fun update(transform: (SettingsData) -> SettingsData) { + context.dataStore.edit { prefs -> + val current = prefs.toSettings() + val next = transform(current) + prefs[Keys.theme] = next.theme + prefs[Keys.lastBoard] = next.lastBoard + prefs[Keys.autoRefresh] = next.autoRefresh + prefs[Keys.showFiles] = next.showFiles + prefs[Keys.compactMode] = next.compactMode + prefs[Keys.pageSize] = next.pageSize + prefs[Keys.enablePagination] = next.enablePagination + prefs[Keys.enableUnstable] = next.enableUnstableFeatures + prefs[Keys.passcode] = next.passcode + prefs[Keys.key] = next.key + prefs[Keys.notificationsEnabled] = next.notificationsEnabled + prefs[Keys.notificationInterval] = next.notificationInterval + prefs[Keys.offlineMode] = next.offlineMode + } + } + + private fun Preferences.toSettings(): SettingsData = SettingsData( + theme = this[Keys.theme] ?: "dark", + lastBoard = this[Keys.lastBoard] ?: "", + autoRefresh = this[Keys.autoRefresh] ?: true, + showFiles = this[Keys.showFiles] ?: true, + compactMode = this[Keys.compactMode] ?: false, + pageSize = this[Keys.pageSize] ?: 10, + enablePagination = this[Keys.enablePagination] ?: false, + enableUnstableFeatures = this[Keys.enableUnstable] ?: false, + passcode = this[Keys.passcode] ?: "", + key = this[Keys.key] ?: "", + notificationsEnabled = this[Keys.notificationsEnabled] ?: false, + notificationInterval = this[Keys.notificationInterval] ?: 300, + offlineMode = this[Keys.offlineMode] ?: false + ) +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/data/SubscriptionsStore.kt b/app/src/main/java/com/mkfunproj/mobilemkch/data/SubscriptionsStore.kt new file mode 100644 index 0000000..d6f13aa --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/data/SubscriptionsStore.kt @@ -0,0 +1,44 @@ +package com.mkfunproj.mobilemkch.data + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +private val Context.subsDataStore by preferencesDataStore(name = "subscriptions") + +class SubscriptionsStore(private val context: Context) { + private object Keys { val boards = stringPreferencesKey("boards") } + + val flow: Flow> = context.subsDataStore.data.map { prefs -> + val raw = prefs[Keys.boards] ?: "[]" + runCatching { Json.decodeFromString>(raw).toSet() }.getOrElse { emptySet() } + } + + suspend fun add(board: String) { + context.subsDataStore.edit { prefs -> + val current = prefs[Keys.boards]?.let { runCatching { Json.decodeFromString>(it) }.getOrNull() }?.toMutableSet() ?: mutableSetOf() + current.add(board) + prefs[Keys.boards] = Json.encodeToString(current.toList()) + } + } + + suspend fun remove(board: String) { + context.subsDataStore.edit { prefs -> + val current = prefs[Keys.boards]?.let { runCatching { Json.decodeFromString>(it) }.getOrNull() }?.toMutableSet() ?: mutableSetOf() + current.remove(board) + prefs[Keys.boards] = Json.encodeToString(current.toList()) + } + } + + suspend fun clear() { context.subsDataStore.edit { it.remove(Keys.boards) } } + + suspend fun getNow(): Set = flow.first() +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/AppRoot.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/AppRoot.kt new file mode 100644 index 0000000..5288526 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/AppRoot.kt @@ -0,0 +1,118 @@ +package com.mkfunproj.mobilemkch.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.List +import androidx.compose.material.icons.filled.Settings +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import com.mkfunproj.mobilemkch.ui.screens.BoardsScreen +import com.mkfunproj.mobilemkch.ui.screens.ThreadDetailScreen +import com.mkfunproj.mobilemkch.ui.screens.FavoritesScreen +import com.mkfunproj.mobilemkch.ui.screens.SettingsScreen +import com.mkfunproj.mobilemkch.ui.screens.ThreadsScreen +import com.mkfunproj.mobilemkch.ui.screens.AddCommentScreen +import com.mkfunproj.mobilemkch.ui.screens.CreateThreadScreen + +@Composable +fun AppRoot() { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route ?: "boards" + Scaffold( + bottomBar = { + NavigationBar { + NavigationBarItem( + selected = currentRoute.startsWith("favorites"), + onClick = { + navController.navigate("favorites") { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(Icons.Default.Favorite, contentDescription = null) }, + label = { Text("Избранное") } + ) + NavigationBarItem( + selected = currentRoute.startsWith("boards"), + onClick = { + navController.navigate("boards") { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(Icons.Default.List, contentDescription = null) }, + label = { Text("Доски") } + ) + NavigationBarItem( + selected = currentRoute.startsWith("settings"), + onClick = { + navController.navigate("settings") { + popUpTo(navController.graph.startDestinationId) { saveState = true } + launchSingleTop = true + restoreState = true + } + }, + icon = { Icon(Icons.Default.Settings, contentDescription = null) }, + label = { Text("Настройки") } + ) + } + } + ) { padding -> + NavHost( + navController = navController, + startDestination = "boards", + modifier = Modifier.fillMaxSize().padding(padding) + ) { + composable("boards") { + BoardsScreen( + onOpenThreads = { boardCode, boardDesc -> + navController.navigate("threads/$boardCode/$boardDesc") + } + ) + } + composable("favorites") { FavoritesScreen(onOpen = { board, id -> navController.navigate("thread/$board/$id") }) } + composable("settings") { SettingsScreen() } + composable("threads/{board}/{desc}") { backStack -> + val board = backStack.arguments?.getString("board").orEmpty() + val desc = backStack.arguments?.getString("desc").orEmpty() + ThreadsScreen( + boardCode = board, + boardDescription = desc, + onOpenThread = { threadId -> navController.navigate("thread/$board/$threadId") }, + onCreateThread = { navController.navigate("threads/$board/new") } + ) + } + composable("thread/{board}/{id}") { backStack -> + val board = backStack.arguments?.getString("board").orEmpty() + val id = backStack.arguments?.getString("id")?.toIntOrNull() ?: 0 + ThreadDetailScreen(boardCode = board, threadId = id, onAddComment = { navController.navigate("thread/$board/$id/comment") }) + } + composable("thread/{board}/{id}/comment") { backStack -> + val board = backStack.arguments?.getString("board").orEmpty() + val id = backStack.arguments?.getString("id")?.toIntOrNull() ?: 0 + AddCommentScreen(boardCode = board, threadId = id) + } + composable("threads/{board}/new") { backStack -> + val board = backStack.arguments?.getString("board").orEmpty() + CreateThreadScreen(boardCode = board) + } + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/AddCommentScreen.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/AddCommentScreen.kt new file mode 100644 index 0000000..d2dfe6e --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/AddCommentScreen.kt @@ -0,0 +1,97 @@ +package com.mkfunproj.mobilemkch.ui.screens + +import android.app.Application +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.mkfunproj.mobilemkch.data.ApiClient +import com.mkfunproj.mobilemkch.data.SettingsStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import com.mkfunproj.mobilemkch.util.ImageUtil +import java.io.InputStream + +class AddCommentVm(app: Application) : AndroidViewModel(app) { + private val api = ApiClient(app) + var isLoading by mutableStateOf(false) + var error by mutableStateOf(null) + fun send(context: Context, board: String, id: Int, text: String, uris: List, onDone: () -> Unit) { + isLoading = true + error = null + viewModelScope.launch { + val settings = SettingsStore(getApplication()).data.first() + val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } } + runCatching { api.addComment(board, id, text, files, passcode = settings.passcode, authKey = settings.key) } + .onSuccess { onDone() } + .onFailure { error = it.message } + isLoading = false + withContext(Dispatchers.IO) { files.forEach { it.delete() } } + } + } + private fun copyUriToTemp(context: Context, uri: Uri): File? = ImageUtil.downscaleJpeg(context, uri) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddCommentScreen(boardCode: String, threadId: Int, vm: AddCommentVm = viewModel()) { + val context = androidx.compose.ui.platform.LocalContext.current + var text by remember { mutableStateOf("") } + var picked by remember { mutableStateOf>(emptyList()) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { result -> + picked = result.take(4) + } + Scaffold(topBar = { TopAppBar(title = { Text("#$threadId Коммент") }) }) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + OutlinedTextField(value = text, onValueChange = { text = it }, label = { Text("Текст") }, modifier = Modifier.fillMaxSize().weight(1f)) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { launcher.launch("image/*") }) { Text("Фото") } + } + if (picked.isNotEmpty()) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + items(picked) { uri -> + AsyncImage(model = uri, contentDescription = null, modifier = Modifier.size(64.dp)) + } + } + } + if (vm.error != null) Text(vm.error ?: "") + Button(onClick = { vm.send(context, boardCode, threadId, text, picked) {} }, enabled = text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Добавить") } + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/BoardsScreen.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/BoardsScreen.kt new file mode 100644 index 0000000..6a23223 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/BoardsScreen.kt @@ -0,0 +1,116 @@ +package com.mkfunproj.mobilemkch.ui.screens + +import android.app.Application +import androidx.compose.foundation.clickable +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TextButton +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mkfunproj.mobilemkch.data.Board +import com.mkfunproj.mobilemkch.data.Repository +import com.mkfunproj.mobilemkch.data.SettingsStore +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.delay + +class BoardsVm(app: Application) : AndroidViewModel(app) { + private val repo = Repository(app) + private val settings = SettingsStore(app) + var isLoading by mutableStateOf(false) + var error by mutableStateOf(null) + var boards by mutableStateOf>(emptyList()) + var offline by mutableStateOf(false) + + init { + viewModelScope.launch { settings.data.collect { offline = it.offlineMode } } + } + + suspend fun load() { + isLoading = true + error = null + runCatching { repo.getBoards(offline) } + .onSuccess { boards = it } + .onFailure { error = it.message } + isLoading = false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BoardsScreen( + onOpenThreads: (boardCode: String, boardDesc: String) -> Unit, + vm: BoardsVm = viewModel() +) { + val snackbar = remember { SnackbarHostState() } + val scope = androidx.compose.runtime.rememberCoroutineScope() + LaunchedEffect(Unit) { vm.load() } + Scaffold(topBar = { + TopAppBar(title = { Text("Доски mkch") }, actions = { + TextButton(onClick = { scope.launch { vm.load() } }) { Text("Обновить") } + }) + }, snackbarHost = { SnackbarHost(snackbar) }) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + if (vm.offline) { + Text("Оффлайн режим. Показаны сохранённые данные", color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(12.dp)) + } + when { + vm.isLoading -> CircularProgressIndicator(Modifier.padding(16.dp)) + vm.error != null -> Column(Modifier.padding(16.dp)) { + Text(vm.error ?: "", color = MaterialTheme.colorScheme.error) + TextButton(onClick = { scope.launch { vm.load() } }) { Text("Повторить") } + } + else -> BoardsList(PaddingValues(12.dp), vm.boards) { onOpenThreads(it.code, it.description) } + } + } + } +} + +@Composable +private fun BoardsList(padding: PaddingValues, itemsList: List, onClick: (Board) -> Unit) { + LazyColumn( + contentPadding = padding, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize().padding(12.dp) + ) { + items(itemsList) { board -> + val desc = if (board.description.isEmpty()) "Без описания" else board.description + Card(colors = CardDefaults.cardColors(), modifier = Modifier.fillMaxWidth().clickable { onClick(board) }) { + Column(Modifier.padding(12.dp)) { + Text(text = "/${board.code}/", style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.height(2.dp)) + Text(text = desc, style = MaterialTheme.typography.bodyMedium, maxLines = 2) + } + } + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/CreateThreadScreen.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/CreateThreadScreen.kt new file mode 100644 index 0000000..6ba51e8 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/CreateThreadScreen.kt @@ -0,0 +1,102 @@ +package com.mkfunproj.mobilemkch.ui.screens + +import android.app.Application +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import com.mkfunproj.mobilemkch.data.ApiClient +import com.mkfunproj.mobilemkch.data.SettingsStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import com.mkfunproj.mobilemkch.util.ImageUtil + +class CreateThreadVm(app: Application) : AndroidViewModel(app) { + private val api = ApiClient(app) + var isLoading by mutableStateOf(false) + var error by mutableStateOf(null) + fun create(context: Context, board: String, title: String, text: String, uris: List, onDone: () -> Unit) { + isLoading = true + error = null + viewModelScope.launch { + val settings = SettingsStore(getApplication()).data.first() + val files = withContext(Dispatchers.IO) { uris.mapNotNull { uri -> ImageUtil.downscaleJpeg(context, uri) } } + runCatching { api.createThread(board, title, text, files, passcode = settings.passcode, authKey = settings.key) } + .onSuccess { onDone() } + .onFailure { error = it.message } + isLoading = false + withContext(Dispatchers.IO) { files.forEach { it.delete() } } + } + } + private fun copyUriToTemp(context: Context, uri: Uri): File? = ImageUtil.downscaleJpeg(context, uri) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun CreateThreadScreen(boardCode: String, vm: CreateThreadVm = viewModel()) { + val context = androidx.compose.ui.platform.LocalContext.current + var title by remember { mutableStateOf("") } + var text by remember { mutableStateOf("") } + var picked by remember { mutableStateOf>(emptyList()) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetMultipleContents()) { result -> + picked = result.take(4) + } + Scaffold(topBar = { TopAppBar(title = { Text("/$boardCode/ Новый тред") }) }) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp)) { + OutlinedTextField(value = title, onValueChange = { title = it }, label = { Text("Заголовок") }, modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text("Текст") }, + modifier = Modifier.fillMaxWidth().weight(1f), + minLines = 6, + maxLines = 12 + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { launcher.launch("image/*") }) { Text("Фото") } + } + if (picked.isNotEmpty()) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 8.dp)) { + items(picked) { uri -> AsyncImage(model = uri, contentDescription = null, modifier = Modifier.size(64.dp)) } + } + } + if (vm.error != null) Text(vm.error ?: "") + Button(onClick = { vm.create(context, boardCode, title, text, picked) {} }, enabled = title.isNotEmpty() && text.isNotEmpty() && !vm.isLoading, modifier = Modifier.padding(top = 8.dp)) { Text(if (vm.isLoading) "Отправка..." else "Создать") } + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/FavoritesScreen.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/FavoritesScreen.kt new file mode 100644 index 0000000..0bdca72 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/FavoritesScreen.kt @@ -0,0 +1,67 @@ +package com.mkfunproj.mobilemkch.ui.screens + +import android.app.Application +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mkfunproj.mobilemkch.data.FavoriteThread +import com.mkfunproj.mobilemkch.data.FavoritesStore + +class FavoritesVm(app: Application) : AndroidViewModel(app) { + private val store = FavoritesStore(app) + var items by mutableStateOf>(emptyList()) + suspend fun observe() { store.flow.collect { items = it } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FavoritesScreen(vm: FavoritesVm = viewModel(), onOpen: (board: String, id: Int) -> Unit = { _, _ -> }) { + LaunchedEffect(Unit) { vm.observe() } + Scaffold(topBar = { TopAppBar(title = { Text("Избранное") }) }) { padding -> + LazyColumn( + contentPadding = padding, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize().padding(12.dp) + ) { + if (vm.items.isEmpty()) { + item { Text("Пока пусто") } + } else { + items(vm.items) { fav -> + Card(colors = CardDefaults.cardColors()) { + Column(Modifier.padding(12.dp).clickable { onOpen(fav.board, fav.id) }) { + Text(fav.title, style = androidx.compose.material3.MaterialTheme.typography.titleMedium, maxLines = 2) + Row { + Text("/${fav.board}/", color = androidx.compose.material3.MaterialTheme.colorScheme.primary) + Spacer(Modifier.weight(1f)) + Text(java.text.SimpleDateFormat("yyyy-MM-dd").format(java.util.Date(fav.addedDate))) + } + } + } + } + } + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/SettingsScreen.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..524dc8a --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/SettingsScreen.kt @@ -0,0 +1,149 @@ +package com.mkfunproj.mobilemkch.ui.screens + +import android.app.Application +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Button +import androidx.compose.runtime.LaunchedEffect +import androidx.lifecycle.viewModelScope +import com.mkfunproj.mobilemkch.data.Board +import com.mkfunproj.mobilemkch.data.Repository +import com.mkfunproj.mobilemkch.data.SubscriptionsStore +import kotlinx.coroutines.launch +import com.mkfunproj.mobilemkch.util.NotificationHelper +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import com.mkfunproj.mobilemkch.workers.ThreadsCheckWorker +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mkfunproj.mobilemkch.data.SettingsData +import com.mkfunproj.mobilemkch.data.SettingsStore +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +class SettingsVm(app: Application) : AndroidViewModel(app) { + private val store = SettingsStore(app) + private val subs = SubscriptionsStore(app) + private val repo = Repository(app) + val flow = store.data + val subsFlow = subs.flow + var boards by mutableStateOf>(emptyList()) + fun update(transform: (SettingsData) -> SettingsData) { + viewModelScope.launch { store.update(transform) } + } + fun toggleSub(board: String, enabled: Boolean) { viewModelScope.launch { if (enabled) subs.add(board) else subs.remove(board) } } + fun loadBoards() { viewModelScope.launch { boards = runCatching { repo.getBoards(offline = false) }.getOrElse { emptyList() } } } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen(vm: SettingsVm = viewModel()) { + val settings by vm.flow.collectAsState(initial = SettingsData()) + val subs by vm.subsFlow.collectAsState(initial = emptySet()) + LaunchedEffect(Unit) { vm.loadBoards() } + Scaffold(topBar = { TopAppBar(title = { Text("Настройки") }) }) { padding -> + Column(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth()) { + Text("Показывать файлы", modifier = Modifier.weight(1f)) + Checkbox(checked = settings.showFiles, onCheckedChange = { vm.update { it.copy(showFiles = it.showFiles.not()) } }) + } + Row(Modifier.fillMaxWidth()) { + Text("Компактный режим", modifier = Modifier.weight(1f)) + Checkbox(checked = settings.compactMode, onCheckedChange = { vm.update { it.copy(compactMode = it.compactMode.not()) } }) + } + Row(Modifier.fillMaxWidth()) { + Text("Разстраничивание", modifier = Modifier.weight(1f)) + Checkbox(checked = settings.enablePagination, onCheckedChange = { vm.update { it.copy(enablePagination = it.enablePagination.not()) } }) + } + Row(Modifier.fillMaxWidth()) { + Text("Тема", modifier = Modifier.weight(1f)) + val themeLabel = if(settings.theme=="oled") "OLED" else "Тёмная" + Button(onClick = { + val next = if(settings.theme=="oled") "dark" else "oled" + vm.update { it.copy(theme = next) } + }) { Text(themeLabel) } + } + OutlinedTextField( + value = settings.passcode, + onValueChange = { v -> vm.update { it.copy(passcode = v) } }, + label = { Text("Passcode для постинга") }, + modifier = Modifier.fillMaxWidth() + ) + OutlinedTextField( + value = settings.key, + onValueChange = { v -> vm.update { it.copy(key = v) } }, + label = { Text("Ключ аутентификации") }, + modifier = Modifier.fillMaxWidth() + ) + Row(Modifier.fillMaxWidth()) { + Text("Уведомления (beta)", modifier = Modifier.weight(1f)) + Checkbox(checked = settings.notificationsEnabled, onCheckedChange = { vm.update { it.copy(notificationsEnabled = it.notificationsEnabled.not()) } }) + } + if (settings.notificationsEnabled) { + Text("Подписки на доски") + if (vm.boards.isEmpty()) { + Text("Загрузка...") + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { + items(vm.boards) { b -> + Row(Modifier.fillMaxWidth()) { + Text("/${'$'}{b.code}/ ${'$'}{b.description}", modifier = Modifier.weight(1f)) + val on = subs.contains(b.code) + Checkbox(checked = on, onCheckedChange = { vm.toggleSub(b.code, !on) }) + } + } + } + } + val ctx = androidx.compose.ui.platform.LocalContext.current + Row(Modifier.fillMaxWidth()) { + Button(onClick = { NotificationHelper.notifyTest(ctx) }) { Text("Тест-уведомление") } + Spacer(Modifier.weight(1f)) + Button(onClick = { + val req = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(ctx).enqueueUniqueWork("mkch_threads_check_now", ExistingWorkPolicy.REPLACE, req) + }) { Text("Проверить сейчас") } + } + Row(Modifier.fillMaxWidth()) { + Text("Интервал проверки", modifier = Modifier.weight(1f)) + val options = listOf(300 to "5 мин", 900 to "15 мин", 1800 to "30 мин", 3600 to "1 час") + androidx.compose.material3.TextButton(onClick = { + val idx = options.indexOfFirst { it.first == settings.notificationInterval }.let { if (it == -1) 1 else it } + val next = options[(idx + 1) % options.size].first + vm.update { it.copy(notificationInterval = next) } + val req = OneTimeWorkRequestBuilder().build() + WorkManager.getInstance(ctx).enqueueUniqueWork("mkch_threads_check_now", ExistingWorkPolicy.REPLACE, req) + }) { Text(options.find { it.first == settings.notificationInterval }?.second ?: "15 мин") } + } + } + Row(Modifier.fillMaxWidth()) { + Text("Принудительно оффлайн", modifier = Modifier.weight(1f)) + Checkbox(checked = settings.offlineMode, onCheckedChange = { vm.update { it.copy(offlineMode = it.offlineMode.not()) } }) + } + Spacer(Modifier.weight(1f)) + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/ThreadDetailScreen.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/ThreadDetailScreen.kt new file mode 100644 index 0000000..d3b1930 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/ThreadDetailScreen.kt @@ -0,0 +1,130 @@ +package com.mkfunproj.mobilemkch.ui.screens + +import android.app.Application +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mkfunproj.mobilemkch.data.Comment +import com.mkfunproj.mobilemkch.data.Repository +import com.mkfunproj.mobilemkch.data.ThreadDetail +import coil.compose.AsyncImage +import com.mkfunproj.mobilemkch.data.SettingsStore +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch + +class ThreadDetailVm(app: Application) : AndroidViewModel(app) { + private val repo = Repository(app) + private val settings = SettingsStore(app) + var isLoading by mutableStateOf(false) + var error by mutableStateOf(null) + var detail by mutableStateOf(null) + var comments by mutableStateOf>(emptyList()) + var offline by mutableStateOf(false) + + init { viewModelScope.launch { settings.data.collect { offline = it.offlineMode } } } + + suspend fun load(board: String, id: Int) { + isLoading = true + error = null + runCatching { + val d = repo.getThread(board, id, offline) + val c = repo.getComments(board, id, offline) + d to c + }.onSuccess { (d, c) -> + detail = d + comments = c + }.onFailure { error = it.message } + isLoading = false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThreadDetailScreen( + boardCode: String, + threadId: Int, + vm: ThreadDetailVm = viewModel(), + onAddComment: () -> Unit = {} +) { + val scope = androidx.compose.runtime.rememberCoroutineScope() + LaunchedEffect(threadId) { vm.load(boardCode, threadId) } + Scaffold( + topBar = { TopAppBar(title = { Text("#$threadId") }, actions = { androidx.compose.material3.TextButton(onClick = { scope.launch { vm.load(boardCode, threadId) } }) { Text("Обновить") } }) }, + floatingActionButton = { FloatingActionButton(onClick = onAddComment) { Icon(Icons.Default.Add, contentDescription = "Add") } } + ) { padding -> + when { + vm.isLoading -> CircularProgressIndicator(Modifier.padding(16.dp)) + vm.error != null -> Text(vm.error ?: "", color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) + else -> Content(padding, vm.detail, vm.comments) + } + } +} + +@Composable +private fun Content(padding: PaddingValues, detail: ThreadDetail?, comments: List) { + LazyColumn( + contentPadding = PaddingValues( + top = padding.calculateTopPadding(), + bottom = padding.calculateBottomPadding(), + start = 12.dp, + end = 12.dp + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize() + ) { + if (detail != null) { + item { + Card(colors = CardDefaults.cardColors()) { + androidx.compose.foundation.layout.Column(Modifier.padding(12.dp)) { + Text(detail.title, style = MaterialTheme.typography.titleLarge) + Text("/${detail.board}/", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) + if (detail.files.isNotEmpty()) { detail.files.forEach { f -> AsyncImage(model = "https://mkch.pooziqo.xyz$f", contentDescription = null) } } + if (detail.text.isNotEmpty()) Text(detail.text, style = MaterialTheme.typography.bodyMedium) + } + } + } + item { androidx.compose.material3.Divider() } + item { Text("Комментарии (${comments.size})", style = MaterialTheme.typography.titleMedium) } + } + items(comments) { c -> + Card(colors = CardDefaults.cardColors()) { + androidx.compose.foundation.layout.Column(Modifier.padding(12.dp)) { + androidx.compose.foundation.layout.Row { + Text("ID: ${c.id}", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) + androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) + Text(c.creation.take(10), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) + } + if (c.files.isNotEmpty()) { c.files.forEach { f -> AsyncImage(model = "https://mkch.pooziqo.xyz$f", contentDescription = null) } } + if (c.text.isNotEmpty()) Text(c.text.replace("#", ">>"), style = MaterialTheme.typography.bodyMedium) + } + } + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/ThreadsScreen.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/ThreadsScreen.kt new file mode 100644 index 0000000..7958172 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/screens/ThreadsScreen.kt @@ -0,0 +1,199 @@ +package com.mkfunproj.mobilemkch.ui.screens + +import android.app.Application +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.Divider +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.PushPin +import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mkfunproj.mobilemkch.data.Repository +import coil.compose.AsyncImage +import com.mkfunproj.mobilemkch.data.SettingsStore +import com.mkfunproj.mobilemkch.data.FavoriteThread +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import com.mkfunproj.mobilemkch.data.ThreadItem + +class ThreadsVm(app: Application) : AndroidViewModel(app) { + private val repo = Repository(app) + private val settings = SettingsStore(app) + private val favStore = com.mkfunproj.mobilemkch.data.FavoritesStore(app) + var isLoading by mutableStateOf(false) + var error by mutableStateOf(null) + var items by mutableStateOf>(emptyList()) + var offline by mutableStateOf(false) + var enablePagination by mutableStateOf(false) + var pageSize by mutableStateOf(10) + var compactMode by mutableStateOf(false) + var showFiles by mutableStateOf(true) + var favorites by mutableStateOf>(emptyList()) + + init { + viewModelScope.launch { settings.data.collect { + offline = it.offlineMode + enablePagination = it.enablePagination + pageSize = it.pageSize + compactMode = it.compactMode + showFiles = it.showFiles + } } + viewModelScope.launch { favStore.flow.collect { favorites = it } } + } + + fun isFavorite(board: String, id: Int): Boolean = favorites.any { it.board == board && it.id == id } + fun addFavorite(id: Int, title: String, board: String, boardDesc: String) { + val fav = FavoriteThread(id = id, title = title, board = board, boardDescription = boardDesc, addedDate = System.currentTimeMillis()) + viewModelScope.launch { favStore.add(fav) } + } + fun removeFavorite(id: Int, board: String) { viewModelScope.launch { favStore.remove(id, board) } } + + suspend fun load(board: String) { + isLoading = true + error = null + runCatching { repo.getThreads(board, offline) } + .onSuccess { items = it } + .onFailure { error = it.message } + isLoading = false + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ThreadsScreen( + boardCode: String, + boardDescription: String, + onOpenThread: (threadId: Int) -> Unit, + onCreateThread: (boardCode: String) -> Unit = {}, + vm: ThreadsVm = viewModel() +) { + LaunchedEffect(boardCode) { vm.load(boardCode) } + var currentPage by androidx.compose.runtime.remember { mutableStateOf(0) } + val scope = androidx.compose.runtime.rememberCoroutineScope() + Scaffold(topBar = { TopAppBar(title = { Text("/$boardCode/ — $boardDescription") }, actions = { + androidx.compose.material3.TextButton(onClick = { scope.launch { vm.load(boardCode) } }) { Text("Обновить") } + androidx.compose.material3.TextButton(onClick = { onCreateThread(boardCode) }) { Text("Создать") } + }) }) { padding -> + when { + vm.isLoading -> CircularProgressIndicator(Modifier.padding(16.dp)) + vm.error != null -> Text(vm.error ?: "", color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(16.dp)) + else -> { + val list = if (vm.enablePagination) { + val totalPages = (vm.items.size + vm.pageSize - 1) / vm.pageSize + val page = currentPage.coerceIn(0, (totalPages - 1).coerceAtLeast(0)) + val start = page * vm.pageSize + val end = kotlin.math.min(start + vm.pageSize, vm.items.size) + vm.items.subList(start, end) + } else vm.items + ThreadList(padding, vm, boardCode, boardDescription, onOpenThread, list) + if (vm.enablePagination) { + val totalPages = (vm.items.size + vm.pageSize - 1) / vm.pageSize + if (totalPages > 1) { + androidx.compose.foundation.layout.Row(Modifier.fillMaxWidth().padding(12.dp)) { + androidx.compose.material3.TextButton(onClick = { if (currentPage > 0) currentPage -= 1 }, enabled = currentPage > 0) { Text("<-") } + androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) + Text("Страница ${currentPage + 1} из $totalPages") + androidx.compose.foundation.layout.Spacer(Modifier.weight(1f)) + androidx.compose.material3.TextButton(onClick = { if (currentPage < totalPages - 1) currentPage += 1 }, enabled = currentPage < totalPages - 1) { Text("->") } + } + } + } + } + } + } +} + +@Composable +private fun ThreadList(padding: PaddingValues, vm: ThreadsVm, boardCode: String, boardDescription: String, onOpen: (Int) -> Unit, itemsList: List) { + LazyColumn( + contentPadding = padding, + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxSize().padding(12.dp) + ) { + if (vm.offline) { + item { Text("Оффлайн режим. Показаны сохранённые данные", color = MaterialTheme.colorScheme.secondary) } + } + items(itemsList) { t -> + if (vm.compactMode) { + Row(Modifier.fillMaxWidth().clickable { onOpen(t.id) }) { + Text("#${t.id}", style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.width(8.dp)) + Text(t.title, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f), maxLines = 1) + val fav = vm.isFavorite(boardCode, t.id) + Icon( + imageVector = if (fav) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = null, + tint = if (fav) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.clickable { if (fav) vm.removeFavorite(t.id, boardCode) else vm.addFavorite(t.id, t.title, boardCode, boardDescription) } + ) + } + Row { + Text(t.creation, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.secondary) + Spacer(Modifier.width(8.dp)) + if ((t.rating ?: 0) > 0) { + Icon(Icons.Filled.Star, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(" ${(t.rating ?: 0)}", style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.width(8.dp)) + } + if (t.pinned == true) { + Icon(Icons.Filled.PushPin, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary) + Spacer(Modifier.width(8.dp)) + } + if (vm.showFiles && t.files.isNotEmpty()) { + Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Text(" ${t.files.size}", style = MaterialTheme.typography.labelSmall) + } + } + } else { + Row(Modifier.fillMaxWidth().clickable { onOpen(t.id) }) { + Text("#${t.id}: ${t.title}", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f), maxLines = 2) + val fav = vm.isFavorite(boardCode, t.id) + Icon( + imageVector = if (fav) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = null, + tint = if (fav) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onBackground, + modifier = Modifier.clickable { if (fav) vm.removeFavorite(t.id, boardCode) else vm.addFavorite(t.id, t.title, boardCode, boardDescription) } + ) + if (t.pinned == true) { Spacer(Modifier.width(8.dp)); Icon(Icons.Filled.PushPin, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary) } + } + Row { + if ((t.rating ?: 0) > 0) { Icon(Icons.Filled.Star, contentDescription = null, tint = MaterialTheme.colorScheme.primary); Spacer(Modifier.width(4.dp)); Text("${t.rating ?: 0}", style = MaterialTheme.typography.labelSmall); Spacer(Modifier.width(12.dp)) } + if (vm.showFiles && t.files.isNotEmpty()) { Icon(Icons.Filled.AttachFile, contentDescription = null, tint = MaterialTheme.colorScheme.primary); Spacer(Modifier.width(4.dp)); Text("${t.files.size}", style = MaterialTheme.typography.labelSmall) } + } + if (t.text.isNotEmpty()) Text(t.text, style = MaterialTheme.typography.bodyMedium, maxLines = 3) + if (vm.showFiles && t.files.isNotEmpty()) { Spacer(Modifier.height(4.dp)); t.files.forEach { f -> AsyncImage(model = "https://mkch.pooziqo.xyz$f", contentDescription = null) } } + } + Divider(Modifier.padding(top = 8.dp)) + } + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Shape.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Shape.kt new file mode 100644 index 0000000..e984968 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Shape.kt @@ -0,0 +1,7 @@ +package com.mkfunproj.mobilemkch.ui.theme + +import androidx.compose.material3.Shapes + +val Shapes = Shapes() + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Theme.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Theme.kt new file mode 100644 index 0000000..12072d8 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Theme.kt @@ -0,0 +1,31 @@ +package com.mkfunproj.mobilemkch.ui.theme + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +private val DarkColors = darkColorScheme() +private val OledColors = darkColorScheme( + background = Color.Black, + surface = Color.Black +) + +@Composable +fun MobileMkchTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + oled: Boolean = false, + content: @Composable () -> Unit +) { + val colors = if (oled) OledColors else DarkColors + MaterialTheme( + colorScheme = colors, + typography = Typography, + shapes = Shapes, + content = content + ) +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Type.kt b/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Type.kt new file mode 100644 index 0000000..b1b7315 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.mkfunproj.mobilemkch.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/util/ImageUtil.kt b/app/src/main/java/com/mkfunproj/mobilemkch/util/ImageUtil.kt new file mode 100644 index 0000000..89472d2 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/util/ImageUtil.kt @@ -0,0 +1,39 @@ +package com.mkfunproj.mobilemkch.util + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.provider.MediaStore +import java.io.File +import java.io.FileOutputStream + +object ImageUtil { + fun downscaleJpeg(context: Context, uri: Uri, maxDim: Int = 1600, quality: Int = 85): File? { + return runCatching { + val source = if (Build.VERSION.SDK_INT >= 28) { + ImageDecoder.createSource(context.contentResolver, uri) + } else null + val bmp: Bitmap = if (source != null) ImageDecoder.decodeBitmap(source) else MediaStore.Images.Media.getBitmap(context.contentResolver, uri) + val ratio = bmp.width.toFloat() / bmp.height + val (w, h) = if (bmp.width >= bmp.height) { + val w = maxDim + val h = (w / ratio).toInt() + w to h + } else { + val h = maxDim + val w = (h * ratio).toInt() + w to h + } + val scaled = Bitmap.createScaledBitmap(bmp, w, h, true) + val out = File(context.cacheDir, "scaled_${System.currentTimeMillis()}.jpg") + FileOutputStream(out).use { fos -> + scaled.compress(Bitmap.CompressFormat.JPEG, quality, fos) + } + out + }.getOrNull() + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/util/NotificationHelper.kt b/app/src/main/java/com/mkfunproj/mobilemkch/util/NotificationHelper.kt new file mode 100644 index 0000000..8860397 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/util/NotificationHelper.kt @@ -0,0 +1,45 @@ +package com.mkfunproj.mobilemkch.util + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import androidx.core.app.NotificationCompat +import com.mkfunproj.mobilemkch.R + +object NotificationHelper { + private const val CHANNEL_ID = "mkch_threads" + + private fun ensureChannel(nm: NotificationManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val ch = NotificationChannel(CHANNEL_ID, "Новые треды", NotificationManager.IMPORTANCE_DEFAULT) + nm.createNotificationChannel(ch) + } + } + + fun notifyThread(context: Context, board: String, threadId: Int, title: String) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureChannel(nm) + val notif = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Новый тред /" + board + "/") + .setContentText(title) + .setAutoCancel(true) + .build() + nm.notify((board.hashCode() xor threadId), notif) + } + + fun notifyTest(context: Context) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + ensureChannel(nm) + val notif = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Тестовое уведомление") + .setContentText("Новый тред: Тестовый тред в /test/") + .setAutoCancel(true) + .build() + nm.notify(0x777777, notif) + } +} + + diff --git a/app/src/main/java/com/mkfunproj/mobilemkch/workers/ThreadsCheckWorker.kt b/app/src/main/java/com/mkfunproj/mobilemkch/workers/ThreadsCheckWorker.kt new file mode 100644 index 0000000..81841b0 --- /dev/null +++ b/app/src/main/java/com/mkfunproj/mobilemkch/workers/ThreadsCheckWorker.kt @@ -0,0 +1,32 @@ +package com.mkfunproj.mobilemkch.workers + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.mkfunproj.mobilemkch.data.ApiClient +import com.mkfunproj.mobilemkch.data.SettingsStore +import com.mkfunproj.mobilemkch.data.SubscriptionsStore +import com.mkfunproj.mobilemkch.util.NotificationHelper +import kotlinx.coroutines.flow.first + +class ThreadsCheckWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result { + val settings = SettingsStore(applicationContext).data.first() + if (!settings.notificationsEnabled) return Result.success() + val subs = SubscriptionsStore(applicationContext).flow.first() + if (subs.isEmpty()) return Result.success() + val api = ApiClient(applicationContext) + var notified = 0 + subs.forEach { board -> + runCatching { api.checkNewThreads(board) }.onSuccess { list -> + list.forEach { t -> + NotificationHelper.notifyThread(applicationContext, board, t.id, t.title) + notified += 1 + } + } + } + return Result.success() + } +} + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_notification.xml b/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..dca65f6 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..e8e18e4 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,4 @@ + + +