diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 012e38d..b2e6839 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -89,6 +89,9 @@ base { dependencies { 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) implementation(libs.androidx.core.ktx) diff --git a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt b/app/src/main/java/ru/sweetbread/unn/UNNApp.kt index 482b5f1..e4896a2 100644 --- a/app/src/main/java/ru/sweetbread/unn/UNNApp.kt +++ b/app/src/main/java/ru/sweetbread/unn/UNNApp.kt @@ -10,6 +10,8 @@ import ru.sweetbread.unn.core.SessionManager import ru.sweetbread.unn.core.network.PortalApi import ru.sweetbread.unn.core.network.createHttpClient 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 splitties.arch.room.roomDb @@ -45,6 +47,16 @@ class AppContainer(context: Context) { fallbackToDestructiveMigration(dropAllTables = true) } + val userRepository = UserRepository( + portalApi, + database, + ) + val newsRepository = NewsRepository( + portalApi, + database, + sessionManager, + userRepository + ) val scheduleRepository = ScheduleRepository( api = portalApi, db = database, diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/NewsDao.kt b/app/src/main/java/ru/sweetbread/unn/core/db/NewsDao.kt new file mode 100644 index 0000000..4d7c7f1 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/db/NewsDao.kt @@ -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> + + @Query("SELECT * FROM newsDB ORDER BY `publishedAt` DESC") + fun paging(): PagingSource + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(news: List) + + @Delete + suspend fun delete(unit: NewsDB) +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/News.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/News.kt new file mode 100644 index 0000000..782367f --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/News.kt @@ -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 +) \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/db/entity/UserDB.kt b/app/src/main/java/ru/sweetbread/unn/core/db/entity/UserDB.kt new file mode 100644 index 0000000..53fa1a0 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/core/db/entity/UserDB.kt @@ -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 +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt b/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt index 6fb761e..b45397a 100644 --- a/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt +++ b/app/src/main/java/ru/sweetbread/unn/core/network/PortalAPI.kt @@ -29,7 +29,7 @@ class PortalApi ( LoginResult.InvalidCredentials } else { session.update( - user = bitrix.getCurrentProfile(), + user = bitrix.getCurrentUser(), sessionId = id, uns = login.drop(1).toInt() ) diff --git a/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt index d3e3cfd..86a3799 100644 --- a/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt +++ b/app/src/main/java/ru/sweetbread/unn/core/network/endpoints.kt @@ -19,6 +19,7 @@ import ru.sweetbread.unn.api.Lecturer import ru.sweetbread.unn.api.LecturerRank import ru.sweetbread.unn.api.ScheduleUnit import ru.sweetbread.unn.core.SessionManager +import ru.sweetbread.unn.model.NewsDto import ru.sweetbread.unn.model.User import java.time.LocalDate 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 + * + * РУЗ - расписание учебных занятий */ class Ruzapi( val client: HttpClient, @@ -181,10 +183,23 @@ class Bitrix( val client: HttpClient, val session: SessionManager ) { - suspend fun getCurrentProfile(): User { + suspend fun getCurrentUser(): User { 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())) } + + suspend fun getNews(page: Int = 1, amount: Int = 20): ArrayList { + 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 { diff --git a/app/src/main/java/ru/sweetbread/unn/db/DB.kt b/app/src/main/java/ru/sweetbread/unn/db/DB.kt index 9f7d2fb..f99f57c 100644 --- a/app/src/main/java/ru/sweetbread/unn/db/DB.kt +++ b/app/src/main/java/ru/sweetbread/unn/db/DB.kt @@ -4,6 +4,9 @@ package ru.sweetbread.unn.db import androidx.room.Database 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 = [ UserDB::class, @@ -12,7 +15,9 @@ import androidx.room.RoomDatabase DisciplineDB::class, KindOfWorkDB::class, LecturerDB::class, - ScheduleUnitDB::class], version = 2) + ScheduleUnitDB::class, + NewsDB::class +], version = 3) abstract class AppDatabase : RoomDatabase() { abstract fun userDao(): UserDao abstract fun scheduleDao(): ScheduleItemDao @@ -21,4 +26,5 @@ abstract class AppDatabase : RoomDatabase() { abstract fun disciplineDao(): DisciplineDao abstract fun kindOfWorkDao(): KindOfWorkDao abstract fun lecturerDao(): LecturerDao + abstract fun newsDao(): NewsDao } diff --git a/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt b/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt index 9a1d807..2335664 100644 --- a/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt +++ b/app/src/main/java/ru/sweetbread/unn/db/UserDB.kt @@ -2,38 +2,19 @@ package ru.sweetbread.unn.db -import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Delete -import androidx.room.Entity import androidx.room.Insert -import androidx.room.PrimaryKey import androidx.room.Query -import ru.sweetbread.unn.model.Type - -@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 -) +import ru.sweetbread.unn.core.db.entity.UserDB @Dao interface UserDao { @Query("SELECT * FROM userDB WHERE userId = :userId LIMIT 1") - fun getUserById(userId: Int): UserDB? + suspend fun get(userId: Int): UserDB? @Insert - fun insert(user: UserDB) + suspend fun insert(user: UserDB) @Delete fun delete(user: UserDB) diff --git a/app/src/main/java/ru/sweetbread/unn/feature/news/News.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/News.kt new file mode 100644 index 0000000..9bfbcdc --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/news/News.kt @@ -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 { + 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 +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRemoteMediator.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRemoteMediator.kt new file mode 100644 index 0000000..59a8ad8 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRemoteMediator.kt @@ -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() { + private var nextPage = 1 + + @RequiresExtension(extension = Build.VERSION_CODES.S, version = 7) + @OptIn(ExperimentalPagingApi::class) + override suspend fun load( + loadType: LoadType, + state: PagingState + ): 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRepository.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRepository.kt new file mode 100644 index 0000000..cfc4545 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsRepository.kt @@ -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> = 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/feature/news/NewsViewModel.kt b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsViewModel.kt new file mode 100644 index 0000000..9fa7f67 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/news/NewsViewModel.kt @@ -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 + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/feature/profile/UserRepository.kt b/app/src/main/java/ru/sweetbread/unn/feature/profile/UserRepository.kt new file mode 100644 index 0000000..7cff4c7 --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/feature/profile/UserRepository.kt @@ -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() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/model/News.kt b/app/src/main/java/ru/sweetbread/unn/model/News.kt new file mode 100644 index 0000000..4b52fdb --- /dev/null +++ b/app/src/main/java/ru/sweetbread/unn/model/News.kt @@ -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, +// 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 { + val out: ArrayList = 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 { +// +// } +// } +//} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/model/User.kt b/app/src/main/java/ru/sweetbread/unn/model/User.kt index 5d9af46..2061885 100644 --- a/app/src/main/java/ru/sweetbread/unn/model/User.kt +++ b/app/src/main/java/ru/sweetbread/unn/model/User.kt @@ -6,47 +6,39 @@ import org.json.JSONObject import ru.sweetbread.unn.R import ru.sweetbread.unn.api.AvatarSet import splitties.resources.appStr -import java.time.Instant -import java.time.LocalDate -import java.time.ZoneId enum class Type(val s: String) { student(appStr(R.string.student)), - group("group"), + // group("group"), lecturer(appStr(R.string.lecturer)), - auditorium("auditorium"), + // auditorium("auditorium"), employee(appStr(R.string.employee)) } -class User( -// val unnId: Int?, -// val userId: Int, - val bx_id: Int?, // Bitrix user ID - val ra_id: Int?, // ruzapi ID (for schedule) - val type: Type, - val email: String, - val nameRu: String, - val nameEn: String, - val isMale: Boolean, - val birthday: LocalDate, - val avatar: AvatarSet +open class User( + open val userId: Int, // vuzapi ID + // val bxId: Int, // Bitrix user ID + // val raId: Int?, // ruzapi ID (for schedule) + open val type: Type, + open val email: String, + open val nameRu: String, + open val nameEn: String, + open val avatar: AvatarSet ) { - constructor(json: JSONObject) : this ( - json.getInt("bitrix_id"), - null, - when (json.getString("type")) { + constructor(json: JSONObject) : this( + json.getInt("id"), + when ( + json.getJSONArray("profiles") + .getJSONObject(0) + .getString("type") + ) { "student" -> Type.student else -> Type.employee }, - json.getJSONObject("user").getString("email"), - json.getJSONObject("user").getString("fullname"), - json.getJSONObject("user").getString("fullname_en"), - json.getJSONObject("user").getString("sex") == "M", - Instant - .parse(json.getJSONObject("user").getString("birthdate")) - .atZone(ZoneId.of("Europe/Moscow")) - .toLocalDate(), - json.getJSONObject("user").getJSONObject("photo").let { + json.getString("email"), + json.getString("fullname"), + json.getString("fullname_en"), + json.getJSONObject("photo").let { AvatarSet( it.getString("orig"), 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 + ) } \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt b/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt deleted file mode 100644 index 9139d04..0000000 --- a/app/src/main/java/ru/sweetbread/unn/ui/composes/Blogpost.kt +++ /dev/null @@ -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 { -// return getBlogposts() -// } -//} -// -//class PostViewModel : ViewModel() { -// private val repository = PostRepository() -// private val _posts = MutableStateFlow>(emptyList()) -// val posts: StateFlow> = _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 { -// 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 -//} \ No newline at end of file diff --git a/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt b/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt index 5c2f3bc..ffb087e 100644 --- a/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt +++ b/app/src/main/java/ru/sweetbread/unn/ui/layout/MainActivity.kt @@ -62,6 +62,7 @@ import kotlinx.coroutines.launch import ru.sweetbread.unn.R import ru.sweetbread.unn.UNNApp 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.ui.composes.About import ru.sweetbread.unn.ui.theme.UNNTheme @@ -175,8 +176,8 @@ class MainActivity : ComponentActivity() { Box(Modifier.padding(innerPadding)) { NavHost(navController, startDestination = "journal/schedule") { composable("portal/blogposts") { - Text("Not implemented") -// Blogposts() +// Text("Not implemented") + Blogposts() } composable("journal/schedule") { Schedule() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 47d2ec1..c8a5ce4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ room = "2.8.4" sentryAndroid = "8.55.0" sentryGradlePlugin = "6.19.0" splitties = "3.0.0" +paging = "3.5.1" [libraries] 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" } 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" } +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] android-application = { id = "com.android.application", version.ref = "agp" }