refactor: Replace .to... with .from() in most cases (#82)

The previous code generally converted between a higher and a lower type
by putting the type conversion functions on the lower type.

This introduced cycles in the code dependency graph, and made it more
difficult to follow the code flow.

Refactor the code so that types generally have a `from(...)` static
factory method that can create an instance from a lower type, and if
appropriate a `to...()` method that can also create an instance of that
lower type.

Add `docs/code-style.md` which explains the rationale for this change
in more detail so that future contributors can write code in the same
style.
This commit is contained in:
Nik Clayton 2023-09-22 15:17:38 +02:00 committed by GitHub
parent f45a3df83f
commit 3a274b0594
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 762 additions and 648 deletions

View File

@ -53,6 +53,7 @@ import app.pachli.entity.Emoji;
import app.pachli.entity.Filter; import app.pachli.entity.Filter;
import app.pachli.entity.FilterResult; import app.pachli.entity.FilterResult;
import app.pachli.entity.HashTag; import app.pachli.entity.HashTag;
import app.pachli.entity.Poll;
import app.pachli.entity.Status; import app.pachli.entity.Status;
import app.pachli.interfaces.StatusActionListener; import app.pachli.interfaces.StatusActionListener;
import app.pachli.util.AbsoluteTimeFormatter; import app.pachli.util.AbsoluteTimeFormatter;
@ -279,7 +280,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
List<Status.Mention> mentions = actionable.getMentions(); List<Status.Mention> mentions = actionable.getMentions();
List<HashTag> tags =actionable.getTags(); List<HashTag> tags =actionable.getTags();
List<Emoji> emojis = actionable.getEmojis(); List<Emoji> emojis = actionable.getEmojis();
PollViewData poll = PollViewDataKt.toViewData(actionable.getPoll()); Poll poll = actionable.getPoll();
if (expanded) { if (expanded) {
CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis()); CharSequence emojifiedText = CustomEmojiHelper.emojify(content, emojis, this.content, statusDisplayOptions.animateEmojis());
@ -288,7 +289,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
updateMediaLabel(i, sensitive, true); updateMediaLabel(i, sensitive, true);
} }
if (poll != null) { if (poll != null) {
setupPoll(poll, emojis, statusDisplayOptions, listener); setupPoll(PollViewData.Companion.from(poll), emojis, statusDisplayOptions, listener);
} else { } else {
hidePoll(); hidePoll();
} }
@ -962,24 +963,28 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder {
private CharSequence getPollDescription(@NonNull StatusViewData status, private CharSequence getPollDescription(@NonNull StatusViewData status,
@NonNull Context context, @NonNull Context context,
@NonNull StatusDisplayOptions statusDisplayOptions) { @NonNull StatusDisplayOptions statusDisplayOptions) {
PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); Poll poll = status.getActionable().getPoll();
if (poll == null) { if (poll == null) {
return ""; return "";
} else {
Object[] args = new CharSequence[5];
List<PollOptionViewData> options = poll.getOptions();
for (int i = 0; i < args.length; i++) {
if (i < options.size()) {
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), poll.getVotersCount(), poll.getVotesCount());
args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context);
} else {
args[i] = "";
}
}
args[4] = getPollInfoText(System.currentTimeMillis(), poll, statusDisplayOptions,
context);
return context.getString(R.string.description_poll, args);
} }
PollViewData pollViewData = PollViewData.Companion.from(poll);
Object[] args = new CharSequence[5];
List<PollOptionViewData> options = pollViewData.getOptions();
int totalVotes = pollViewData.getVotesCount();
Integer totalVoters = pollViewData.getVotersCount();
for (int i = 0; i < args.length; i++) {
if (i < options.size()) {
int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), totalVoters, totalVotes);
args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context);
} else {
args[i] = "";
}
}
args[4] = getPollInfoText(System.currentTimeMillis(), pollViewData, statusDisplayOptions,
context);
return context.getString(R.string.description_poll, args);
} }
@NonNull @NonNull

View File

@ -26,7 +26,6 @@ import app.pachli.entity.HashTag
import app.pachli.entity.Poll import app.pachli.entity.Poll
import app.pachli.entity.Status import app.pachli.entity.Status
import app.pachli.entity.TimelineAccount import app.pachli.entity.TimelineAccount
import app.pachli.viewdata.StatusViewData
import java.util.Date import java.util.Date
@Entity(primaryKeys = ["id", "accountId"]) @Entity(primaryKeys = ["id", "accountId"])
@ -39,13 +38,26 @@ data class ConversationEntity(
val unread: Boolean, val unread: Boolean,
@Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity, @Embedded(prefix = "s_") val lastStatus: ConversationStatusEntity,
) { ) {
fun toViewData(): ConversationViewData { companion object {
return ConversationViewData( fun from(
id = id, conversation: Conversation,
accountId: Long,
order: Int,
expanded: Boolean,
contentShowing: Boolean,
contentCollapsed: Boolean,
) = ConversationEntity(
accountId = accountId,
id = conversation.id,
order = order, order = order,
accounts = accounts, accounts = conversation.accounts.map { ConversationAccountEntity.from(it) },
unread = unread, unread = conversation.unread,
lastStatus = lastStatus.toViewData(), lastStatus = ConversationStatusEntity.from(
conversation.lastStatus!!,
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed,
),
) )
} }
} }
@ -70,6 +82,17 @@ data class ConversationAccountEntity(
emojis = emojis, emojis = emojis,
) )
} }
companion object {
fun from(timelineAccount: TimelineAccount) = ConversationAccountEntity(
id = timelineAccount.id,
localUsername = timelineAccount.localUsername,
username = timelineAccount.username,
displayName = timelineAccount.name,
avatar = timelineAccount.avatar,
emojis = timelineAccount.emojis.orEmpty(),
)
}
} }
@TypeConverters(Converters::class) @TypeConverters(Converters::class)
@ -100,104 +123,37 @@ data class ConversationStatusEntity(
val language: String?, val language: String?,
) { ) {
fun toViewData(): StatusViewData { companion object {
return StatusViewData( fun from(
status = Status( status: Status,
id = id, expanded: Boolean,
url = url, contentShowing: Boolean,
account = account.toAccount(), contentCollapsed: Boolean,
inReplyToId = inReplyToId, ) = ConversationStatusEntity(
inReplyToAccountId = inReplyToAccountId, id = status.id,
content = content, url = status.url,
reblog = null, inReplyToId = status.inReplyToId,
createdAt = createdAt, inReplyToAccountId = status.inReplyToAccountId,
editedAt = editedAt, account = ConversationAccountEntity.from(status.account),
emojis = emojis, content = status.content,
reblogsCount = 0, createdAt = status.createdAt,
favouritesCount = favouritesCount, editedAt = status.editedAt,
repliesCount = repliesCount, emojis = status.emojis,
reblogged = false, favouritesCount = status.favouritesCount,
favourited = favourited, repliesCount = status.repliesCount,
bookmarked = bookmarked, favourited = status.favourited,
sensitive = sensitive, bookmarked = status.bookmarked,
spoilerText = spoilerText, sensitive = status.sensitive,
visibility = Status.Visibility.DIRECT, spoilerText = status.spoilerText,
attachments = attachments, attachments = status.attachments,
mentions = mentions, mentions = status.mentions,
tags = tags, tags = status.tags,
application = null, showingHiddenContent = contentShowing,
pinned = false, expanded = expanded,
muted = muted, collapsed = contentCollapsed,
poll = poll, muted = status.muted ?: false,
card = null, poll = status.poll,
language = language, language = status.language,
filtered = null,
),
isExpanded = expanded,
isShowingContent = showingHiddenContent,
isCollapsed = collapsed,
) )
} }
} }
fun TimelineAccount.toEntity() =
ConversationAccountEntity(
id = id,
localUsername = localUsername,
username = username,
displayName = name,
avatar = avatar,
emojis = emojis.orEmpty(),
)
fun Status.toEntity(
expanded: Boolean,
contentShowing: Boolean,
contentCollapsed: Boolean,
) =
ConversationStatusEntity(
id = id,
url = url,
inReplyToId = inReplyToId,
inReplyToAccountId = inReplyToAccountId,
account = account.toEntity(),
content = content,
createdAt = createdAt,
editedAt = editedAt,
emojis = emojis,
favouritesCount = favouritesCount,
repliesCount = repliesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = sensitive,
spoilerText = spoilerText,
attachments = attachments,
mentions = mentions,
tags = tags,
showingHiddenContent = contentShowing,
expanded = expanded,
collapsed = contentCollapsed,
muted = muted ?: false,
poll = poll,
language = language,
)
fun Conversation.toEntity(
accountId: Long,
order: Int,
expanded: Boolean,
contentShowing: Boolean,
contentCollapsed: Boolean,
) =
ConversationEntity(
accountId = accountId,
id = id,
order = order,
accounts = accounts.map { it.toEntity() },
unread = unread,
lastStatus = lastStatus!!.toEntity(
expanded = expanded,
contentShowing = contentShowing,
contentCollapsed = contentCollapsed,
),
)

View File

@ -25,7 +25,7 @@ data class ConversationViewData(
val unread: Boolean, val unread: Boolean,
val lastStatus: StatusViewData, val lastStatus: StatusViewData,
) { ) {
fun toEntity( fun toConversationEntity(
accountId: Long, accountId: Long,
favourited: Boolean = lastStatus.status.favourited, favourited: Boolean = lastStatus.status.favourited,
bookmarked: Boolean = lastStatus.status.bookmarked, bookmarked: Boolean = lastStatus.status.bookmarked,
@ -52,41 +52,14 @@ data class ConversationViewData(
), ),
) )
} }
}
fun StatusViewData.toConversationStatusEntity( companion object {
favourited: Boolean = status.favourited, fun from(conversationEntity: ConversationEntity) = ConversationViewData(
bookmarked: Boolean = status.bookmarked, id = conversationEntity.id,
muted: Boolean = status.muted ?: false, order = conversationEntity.order,
poll: Poll? = status.poll, accounts = conversationEntity.accounts,
expanded: Boolean = isExpanded, unread = conversationEntity.unread,
collapsed: Boolean = isCollapsed, lastStatus = StatusViewData.from(conversationEntity.lastStatus),
showingHiddenContent: Boolean = isShowingContent, )
): ConversationStatusEntity { }
return ConversationStatusEntity(
id = id,
url = status.url,
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
account = status.account.toEntity(),
content = status.content,
createdAt = status.createdAt,
editedAt = status.editedAt,
emojis = status.emojis,
favouritesCount = status.favouritesCount,
repliesCount = status.repliesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
attachments = status.attachments,
mentions = status.mentions,
tags = status.tags,
showingHiddenContent = showingHiddenContent,
expanded = expanded,
collapsed = collapsed,
muted = muted,
poll = poll,
language = status.language,
)
} }

View File

@ -58,17 +58,13 @@ class ConversationsRemoteMediator(
conversations conversations
.filterNot { it.lastStatus == null } .filterNot { it.lastStatus == null }
.map { conversation -> .map { conversation ->
ConversationEntity.from(
val expanded = activeAccount.alwaysOpenSpoiler conversation,
val contentShowing = activeAccount.alwaysShowSensitiveMedia || !conversation.lastStatus!!.sensitive
val contentCollapsed = true
conversation.toEntity(
accountId = activeAccount.id, accountId = activeAccount.id,
order = order++, order = order++,
expanded = expanded, expanded = activeAccount.alwaysOpenSpoiler,
contentShowing = contentShowing, contentShowing = activeAccount.alwaysShowSensitiveMedia || !conversation.lastStatus!!.sensitive,
contentCollapsed = contentCollapsed, contentCollapsed = true,
) )
}, },
) )

View File

@ -55,14 +55,14 @@ class ConversationsViewModel @Inject constructor(
) )
.flow .flow
.map { pagingData -> .map { pagingData ->
pagingData.map { conversation -> conversation.toViewData() } pagingData.map { conversation -> ConversationViewData.from(conversation) }
} }
.cachedIn(viewModelScope) .cachedIn(viewModelScope)
fun favourite(favourite: Boolean, conversation: ConversationViewData) { fun favourite(favourite: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
timelineCases.favourite(conversation.lastStatus.id, favourite).fold({ timelineCases.favourite(conversation.lastStatus.id, favourite).fold({
val newConversation = conversation.toEntity( val newConversation = conversation.toConversationEntity(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
favourited = favourite, favourited = favourite,
) )
@ -77,7 +77,7 @@ class ConversationsViewModel @Inject constructor(
fun bookmark(bookmark: Boolean, conversation: ConversationViewData) { fun bookmark(bookmark: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({ timelineCases.bookmark(conversation.lastStatus.id, bookmark).fold({
val newConversation = conversation.toEntity( val newConversation = conversation.toConversationEntity(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
bookmarked = bookmark, bookmarked = bookmark,
) )
@ -93,7 +93,7 @@ class ConversationsViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices) timelineCases.voteInPoll(conversation.lastStatus.id, conversation.lastStatus.status.poll?.id!!, choices)
.fold({ poll -> .fold({ poll ->
val newConversation = conversation.toEntity( val newConversation = conversation.toConversationEntity(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
poll = poll, poll = poll,
) )
@ -107,7 +107,7 @@ class ConversationsViewModel @Inject constructor(
fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) { fun expandHiddenStatus(expanded: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.toEntity( val newConversation = conversation.toConversationEntity(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
expanded = expanded, expanded = expanded,
) )
@ -117,7 +117,7 @@ class ConversationsViewModel @Inject constructor(
fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) { fun collapseLongStatus(collapsed: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.toEntity( val newConversation = conversation.toConversationEntity(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
collapsed = collapsed, collapsed = collapsed,
) )
@ -127,7 +127,7 @@ class ConversationsViewModel @Inject constructor(
fun showContent(showing: Boolean, conversation: ConversationViewData) { fun showContent(showing: Boolean, conversation: ConversationViewData) {
viewModelScope.launch { viewModelScope.launch {
val newConversation = conversation.toEntity( val newConversation = conversation.toConversationEntity(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
showingHiddenContent = showing, showingHiddenContent = showing,
) )
@ -158,7 +158,7 @@ class ConversationsViewModel @Inject constructor(
!(conversation.lastStatus.status.muted ?: false), !(conversation.lastStatus.status.muted ?: false),
) )
val newConversation = conversation.toEntity( val newConversation = conversation.toConversationEntity(
accountId = accountManager.activeAccount!!.id, accountId = accountManager.activeAccount!!.id,
muted = !(conversation.lastStatus.status.muted ?: false), muted = !(conversation.lastStatus.status.muted ?: false),
) )

View File

@ -47,7 +47,6 @@ import app.pachli.util.StatusDisplayOptions
import app.pachli.util.deserialize import app.pachli.util.deserialize
import app.pachli.util.serialize import app.pachli.util.serialize
import app.pachli.util.throttleFirst import app.pachli.util.throttleFirst
import app.pachli.util.toViewData
import app.pachli.viewdata.NotificationViewData import app.pachli.viewdata.NotificationViewData
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.getOrThrow import at.connyduck.calladapter.networkresult.getOrThrow
@ -534,7 +533,8 @@ class NotificationsViewModel @Inject constructor(
.map { pagingData -> .map { pagingData ->
pagingData.map { notification -> pagingData.map { notification ->
val filterAction = notification.status?.actionableStatus?.let { filterModel.shouldFilterStatus(it) } ?: Filter.Action.NONE val filterAction = notification.status?.actionableStatus?.let { filterModel.shouldFilterStatus(it) } ?: Filter.Action.NONE
notification.toViewData( NotificationViewData.from(
notification,
isShowingContent = statusDisplayOptions.value.showSensitiveMedia || isShowingContent = statusDisplayOptions.value.showSensitiveMedia ||
!(notification.status?.actionableStatus?.sensitive ?: false), !(notification.status?.actionableStatus?.sensitive ?: false),
isExpanded = statusDisplayOptions.value.openSpoiler, isExpanded = statusDisplayOptions.value.openSpoiler,

View File

@ -35,7 +35,7 @@ import app.pachli.util.Error
import app.pachli.util.Loading import app.pachli.util.Loading
import app.pachli.util.Resource import app.pachli.util.Resource
import app.pachli.util.Success import app.pachli.util.Success
import app.pachli.util.toViewData import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
@ -79,7 +79,7 @@ class ReportViewModel @Inject constructor(
.map { pagingData -> .map { pagingData ->
/* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData
instead of StatusViewState */ instead of StatusViewState */
pagingData.map { status -> status.toViewData(false, false, false) } pagingData.map { status -> StatusViewData.from(status, false, false, false) }
} }
.cachedIn(viewModelScope) .cachedIn(viewModelScope)

View File

@ -38,8 +38,8 @@ import app.pachli.util.setClickableMentions
import app.pachli.util.setClickableText import app.pachli.util.setClickableText
import app.pachli.util.shouldTrimStatus import app.pachli.util.shouldTrimStatus
import app.pachli.util.show import app.pachli.util.show
import app.pachli.viewdata.PollViewData
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import app.pachli.viewdata.toViewData
import java.util.Date import java.util.Date
class StatusViewHolder( class StatusViewHolder(
@ -93,7 +93,10 @@ class StatusViewHolder(
mediaViewHeight, mediaViewHeight,
) )
statusViewHelper.setupPollReadonly(viewData.status.poll.toViewData(), viewData.status.emojis, statusDisplayOptions) viewData.status.poll?.let {
statusViewHelper.setupPollReadonly(PollViewData.from(it), viewData.status.emojis, statusDisplayOptions)
} ?: statusViewHelper.hidePoll()
setCreatedAt(viewData.status.createdAt) setCreatedAt(viewData.status.createdAt)
} }

View File

@ -28,7 +28,6 @@ import app.pachli.entity.DeletedStatus
import app.pachli.entity.Status import app.pachli.entity.Status
import app.pachli.network.MastodonApi import app.pachli.network.MastodonApi
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.toViewData
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.NetworkResult import at.connyduck.calladapter.networkresult.NetworkResult
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
@ -58,7 +57,8 @@ class SearchViewModel @Inject constructor(
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
it.statuses.map { status -> it.statuses.map { status ->
status.toViewData( StatusViewData.from(
status,
isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, isShowingContent = alwaysShowSensitiveMedia || !status.actionableStatus.sensitive,
isExpanded = alwaysOpenSpoiler, isExpanded = alwaysOpenSpoiler,
isCollapsed = true, isCollapsed = true,

View File

@ -1,235 +0,0 @@
/* Copyright 2021 Tusky Contributors
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package app.pachli.components.timeline
import app.pachli.db.TimelineAccountEntity
import app.pachli.db.TimelineStatusEntity
import app.pachli.db.TimelineStatusWithAccount
import app.pachli.entity.Attachment
import app.pachli.entity.Card
import app.pachli.entity.Emoji
import app.pachli.entity.HashTag
import app.pachli.entity.Poll
import app.pachli.entity.Status
import app.pachli.entity.TimelineAccount
import app.pachli.viewdata.StatusViewData
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.util.Date
@Suppress("unused")
private const val TAG = "TimelineTypeMappers"
private val attachmentArrayListType = object : TypeToken<ArrayList<Attachment>>() {}.type
private val emojisListType = object : TypeToken<List<Emoji>>() {}.type
private val mentionListType = object : TypeToken<List<Status.Mention>>() {}.type
private val tagListType = object : TypeToken<List<HashTag>>() {}.type
fun TimelineAccount.toEntity(accountId: Long, gson: Gson): TimelineAccountEntity {
return TimelineAccountEntity(
serverId = id,
timelineUserId = accountId,
localUsername = localUsername,
username = username,
displayName = name,
url = url,
avatar = avatar,
emojis = gson.toJson(emojis),
bot = bot,
)
}
fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount {
return TimelineAccount(
id = serverId,
localUsername = localUsername,
username = username,
displayName = displayName,
note = "",
url = url,
avatar = avatar,
bot = bot,
emojis = gson.fromJson(emojis, emojisListType),
)
}
fun Status.toEntity(
timelineUserId: Long,
gson: Gson,
): TimelineStatusEntity {
return TimelineStatusEntity(
serverId = this.id,
url = actionableStatus.url,
timelineUserId = timelineUserId,
authorServerId = actionableStatus.account.id,
inReplyToId = actionableStatus.inReplyToId,
inReplyToAccountId = actionableStatus.inReplyToAccountId,
content = actionableStatus.content,
createdAt = actionableStatus.createdAt.time,
editedAt = actionableStatus.editedAt?.time,
emojis = actionableStatus.emojis.let(gson::toJson),
reblogsCount = actionableStatus.reblogsCount,
favouritesCount = actionableStatus.favouritesCount,
reblogged = actionableStatus.reblogged,
favourited = actionableStatus.favourited,
bookmarked = actionableStatus.bookmarked,
sensitive = actionableStatus.sensitive,
spoilerText = actionableStatus.spoilerText,
visibility = actionableStatus.visibility,
attachments = actionableStatus.attachments.let(gson::toJson),
mentions = actionableStatus.mentions.let(gson::toJson),
tags = actionableStatus.tags.let(gson::toJson),
application = actionableStatus.application.let(gson::toJson),
reblogServerId = reblog?.id,
reblogAccountId = reblog?.let { this.account.id },
poll = actionableStatus.poll.let(gson::toJson),
muted = actionableStatus.muted,
pinned = actionableStatus.pinned == true,
card = actionableStatus.card?.let(gson::toJson),
repliesCount = actionableStatus.repliesCount,
language = actionableStatus.language,
filtered = actionableStatus.filtered,
)
}
fun TimelineStatusWithAccount.toViewData(gson: Gson, alwaysOpenSpoiler: Boolean, alwaysShowSensitiveMedia: Boolean, isDetailed: Boolean = false): StatusViewData {
val attachments: ArrayList<Attachment> = gson.fromJson(
status.attachments,
attachmentArrayListType,
) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(
status.mentions,
mentionListType,
) ?: emptyList()
val tags: List<HashTag>? = gson.fromJson(
status.tags,
tagListType,
)
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(
status.emojis,
emojisListType,
) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val card: Card? = gson.fromJson(status.card, Card::class.java)
val reblog = status.reblogServerId?.let { id ->
Status(
id = id,
url = status.url,
account = account.toAccount(gson),
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
editedAt = status.editedAt?.let { Date(it) },
emojis = emojis,
reblogsCount = status.reblogsCount,
favouritesCount = status.favouritesCount,
reblogged = status.reblogged,
favourited = status.favourited,
bookmarked = status.bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = false,
muted = status.muted,
poll = poll,
card = card,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
}
val status = if (reblog != null) {
Status(
id = status.serverId,
url = null, // no url for reblogs
account = this.reblogAccount!!.toAccount(gson),
inReplyToId = null,
inReplyToAccountId = null,
reblog = reblog,
content = "",
createdAt = Date(status.createdAt), // lie but whatever?
editedAt = null,
emojis = listOf(),
reblogsCount = 0,
favouritesCount = 0,
reblogged = false,
favourited = false,
bookmarked = false,
sensitive = false,
spoilerText = "",
visibility = status.visibility,
attachments = ArrayList(),
mentions = listOf(),
tags = listOf(),
application = null,
pinned = status.pinned,
muted = status.muted,
poll = null,
card = null,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
} else {
Status(
id = status.serverId,
url = status.url,
account = account.toAccount(gson),
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
editedAt = status.editedAt?.let { Date(it) },
emojis = emojis,
reblogsCount = status.reblogsCount,
favouritesCount = status.favouritesCount,
reblogged = status.reblogged,
favourited = status.favourited,
bookmarked = status.bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = status.pinned,
muted = status.muted,
poll = poll,
card = card,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
}
return StatusViewData(
status = status,
isExpanded = this.viewData?.expanded ?: alwaysOpenSpoiler,
isShowingContent = this.viewData?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isCollapsed = this.viewData?.contentCollapsed ?: true,
isDetailed = isDetailed,
)
}

View File

@ -26,11 +26,12 @@ import androidx.paging.PagingState
import androidx.paging.RemoteMediator import androidx.paging.RemoteMediator
import androidx.room.Transaction import androidx.room.Transaction
import androidx.room.withTransaction import androidx.room.withTransaction
import app.pachli.components.timeline.toEntity
import app.pachli.db.AccountManager import app.pachli.db.AccountManager
import app.pachli.db.AppDatabase import app.pachli.db.AppDatabase
import app.pachli.db.RemoteKeyEntity import app.pachli.db.RemoteKeyEntity
import app.pachli.db.RemoteKeyKind import app.pachli.db.RemoteKeyKind
import app.pachli.db.TimelineAccountEntity
import app.pachli.db.TimelineStatusEntity
import app.pachli.db.TimelineStatusWithAccount import app.pachli.db.TimelineStatusWithAccount
import app.pachli.entity.Status import app.pachli.entity.Status
import app.pachli.network.Links import app.pachli.network.Links
@ -258,13 +259,15 @@ class CachedTimelineRemoteMediator(
@Transaction @Transaction
private suspend fun insertStatuses(statuses: List<Status>) { private suspend fun insertStatuses(statuses: List<Status>) {
for (status in statuses) { for (status in statuses) {
timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) timelineDao.insertAccount(TimelineAccountEntity.from(status.account, activeAccount.id, gson))
status.reblog?.account?.toEntity(activeAccount.id, gson)?.let { rebloggedAccount -> status.reblog?.account?.let {
timelineDao.insertAccount(rebloggedAccount) val account = TimelineAccountEntity.from(it, activeAccount.id, gson)
timelineDao.insertAccount(account)
} }
timelineDao.insertStatus( timelineDao.insertStatus(
status.toEntity( TimelineStatusEntity.from(
status,
timelineUserId = activeAccount.id, timelineUserId = activeAccount.id,
gson = gson, gson = gson,
), ),

View File

@ -32,7 +32,6 @@ import app.pachli.appstore.ReblogEvent
import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.components.timeline.CachedTimelineRepository
import app.pachli.components.timeline.FiltersRepository import app.pachli.components.timeline.FiltersRepository
import app.pachli.components.timeline.TimelineKind import app.pachli.components.timeline.TimelineKind
import app.pachli.components.timeline.toViewData
import app.pachli.db.AccountManager import app.pachli.db.AccountManager
import app.pachli.entity.Filter import app.pachli.entity.Filter
import app.pachli.entity.Poll import app.pachli.entity.Poll
@ -97,10 +96,11 @@ class CachedTimelineViewModel @Inject constructor(
.map { pagingData -> .map { pagingData ->
pagingData pagingData
.map { .map {
it.toViewData( StatusViewData.from(
it,
gson, gson,
alwaysOpenSpoiler = activeAccount.alwaysOpenSpoiler, isExpanded = activeAccount.alwaysOpenSpoiler,
alwaysShowSensitiveMedia = activeAccount.alwaysShowSensitiveMedia, isShowingContent = activeAccount.alwaysShowSensitiveMedia,
) )
} }
.filter { shouldFilterStatus(it) != Filter.Action.HIDE } .filter { shouldFilterStatus(it) != Filter.Action.HIDE }

View File

@ -38,7 +38,6 @@ import app.pachli.entity.Poll
import app.pachli.network.FilterModel import app.pachli.network.FilterModel
import app.pachli.settings.AccountPreferenceDataStore import app.pachli.settings.AccountPreferenceDataStore
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.toViewData
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -90,7 +89,8 @@ class NetworkTimelineViewModel @Inject constructor(
return repository.getStatusStream(viewModelScope, kind = kind, initialKey = initialKey) return repository.getStatusStream(viewModelScope, kind = kind, initialKey = initialKey)
.map { pagingData -> .map { pagingData ->
pagingData.map { pagingData.map {
modifiedViewData[it.id] ?: it.toViewData( modifiedViewData[it.id] ?: StatusViewData.from(
it,
isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive, isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive,
isExpanded = statusDisplayOptions.value.openSpoiler, isExpanded = statusDisplayOptions.value.openSpoiler,
isCollapsed = true, isCollapsed = true,

View File

@ -21,10 +21,10 @@ import androidx.lifecycle.viewModelScope
import app.pachli.appstore.EventHub import app.pachli.appstore.EventHub
import app.pachli.appstore.PreferenceChangedEvent import app.pachli.appstore.PreferenceChangedEvent
import app.pachli.entity.Filter import app.pachli.entity.Filter
import app.pachli.entity.TrendingTag
import app.pachli.entity.end import app.pachli.entity.end
import app.pachli.entity.start import app.pachli.entity.start
import app.pachli.network.MastodonApi import app.pachli.network.MastodonApi
import app.pachli.util.toViewData
import app.pachli.viewdata.TrendingViewData import app.pachli.viewdata.TrendingViewData
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import kotlinx.coroutines.async import kotlinx.coroutines.async
@ -97,7 +97,7 @@ class TrendingTagsViewModel @Inject constructor(
} ?: false } ?: false
} }
.sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } } .sortedByDescending { tag -> tag.history.sumOf { it.uses.toLongOrNull() ?: 0 } }
.toViewData() .toTrendingViewDataTag()
val header = TrendingViewData.Header(firstTag.start(), firstTag.end()) val header = TrendingViewData.Header(firstTag.start(), firstTag.end())
TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED) TrendingTagsUiState(listOf(header) + tags, LoadingState.LOADED)
@ -114,6 +114,14 @@ class TrendingTagsViewModel @Inject constructor(
) )
} }
private fun List<TrendingTag>.toTrendingViewDataTag(): List<TrendingViewData.Tag> {
val maxTrendingValue = flatMap { tag -> tag.history }
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
return map { TrendingViewData.Tag.from(it, maxTrendingValue) }
}
companion object { companion object {
private const val TAG = "TrendingViewModel" private const val TAG = "TrendingViewModel"
} }

View File

@ -28,19 +28,16 @@ import app.pachli.appstore.StatusComposedEvent
import app.pachli.appstore.StatusDeletedEvent import app.pachli.appstore.StatusDeletedEvent
import app.pachli.appstore.StatusEditedEvent import app.pachli.appstore.StatusEditedEvent
import app.pachli.components.timeline.CachedTimelineRepository import app.pachli.components.timeline.CachedTimelineRepository
import app.pachli.components.timeline.toViewData
import app.pachli.components.timeline.util.ifExpected import app.pachli.components.timeline.util.ifExpected
import app.pachli.db.AccountEntity import app.pachli.db.AccountEntity
import app.pachli.db.AccountManager import app.pachli.db.AccountManager
import app.pachli.db.AppDatabase import app.pachli.db.AppDatabase
import app.pachli.db.StatusViewDataEntity
import app.pachli.entity.Filter import app.pachli.entity.Filter
import app.pachli.entity.FilterV1 import app.pachli.entity.FilterV1
import app.pachli.entity.Status import app.pachli.entity.Status
import app.pachli.network.FilterModel import app.pachli.network.FilterModel
import app.pachli.network.MastodonApi import app.pachli.network.MastodonApi
import app.pachli.usecase.TimelineCases import app.pachli.usecase.TimelineCases
import app.pachli.util.toViewData
import app.pachli.viewdata.StatusViewData import app.pachli.viewdata.StatusViewData
import at.connyduck.calladapter.networkresult.fold import at.connyduck.calladapter.networkresult.fold
import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrElse
@ -113,25 +110,32 @@ class ViewThreadViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
Log.d(TAG, "Finding status with: $id") Log.d(TAG, "Finding status with: $id")
val contextCall = async { api.statusContext(id) } val contextCall = async { api.statusContext(id) }
val timelineStatus = db.timelineDao().getStatus(id) val timelineStatusWithAccount = db.timelineDao().getStatus(id)
var detailedStatus = if (timelineStatus != null) { var detailedStatus = if (timelineStatusWithAccount != null) {
Log.d(TAG, "Loaded status from local timeline") Log.d(TAG, "Loaded status from local timeline")
val viewData = timelineStatus.toViewData( val status = timelineStatusWithAccount.toStatus(gson)
gson,
alwaysOpenSpoiler = alwaysOpenSpoiler,
alwaysShowSensitiveMedia = alwaysShowSensitiveMedia,
isDetailed = true,
)
// Return the correct status, depending on which one matched. If you do not do // Return the correct status, depending on which one matched. If you do not do
// this the status IDs will be different between the status that's displayed with // this the status IDs will be different between the status that's displayed with
// ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent // ThreadUiState.LoadingThread and ThreadUiState.Success, even though the apparent
// status content is the same. Then the status flickers as it is drawn twice. // status content is the same. Then the status flickers as it is drawn twice.
if (viewData.actionableId == id) { if (status.actionableId == id) {
viewData.actionable.toViewData(isDetailed = true, viewData) StatusViewData.from(
status = status.actionableStatus,
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: alwaysOpenSpoiler,
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: alwaysShowSensitiveMedia,
isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true,
isDetailed = true,
)
} else { } else {
viewData StatusViewData.from(
timelineStatusWithAccount,
gson,
isExpanded = alwaysOpenSpoiler,
isShowingContent = alwaysShowSensitiveMedia,
isDetailed = true,
)
} }
} else { } else {
Log.d(TAG, "Loaded status from network") Log.d(TAG, "Loaded status from network")
@ -139,7 +143,7 @@ class ViewThreadViewModel @Inject constructor(
_uiState.value = ThreadUiState.Error(exception) _uiState.value = ThreadUiState.Error(exception)
return@launch return@launch
} }
result.toViewData(isDetailed = true) StatusViewData.fromStatusAndUiState(result, isDetailed = true)
} }
_uiState.value = ThreadUiState.LoadingThread( _uiState.value = ThreadUiState.LoadingThread(
@ -151,9 +155,16 @@ class ViewThreadViewModel @Inject constructor(
// compared to the remote one. Now the user has a working UI do a background fetch // compared to the remote one. Now the user has a working UI do a background fetch
// for the status. Ignore errors, the user still has a functioning UI if the fetch // for the status. Ignore errors, the user still has a functioning UI if the fetch
// failed. // failed.
if (timelineStatus != null) { if (timelineStatusWithAccount != null) {
val viewData = api.status(id).getOrNull()?.toViewData(isDetailed = true, detailedStatus) api.status(id).getOrNull()?.let {
if (viewData != null) { detailedStatus = viewData } detailedStatus = StatusViewData.from(
it,
isShowingContent = detailedStatus.isShowingContent,
isExpanded = detailedStatus.isExpanded,
isCollapsed = detailedStatus.isCollapsed,
isDetailed = true,
)
}
} }
val contextResult = contextCall.await() val contextResult = contextCall.await()
@ -163,12 +174,26 @@ class ViewThreadViewModel @Inject constructor(
val cachedViewData = repository.getStatusViewData(ids) val cachedViewData = repository.getStatusViewData(ids)
val ancestors = statusContext.ancestors.map { val ancestors = statusContext.ancestors.map {
status -> status ->
status.toViewData(statusViewDataEntity = cachedViewData[status.id]) val svd = cachedViewData[status.id]
}.filter() StatusViewData.from(
status,
isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
isCollapsed = svd?.contentCollapsed ?: true,
isDetailed = false,
)
}.filterByFilterAction()
val descendants = statusContext.descendants.map { val descendants = statusContext.descendants.map {
status -> status ->
status.toViewData(statusViewDataEntity = cachedViewData[status.id]) val svd = cachedViewData[status.id]
}.filter() StatusViewData.from(
status,
isShowingContent = svd?.contentShowing ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isExpanded = svd?.expanded ?: alwaysOpenSpoiler,
isCollapsed = svd?.contentCollapsed ?: true,
isDetailed = false,
)
}.filterByFilterAction()
val statuses = ancestors + detailedStatus + descendants val statuses = ancestors + detailedStatus + descendants
_uiState.value = ThreadUiState.Success( _uiState.value = ThreadUiState.Success(
@ -345,7 +370,7 @@ class ViewThreadViewModel @Inject constructor(
if (detailedIndex != -1 && repliedIndex >= detailedIndex) { if (detailedIndex != -1 && repliedIndex >= detailedIndex) {
// there is a new reply to the detailed status or below -> display it // there is a new reply to the detailed status or below -> display it
val newStatuses = statuses.subList(0, repliedIndex + 1) + val newStatuses = statuses.subList(0, repliedIndex + 1) +
eventStatus.toViewData() + StatusViewData.fromStatusAndUiState(eventStatus) +
statuses.subList(repliedIndex + 1, statuses.size) statuses.subList(repliedIndex + 1, statuses.size)
uiState.copy(statusViewData = newStatuses) uiState.copy(statusViewData = newStatuses)
} else { } else {
@ -359,7 +384,7 @@ class ViewThreadViewModel @Inject constructor(
uiState.copy( uiState.copy(
statusViewData = uiState.statusViewData.map { status -> statusViewData = uiState.statusViewData.map { status ->
if (status.actionableId == event.originalId) { if (status.actionableId == event.originalId) {
event.status.toViewData() StatusViewData.fromStatusAndUiState(event.status)
} else { } else {
status status
} }
@ -465,7 +490,7 @@ class ViewThreadViewModel @Inject constructor(
private fun updateStatuses() { private fun updateStatuses() {
updateSuccess { uiState -> updateSuccess { uiState ->
val statuses = uiState.statusViewData.filter() val statuses = uiState.statusViewData.filterByFilterAction()
uiState.copy( uiState.copy(
statusViewData = statuses, statusViewData = statuses,
revealButton = statuses.getRevealButtonState(), revealButton = statuses.getRevealButtonState(),
@ -473,7 +498,7 @@ class ViewThreadViewModel @Inject constructor(
} }
} }
private fun List<StatusViewData>.filter(): List<StatusViewData> { private fun List<StatusViewData>.filterByFilterAction(): List<StatusViewData> {
return filter { status -> return filter { status ->
if (status.isDetailed) { if (status.isDetailed) {
true true
@ -485,35 +510,14 @@ class ViewThreadViewModel @Inject constructor(
} }
/** /**
* Convert the status to a [StatusViewData], copying the view data from [statusViewData] * Creates a [StatusViewData] from `status`, copying over the viewdata state from the same
* status in _uiState (if that status exists).
*/ */
private fun Status.toViewData(isDetailed: Boolean = false, statusViewData: StatusViewData): StatusViewData { private fun StatusViewData.Companion.fromStatusAndUiState(status: Status, isDetailed: Boolean = false): StatusViewData {
return toViewData( val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == status.id }
isShowingContent = statusViewData.isShowingContent, return from(
isExpanded = statusViewData.isExpanded, status,
isCollapsed = statusViewData.isCollapsed, isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !status.actionableStatus.sensitive),
isDetailed = isDetailed,
)
}
/**
* Convert the status to a [StatusViewData], copying the view data from [statusViewDataEntity]
*/
private fun Status.toViewData(isDetailed: Boolean = false, statusViewDataEntity: StatusViewDataEntity?): StatusViewData {
return toViewData(
isShowingContent = statusViewDataEntity?.contentShowing ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
isExpanded = statusViewDataEntity?.expanded ?: alwaysOpenSpoiler,
isCollapsed = statusViewDataEntity?.contentCollapsed ?: !isDetailed,
isDetailed = isDetailed,
)
}
private fun Status.toViewData(
isDetailed: Boolean = false,
): StatusViewData {
val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id }
return toViewData(
isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive),
isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler, isExpanded = oldStatus?.isExpanded ?: alwaysOpenSpoiler,
isCollapsed = oldStatus?.isCollapsed ?: !isDetailed, isCollapsed = oldStatus?.isCollapsed ?: !isDetailed,
isDetailed = oldStatus?.isDetailed ?: isDetailed, isDetailed = oldStatus?.isDetailed ?: isDetailed,

View File

@ -35,7 +35,7 @@ import app.pachli.util.parseAsMastodonHtml
import app.pachli.util.setClickableText import app.pachli.util.setClickableText
import app.pachli.util.show import app.pachli.util.show
import app.pachli.util.visible import app.pachli.util.visible
import app.pachli.viewdata.toViewData import app.pachli.viewdata.PollOptionViewData
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.google.android.material.color.MaterialColors import com.google.android.material.color.MaterialColors
import org.xml.sax.XMLReader import org.xml.sax.XMLReader
@ -138,7 +138,7 @@ class ViewEditsAdapter(
binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context) binding.statusEditPollOptions.layoutManager = LinearLayoutManager(context)
pollAdapter.setup( pollAdapter.setup(
options = edit.poll.options.map { it.toViewData(false) }, options = edit.poll.options.map { PollOptionViewData.from(it, false) },
voteCount = 0, voteCount = 0,
votersCount = null, votersCount = null,
emojis = edit.emojis, emojis = edit.emojis,

View File

@ -20,8 +20,18 @@ import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.TypeConverters import androidx.room.TypeConverters
import app.pachli.entity.Attachment
import app.pachli.entity.Card
import app.pachli.entity.Emoji
import app.pachli.entity.FilterResult import app.pachli.entity.FilterResult
import app.pachli.entity.HashTag
import app.pachli.entity.Poll
import app.pachli.entity.Status import app.pachli.entity.Status
import app.pachli.entity.TimelineAccount
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.lang.reflect.Type
import java.util.Date
/** /**
* We're trying to play smart here. Server sends us reblogs as two entities one embedded into * We're trying to play smart here. Server sends us reblogs as two entities one embedded into
@ -82,7 +92,43 @@ data class TimelineStatusEntity(
val card: String?, val card: String?,
val language: String?, val language: String?,
val filtered: List<FilterResult>?, val filtered: List<FilterResult>?,
) ) {
companion object {
fun from(status: Status, timelineUserId: Long, gson: Gson) = TimelineStatusEntity(
serverId = status.id,
url = status.actionableStatus.url,
timelineUserId = timelineUserId,
authorServerId = status.actionableStatus.account.id,
inReplyToId = status.actionableStatus.inReplyToId,
inReplyToAccountId = status.actionableStatus.inReplyToAccountId,
content = status.actionableStatus.content,
createdAt = status.actionableStatus.createdAt.time,
editedAt = status.actionableStatus.editedAt?.time,
emojis = status.actionableStatus.emojis.let(gson::toJson),
reblogsCount = status.actionableStatus.reblogsCount,
favouritesCount = status.actionableStatus.favouritesCount,
reblogged = status.actionableStatus.reblogged,
favourited = status.actionableStatus.favourited,
bookmarked = status.actionableStatus.bookmarked,
sensitive = status.actionableStatus.sensitive,
spoilerText = status.actionableStatus.spoilerText,
visibility = status.actionableStatus.visibility,
attachments = status.actionableStatus.attachments.let(gson::toJson),
mentions = status.actionableStatus.mentions.let(gson::toJson),
tags = status.actionableStatus.tags.let(gson::toJson),
application = status.actionableStatus.application.let(gson::toJson),
reblogServerId = status.reblog?.id,
reblogAccountId = status.reblog?.let { status.account.id },
poll = status.actionableStatus.poll.let(gson::toJson),
muted = status.actionableStatus.muted,
pinned = status.actionableStatus.pinned == true,
card = status.actionableStatus.card?.let(gson::toJson),
repliesCount = status.actionableStatus.repliesCount,
language = status.actionableStatus.language,
filtered = status.actionableStatus.filtered,
)
}
}
@Entity( @Entity(
primaryKeys = ["serverId", "timelineUserId"], primaryKeys = ["serverId", "timelineUserId"],
@ -97,7 +143,35 @@ data class TimelineAccountEntity(
val avatar: String, val avatar: String,
val emojis: String, val emojis: String,
val bot: Boolean, val bot: Boolean,
) ) {
fun toTimelineAccount(gson: Gson): TimelineAccount {
return TimelineAccount(
id = serverId,
localUsername = localUsername,
username = username,
displayName = displayName,
note = "",
url = url,
avatar = avatar,
bot = bot,
emojis = gson.fromJson(emojis, emojisListType),
)
}
companion object {
fun from(timelineAccount: TimelineAccount, accountId: Long, gson: Gson) = TimelineAccountEntity(
serverId = timelineAccount.id,
timelineUserId = accountId,
localUsername = timelineAccount.localUsername,
username = timelineAccount.username,
displayName = timelineAccount.name,
url = timelineAccount.url,
avatar = timelineAccount.avatar,
emojis = gson.toJson(timelineAccount.emojis),
bot = timelineAccount.bot,
)
}
}
/** /**
* The local view data for a status. * The local view data for a status.
@ -120,6 +194,11 @@ data class StatusViewDataEntity(
val contentCollapsed: Boolean, val contentCollapsed: Boolean,
) )
val attachmentArrayListType: Type = object : TypeToken<ArrayList<Attachment>>() {}.type
val emojisListType: Type = object : TypeToken<List<Emoji>>() {}.type
val mentionListType: Type = object : TypeToken<List<Status.Mention>>() {}.type
val tagListType: Type = object : TypeToken<List<HashTag>>() {}.type
data class TimelineStatusWithAccount( data class TimelineStatusWithAccount(
@Embedded @Embedded
val status: TimelineStatusEntity, val status: TimelineStatusEntity,
@ -129,4 +208,125 @@ data class TimelineStatusWithAccount(
val reblogAccount: TimelineAccountEntity? = null, // null when no reblog val reblogAccount: TimelineAccountEntity? = null, // null when no reblog
@Embedded(prefix = "svd_") @Embedded(prefix = "svd_")
val viewData: StatusViewDataEntity? = null, val viewData: StatusViewDataEntity? = null,
) ) {
fun toStatus(gson: Gson): Status {
val attachments: ArrayList<Attachment> = gson.fromJson(
status.attachments,
attachmentArrayListType,
) ?: arrayListOf()
val mentions: List<Status.Mention> = gson.fromJson(
status.mentions,
mentionListType,
) ?: emptyList()
val tags: List<HashTag>? = gson.fromJson(
status.tags,
tagListType,
)
val application = gson.fromJson(status.application, Status.Application::class.java)
val emojis: List<Emoji> = gson.fromJson(
status.emojis,
emojisListType,
) ?: emptyList()
val poll: Poll? = gson.fromJson(status.poll, Poll::class.java)
val card: Card? = gson.fromJson(status.card, Card::class.java)
val reblog = status.reblogServerId?.let { id ->
Status(
id = id,
url = status.url,
account = account.toTimelineAccount(gson),
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
editedAt = status.editedAt?.let { Date(it) },
emojis = emojis,
reblogsCount = status.reblogsCount,
favouritesCount = status.favouritesCount,
reblogged = status.reblogged,
favourited = status.favourited,
bookmarked = status.bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = false,
muted = status.muted,
poll = poll,
card = card,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
}
return if (reblog != null) {
Status(
id = status.serverId,
url = null, // no url for reblogs
account = reblogAccount!!.toTimelineAccount(gson),
inReplyToId = null,
inReplyToAccountId = null,
reblog = reblog,
content = "",
createdAt = Date(status.createdAt), // lie but whatever?
editedAt = null,
emojis = listOf(),
reblogsCount = 0,
favouritesCount = 0,
reblogged = false,
favourited = false,
bookmarked = false,
sensitive = false,
spoilerText = "",
visibility = status.visibility,
attachments = ArrayList(),
mentions = listOf(),
tags = listOf(),
application = null,
pinned = status.pinned,
muted = status.muted,
poll = null,
card = null,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
} else {
Status(
id = status.serverId,
url = status.url,
account = account.toTimelineAccount(gson),
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
reblog = null,
content = status.content.orEmpty(),
createdAt = Date(status.createdAt),
editedAt = status.editedAt?.let { Date(it) },
emojis = emojis,
reblogsCount = status.reblogsCount,
favouritesCount = status.favouritesCount,
reblogged = status.reblogged,
favourited = status.favourited,
bookmarked = status.bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
visibility = status.visibility,
attachments = attachments,
mentions = mentions,
tags = tags,
application = application,
pinned = status.pinned,
muted = status.muted,
poll = poll,
card = card,
repliesCount = status.repliesCount,
language = status.language,
filtered = status.filtered,
)
}
}
}

View File

@ -261,7 +261,10 @@ class StatusViewHelper(private val itemView: View) {
} }
} }
fun setupPollReadonly(poll: PollViewData?, emojis: List<Emoji>, statusDisplayOptions: StatusDisplayOptions) { /**
* Configures and shows poll views based on [poll].
*/
fun setupPollReadonly(poll: PollViewData, emojis: List<Emoji>, statusDisplayOptions: StatusDisplayOptions) {
val pollResults = listOf<TextView>( val pollResults = listOf<TextView>(
itemView.findViewById(R.id.status_poll_option_result_0), itemView.findViewById(R.id.status_poll_option_result_0),
itemView.findViewById(R.id.status_poll_option_result_1), itemView.findViewById(R.id.status_poll_option_result_1),
@ -271,19 +274,29 @@ class StatusViewHelper(private val itemView: View) {
val pollDescription = itemView.findViewById<TextView>(R.id.status_poll_description) val pollDescription = itemView.findViewById<TextView>(R.id.status_poll_description)
if (poll == null) { val timestamp = System.currentTimeMillis()
for (pollResult in pollResults) {
pollResult.visibility = View.GONE
}
pollDescription.visibility = View.GONE
} else {
val timestamp = System.currentTimeMillis()
setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis) setupPollResult(poll, emojis, pollResults, statusDisplayOptions.animateEmojis)
pollDescription.visibility = View.VISIBLE pollDescription.show()
pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, statusDisplayOptions.useAbsoluteTime) pollDescription.text = getPollInfoText(timestamp, poll, pollDescription, statusDisplayOptions.useAbsoluteTime)
}
/**
* Hides views related to polls.
*/
fun hidePoll() {
val pollResults = listOf<TextView>(
itemView.findViewById(R.id.status_poll_option_result_0),
itemView.findViewById(R.id.status_poll_option_result_1),
itemView.findViewById(R.id.status_poll_option_result_2),
itemView.findViewById(R.id.status_poll_option_result_3),
)
for (pollResult in pollResults) {
pollResult.hide()
} }
itemView.findViewById<TextView>(R.id.status_poll_description).hide()
} }
private fun getPollInfoText(timestamp: Long, poll: PollViewData, pollDescription: TextView, useAbsoluteTime: Boolean): CharSequence { private fun getPollInfoText(timestamp: Long, poll: PollViewData, pollDescription: TextView, useAbsoluteTime: Boolean): CharSequence {

View File

@ -1,97 +0,0 @@
/*
* Copyright 2023 Tusky Contributors
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>.
*/
@file:JvmName("ViewDataUtils")
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Pachli.
*
* This program is free software; you can redistribute it and/or modify it under the terms of the
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
* License, or (at your option) any later version.
*
* Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
* Public License for more details.
*
* You should have received a copy of the GNU General Public License along with Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package app.pachli.util
import app.pachli.entity.Filter
import app.pachli.entity.Notification
import app.pachli.entity.Status
import app.pachli.entity.TrendingTag
import app.pachli.viewdata.NotificationViewData
import app.pachli.viewdata.StatusViewData
import app.pachli.viewdata.TrendingViewData
fun Status.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean,
isDetailed: Boolean = false,
filterAction: Filter.Action = app.pachli.entity.Filter.Action.NONE,
): StatusViewData {
return StatusViewData(
status = this,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isExpanded = isExpanded,
isDetailed = isDetailed,
filterAction = filterAction,
)
}
fun Notification.toViewData(
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean,
filterAction: Filter.Action,
): NotificationViewData {
return NotificationViewData(
this.type,
this.id,
this.account,
this.status?.toViewData(
isShowingContent,
isExpanded,
isCollapsed,
filterAction = filterAction,
),
this.report,
)
}
fun List<TrendingTag>.toViewData(): List<TrendingViewData.Tag> {
val maxTrendingValue = flatMap { tag -> tag.history }
.mapNotNull { it.uses.toLongOrNull() }
.maxOrNull() ?: 1
return map { tag ->
val reversedHistory = tag.history.asReversed()
TrendingViewData.Tag(
name = tag.name,
usage = reversedHistory.mapNotNull { it.uses.toLongOrNull() },
accounts = reversedHistory.mapNotNull { it.accounts.toLongOrNull() },
maxTrendingValue = maxTrendingValue,
)
}
}

View File

@ -17,6 +17,7 @@
package app.pachli.viewdata package app.pachli.viewdata
import app.pachli.entity.Filter
import app.pachli.entity.Notification import app.pachli.entity.Notification
import app.pachli.entity.Report import app.pachli.entity.Report
import app.pachli.entity.TimelineAccount import app.pachli.entity.TimelineAccount
@ -27,4 +28,28 @@ data class NotificationViewData(
val account: TimelineAccount, val account: TimelineAccount,
var statusViewData: StatusViewData?, var statusViewData: StatusViewData?,
val report: Report?, val report: Report?,
) ) {
companion object {
fun from(
notification: Notification,
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean,
filterAction: Filter.Action,
) = NotificationViewData(
notification.type,
notification.id,
notification.account,
notification.status?.let { status ->
StatusViewData.from(
status,
isShowingContent,
isExpanded,
isCollapsed,
filterAction = filterAction,
)
},
notification.report,
)
}
}

View File

@ -34,14 +34,36 @@ data class PollViewData(
val votersCount: Int?, val votersCount: Int?,
val options: List<PollOptionViewData>, val options: List<PollOptionViewData>,
var voted: Boolean, var voted: Boolean,
) ) {
companion object {
fun from(poll: Poll) = PollViewData(
id = poll.id,
expiresAt = poll.expiresAt,
expired = poll.expired,
multiple = poll.multiple,
votesCount = poll.votesCount,
votersCount = poll.votersCount,
options = poll.options.mapIndexed { index, option -> PollOptionViewData.from(option, poll.ownVotes?.contains(index) == true) },
voted = poll.voted,
)
}
}
data class PollOptionViewData( data class PollOptionViewData(
val title: String, val title: String,
var votesCount: Int, var votesCount: Int,
var selected: Boolean, var selected: Boolean,
var voted: Boolean, var voted: Boolean,
) ) {
companion object {
fun from(pollOption: PollOption, voted: Boolean) = PollOptionViewData(
title = pollOption.title,
votesCount = pollOption.votesCount,
selected = false,
voted = voted,
)
}
}
fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int { fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int {
return if (fraction == 0) { return if (fraction == 0) {
@ -61,26 +83,3 @@ fun buildDescription(title: String, percent: Int, voted: Boolean, context: Conte
} }
return builder.append(title) return builder.append(title)
} }
fun Poll?.toViewData(): PollViewData? {
if (this == null) return null
return PollViewData(
id = id,
expiresAt = expiresAt,
expired = expired,
multiple = multiple,
votesCount = votesCount,
votersCount = votersCount,
options = options.mapIndexed { index, option -> option.toViewData(ownVotes?.contains(index) == true) },
voted = voted,
)
}
fun PollOption.toViewData(voted: Boolean): PollOptionViewData {
return PollOptionViewData(
title = title,
votesCount = votesCount,
selected = false,
voted = voted,
)
}

View File

@ -16,11 +16,16 @@ package app.pachli.viewdata
import android.os.Build import android.os.Build
import android.text.Spanned import android.text.Spanned
import app.pachli.components.conversation.ConversationAccountEntity
import app.pachli.components.conversation.ConversationStatusEntity
import app.pachli.db.TimelineStatusWithAccount
import app.pachli.entity.Filter import app.pachli.entity.Filter
import app.pachli.entity.Poll
import app.pachli.entity.Status import app.pachli.entity.Status
import app.pachli.util.parseAsMastodonHtml import app.pachli.util.parseAsMastodonHtml
import app.pachli.util.replaceCrashingCharacters import app.pachli.util.replaceCrashingCharacters
import app.pachli.util.shouldTrimStatus import app.pachli.util.shouldTrimStatus
import com.google.gson.Gson
/** /**
* Data required to display a status. * Data required to display a status.
@ -113,4 +118,111 @@ data class StatusViewData(
/** Helper for Java */ /** Helper for Java */
fun copyWithCollapsed(isCollapsed: Boolean) = copy(isCollapsed = isCollapsed) fun copyWithCollapsed(isCollapsed: Boolean) = copy(isCollapsed = isCollapsed)
fun toConversationStatusEntity(
favourited: Boolean = status.favourited,
bookmarked: Boolean = status.bookmarked,
muted: Boolean = status.muted ?: false,
poll: Poll? = status.poll,
expanded: Boolean = isExpanded,
collapsed: Boolean = isCollapsed,
showingHiddenContent: Boolean = isShowingContent,
) = ConversationStatusEntity(
id = id,
url = status.url,
inReplyToId = status.inReplyToId,
inReplyToAccountId = status.inReplyToAccountId,
account = ConversationAccountEntity.from(status.account),
content = status.content,
createdAt = status.createdAt,
editedAt = status.editedAt,
emojis = status.emojis,
favouritesCount = status.favouritesCount,
repliesCount = status.repliesCount,
favourited = favourited,
bookmarked = bookmarked,
sensitive = status.sensitive,
spoilerText = status.spoilerText,
attachments = status.attachments,
mentions = status.mentions,
tags = status.tags,
showingHiddenContent = showingHiddenContent,
expanded = expanded,
collapsed = collapsed,
muted = muted,
poll = poll,
language = status.language,
)
companion object {
fun from(
status: Status,
isShowingContent: Boolean,
isExpanded: Boolean,
isCollapsed: Boolean,
isDetailed: Boolean = false,
filterAction: Filter.Action = Filter.Action.NONE,
) = StatusViewData(
status = status,
isShowingContent = isShowingContent,
isCollapsed = isCollapsed,
isExpanded = isExpanded,
isDetailed = isDetailed,
filterAction = filterAction,
)
fun from(conversationStatusEntity: ConversationStatusEntity) = StatusViewData(
status = Status(
id = conversationStatusEntity.id,
url = conversationStatusEntity.url,
account = conversationStatusEntity.account.toAccount(),
inReplyToId = conversationStatusEntity.inReplyToId,
inReplyToAccountId = conversationStatusEntity.inReplyToAccountId,
content = conversationStatusEntity.content,
reblog = null,
createdAt = conversationStatusEntity.createdAt,
editedAt = conversationStatusEntity.editedAt,
emojis = conversationStatusEntity.emojis,
reblogsCount = 0,
favouritesCount = conversationStatusEntity.favouritesCount,
repliesCount = conversationStatusEntity.repliesCount,
reblogged = false,
favourited = conversationStatusEntity.favourited,
bookmarked = conversationStatusEntity.bookmarked,
sensitive = conversationStatusEntity.sensitive,
spoilerText = conversationStatusEntity.spoilerText,
visibility = Status.Visibility.DIRECT,
attachments = conversationStatusEntity.attachments,
mentions = conversationStatusEntity.mentions,
tags = conversationStatusEntity.tags,
application = null,
pinned = false,
muted = conversationStatusEntity.muted,
poll = conversationStatusEntity.poll,
card = null,
language = conversationStatusEntity.language,
filtered = null,
),
isExpanded = conversationStatusEntity.expanded,
isShowingContent = conversationStatusEntity.showingHiddenContent,
isCollapsed = conversationStatusEntity.collapsed,
)
fun from(
timelineStatusWithAccount: TimelineStatusWithAccount,
gson: Gson,
isExpanded: Boolean,
isShowingContent: Boolean,
isDetailed: Boolean = false,
): StatusViewData {
val status = timelineStatusWithAccount.toStatus(gson)
return StatusViewData(
status = status,
isExpanded = timelineStatusWithAccount.viewData?.expanded ?: isExpanded,
isShowingContent = timelineStatusWithAccount.viewData?.contentShowing ?: (isShowingContent || !status.actionableStatus.sensitive),
isCollapsed = timelineStatusWithAccount.viewData?.contentCollapsed ?: true,
isDetailed = isDetailed,
)
}
}
} }

View File

@ -15,6 +15,7 @@
package app.pachli.viewdata package app.pachli.viewdata
import app.pachli.entity.TrendingTag
import java.util.Date import java.util.Date
sealed class TrendingViewData { sealed class TrendingViewData {
@ -36,5 +37,19 @@ sealed class TrendingViewData {
) : TrendingViewData() { ) : TrendingViewData() {
override val id: String override val id: String
get() = name get() = name
companion object {
fun from(trendingTag: TrendingTag, maxTrendingValue: Long): Tag {
// Reverse the list to put oldest items first
val reversedHistory = trendingTag.history.asReversed()
return Tag(
name = trendingTag.name,
usage = reversedHistory.map { it.uses.toLongOrNull() ?: 0 },
accounts = reversedHistory.map { it.accounts.toLongOrNull() ?: 0 },
maxTrendingValue = maxTrendingValue,
)
}
}
} }
} }

View File

@ -1,6 +1,8 @@
package app.pachli.components.timeline package app.pachli.components.timeline
import app.pachli.db.StatusViewDataEntity import app.pachli.db.StatusViewDataEntity
import app.pachli.db.TimelineAccountEntity
import app.pachli.db.TimelineStatusEntity
import app.pachli.db.TimelineStatusWithAccount import app.pachli.db.TimelineStatusWithAccount
import app.pachli.entity.Status import app.pachli.entity.Status
import app.pachli.entity.TimelineAccount import app.pachli.entity.TimelineAccount
@ -95,11 +97,13 @@ fun mockStatusEntityWithAccount(
val gson = Gson() val gson = Gson()
return TimelineStatusWithAccount( return TimelineStatusWithAccount(
status = mockedStatus.toEntity( status = TimelineStatusEntity.from(
mockedStatus,
timelineUserId = userId, timelineUserId = userId,
gson = gson, gson = gson,
), ),
account = mockedStatus.account.toEntity( account = TimelineAccountEntity.from(
mockedStatus.account,
accountId = userId, accountId = userId,
gson = gson, gson = gson,
), ),

122
docs/code-style.md Normal file
View File

@ -0,0 +1,122 @@
# Code style guide
## Synopsis
This document describes aspects of code style that are not enforced with linters or formatting tools but the project still tries to adhere to. Some of these are things that developers might reasonably disagree on, but the project has a specific stance.
## Topics
### On converting between types
Roughly speaking the code that handles data from the user's server deals with three variations of that data.
1. Data from the network
2. Data cached locally (either to disk or memory)
3. Data displayed to the user
There must be code to convert between those representations, and it's important to make sure there isn't a loop in the dependency graph between the types.
Consider two types, `N`, representing data received from the network, and `C`, representing data that will be cached.
The wrong way to do it is code like:
```kotlin
/** In N.kt (the network data type) */
import C
data class N() {
fun toC(): C { /* return a C created from a N */ }
}
// ---
/** in C.kt (the cache data type) */
import N
data class C() {
fun toN(): N { /* return a N created from a C */ }
}
```
This creates a loop in their dependency graph as they import each other's types.
```mermaid
classDiagram
direction RL
class N{
toC() C
}
class C{
toN() N
}
C ..> N: Imports
N ..> C: Imports
```
This is a problem because:
- They can't be placed in separate modules
- Modifying code in `N` can cause `C` to be recompiled, and vice-versa
To fix this:
1. Pick one type as being "higher" in the dependency tree than the other
2. Remove the `to...()` method from the lower type, and implement it as a companion `from()` method on the higher type
In Pachli the dependency hierarchy is (higher types on the left):
```mermaid
flowchart LR
ViewData --> ViewModel --> Cache --> Network --> Core
```
so the previous example involving a network type and a cache type would instead be written as:
```kotlin
/** In N.kt (the network data type) */
data class N() {
// No import, no toC() method
}
// ---
/** in C.kt (the cache data type) */
import N
data class C() {
fun toN(): N { /* return a N created from a C */ }
companion object {
fun from(n: N): C {
// code from the N.toC() in the previous example
}
}
}
```
Now the dependency between the two types is:
```mermaid
classDiagram
direction RL
class N{
}
class C{
toN() N
from(n: N)$ C
}
C <.. N: Imports
```
The circular dependency is gone, the `N` type can easily be placed in a separate module to the `C` type, and changes to the `C` type will not require the `N` type to be recompiled.
In these examples the `from` method could also have been written as a secondary constructor instead of a static factory method in the companion object. We prefer static factory methods over secondary constructors because:
1. Functions have names that can more clearly indicate their intent
2. Functions can return objects of any subtype. If the example class `C` had multiple subtypes the correct subtype could be returned based on properties of `N`
3. Functions can return null or other values to signify an error. Perhaps the network type is expected to contain a particular property, but the server has a bug and returned data without that property.
4. Functions can have more specific visibility modifiers
5. Functions can be marked `inline`

View File

@ -93,7 +93,7 @@ Pull requests (PRs) are the primary unit of collaboration for code.
### Work on branches in your own fork of the repository ### Work on branches in your own fork of the repository
Do not clone the `pachli-android` repository. Instead, create a fork, create a branch in your fork from the `main` branch, and commit your changes to that. Do not clone the `pachli-android` repository. Instead, create a fork, create a branch in your fork from the `main` branch, and commit your changes to that branch.
See the GitHub [Collaborating with pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/about-collaborative-development-models) documentation. See the GitHub [Collaborating with pull requests](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/getting-started/about-collaborative-development-models) documentation.
@ -114,6 +114,32 @@ Typically you would configure the build variant in Android Studio with Build > S
This is not mandatory, but may make developing easier for you. This is not mandatory, but may make developing easier for you.
### Code style
#### `ktlintCheck` and `ktlintFormat`
The project uses [ktlint](https://pinterest.github.io/ktlint/) to enforce common code and formatting standards.
You can check your code before creating the PR with the `ktlintCheck` task.
```shell
./gradlew ktlintCheck
```
Most code formatting issues can be automatically resolved with the `ktlintFormat` task.
```shell
./gradlew ktlintFormat
```
The code in your PR will be checked for this every time it changes. If it is not lint-clean and automated fixes are possible they will be added as comments to the PR.
#### Questions of taste
Some code style issues are questions of taste, where developers might reasonably differ but the project has a specific stance.
Please read the [Code style guide](/docs/code-style.md).
### Individual commits ### Individual commits
A PR is typically made up multiple commits. A PR is typically made up multiple commits.
@ -148,7 +174,7 @@ This makes things needlessly difficult for your reviewers.
The project uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard for commit messages. If you are not familiar with them [Conventional Commits: A better way](https://medium.com/neudesic-innovation/conventional-commits-a-better-way-78d6785c2e08) is also a good introduction. The project uses the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) standard for commit messages. If you are not familiar with them [Conventional Commits: A better way](https://medium.com/neudesic-innovation/conventional-commits-a-better-way-78d6785c2e08) is also a good introduction.
> [!NOTE] > [!NOTE]
> See [docs/decisions/0001-use-conventional-commits.md](https://github.com/pachli/pachli-android/docs/decisions/0001-use-conventional-commits.md) > See [docs/decisions/0001-use-conventional-commits.md](/docs/decisions/0001-use-conventional-commits.md)
The PR's title and description will become the first line and remaining body of the commit message when the PR is merged, so your PR title and description should also follow the conventional commits approach. The PR's title and description will become the first line and remaining body of the commit message when the PR is merged, so your PR title and description should also follow the conventional commits approach.
@ -175,7 +201,7 @@ The types are:
- `test`, modify the test suite - `test`, modify the test suite
- `wip`, work-in-progress - `wip`, work-in-progress
More details on each is in [docs/decisions/conventional-commits.md](https://github.com/pachli/pachli-android/docs/decisions/conventional-commits.md). More details on each is in [docs/decisions/0001-use-conventional-commits.md](/docs/decisions/0001-use-conventional-commits.md).
`feat` for new features and `fix` for bug fixes are the most common. `feat` for new features and `fix` for bug fixes are the most common.
@ -250,24 +276,6 @@ You should periodically merge changes from the `main` branch in to your PR branc
If your PR can not be cleanly merged in to `main` it is difficult to review effectively, because merging the changes from `main` in to your PR will invalidate the review. You've changed the code, so the reviewer needs to look at it again. If your PR can not be cleanly merged in to `main` it is difficult to review effectively, because merging the changes from `main` in to your PR will invalidate the review. You've changed the code, so the reviewer needs to look at it again.
#### `ktlintCheck` and `ktlintFormat`
The project uses [ktlint](https://pinterest.github.io/ktlint/) to enforce common code and formatting standards.
You can check your code before creating the PR with the `ktlintCheck` task.
```shell
./gradlew ktlintCheck
```
Most code formatting issues can be automatically resolved with the `ktlintFormat` task.
```shell
./gradlew ktlintFormat
```
The code in your PR will be checked for this every time it changes. If it is not lint-clean and automated fixes are possible they will be added as comments to the PR.
#### Tests #### Tests
The project has a number of automated tests, they will automatically be run on your PR when it is submitted. The project has a number of automated tests, they will automatically be run on your PR when it is submitted.