diff --git a/api/src/main/java/com/readrops/api/utils/DateUtils.kt b/api/src/main/java/com/readrops/api/utils/DateUtils.kt index b201b4af..a765ebee 100644 --- a/api/src/main/java/com/readrops/api/utils/DateUtils.kt +++ b/api/src/main/java/com/readrops/api/utils/DateUtils.kt @@ -3,7 +3,7 @@ package com.readrops.api.utils import org.joda.time.LocalDateTime import org.joda.time.format.DateTimeFormat import org.joda.time.format.DateTimeFormatterBuilder -import java.util.* +import java.util.Locale object DateUtils { @@ -35,16 +35,16 @@ object DateUtils { null } else try { val formatter = DateTimeFormatterBuilder() - .appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone - .appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here - .appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser) - .appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser) - .appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser) - .appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser) - .appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser) - .toFormatter() - .withLocale(Locale.ENGLISH) - .withOffsetParsed() + .appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone + .appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here + .appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser) + .appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser) + .appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser) + .appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser) + .appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser) + .toFormatter() + .withLocale(Locale.ENGLISH) + .withOffsetParsed() formatter.parseLocalDateTime(value) } catch (e: Exception) { @@ -54,14 +54,26 @@ object DateUtils { @JvmStatic fun formattedDateByLocal(dateTime: LocalDateTime): String { return DateTimeFormat.mediumDate() - .withLocale(Locale.getDefault()) - .print(dateTime) + .withLocale(Locale.getDefault()) + .print(dateTime) } @JvmStatic fun formattedDateTimeByLocal(dateTime: LocalDateTime): String { return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm") - .withLocale(Locale.getDefault()) - .print(dateTime) + .withLocale(Locale.getDefault()) + .print(dateTime) + } + + fun formattedDate(dateTime: LocalDateTime): String { + val pattern = if (dateTime.year != LocalDateTime.now().year) { + "dd MMMM yyyy" + } else { + "dd MMMM" + } + + return DateTimeFormat.forPattern(pattern) + .withLocale(Locale.getDefault()) + .print(dateTime) } } \ No newline at end of file diff --git a/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt b/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt index 0e5eef90..a34d4202 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/ComposeAppModule.kt @@ -3,6 +3,7 @@ package com.readrops.app.compose import com.readrops.app.compose.account.AccountScreenModel import com.readrops.app.compose.account.selection.AccountSelectionViewModel import com.readrops.app.compose.feeds.FeedScreenModel +import com.readrops.app.compose.item.ItemScreenModel import com.readrops.app.compose.repositories.BaseRepository import com.readrops.app.compose.repositories.GetFoldersWithFeeds import com.readrops.app.compose.repositories.LocalRSSRepository @@ -20,6 +21,10 @@ val composeAppModule = module { factory { AccountScreenModel(get()) } + factory { (itemId: Int) -> + ItemScreenModel(get(), itemId) + } + single { GetFoldersWithFeeds(get()) } // repositories diff --git a/appcompose/src/main/java/com/readrops/app/compose/item/ItemScreen.kt b/appcompose/src/main/java/com/readrops/app/compose/item/ItemScreen.kt index 908441b5..db98fd14 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/item/ItemScreen.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/item/ItemScreen.kt @@ -1,13 +1,194 @@ package com.readrops.app.compose.item +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +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.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import cafe.adriel.voyager.koin.getScreenModel +import coil.compose.AsyncImage +import com.readrops.api.utils.DateUtils +import com.readrops.app.compose.R import com.readrops.app.compose.util.components.AndroidScreen +import com.readrops.app.compose.util.components.CenteredProgressIndicator +import com.readrops.app.compose.util.components.IconText +import com.readrops.app.compose.util.theme.ShortSpacer +import com.readrops.app.compose.util.theme.VeryShortSpacer +import com.readrops.app.compose.util.theme.spacing +import com.readrops.db.pojo.ItemWithFeed +import org.koin.core.parameter.parametersOf +import kotlin.math.roundToInt -class ItemScreen : AndroidScreen() { +class ItemScreen( + private val itemId: Int, +) : AndroidScreen() { @Composable override fun Content() { - Text(text ="item screen") + val screenModel = + getScreenModel(parameters = { parametersOf(itemId) }) + val scrollState = rememberScrollState() + + val state by screenModel.state.collectAsStateWithLifecycle() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .background(color = MaterialTheme.colorScheme.background) + .verticalScroll(scrollState) + ) { + if (state.itemWithFeed != null) { + val itemWithFeed = state.itemWithFeed!! + + if (itemWithFeed.item.imageLink != null) { + BackgroundTitle( + itemWithFeed = itemWithFeed + ) + } else { + val tintColor = if (itemWithFeed.bgColor != 0) { + Color(itemWithFeed.bgColor) + } else { + MaterialTheme.colorScheme.onBackground + } + + SimpleTitle( + itemWithFeed = itemWithFeed, + titleColor = tintColor, + tintColor = tintColor, + baseColor = MaterialTheme.colorScheme.onBackground + ) + } + } else { + CenteredProgressIndicator() + } + } + } +} + +@Composable +fun BackgroundTitle( + itemWithFeed: ItemWithFeed, +) { + val onScrimColor = Color.White.copy(alpha = 0.85f) + val tintColor = if (itemWithFeed.bgColor != 0) { + Color(itemWithFeed.bgColor) + } else { + onScrimColor + } + + Surface( + shape = RoundedCornerShape( + bottomStart = 24.dp, + bottomEnd = 24.dp + ), + modifier = Modifier.height(IntrinsicSize.Max) + ) { + AsyncImage( + model = itemWithFeed.item.imageLink, + contentDescription = null, + contentScale = ContentScale.Crop, + error = painterResource(id = R.drawable.ic_broken_image), + modifier = Modifier + .fillMaxSize() + ) + + Surface( + color = Color.Black.copy(alpha = 0.7f), + modifier = Modifier + .fillMaxSize() + ) { + SimpleTitle( + itemWithFeed = itemWithFeed, + titleColor = onScrimColor, + tintColor = tintColor, + baseColor = onScrimColor + ) + } + } +} + +@Composable +fun SimpleTitle( + itemWithFeed: ItemWithFeed, + titleColor: Color, + tintColor: Color, + baseColor: Color +) { + val item = itemWithFeed.item + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing) + ) { + AsyncImage( + model = itemWithFeed.feedIconUrl, + contentDescription = itemWithFeed.feedName, + placeholder = painterResource(id = R.drawable.ic_rss_feed_grey), + error = painterResource(id = R.drawable.ic_rss_feed_grey), + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + ) + + VeryShortSpacer() + + Text( + text = itemWithFeed.feedName, + style = MaterialTheme.typography.labelLarge, + color = baseColor + ) + + ShortSpacer() + + Text( + text = item.title!!, + style = MaterialTheme.typography.headlineMedium, + color = titleColor, + textAlign = TextAlign.Center, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + if (item.author != null) { + ShortSpacer() + + IconText( + icon = painterResource(id = R.drawable.ic_person), + text = itemWithFeed.item.author!!, + style = MaterialTheme.typography.labelMedium, + color = baseColor, + tint = tintColor + ) + } + + ShortSpacer() + + val readTime = + if (item.readTime < 1) "< 1 min" else "${item.readTime.roundToInt()} mins" + Text( + text = "${DateUtils.formattedDate(item.pubDate!!)} · $readTime", + style = MaterialTheme.typography.labelMedium, + color = baseColor + ) } } \ No newline at end of file diff --git a/appcompose/src/main/java/com/readrops/app/compose/item/ItemScreenModel.kt b/appcompose/src/main/java/com/readrops/app/compose/item/ItemScreenModel.kt new file mode 100644 index 00000000..b9902b12 --- /dev/null +++ b/appcompose/src/main/java/com/readrops/app/compose/item/ItemScreenModel.kt @@ -0,0 +1,35 @@ +package com.readrops.app.compose.item + +import androidx.compose.runtime.Stable +import cafe.adriel.voyager.core.model.StateScreenModel +import cafe.adriel.voyager.core.model.screenModelScope +import com.readrops.db.Database +import com.readrops.db.pojo.ItemWithFeed +import com.readrops.db.queries.ItemSelectionQueryBuilder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ItemScreenModel( + private val database: Database, + private val itemId: Int +) : StateScreenModel(ItemState()) { + + init { + screenModelScope.launch(Dispatchers.IO) { + mutableState.update { + val query = ItemSelectionQueryBuilder.buildQuery(itemId, false) + + it.copy( + itemWithFeed = database.newItemDao().selectItemById(query) + ) + } + } + } + +} + +@Stable +data class ItemState( + val itemWithFeed: ItemWithFeed? = null +) \ No newline at end of file diff --git a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineItem.kt b/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineItem.kt index 83e58481..ae40d8fd 100644 --- a/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineItem.kt +++ b/appcompose/src/main/java/com/readrops/app/compose/timelime/TimelineItem.kt @@ -135,7 +135,7 @@ fun TimelineItem( IconText( icon = painterResource(id = R.drawable.ic_hourglass_empty), - text = if (itemWithFeed.item.readTime < 1) "> 1 min" else "${itemWithFeed.item.readTime.roundToInt()} mins", + text = if (itemWithFeed.item.readTime < 1) "< 1 min" else "${itemWithFeed.item.readTime.roundToInt()} mins", style = MaterialTheme.typography.labelMedium ) } diff --git a/appcompose/src/main/res/drawable/ic_broken_image.xml b/appcompose/src/main/res/drawable/ic_broken_image.xml new file mode 100644 index 00000000..fa7a353f --- /dev/null +++ b/appcompose/src/main/res/drawable/ic_broken_image.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/appcompose/src/main/res/drawable/ic_person.xml b/appcompose/src/main/res/drawable/ic_person.xml new file mode 100644 index 00000000..f4cc4181 --- /dev/null +++ b/appcompose/src/main/res/drawable/ic_person.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/db/src/main/java/com/readrops/db/dao/newdao/NewItemDao.kt b/db/src/main/java/com/readrops/db/dao/newdao/NewItemDao.kt index 66a5fd33..33e2111b 100644 --- a/db/src/main/java/com/readrops/db/dao/newdao/NewItemDao.kt +++ b/db/src/main/java/com/readrops/db/dao/newdao/NewItemDao.kt @@ -17,6 +17,9 @@ abstract class NewItemDao : NewBaseDao { @RawQuery(observedEntities = [Item::class, Feed::class, Folder::class, ItemState::class]) abstract fun selectAll(query: SupportSQLiteQuery): PagingSource + @RawQuery(observedEntities = [Item::class, ItemState::class]) + abstract suspend fun selectItemById(query: SupportSQLiteQuery): ItemWithFeed + @Query("Update Item Set read = :read Where id = :itemId") abstract suspend fun updateReadState(itemId: Int, read: Boolean) diff --git a/db/src/main/java/com/readrops/db/queries/ItemSelectionQueryBuilder.kt b/db/src/main/java/com/readrops/db/queries/ItemSelectionQueryBuilder.kt index 1da869d1..f8ef2e57 100644 --- a/db/src/main/java/com/readrops/db/queries/ItemSelectionQueryBuilder.kt +++ b/db/src/main/java/com/readrops/db/queries/ItemSelectionQueryBuilder.kt @@ -5,16 +5,21 @@ import androidx.sqlite.db.SupportSQLiteQueryBuilder object ItemSelectionQueryBuilder { - private val COLUMNS = arrayOf("Item.id", "Item.remoteId", "title", "Item.description", "content", - "link", "pub_date", "image_link", "author", "Item.read", "text_color", - "background_color", "read_time", "Feed.name", "Feed.id as feedId", "siteUrl", - "Folder.id as folder_id", "Folder.name as folder_name") + private val COLUMNS = arrayOf( + "Item.id", "Item.remoteId", "title", "Item.description", "content", + "link", "pub_date", "image_link", "author", "Item.read", "text_color", "icon_url", + "background_color", "read_time", "Feed.name", "Feed.id as feedId", "siteUrl", + "Folder.id as folder_id", "Folder.name as folder_name" + ) - private val SEPARATE_STATE_COLUMNS = arrayOf("case When ItemState.starred = 1 Then 1 else 0 End starred") + private val SEPARATE_STATE_COLUMNS = + arrayOf("case When ItemState.starred = 1 Then 1 else 0 End starred") - private const val JOIN = "Item Inner Join Feed On Item.feed_id = Feed.id Left Join Folder on Folder.id = Feed.folder_id" + private const val JOIN = + "Item Inner Join Feed On Item.feed_id = Feed.id Left Join Folder on Folder.id = Feed.folder_id" - private const val SEPARATE_STATE_JOIN = " Left Join ItemState On ItemState.remote_id = Item.remoteId" + private const val SEPARATE_STATE_JOIN = + " Left Join ItemState On ItemState.remote_id = Item.remoteId" /** * @param separateState Indicates if item state must be retrieved from ItemState table @@ -22,7 +27,8 @@ object ItemSelectionQueryBuilder { @JvmStatic fun buildQuery(itemId: Int, separateState: Boolean): SupportSQLiteQuery { val tables = if (separateState) JOIN + SEPARATE_STATE_JOIN else JOIN - val columns = if (separateState) COLUMNS.plus(SEPARATE_STATE_COLUMNS) else COLUMNS.plus("starred") + val columns = + if (separateState) COLUMNS.plus(SEPARATE_STATE_COLUMNS) else COLUMNS.plus("starred") return SupportSQLiteQueryBuilder.builder(tables).run { columns(columns)