ref: blogposts

This commit is contained in:
2026-09-11 01:56:10 +03:00
parent 5b76f55cb7
commit b7647d7267
19 changed files with 838 additions and 401 deletions
+3
View File
@@ -89,6 +89,9 @@ base {
dependencies { dependencies {
implementation(libs.androidx.material.icons.core) implementation(libs.androidx.material.icons.core)
implementation(libs.androidx.paging.common)
implementation(libs.androidx.paging.compose)
implementation(libs.androidx.paging.room)
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
@@ -10,6 +10,8 @@ import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.core.network.PortalApi import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.core.network.createHttpClient import ru.sweetbread.unn.core.network.createHttpClient
import ru.sweetbread.unn.db.AppDatabase import ru.sweetbread.unn.db.AppDatabase
import ru.sweetbread.unn.feature.news.NewsRepository
import ru.sweetbread.unn.feature.profile.UserRepository
import ru.sweetbread.unn.feature.schedule.ScheduleRepository import ru.sweetbread.unn.feature.schedule.ScheduleRepository
import splitties.arch.room.roomDb import splitties.arch.room.roomDb
@@ -45,6 +47,16 @@ class AppContainer(context: Context) {
fallbackToDestructiveMigration(dropAllTables = true) fallbackToDestructiveMigration(dropAllTables = true)
} }
val userRepository = UserRepository(
portalApi,
database,
)
val newsRepository = NewsRepository(
portalApi,
database,
sessionManager,
userRepository
)
val scheduleRepository = ScheduleRepository( val scheduleRepository = ScheduleRepository(
api = portalApi, api = portalApi,
db = database, db = database,
@@ -0,0 +1,30 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core.db
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
import ru.sweetbread.unn.core.db.entity.NewsDB
@Dao
interface NewsDao {
@Query("SELECT * FROM newsDB WHERE id = :id LIMIT 1")
suspend fun get(id: Int): NewsDB?
@Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC")
fun getFlow(): Flow<List<NewsDB>>
@Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC")
fun paging(): PagingSource<Int, NewsDB>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(news: List<NewsDB>)
@Delete
suspend fun delete(unit: NewsDB)
}
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import ru.sweetbread.unn.feature.profile.UserRepository
import ru.sweetbread.unn.model.News
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Entity
data class NewsDB(
@PrimaryKey val id: Int,
@ColumnInfo val publishedAt: String,
@ColumnInfo val content: String,
@ColumnInfo val authorId: Int
)
suspend fun NewsDB.toModel(userRepo: UserRepository): News = News(
id,
LocalDateTime.parse(publishedAt, DateTimeFormatter.ISO_DATE_TIME),
content,
userRepo.getByUserId(this.authorId),
)
fun News.toEntity(): NewsDB = NewsDB(
id,
publishedAt.format(DateTimeFormatter.ISO_DATE_TIME),
content,
author.userId
)
@@ -0,0 +1,65 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.core.db.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import io.ktor.util.reflect.instanceOf
import ru.sweetbread.unn.api.AvatarSet
import ru.sweetbread.unn.model.Student
import ru.sweetbread.unn.model.Type
import ru.sweetbread.unn.model.User
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
@Entity
data class UserDB(
@PrimaryKey val userId: Int,
@ColumnInfo val uns: Int?,
@ColumnInfo val group: String?,
@ColumnInfo val extId: Int?,
@ColumnInfo val type: Type,
@ColumnInfo val email: String,
@ColumnInfo val nameRu: String,
@ColumnInfo val nameEn: String,
@ColumnInfo val origAvatar: String,
@ColumnInfo val thumbAvatar: String,
@ColumnInfo val smallAvatar: String,
@ColumnInfo val updatedAt: String
)
fun User.toEntity(): UserDB = UserDB(
userId,
if (this.instanceOf(Student::class)) (this as Student).uns else null,
if (this.instanceOf(Student::class)) (this as Student).group else null,
null,
type,
email,
nameRu,
nameEn,
avatar.original,
avatar.thumbnail,
avatar.small,
LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)
)
fun UserDB.toModel(): User {
var user = User(
userId,
type,
email,
nameRu,
nameEn,
AvatarSet(
origAvatar,
thumbAvatar,
smallAvatar
)
)
// if (type == Type.student)
// user = Student(uns!!, user, group!!)
return user
}
@@ -29,7 +29,7 @@ class PortalApi (
LoginResult.InvalidCredentials LoginResult.InvalidCredentials
} else { } else {
session.update( session.update(
user = bitrix.getCurrentProfile(), user = bitrix.getCurrentUser(),
sessionId = id, sessionId = id,
uns = login.drop(1).toInt() uns = login.drop(1).toInt()
) )
@@ -19,6 +19,7 @@ import ru.sweetbread.unn.api.Lecturer
import ru.sweetbread.unn.api.LecturerRank import ru.sweetbread.unn.api.LecturerRank
import ru.sweetbread.unn.api.ScheduleUnit import ru.sweetbread.unn.api.ScheduleUnit
import ru.sweetbread.unn.core.SessionManager import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.model.NewsDto
import ru.sweetbread.unn.model.User import ru.sweetbread.unn.model.User
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -67,9 +68,10 @@ class Auth (
/** /**
* Bitrix addon for universities * Bitrix addon for universities.
*
* Here are schedules, students and lecturers, auditorium and building * Here are schedules, students and lecturers, auditorium and building
*
* РУЗ - расписание учебных занятий
*/ */
class Ruzapi( class Ruzapi(
val client: HttpClient, val client: HttpClient,
@@ -181,10 +183,23 @@ class Bitrix(
val client: HttpClient, val client: HttpClient,
val session: SessionManager val session: SessionManager
) { ) {
suspend fun getCurrentProfile(): User { suspend fun getCurrentUser(): User {
val r = client.get("$vuzapiURL/profile/current") val r = client.get("$vuzapiURL/profile/current")
return User.fromProfile(JSONObject(r.bodyAsText()))
}
suspend fun getUserByID(userId: Int): User {
val r = client.get("$vuzapiURL/user/$userId")
return User(JSONObject(r.bodyAsText())) return User(JSONObject(r.bodyAsText()))
} }
suspend fun getNews(page: Int = 1, amount: Int = 20): ArrayList<NewsDto> {
val r = client.get("$prtl2URL/news_.php") {
parameter("numpage", page)
parameter("perpage", amount)
}
return NewsDto.fromJsonArray(JSONArray(r.bodyAsText()))
}
} }
//suspend fun getId(login: String): Int { //suspend fun getId(login: String): Int {
+7 -1
View File
@@ -4,6 +4,9 @@ package ru.sweetbread.unn.db
import androidx.room.Database import androidx.room.Database
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import ru.sweetbread.unn.core.db.NewsDao
import ru.sweetbread.unn.core.db.entity.NewsDB
import ru.sweetbread.unn.core.db.entity.UserDB
@Database(entities = [ @Database(entities = [
UserDB::class, UserDB::class,
@@ -12,7 +15,9 @@ import androidx.room.RoomDatabase
DisciplineDB::class, DisciplineDB::class,
KindOfWorkDB::class, KindOfWorkDB::class,
LecturerDB::class, LecturerDB::class,
ScheduleUnitDB::class], version = 2) ScheduleUnitDB::class,
NewsDB::class
], version = 3)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao abstract fun userDao(): UserDao
abstract fun scheduleDao(): ScheduleItemDao abstract fun scheduleDao(): ScheduleItemDao
@@ -21,4 +26,5 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun disciplineDao(): DisciplineDao abstract fun disciplineDao(): DisciplineDao
abstract fun kindOfWorkDao(): KindOfWorkDao abstract fun kindOfWorkDao(): KindOfWorkDao
abstract fun lecturerDao(): LecturerDao abstract fun lecturerDao(): LecturerDao
abstract fun newsDao(): NewsDao
} }
@@ -2,38 +2,19 @@
package ru.sweetbread.unn.db package ru.sweetbread.unn.db
import androidx.room.ColumnInfo
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert import androidx.room.Insert
import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import ru.sweetbread.unn.model.Type import ru.sweetbread.unn.core.db.entity.UserDB
@Entity
data class UserDB(
@PrimaryKey val userId: Int,
@ColumnInfo val unnId: Int?,
@ColumnInfo val type: Type,
@ColumnInfo val email: String,
@ColumnInfo val nameRu: String,
@ColumnInfo val nameEn: String,
@ColumnInfo val isMale: Boolean,
@ColumnInfo val birthday: String,
@ColumnInfo val origAvatar: String,
@ColumnInfo val thumbAvatar: String,
@ColumnInfo val smallAvatar: String,
@ColumnInfo val expiredAt: String
)
@Dao @Dao
interface UserDao { interface UserDao {
@Query("SELECT * FROM userDB WHERE userId = :userId LIMIT 1") @Query("SELECT * FROM userDB WHERE userId = :userId LIMIT 1")
fun getUserById(userId: Int): UserDB? suspend fun get(userId: Int): UserDB?
@Insert @Insert
fun insert(user: UserDB) suspend fun insert(user: UserDB)
@Delete @Delete
fun delete(user: UserDB) fun delete(user: UserDB)
@@ -0,0 +1,317 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.news
import android.graphics.drawable.Drawable
import android.text.Html
import android.text.method.LinkMovementMethod
import android.text.util.Linkify
import android.widget.TextView
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.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey
import coil3.ImageLoader
import coil3.asDrawable
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import ru.sweetbread.unn.core.network.portalURL
import ru.sweetbread.unn.model.News
import ru.sweetbread.unn.model.User
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
//val defUser = User(
// null,
// 123,
// Type.student,
// "cool.email@domain.com",
// "Джон Сигма Омегович",
// "Jon Sigma Omega",
// true,
// LocalDate.now(),
// AvatarSet(
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg"
// )
//)
//
//
@Composable
fun Blogposts(viewModel: NewsViewModel = viewModel(factory = NewsViewModel.Factory)) {
val news = viewModel.news.collectAsLazyPagingItems()
var extended by remember { mutableIntStateOf(0) }
LazyColumn {
items(
count = news.itemCount,
key = news.itemKey { it.id }
) {
news[it]?.let { post ->
PostItem(
Modifier
.padding(8.dp)
.clip(RoundedCornerShape(16.dp))
.background(MaterialTheme.colorScheme.secondaryContainer)
.clickable {
extended = if (extended == post.id) 0 else post.id
},
post = post,
extended == post.id
)
}
}
}
}
@Composable
fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) {
Row(
modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
AsyncImage(
modifier = Modifier
.padding(end = 8.dp)
.size(48.dp)
.clip(RoundedCornerShape(50)),
model = portalURL + user.avatar.thumbnail,
contentDescription = user.nameEn
)
Column {
Text(user.nameRu, fontWeight = FontWeight.Bold)
if (!info.isNullOrBlank())
Text(
text = info,
fontStyle = FontStyle.Italic,
fontSize = MaterialTheme.typography.labelLarge.fontSize
)
}
}
}
@Composable
fun PostItem(modifier: Modifier = Modifier, post: News, extended: Boolean = false) {
var html: String by remember { mutableStateOf("") }
LaunchedEffect(post) {
html = post.content
}
Column(modifier.padding(16.dp)) {
UserItem(user = post.author)
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
val linkColor = MaterialTheme.colorScheme.primary.toArgb()
AndroidView(
modifier = Modifier,
factory = {
TextView(it).apply {
movementMethod = LinkMovementMethod.getInstance()
autoLinkMask = Linkify.WEB_URLS
linksClickable = true
setTextColor(textColor)
setLinkTextColor(linkColor)
}
},
update = {
it.maxLines = if (extended) Int.MAX_VALUE else 5
it.text = Html.fromHtml(
html,
Html.FROM_HTML_MODE_LEGACY,
CoilImageGetter(it),
null
)
}
)
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
thickness = 1.dp,
color = MaterialTheme.colorScheme.onBackground
)
Text(text = post.publishedAt.format(
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
))
}
}
//
//
//@Preview
//@Composable
//fun UserItemPreview() {
// UNNTheme {
// Surface {
// UserItem(
// Modifier
// .width(300.dp)
// .clip(RoundedCornerShape(8.dp))
// .background(MaterialTheme.colorScheme.primaryContainer),
// defUser,
// Type.student.s
// )
// }
// }
//}
//
//@Preview
//@Composable
//fun PostItemPreview() {
// val post = Post(
// id = 154923,
// authorId = 165945,
// enableComments = true,
// numComments = 0,
// date = LocalDateTime.of(2024, 3, 20, 18, 55, 20),
// content = stringResource(id = R.string.lorem)
// )
//
// UNNTheme {
// Surface {
// PostItem(
// Modifier
// .clip(RoundedCornerShape(8.dp))
// .background(MaterialTheme.colorScheme.primaryContainer), post
// )
// }
// }
//}
class CoilImageGetter(
private val textView: TextView,
private val maxImageWidth: Int = textView.width
) : Html.ImageGetter {
override fun getDrawable(source: String): Drawable {
val urlDrawable = UrlDrawable()
if (maxImageWidth <= 0) {
textView.post {
updateImage(
source,
urlDrawable,
textView.width
)
}
} else {
updateImage(
source,
urlDrawable,
maxImageWidth
)
}
return urlDrawable
}
private fun updateImage(
source: String,
urlDrawable: UrlDrawable,
maxWidth: Int
) {
val imageLoader =
ImageLoader.Builder(textView.context)
.build()
val request =
ImageRequest.Builder(textView.context)
.data(source)
.target { image ->
val drawable =
image.asDrawable(
textView.resources
)
val (width, height) =
calculateScaledSize(
drawable.intrinsicWidth,
drawable.intrinsicHeight,
maxWidth
)
drawable.setBounds(
0, 0,
width, height
)
urlDrawable.drawable = drawable
urlDrawable.setBounds(
0, 0,
width, height
)
// заставляем TextView заново нарисовать
textView.text = textView.text
}
.build()
imageLoader.enqueue(request)
}
private fun calculateScaledSize(
originalWidth: Int,
originalHeight: Int,
maxWidth: Int
): Pair<Int, Int> {
if (originalWidth <= maxWidth) {
return originalWidth to originalHeight
}
val ratio =
maxWidth.toFloat() / originalWidth
return maxWidth to
(originalHeight * ratio).toInt()
}
}
class UrlDrawable() : Drawable() {
var drawable: Drawable? = null
set(value) {
field = value
invalidateSelf()
}
override fun draw(canvas: android.graphics.Canvas) {
drawable?.draw(canvas)
}
override fun setAlpha(alpha: Int) {}
override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
}
@@ -0,0 +1,89 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.news
import android.net.http.HttpException
import android.os.Build
import androidx.annotation.RequiresExtension
import androidx.paging.ExperimentalPagingApi
import androidx.paging.LoadType
import androidx.paging.PagingState
import androidx.paging.RemoteMediator
import androidx.room.withTransaction
import kotlinx.io.IOException
import ru.sweetbread.unn.core.db.entity.NewsDB
import ru.sweetbread.unn.core.db.entity.toEntity
import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.db.AppDatabase
import ru.sweetbread.unn.feature.profile.UserRepository
import ru.sweetbread.unn.model.News
@OptIn(ExperimentalPagingApi::class)
class NewsRemoteMediator(
private val db: AppDatabase,
private val api: PortalApi,
private val userRepo: UserRepository
) : RemoteMediator<Int, NewsDB>() {
private var nextPage = 1
@RequiresExtension(extension = Build.VERSION_CODES.S, version = 7)
@OptIn(ExperimentalPagingApi::class)
override suspend fun load(
loadType: LoadType,
state: PagingState<Int, NewsDB>
): MediatorResult {
val page = when (loadType) {
LoadType.REFRESH -> {
nextPage = 1
1
}
LoadType.PREPEND -> {
return MediatorResult.Success(
endOfPaginationReached = true
)
}
LoadType.APPEND -> { nextPage }
}
return try {
val response = api.bitrix.getNews(page)
val news = response.map { dto ->
val author = userRepo.getByUserId(dto.authorId)
News(
dto.id,
dto.publishedAt,
dto.content,
author
)
}
db.withTransaction {
// if (loadType == LoadType.REFRESH) {
// db.newsDao().clear()
// }
db.newsDao().insertAll(
news.map { it.toEntity() }
)
}
nextPage = page + 1
MediatorResult.Success(
endOfPaginationReached =
news.size < 20
)
} catch (e: IOException) {
MediatorResult.Error(e)
} catch (e: HttpException) {
MediatorResult.Error(e)
}
}
}
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.news
import androidx.paging.ExperimentalPagingApi
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.map
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import ru.sweetbread.unn.core.SessionManager
import ru.sweetbread.unn.core.db.entity.toModel
import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.db.AppDatabase
import ru.sweetbread.unn.feature.profile.UserRepository
import ru.sweetbread.unn.model.News
class NewsRepository (
val api: PortalApi,
val db: AppDatabase,
val session: SessionManager,
val userRepository: UserRepository
) {
@OptIn(ExperimentalPagingApi::class)
fun getNews(): Flow<PagingData<News>> = Pager(
config = PagingConfig(
pageSize = 20,
initialLoadSize = 20,
prefetchDistance = 10,
enablePlaceholders = false
),
remoteMediator = NewsRemoteMediator(
db,
api,
userRepository
),
pagingSourceFactory = {
db.newsDao().paging()
}
)
.flow
.map { pagingData ->
pagingData.map { entity ->
entity.toModel(userRepository)
}
}
}
@@ -0,0 +1,33 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.news
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.paging.cachedIn
import ru.sweetbread.unn.UNNApp
class NewsViewModel(
repository: NewsRepository
) : ViewModel() {
val news = repository
.getNews()
.cachedIn(viewModelScope)
companion object {
val Factory = viewModelFactory {
initializer {
val application = checkNotNull(
this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY]
) as UNNApp
NewsViewModel(
repository = application.container.newsRepository
)
}
}
}
}
@@ -0,0 +1,28 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.feature.profile
import android.util.Log
import ru.sweetbread.unn.core.db.entity.toEntity
import ru.sweetbread.unn.core.db.entity.toModel
import ru.sweetbread.unn.core.network.PortalApi
import ru.sweetbread.unn.db.AppDatabase
import ru.sweetbread.unn.model.User
class UserRepository (
val api: PortalApi,
val db: AppDatabase,
) {
suspend fun getByUserId(id: Int): User {
if (id == 0) return User.getPlaceholder()
val fromDb = db.userDao().get(id)
if (fromDb == null) {
Log.d("user", "id: $id")
val fromApi = api.bitrix.getUserByID(id)
db.userDao().insert(fromApi.toEntity())
return fromApi
}
return fromDb.toModel()
}
}
@@ -0,0 +1,60 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.model
import org.json.JSONArray
import org.json.JSONObject
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
class News (
val id: Int,
val publishedAt: LocalDateTime,
val content: String,
val author: User
)
class NewsDto (
val id: Int,
val publishedAt: LocalDateTime,
val content: String,
val authorId: Int,
val authorLogin: String
// val comments: ArrayList<NewsComment>,
// val important: Boolean,
// val pinned: Boolean
) {
constructor(json: JSONObject) : this (
json.getString("id").toInt(),
LocalDateTime.parse(
json.getString("time"),
DateTimeFormatter.ofPattern("dd.MM.yyyy HH:mm:ss")
),
json.getString("fulltext"),
json.getJSONObject("author").getString("user_id").toInt(),
json.getJSONObject("author").getString("login")
// NewsComment.fromJsonArray(json.getJSONArray("comments")),
// json.getBoolean("important"),
// json.getBoolean("pinned")
)
companion object {
fun fromJsonArray(arr: JSONArray): ArrayList<NewsDto> {
val out: ArrayList<NewsDto> = arrayListOf()
for (i in 0 until arr.length()) {
out.add(NewsDto(arr.getJSONObject(i)))
}
return out
}
}
}
//class NewsComment() {
// companion object {
// fun fromJsonArray(json: JSONArray): ArrayList<NewsComment> {
//
// }
// }
//}
@@ -6,47 +6,39 @@ import org.json.JSONObject
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import ru.sweetbread.unn.api.AvatarSet import ru.sweetbread.unn.api.AvatarSet
import splitties.resources.appStr import splitties.resources.appStr
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
enum class Type(val s: String) { enum class Type(val s: String) {
student(appStr(R.string.student)), student(appStr(R.string.student)),
group("group"), // group("group"),
lecturer(appStr(R.string.lecturer)), lecturer(appStr(R.string.lecturer)),
auditorium("auditorium"), // auditorium("auditorium"),
employee(appStr(R.string.employee)) employee(appStr(R.string.employee))
} }
class User( open class User(
// val unnId: Int?, open val userId: Int, // vuzapi ID
// val userId: Int, // val bxId: Int, // Bitrix user ID
val bx_id: Int?, // Bitrix user ID // val raId: Int?, // ruzapi ID (for schedule)
val ra_id: Int?, // ruzapi ID (for schedule) open val type: Type,
val type: Type, open val email: String,
val email: String, open val nameRu: String,
val nameRu: String, open val nameEn: String,
val nameEn: String, open val avatar: AvatarSet
val isMale: Boolean,
val birthday: LocalDate,
val avatar: AvatarSet
) { ) {
constructor(json: JSONObject) : this ( constructor(json: JSONObject) : this(
json.getInt("bitrix_id"), json.getInt("id"),
null, when (
when (json.getString("type")) { json.getJSONArray("profiles")
.getJSONObject(0)
.getString("type")
) {
"student" -> Type.student "student" -> Type.student
else -> Type.employee else -> Type.employee
}, },
json.getJSONObject("user").getString("email"), json.getString("email"),
json.getJSONObject("user").getString("fullname"), json.getString("fullname"),
json.getJSONObject("user").getString("fullname_en"), json.getString("fullname_en"),
json.getJSONObject("user").getString("sex") == "M", json.getJSONObject("photo").let {
Instant
.parse(json.getJSONObject("user").getString("birthdate"))
.atZone(ZoneId.of("Europe/Moscow"))
.toLocalDate(),
json.getJSONObject("user").getJSONObject("photo").let {
AvatarSet( AvatarSet(
it.getString("orig"), it.getString("orig"),
it.getString("thumbnail"), it.getString("thumbnail"),
@@ -54,4 +46,65 @@ class User(
) )
} }
) )
companion object {
fun fromProfile(json: JSONObject): User {
val user = json.getJSONObject("user")
return User(
user.getInt("id"),
when (json.getString("type")) {
"student" -> Type.student
else -> Type.employee
},
user.getString("email"),
user.getString("fullname"),
user.getString("fullname_en"),
user.getJSONObject("photo").let {
AvatarSet(
it.getString("orig"),
it.getString("thumbnail"),
it.getString("small"),
)
}
)
}
fun getPlaceholder() = User(
0,
Type.employee,
"",
"Удалённый пользователь",
"Deleted user",
AvatarSet("", "", "")
)
}
}
class Student(
val uns: Int?,
override val userId: Int,
override val type: Type,
override val email: String,
override val nameRu: String,
override val nameEn: String,
override val avatar: AvatarSet,
val group: String
) : User (
userId,
type,
email,
nameRu,
nameEn,
avatar,
) {
constructor(uns: Int? = null, user: User, group: String) : this (
uns,
user.userId,
user.type,
user.email,
user.nameRu,
user.nameEn,
user.avatar,
group
)
} }
@@ -1,342 +0,0 @@
// Copyright (c) 2026 Gleb Zaharov. License: GPLv3 (see LICENSE).
package ru.sweetbread.unn.ui.composes
//import android.graphics.drawable.Drawable
//import android.text.Html
//import android.text.method.LinkMovementMethod
//import android.text.util.Linkify
//import android.util.Log
//import android.widget.TextView
//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.Row
//import androidx.compose.foundation.layout.fillMaxWidth
//import androidx.compose.foundation.layout.padding
//import androidx.compose.foundation.layout.size
//import androidx.compose.foundation.layout.width
//import androidx.compose.foundation.lazy.LazyColumn
//import androidx.compose.foundation.lazy.items
//import androidx.compose.foundation.shape.RoundedCornerShape
//import androidx.compose.material3.HorizontalDivider
//import androidx.compose.material3.LinearProgressIndicator
//import androidx.compose.material3.MaterialTheme
//import androidx.compose.material3.Surface
//import androidx.compose.material3.Text
//import androidx.compose.runtime.Composable
//import androidx.compose.runtime.LaunchedEffect
//import androidx.compose.runtime.NonRestartableComposable
//import androidx.compose.runtime.collectAsState
//import androidx.compose.runtime.getValue
//import androidx.compose.runtime.mutableIntStateOf
//import androidx.compose.runtime.mutableStateOf
//import androidx.compose.runtime.remember
//import androidx.compose.runtime.setValue
//import androidx.compose.ui.Alignment
//import androidx.compose.ui.Modifier
//import androidx.compose.ui.draw.clip
//import androidx.compose.ui.graphics.toArgb
//import androidx.compose.ui.res.stringResource
//import androidx.compose.ui.text.font.FontStyle
//import androidx.compose.ui.text.font.FontWeight
//import androidx.compose.ui.tooling.preview.Preview
//import androidx.compose.ui.unit.dp
//import androidx.compose.ui.viewinterop.AndroidView
//import androidx.lifecycle.ViewModel
//import androidx.lifecycle.viewmodel.compose.viewModel
//import coil.ImageLoader
//import coil.compose.AsyncImage
//import coil.request.ImageRequest
//import kotlinx.coroutines.flow.MutableStateFlow
//import kotlinx.coroutines.flow.StateFlow
//import kotlinx.coroutines.flow.asStateFlow
//import ru.sweetbread.unn.R
//import ru.sweetbread.unn.api.AvatarSet
//import ru.sweetbread.unn.api.Post
//import ru.sweetbread.unn.api.Type
//import ru.sweetbread.unn.api.User
//import ru.sweetbread.unn.core.network.getBlogposts
//import ru.sweetbread.unn.core.network.getUserByBitrixId
//import ru.sweetbread.unn.core.network.portalURL
//import ru.sweetbread.unn.ui.theme.UNNTheme
//import java.time.LocalDate
//import java.time.LocalDateTime
//import java.time.format.DateTimeFormatter
//import java.time.format.FormatStyle
//
//
//val defUser = User(
// null,
// 123,
// Type.student,
// "cool.email@domain.com",
// "Джон Сигма Омегович",
// "Jon Sigma Omega",
// true,
// LocalDate.now(),
// AvatarSet(
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg",
// "https://upload.wikimedia.org/wikipedia/ru/thumb/9/94/%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg/500px-%D0%93%D0%B8%D0%B3%D0%B0%D1%87%D0%B0%D0%B4.jpg"
// )
//)
//
//
//@Composable
//fun Blogposts(viewModel: PostViewModel = viewModel()) {
// val posts by viewModel.posts.collectAsState()
// var extended by remember { mutableIntStateOf(0) }
//
// LaunchedEffect(Unit) {
// viewModel.loadPosts()
// }
//
// if (posts.isNotEmpty()) {
// Log.d("Another fuck", posts.size.toString())
// LazyColumn {
// items(posts) {
// PostItem(
// Modifier
// .padding(8.dp)
// .clip(RoundedCornerShape(16.dp))
// .background(MaterialTheme.colorScheme.secondaryContainer)
// .clickable {
// extended = if (extended == it.id) 0 else it.id
// },
// post = it,
// extended == it.id
// )
// }
// }
// } else {
// LinearProgressIndicator(
// modifier = Modifier
// .fillMaxWidth()
// .padding(8.dp),
// color = MaterialTheme.colorScheme.surfaceVariant,
// trackColor = MaterialTheme.colorScheme.secondary,
// )
// }
//}
//
//
//class PostRepository {
// suspend fun loadPosts(): List<Post> {
// return getBlogposts()
// }
//}
//
//class PostViewModel : ViewModel() {
// private val repository = PostRepository()
// private val _posts = MutableStateFlow<List<Post>>(emptyList())
// val posts: StateFlow<List<Post>> = _posts.asStateFlow()
//
// suspend fun loadPosts() {
// _posts.value = repository.loadPosts()
// }
//}
//
//
//@Composable
//@NonRestartableComposable
//fun UserItem(modifier: Modifier = Modifier, user: User, info: String? = null) {
// Row(
// modifier.padding(16.dp),
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// AsyncImage(
// modifier = Modifier
// .padding(end = 8.dp)
// .size(48.dp)
// .clip(RoundedCornerShape(50)),
// model = portalURL + user.avatar.thumbnail,
// contentDescription = user.nameEn
// )
//
// Column {
// Text(user.nameRu, fontWeight = FontWeight.Bold)
// if (!info.isNullOrBlank())
// Text(
// text = info,
// fontStyle = FontStyle.Italic,
// fontSize = MaterialTheme.typography.labelLarge.fontSize
// )
// }
// }
//}
//
//@Composable
//@NonRestartableComposable
//fun PostItem(modifier: Modifier = Modifier, post: Post, extended: Boolean = false) {
// var user: User? by remember { mutableStateOf(null) }
// var html: String by remember { mutableStateOf("") }
//
//
// LaunchedEffect(post) {
// html = post.content
// user = getUserByBitrixId(post.authorId)
// }
//
// Column(modifier.padding(16.dp)) {
// if (user != null)
// UserItem(user = user!!)
// else
// LinearProgressIndicator(
// modifier = Modifier
// .fillMaxWidth()
// .padding(horizontal = 8.dp, vertical = 38.dp),
// color = MaterialTheme.colorScheme.surfaceVariant,
// trackColor = MaterialTheme.colorScheme.secondary,
// )
//
// val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
// val linkColor = MaterialTheme.colorScheme.primary.toArgb()
//
// AndroidView(
// modifier = Modifier,
// factory = {
// TextView(it).apply {
// movementMethod = LinkMovementMethod.getInstance()
// autoLinkMask = Linkify.WEB_URLS
// linksClickable = true
// setTextColor(textColor)
// setLinkTextColor(linkColor)
// }
// },
// update = {
// it.maxLines = if (extended) Int.MAX_VALUE else 5
// it.text = Html.fromHtml(
// html,
// Html.FROM_HTML_MODE_LEGACY,
// CoilImageGetter(it),
// null
// )
// }
// )
//
// HorizontalDivider(
// modifier = Modifier.padding(vertical = 16.dp),
// thickness = 1.dp,
// color = MaterialTheme.colorScheme.onBackground
// )
//
// Text(text = post.date.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)))
// }
//}
//
//
//@Preview
//@Composable
//fun UserItemPreview() {
// UNNTheme {
// Surface {
// UserItem(
// Modifier
// .width(300.dp)
// .clip(RoundedCornerShape(8.dp))
// .background(MaterialTheme.colorScheme.primaryContainer),
// defUser,
// Type.student.s
// )
// }
// }
//}
//
//@Preview
//@Composable
//fun PostItemPreview() {
// val post = Post(
// id = 154923,
// authorId = 165945,
// enableComments = true,
// numComments = 0,
// date = LocalDateTime.of(2024, 3, 20, 18, 55, 20),
// content = stringResource(id = R.string.lorem)
// )
//
// UNNTheme {
// Surface {
// PostItem(
// Modifier
// .clip(RoundedCornerShape(8.dp))
// .background(MaterialTheme.colorScheme.primaryContainer), post
// )
// }
// }
//}
//
//
//class CoilImageGetter(
// private val textView: TextView,
// private val maxImageWidth: Int = textView.width
//) : Html.ImageGetter {
//
// override fun getDrawable(source: String): Drawable {
// val urlDrawable = UrlDrawable()
//
// if (maxImageWidth <= 0)
// textView.post { updateImage(source, urlDrawable, textView.width) }
// else
// updateImage(source, urlDrawable, maxImageWidth)
//
// return urlDrawable
// }
//
// private fun updateImage(source: String, urlDrawable: UrlDrawable, maxWidth: Int) {
// val imageLoader = ImageLoader.Builder(textView.context)
// .build()
//
// val request = ImageRequest.Builder(textView.context)
// .data(source)
// .target { drawable ->
// val (scaledWidth, scaledHeight) = calculateScaledSize(
// drawable.intrinsicWidth,
// drawable.intrinsicHeight,
// maxWidth
// )
//
// drawable.setBounds(0, 0, scaledWidth, scaledHeight)
// urlDrawable.drawable = drawable
// urlDrawable.setBounds(0, 0, scaledWidth, scaledHeight)
//
// textView.text = textView.text
// }
// .build()
//
// imageLoader.enqueue(request)
// }
//
// private fun calculateScaledSize(
// originalWidth: Int,
// originalHeight: Int,
// maxWidth: Int
// ): Pair<Int, Int> {
// if (originalWidth <= maxWidth)
// return Pair(originalWidth, originalHeight)
//
// val ratio = maxWidth.toFloat() / originalWidth.toFloat()
// return Pair(
// maxWidth,
// (originalHeight * ratio).toInt()
// )
// }
//}
//
//class UrlDrawable() : Drawable() {
// var drawable: Drawable? = null
// set(value) {
// field = value
// invalidateSelf()
// }
//
// override fun draw(canvas: android.graphics.Canvas) {
// drawable?.draw(canvas)
// }
//
// override fun setAlpha(alpha: Int) {}
// override fun setColorFilter(colorFilter: android.graphics.ColorFilter?) {}
// override fun getOpacity(): Int = android.graphics.PixelFormat.TRANSLUCENT
//}
@@ -62,6 +62,7 @@ import kotlinx.coroutines.launch
import ru.sweetbread.unn.R import ru.sweetbread.unn.R
import ru.sweetbread.unn.UNNApp import ru.sweetbread.unn.UNNApp
import ru.sweetbread.unn.core.network.portalURL import ru.sweetbread.unn.core.network.portalURL
import ru.sweetbread.unn.feature.news.Blogposts
import ru.sweetbread.unn.feature.schedule.Schedule import ru.sweetbread.unn.feature.schedule.Schedule
import ru.sweetbread.unn.ui.composes.About import ru.sweetbread.unn.ui.composes.About
import ru.sweetbread.unn.ui.theme.UNNTheme import ru.sweetbread.unn.ui.theme.UNNTheme
@@ -175,8 +176,8 @@ class MainActivity : ComponentActivity() {
Box(Modifier.padding(innerPadding)) { Box(Modifier.padding(innerPadding)) {
NavHost(navController, startDestination = "journal/schedule") { NavHost(navController, startDestination = "journal/schedule") {
composable("portal/blogposts") { composable("portal/blogposts") {
Text("Not implemented") // Text("Not implemented")
// Blogposts() Blogposts()
} }
composable("journal/schedule") { composable("journal/schedule") {
Schedule() Schedule()
+5
View File
@@ -18,6 +18,7 @@ room = "2.8.4"
sentryAndroid = "8.55.0" sentryAndroid = "8.55.0"
sentryGradlePlugin = "6.19.0" sentryGradlePlugin = "6.19.0"
splitties = "3.0.0" splitties = "3.0.0"
paging = "3.5.1"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -52,6 +53,10 @@ ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "k
sentry = { module = "io.sentry:sentry-android", version.ref = "sentryAndroid" } sentry = { module = "io.sentry:sentry-android", version.ref = "sentryAndroid" }
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl", version.ref = "splitties" } splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base-with-views-dsl", version.ref = "splitties" }
splitties-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" } splitties-room = { module = "com.louiscad.splitties:splitties-arch-room", version.ref = "splitties" }
androidx-paging-common = { group = "androidx.paging", name = "paging-common", version.ref = "paging" }
androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "paging" }
androidx-paging-compose = { module = "androidx.paging:paging-compose", version.ref = "paging" }
androidx-paging-room = { module = "androidx.room:room-paging", version.ref = "room" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }