diff --git a/app/src/main/java/xyz/wingio/dimett/Dimett.kt b/app/src/main/java/xyz/wingio/dimett/Dimett.kt index 7f5b919..a7903f8 100644 --- a/app/src/main/java/xyz/wingio/dimett/Dimett.kt +++ b/app/src/main/java/xyz/wingio/dimett/Dimett.kt @@ -21,6 +21,7 @@ class Dimett : Application() { override fun onCreate() { super.onCreate() + // Add gif support (https://coil-kt.github.io/coil/gifs/) val imageLoader = ImageLoader.Builder(this) .components { if (Build.VERSION.SDK_INT >= 28) { @@ -46,7 +47,6 @@ class Dimett : Application() { loggerModule ) } - } } \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/ast/Renderer.kt b/app/src/main/java/xyz/wingio/dimett/ast/Renderer.kt index 109e6cc..75e557d 100644 --- a/app/src/main/java/xyz/wingio/dimett/ast/Renderer.kt +++ b/app/src/main/java/xyz/wingio/dimett/ast/Renderer.kt @@ -10,6 +10,14 @@ import xyz.wingio.syntakts.compose.rememberAsyncRendered import xyz.wingio.syntakts.markdown.addBasicMarkdownRules import xyz.wingio.syntakts.syntakts +/** + * Renders a formatted version of [text] + * + * @param text Text to parse and format + * @param emojiMap Used to insert custom emoji into the text + * @param mentionMap All the users mentioned in the post + * @param actionHandler Callback that is passed the actions name + */ @Composable fun Syntakts.render( text: String, @@ -21,6 +29,11 @@ fun Syntakts.render( context = rememberRenderContext(emojiMap, mentionMap, actionHandler) ) +/** + * Creates a remembered version of [DefaultRenderContext] + * + * @see DefaultRenderContext + */ @Composable fun rememberRenderContext( emojiMap: Map, @@ -35,6 +48,9 @@ fun rememberRenderContext( } } +/** + * Contains all the rules for rendering post content + */ val DefaultSyntakts = syntakts { addHashtagRule() addMentionRule() @@ -43,6 +59,9 @@ val DefaultSyntakts = syntakts { addUnicodeEmojiRule() } +/** + * Contains the rules for rendering styled string resources + */ val StringSyntakts = syntakts { addUrlRule() addClickableRule() @@ -50,6 +69,9 @@ val StringSyntakts = syntakts { addUnicodeEmojiRule() } +/** + * Only contains rules necessary for emotes and Twemoji + */ val EmojiSyntakts = syntakts { addEmojiRule() addUnicodeEmojiRule() diff --git a/app/src/main/java/xyz/wingio/dimett/ast/Rules.kt b/app/src/main/java/xyz/wingio/dimett/ast/Rules.kt index 5bfc133..37908f5 100644 --- a/app/src/main/java/xyz/wingio/dimett/ast/Rules.kt +++ b/app/src/main/java/xyz/wingio/dimett/ast/Rules.kt @@ -8,22 +8,22 @@ import xyz.wingio.syntakts.compose.style.toSyntaktsColor import xyz.wingio.syntakts.style.Style const val urlRegex = - "https?:\\/\\/([\\w+?]+\\.[\\w+]+)([a-zA-Z0-9\\~\\!\\@\\#\\\$\\%\\^\\&\\*\\(\\)_\\-\\=\\+\\\\\\/\\?\\.\\:\\;\\'\\,]*)?" + "https?:\\/\\/([\\w+?]+\\.[\\w+]+)([a-zA-Z0-9\\~\\!\\@\\#\\\$\\%\\^\\&\\*\\(\\)_\\-\\=\\+\\\\\\/\\?\\.\\:\\;\\'\\,]*)?" // https://example.com const val hyperlinkRegex = - "\\[(.+?)\\]\\(($urlRegex)\\)" + "\\[(.+?)\\]\\(($urlRegex)\\)" // [link](https://example.com) const val clickableRegex = - "\\[(.+?)\\]\\{(.+?)\\}" + "\\[(.+?)\\]\\{(.+?)\\}" // [some text]{onSomeAction} const val emojiRegex = - ":(.+?):" + ":(.+?):" // :shortcode: const val mentionRegex = - "@(\\S+?)\\b(@(\\S+)\\b)?" + "@(\\S+?)\\b(@(\\S+)\\b)?" // @user or @user@example.social const val hashtagRegex = - "#(.+?)\\b" + "#(.+?)\\b" // #sometext fun Syntakts.Builder.addUrlRule() { rule(urlRegex) { result, context -> diff --git a/app/src/main/java/xyz/wingio/dimett/ast/rendercontext/DefaultRenderContext.kt b/app/src/main/java/xyz/wingio/dimett/ast/rendercontext/DefaultRenderContext.kt index bc74eab..81e28f0 100644 --- a/app/src/main/java/xyz/wingio/dimett/ast/rendercontext/DefaultRenderContext.kt +++ b/app/src/main/java/xyz/wingio/dimett/ast/rendercontext/DefaultRenderContext.kt @@ -4,9 +4,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.UriHandler data class DefaultRenderContext( - val emojiMap: Map, - val mentionMap: Map, + val emojiMap: Map, // shortcode: url + val mentionMap: Map, // username to id val linkColor: Color, val uriHandler: UriHandler, - val clickActionHandler: (String) -> Unit + val clickActionHandler: (String) -> Unit // Is passed the action name (Ex. onUserClick) ) \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/di/HttpModule.kt b/app/src/main/java/xyz/wingio/dimett/di/HttpModule.kt index 720aae3..ccb0084 100644 --- a/app/src/main/java/xyz/wingio/dimett/di/HttpModule.kt +++ b/app/src/main/java/xyz/wingio/dimett/di/HttpModule.kt @@ -27,7 +27,7 @@ val httpModule = module { defaultRequest { header( HttpHeaders.UserAgent, - "Dimett/${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})${if (BuildConfig.DEBUG) " - Debug" else ""}" + "Dimett/${BuildConfig.APPLICATION_ID} v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})" // Dimett/xyz.wingio.dimett v1.0.0 (1) ) } install(Logging) { diff --git a/app/src/main/java/xyz/wingio/dimett/domain/db/AccountsDao.kt b/app/src/main/java/xyz/wingio/dimett/domain/db/AccountsDao.kt index 3f42b46..e11e746 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/db/AccountsDao.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/db/AccountsDao.kt @@ -8,6 +8,9 @@ import androidx.room.Query import androidx.room.Update import xyz.wingio.dimett.domain.db.entities.Account +/** + * Used to manage saved [Account]s + */ @Dao interface AccountsDao { diff --git a/app/src/main/java/xyz/wingio/dimett/domain/db/AppDatabase.kt b/app/src/main/java/xyz/wingio/dimett/domain/db/AppDatabase.kt index 0eb0a9b..01e057f 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/db/AppDatabase.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/db/AppDatabase.kt @@ -6,6 +6,9 @@ import androidx.room.TypeConverters import xyz.wingio.dimett.domain.db.entities.Account import xyz.wingio.dimett.domain.db.entities.Instance +/** + * Manages complex persistent data (Such as accounts and instances) + */ @Database( entities = [Account::class, Instance::class], version = 1 diff --git a/app/src/main/java/xyz/wingio/dimett/domain/db/Converters.kt b/app/src/main/java/xyz/wingio/dimett/domain/db/Converters.kt index 9001a3b..fb9fb58 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/db/Converters.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/db/Converters.kt @@ -8,6 +8,9 @@ import kotlinx.serialization.json.Json import xyz.wingio.dimett.rest.dto.CustomEmoji import xyz.wingio.dimett.rest.dto.user.Field +/** + * Contains methods to convert any non-primitive data types + */ @ProvidedTypeConverter class Converters( private val json: Json diff --git a/app/src/main/java/xyz/wingio/dimett/domain/db/InstancesDao.kt b/app/src/main/java/xyz/wingio/dimett/domain/db/InstancesDao.kt index be3dc3d..3431bae 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/db/InstancesDao.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/db/InstancesDao.kt @@ -5,8 +5,12 @@ import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update +import xyz.wingio.dimett.domain.db.entities.Account import xyz.wingio.dimett.domain.db.entities.Instance +/** + * Used to manage saved [Instance]s + */ @Dao interface InstancesDao { diff --git a/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Account.kt b/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Account.kt index 54339ae..ed9e8d7 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Account.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Account.kt @@ -6,6 +6,11 @@ import androidx.room.PrimaryKey import xyz.wingio.dimett.rest.dto.CustomEmoji import xyz.wingio.dimett.rest.dto.user.Field +/** + * Represents an account that can be logged into + * + * @see xyz.wingio.dimett.rest.dto.user.CredentialUser + */ @Entity( foreignKeys = [ForeignKey( entity = Instance::class, diff --git a/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Instance.kt b/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Instance.kt index c8f8e55..948c01f 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Instance.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/db/entities/Instance.kt @@ -4,6 +4,9 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +/** + * Represents a Mastodon compatible instance with an existing OAuth application + */ @Entity data class Instance( @PrimaryKey val url: String, diff --git a/app/src/main/java/xyz/wingio/dimett/domain/manager/AccountManager.kt b/app/src/main/java/xyz/wingio/dimett/domain/manager/AccountManager.kt index 3ba8f29..4d9cbef 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/manager/AccountManager.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/manager/AccountManager.kt @@ -12,33 +12,55 @@ import xyz.wingio.dimett.domain.db.entities.Account import xyz.wingio.dimett.rest.dto.user.CredentialUser import xyz.wingio.dimett.utils.mainThread +/** + * Manage all stored accounts (adding, switching, deleting) + * + * @param preferenceManager Used for obtaining the logged in id + */ class AccountManager( db: AppDatabase, private val preferenceManager: PreferenceManager ) { + // All db accesses need to be done on another thread private val managerScope = CoroutineScope(Dispatchers.IO) private val dao = db.accountsDao() + /** + * Whether or not all accounts have been loaded yet + */ var isInitialized by mutableStateOf(false) private set + /** + * Cached version of the accounts table for fast synchronous account fetching + */ var accounts: MutableList = mutableStateListOf() private set + /** + * Currently logged on account, null when not logged in to any account + */ val current: Account? get() = get(preferenceManager.currentAccount) init { managerScope.launch { - val accs = dao.listAccounts() - mainThread { - accounts += accs - isInitialized = true + val accs = dao.listAccounts() // Fetch all accounts from the database + mainThread { // Compose state needs to be altered on the main thread + accounts += accs // Cache them all in memory + isInitialized = true // Mark us as ready } } } + /** + * Adds an account using a [CredentialUser] + * + * @param user User to add as an account + * @param token The token for the [user] + * @param instance Instance the [user] is on + */ fun addAccount(user: CredentialUser, token: String, instance: String) { managerScope.launch { val acct = with(user) { @@ -77,6 +99,9 @@ class AccountManager( } } + /** + * Updates an account + */ fun updateAccount(account: Account) { managerScope.launch { if (account.id.isNotBlank()) { @@ -85,12 +110,18 @@ class AccountManager( } } + /** + * Switches to another account + */ fun switchAccount(id: String) { val otherAccount = accounts.find { it.id == id } ?: return preferenceManager.currentAccount = otherAccount.id } + /** + * Retrieves an account by its id or null if one doesn't exist + */ operator fun get(id: String): Account? { return accounts.find { it.id == id } } diff --git a/app/src/main/java/xyz/wingio/dimett/domain/manager/InstanceManager.kt b/app/src/main/java/xyz/wingio/dimett/domain/manager/InstanceManager.kt index f93afe5..4a2068a 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/manager/InstanceManager.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/manager/InstanceManager.kt @@ -5,14 +5,29 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import xyz.wingio.dimett.domain.db.AppDatabase import xyz.wingio.dimett.domain.db.entities.Instance +import xyz.wingio.dimett.utils.mainThread -class InstanceManager(database: AppDatabase, val accountManager: AccountManager) { - private val dao = database.instancesDao() +/** + * Managed saved instances and associated OAuth apps + */ +class InstanceManager( + database: AppDatabase, + val accountManager: AccountManager +) { + + // All db accesses need to be done on another thread private val managerScope = CoroutineScope(Dispatchers.IO) + private val dao = database.instancesDao() + /** + * Cached version of the instances table for fast synchronous instance fetching + */ var instances: MutableList = mutableListOf() private set + /** + * Gets the instance for the currently logged on account + */ val current: Instance? get() { val account = accountManager.current ?: return null @@ -22,10 +37,21 @@ class InstanceManager(database: AppDatabase, val accountManager: AccountManager) init { managerScope.launch { - instances = dao.listInstances().toMutableList() + val _instances = dao.listInstances() // Fetch all accounts from the database + mainThread { // Compose state needs to be altered on the main thread + instances += _instances // Cache them all in memory + } } } + /** + * Stores an [Instance] + * + * @param url Base url for the instance + * @param clientId Client id for the associated OAuth app + * @param clientSecret Client secret for the associated OAuth app + * @param features Supported features that this instance has (Ex. reactions) + */ fun addInstance( url: String, clientId: String, @@ -40,10 +66,16 @@ class InstanceManager(database: AppDatabase, val accountManager: AccountManager) return i } + /** + * Checks if the instance with the given [url] already has an OAuth app we could use + */ fun exists(url: String): Boolean { return instances.find { it.url == url } != null } + /** + * Obtains the instance with the given [url] + */ operator fun get(url: String) = instances.find { it.url == url } } \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/domain/manager/PreferenceManager.kt b/app/src/main/java/xyz/wingio/dimett/domain/manager/PreferenceManager.kt index 05239ab..fb48df2 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/manager/PreferenceManager.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/manager/PreferenceManager.kt @@ -3,9 +3,15 @@ package xyz.wingio.dimett.domain.manager import android.content.Context import xyz.wingio.dimett.domain.manager.base.BasePreferenceManager +/** + * Manage general app preferences + */ class PreferenceManager(context: Context) : BasePreferenceManager(context.getSharedPreferences("prefs", Context.MODE_PRIVATE)) { + /** + * Id for the currently logged in account + */ var currentAccount by stringPreference("current_account") } \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/domain/manager/base/BasePreferenceManager.kt b/app/src/main/java/xyz/wingio/dimett/domain/manager/base/BasePreferenceManager.kt index 52fe111..e63935c 100644 --- a/app/src/main/java/xyz/wingio/dimett/domain/manager/base/BasePreferenceManager.kt +++ b/app/src/main/java/xyz/wingio/dimett/domain/manager/base/BasePreferenceManager.kt @@ -8,6 +8,11 @@ import androidx.compose.ui.graphics.Color import androidx.core.content.edit import kotlin.reflect.KProperty +/** + * Utility for managing [SharedPreferences] with Compose state + * + * @param prefs [SharedPreferences] instance to be managed + */ abstract class BasePreferenceManager( private val prefs: SharedPreferences ) { @@ -35,6 +40,9 @@ abstract class BasePreferenceManager( protected inline fun > putEnum(key: String, value: E) = putString(key, value.name) + /** + * Delegate that wraps around a preference to give it state + */ protected class Preference( private val key: String, defaultValue: T, @@ -52,6 +60,12 @@ abstract class BasePreferenceManager( } } + /** + * Delegate for a [String] based preference + * + * @param key The preferences name + * @param defaultValue Starting value for this preference + */ protected fun stringPreference( key: String, defaultValue: String = "" @@ -62,6 +76,12 @@ abstract class BasePreferenceManager( setter = ::putString ) + /** + * Delegate for a [Boolean] based preference + * + * @param key The preferences name + * @param defaultValue Starting value for this preference + */ protected fun booleanPreference( key: String, defaultValue: Boolean @@ -72,6 +92,12 @@ abstract class BasePreferenceManager( setter = ::putBoolean ) + /** + * Delegate for an [Int] based preference + * + * @param key The preferences name + * @param defaultValue Starting value for this preference + */ protected fun intPreference( key: String, defaultValue: Int @@ -82,6 +108,12 @@ abstract class BasePreferenceManager( setter = ::putInt ) + /** + * Delegate for a [Float] based preference + * + * @param key The preferences name + * @param defaultValue Starting value for this preference + */ protected fun floatPreference( key: String, defaultValue: Float @@ -92,6 +124,12 @@ abstract class BasePreferenceManager( setter = ::putFloat ) + /** + * Delegate for a [Color] based preference + * + * @param key The preferences name + * @param defaultValue Starting value for this preference + */ protected fun colorPreference( key: String, defaultValue: Color @@ -102,7 +140,12 @@ abstract class BasePreferenceManager( setter = ::putColor ) - + /** + * Delegate for an [Enum] based preference + * + * @param key The preferences name + * @param defaultValue Starting value for this preference + */ protected inline fun > enumPreference( key: String, defaultValue: E diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/Application.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/Application.kt index 2c3007c..ce775b4 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/Application.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/Application.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Application/ @Serializable data class Application( val name: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/CustomEmoji.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/CustomEmoji.kt index c4bd62d..39bc1e7 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/CustomEmoji.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/CustomEmoji.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/CustomEmoji/ @Serializable data class CustomEmoji( val shortcode: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/Filter.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/Filter.kt index 64da4c0..07de0f8 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/Filter.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/Filter.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Filter/ @Serializable data class Filter( val id: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterKeyword.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterKeyword.kt index 1e1c795..d769a0e 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterKeyword.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterKeyword.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/FilterKeyword/ @Serializable data class FilterKeyword( val id: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterResult.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterResult.kt index 6ab7887..f0f2d3f 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterResult.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterResult.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/FilterResult/ @Serializable data class FilterResult( val filter: Filter, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterStatus.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterStatus.kt index c4079a5..1c1af59 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterStatus.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/FilterStatus.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/FilterStatus/ @Serializable data class FilterStatus( val id: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/Poll.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/Poll.kt index 87001da..84a071c 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/Poll.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/Poll.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Poll/ @Serializable data class Poll( val id: String, @@ -18,6 +19,7 @@ data class Poll( @SerialName("own_votes") val ownVotes: List = emptyList() ) { + // https://docs.joinmastodon.org/entities/Poll/#Option @Serializable data class Option( val title: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/PreviewCard.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/PreviewCard.kt index 6cae13d..b91d440 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/PreviewCard.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/PreviewCard.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/PreviewCard/ @Serializable data class PreviewCard( val url: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/Role.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/Role.kt index e888aa1..bb1cb5f 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/Role.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/Role.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Role/ @Serializable data class Role( val id: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/Token.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/Token.kt index dc86449..3df0bea 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/Token.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/Token.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Token/ @Serializable data class Token( @SerialName("access_token") val accessToken: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/meta/NodeInfo.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/meta/NodeInfo.kt index d969ad5..e9ee498 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/meta/NodeInfo.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/meta/NodeInfo.kt @@ -2,6 +2,7 @@ package xyz.wingio.dimett.rest.dto.meta import kotlinx.serialization.Serializable +// http://nodeinfo.diaspora.software/schema.html @Serializable data class NodeInfo( val metadata: MetaData? = null, @@ -43,6 +44,7 @@ data class NodeInfo( } +// Response from /.well-known/nodeinfo @Serializable data class NodeInfoLocation( val links: List diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/MediaAttachment.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/MediaAttachment.kt index 1145e05..3fd97e6 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/MediaAttachment.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/MediaAttachment.kt @@ -3,6 +3,7 @@ package xyz.wingio.dimett.rest.dto.post import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/MediaAttachment/ @Serializable data class MediaAttachment( val id: String, @@ -33,6 +34,7 @@ data class MediaAttachment( AUDIO } + // This isn't really standardized so I had to find this via checking response json @Serializable data class Meta( val original: MetaData? = null, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Mention.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Mention.kt index 5721861..32f86b5 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Mention.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Mention.kt @@ -2,6 +2,7 @@ package xyz.wingio.dimett.rest.dto.post import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Status/#Mention @Serializable data class Mention( val id: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Post.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Post.kt index 3b992f1..7cfec14 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Post.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Post.kt @@ -9,6 +9,7 @@ import xyz.wingio.dimett.rest.dto.Poll import xyz.wingio.dimett.rest.dto.PreviewCard import xyz.wingio.dimett.rest.dto.user.User +// https://docs.joinmastodon.org/entities/Status @Serializable data class Post( val id: String, @@ -64,6 +65,7 @@ data class Post( } +// https://docs.joinmastodon.org/entities/Status/#application @Serializable data class Application( val name: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Tag.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Tag.kt index 9c33daa..acb3910 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Tag.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/post/Tag.kt @@ -2,6 +2,7 @@ package xyz.wingio.dimett.rest.dto.post import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Status/#Tag @Serializable data class Tag( val name: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/user/CredentialUser.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/user/CredentialUser.kt index 547e5cb..0815025 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/user/CredentialUser.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/user/CredentialUser.kt @@ -6,6 +6,7 @@ import kotlinx.serialization.Serializable import xyz.wingio.dimett.rest.dto.CustomEmoji import xyz.wingio.dimett.rest.dto.Role +// https://docs.joinmastodon.org/entities/Account/#CredentialAccount @Serializable data class CredentialUser( val id: String, @@ -38,6 +39,7 @@ data class CredentialUser( val role: Role? = null ) +// https://docs.joinmastodon.org/entities/Account/#source @Serializable data class Source( @SerialName("note") val bio: String? = null, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/user/Field.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/user/Field.kt index 5adcca8..ac1b3d7 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/user/Field.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/user/Field.kt @@ -4,6 +4,7 @@ import kotlinx.datetime.Instant import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +// https://docs.joinmastodon.org/entities/Account/#Field @Serializable data class Field( val name: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/dto/user/User.kt b/app/src/main/java/xyz/wingio/dimett/rest/dto/user/User.kt index ce6ad6f..64518d0 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/dto/user/User.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/dto/user/User.kt @@ -5,6 +5,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import xyz.wingio.dimett.rest.dto.CustomEmoji +// https://docs.joinmastodon.org/entities/Account @Serializable data class User( val id: String, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/service/HttpService.kt b/app/src/main/java/xyz/wingio/dimett/rest/service/HttpService.kt index e24b152..facb8be 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/service/HttpService.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/service/HttpService.kt @@ -14,11 +14,23 @@ import xyz.wingio.dimett.rest.utils.ApiError import xyz.wingio.dimett.rest.utils.ApiFailure import xyz.wingio.dimett.rest.utils.ApiResponse +/** + * Handles sending requests and getting responses in a safe manner + * + * @param json Used to deserialize response bodies + * @param http Ktor client used to build and send the requests + */ class HttpService( val json: Json, val http: HttpClient ) { + /** + * Makes a request and safely wraps responses for better error handling + * + * @param builder Used to build the request + * @return Wrapped response body + */ suspend inline fun request(crossinline builder: HttpRequestBuilder.() -> Unit = {}): ApiResponse = withContext(Dispatchers.IO) { var body: String? = null diff --git a/app/src/main/java/xyz/wingio/dimett/rest/service/MastodonService.kt b/app/src/main/java/xyz/wingio/dimett/rest/service/MastodonService.kt index 6b946ad..25b095e 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/service/MastodonService.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/service/MastodonService.kt @@ -16,25 +16,54 @@ import xyz.wingio.dimett.rest.dto.user.CredentialUser import xyz.wingio.dimett.rest.utils.Routes import xyz.wingio.dimett.rest.utils.setForm +/** + * Service for interacting with the [Mastodon API](https://docs.joinmastodon.org/methods) + * + * @param http Instance of [HttpService] used to make requests + * @param accounts Used to obtain the correct credentials + */ class MastodonService( private val http: HttpService, private val accounts: AccountManager ) { + /** + * Applies the instances base url to the desired [route] + */ private fun HttpRequestBuilder.route(route: String) = url("https://${accounts.current?.instance}$route") + /** + * Adds the correct authorization header to the request + */ private fun HttpRequestBuilder.authorize() = header(HttpHeaders.Authorization, "Bearer ${accounts.current?.token}") + /** + * Fetches the location of the instances Nodeinfo + * + * @see Routes.WELL_KNOWN.NODEINFO + */ suspend fun getNodeInfoLocation(instanceUrl: String) = http.request { url("https://$instanceUrl${Routes.WELL_KNOWN.NODEINFO}") } + /** + * Fetches the Nodeinfo object from the route obtained from [getNodeInfoLocation] + * + * @see NodeInfo + */ suspend fun getNodeInfo(url: String) = http.request { url(url) } + /** + * Creates an [Application] used to authenticate on behalf of the user + * + * [Ref](https://docs.joinmastodon.org/methods/apps/) + * + * @see Application + */ suspend fun createApp(instanceUrl: String) = http.request { url("https://$instanceUrl${Routes.V1.APPS}") @@ -50,6 +79,13 @@ class MastodonService( method = HttpMethod.Post } + /** + * Obtains an api token to be used for future api calls, part of the OAuth flow + * + * [Ref](https://docs.joinmastodon.org/methods/oauth/#token) + * + * @see Token + */ suspend fun getToken( instanceUrl: String, code: String, @@ -68,6 +104,13 @@ class MastodonService( } } + /** + * Obtains the user associated with the [token] + * + * [Ref](https://docs.joinmastodon.org/methods/accounts/#verify_credentials) + * + * @see CredentialUser + */ suspend fun verifyCredentials( instanceUrl: String = accounts.current?.instance ?: "", token: String? = accounts.current?.token @@ -77,6 +120,13 @@ class MastodonService( header("authorization", "Bearer $token") } + /** + * Gets the home feed for the logged in user + * + * [Ref](https://docs.joinmastodon.org/methods/timelines/#home) + * + * @see Post + */ suspend fun getFeed( max: String? = null, since: String? = null, @@ -92,24 +142,52 @@ class MastodonService( parameter("limit", limit) } + /** + * Favorites (likes) a post with the given [id] + * + * [Ref](https://docs.joinmastodon.org/methods/statuses/#favourite) + * + * @see Post + */ suspend fun favoritePost(id: String) = http.request { route(Routes.V1.Posts.FAVORITE(id)) authorize() method = HttpMethod.Post } + /** + * Unfavorites (unlikes) a post with the given [id] + * + * [Ref](https://docs.joinmastodon.org/methods/statuses/#unfavourite) + * + * @see Post + */ suspend fun unfavoritePost(id: String) = http.request { route(Routes.V1.Posts.UNFAVORITE(id)) authorize() method = HttpMethod.Post } + /** + * Boosts (reposts/retweets) a post with the given [id] + * + * [Ref](https://docs.joinmastodon.org/methods/statuses/#boost) + * + * @see Post + */ suspend fun boostPost(id: String) = http.request { route(Routes.V1.Posts.BOOST(id)) authorize() method = HttpMethod.Post } + /** + * Unboosts (unreposts/unretweets) a post with the given [id] + * + * [Ref](https://docs.joinmastodon.org/methods/statuses/#boost) + * + * @see Post + */ suspend fun unboostPost(id: String) = http.request { route(Routes.V1.Posts.UNBOOST(id)) authorize() diff --git a/app/src/main/java/xyz/wingio/dimett/rest/utils/ApiResponse.kt b/app/src/main/java/xyz/wingio/dimett/rest/utils/ApiResponse.kt index c2f47d2..78d86ef 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/utils/ApiResponse.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/utils/ApiResponse.kt @@ -2,17 +2,52 @@ package xyz.wingio.dimett.rest.utils import io.ktor.http.HttpStatusCode +/** + * Wrapper for api responses that enables better error handling + * + * @param T The api model for the response + */ sealed interface ApiResponse { + /** + * [Data][data] was returned without any issues + */ data class Success(val data: T) : ApiResponse + + /** + * Represents a response that had no body + */ class Empty : ApiResponse + + /** + * Represents an error returned by the server + */ data class Error(val error: ApiError) : ApiResponse + + /** + * Represents an error on the client (Ex. deserialization problem or device is offline) + */ data class Failure(val error: ApiFailure) : ApiResponse } +/** + * Contains the error information returned by the server + * + * @param code A valid HTTP status code + * @param body The response body as text + */ class ApiError(code: HttpStatusCode, body: String?) : Error("HTTP Code $code, Body: $body") +/** + * Contains the exact error caused by the client + * + * @param error The error thrown during the request + * @param body The response body (Only available if the failure occurred after a successful request, usually due to an incorrect api model) + */ class ApiFailure(error: Throwable, body: String?) : Error(body, error) +/** + * Callbacks for every kind of response status + */ inline fun ApiResponse.fold( success: (T) -> Unit = {}, empty: () -> Unit = {}, @@ -25,7 +60,9 @@ inline fun ApiResponse.fold( is ApiResponse.Failure -> failure(this.error) } - +/** + * Version of [fold] that treats both [ApiResponse.Error] and [ApiResponse.Failure] as an error + */ inline fun ApiResponse.fold( success: (T) -> Unit = {}, fail: (Error) -> Unit = {}, @@ -37,14 +74,23 @@ inline fun ApiResponse.fold( is ApiResponse.Failure -> fail(error) } +/** + * Only calls the provided [block] if the response was successful (returned data) + */ inline fun ApiResponse.ifSuccessful(block: (T) -> Unit) { if (this is ApiResponse.Success) block(data) } +/** + * Only calls the provided [block] if the response was empty (returned no data) + */ inline fun ApiResponse.ifEmpty(crossinline block: () -> Unit) { if (this is ApiResponse.Empty) block() } +/** + * If successful it returns the underlying [data][T] otherwise returns null + */ fun ApiResponse.getOrNull() = when (this) { is ApiResponse.Success -> data is ApiResponse.Empty, diff --git a/app/src/main/java/xyz/wingio/dimett/rest/utils/Utils.kt b/app/src/main/java/xyz/wingio/dimett/rest/utils/Utils.kt index e123808..0e15337 100644 --- a/app/src/main/java/xyz/wingio/dimett/rest/utils/Utils.kt +++ b/app/src/main/java/xyz/wingio/dimett/rest/utils/Utils.kt @@ -6,6 +6,9 @@ import io.ktor.client.request.forms.MultiPartFormDataContent import io.ktor.client.request.forms.formData import io.ktor.client.request.setBody +/** + * Shorthand for setting a MultiPart Form Data request body + */ fun HttpRequestBuilder.setForm(formBuilder: FormBuilder.() -> Unit) { setBody( MultiPartFormDataContent( diff --git a/app/src/main/java/xyz/wingio/dimett/ui/activity/MainActivity.kt b/app/src/main/java/xyz/wingio/dimett/ui/activity/MainActivity.kt index 1ef6495..a4f9171 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/activity/MainActivity.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/activity/MainActivity.kt @@ -9,27 +9,29 @@ import androidx.compose.ui.Modifier import androidx.core.view.WindowCompat import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.SlideTransition -import org.koin.core.component.KoinComponent -import org.koin.core.component.inject +import org.koin.android.ext.android.get import xyz.wingio.dimett.domain.manager.AccountManager import xyz.wingio.dimett.ui.screens.auth.LoginScreen import xyz.wingio.dimett.ui.screens.main.MainScreen import xyz.wingio.dimett.ui.theme.DimettTheme -class MainActivity : ComponentActivity(), KoinComponent { - - private val accountManager: AccountManager by inject() +class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) + val accountManager: AccountManager = get() + WindowCompat.setDecorFitsSystemWindows(window, false) // Support edge to edge setContent { DimettTheme { Surface( modifier = Modifier.fillMaxSize() ) { + /* + * Loading accounts takes some time, not very much but it can cause issues. + * As such we wait until its done before showing the ui + */ if (accountManager.isInitialized) { val isSignedIn = accountManager.current != null val startScreen = if (isSignedIn) MainScreen() else LoginScreen() diff --git a/app/src/main/java/xyz/wingio/dimett/ui/components/BadgedItem.kt b/app/src/main/java/xyz/wingio/dimett/ui/components/BadgedItem.kt index a44d022..a4f722c 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/components/BadgedItem.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/components/BadgedItem.kt @@ -10,7 +10,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -// Credit to Xinto (github.com/X1nto) +/** + * Overlays a [badge] component on top of some [content] with a cutout + * + * @param badge The component to display over the content + * @param modifier Modifiers for the BadgedItem + * @param badgeAlignment How to align the badge relative to the content + */ +// Credit to Xinto (https://github.com/X1nto) @Composable fun BadgedItem( badge: @Composable (() -> Unit)?, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/components/IntentHandler.kt b/app/src/main/java/xyz/wingio/dimett/ui/components/IntentHandler.kt index 4e8a64b..40c8d83 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/components/IntentHandler.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/components/IntentHandler.kt @@ -2,6 +2,7 @@ package xyz.wingio.dimett.ui.components import android.content.Intent import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -11,6 +12,12 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.core.util.Consumer +/** + * An effect for handling incoming [Intent]s + * + * @param enabled Whether or not intents are sent to the callback + * @param onIntent Callback that gets sent all incoming [Intent]s received by the root [ComponentActivity] + */ @Composable fun IntentHandler(enabled: Boolean = true, onIntent: (intent: Intent) -> Unit) { val activity = LocalContext.current as? ComponentActivity ?: return // Make sure we are actually in a `ComponentActivity` diff --git a/app/src/main/java/xyz/wingio/dimett/ui/components/RefreshIndicator.kt b/app/src/main/java/xyz/wingio/dimett/ui/components/RefreshIndicator.kt index 6d707c6..e205f8a 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/components/RefreshIndicator.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/components/RefreshIndicator.kt @@ -10,6 +10,12 @@ import androidx.compose.ui.unit.dp import dev.materii.pullrefresh.PullRefreshIndicator import dev.materii.pullrefresh.PullRefreshState +/** + * Wrapper around [PullRefreshIndicator] that adds Material 3 theming + * + * @param refreshing A boolean representing whether a refresh is occurring + * @param state The [PullRefreshState] which controls where and how the indicator will be drawn. + */ @Composable fun BoxScope.RefreshIndicator( refreshing: Boolean, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/components/Text.kt b/app/src/main/java/xyz/wingio/dimett/ui/components/Text.kt index 042b2a1..f8fabe3 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/components/Text.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/components/Text.kt @@ -20,6 +20,9 @@ import xyz.wingio.dimett.utils.inlineContent import xyz.wingio.dimett.utils.toAnnotatedString import xyz.wingio.syntakts.compose.material3.clickable.ClickableText +/** + * Custom version of the Material 3 Text component that lets us deal with styled text much more easily + */ @Composable fun Text( text: String, @@ -55,6 +58,9 @@ fun Text( modifier = modifier ) +/** + * Custom version of the Material 3 Text component that lets us deal with styled text much more easily + */ @Composable fun Text( text: AnnotatedString, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/screens/auth/LoginScreen.kt b/app/src/main/java/xyz/wingio/dimett/ui/screens/auth/LoginScreen.kt index 5224819..0dfe848 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/screens/auth/LoginScreen.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/screens/auth/LoginScreen.kt @@ -44,12 +44,8 @@ import kotlin.time.Duration.Companion.seconds class LoginScreen : Screen { @Composable - override fun Content() = Screen() - - @Composable - private fun Screen( - viewModel: LoginViewModel = getScreenModel() - ) { + override fun Content() { + val viewModel: LoginViewModel = getScreenModel() val navigator = LocalNavigator.currentOrThrow IntentHandler { intent -> @@ -75,6 +71,9 @@ class LoginScreen : Screen { } } + /** + * The main body of this screen + */ @Composable private fun ColumnScope.Login( viewModel: LoginViewModel @@ -123,7 +122,7 @@ class LoginScreen : Screen { viewModel.nodeInfo = null }, label = { Text(getString(R.string.label_instance)) }, - placeholder = { Text("mastodon.online") }, + placeholder = { Text("mastodon.online") }, // TODO: Maybe replace with random instance from some list? isError = viewModel.didError, singleLine = true ) @@ -140,7 +139,7 @@ class LoginScreen : Screen { Button( onClick = { viewModel.login(ctx) }, - enabled = viewModel.nodeInfo != null && viewModel.instanceIsMastodon + enabled = viewModel.nodeInfo != null && viewModel.instanceIsMastodon // For now we only support the Mastodon api ) { Text(getString(R.string.action_login)) } diff --git a/app/src/main/java/xyz/wingio/dimett/ui/screens/explore/ExploreScreen.kt b/app/src/main/java/xyz/wingio/dimett/ui/screens/explore/ExploreScreen.kt index 70e92c3..75a8275 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/screens/explore/ExploreScreen.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/screens/explore/ExploreScreen.kt @@ -24,6 +24,7 @@ import xyz.wingio.dimett.ui.components.Text import xyz.wingio.dimett.utils.TabOptions import xyz.wingio.dimett.utils.getString +// TODO: Implement class ExploreTab : Tab { override val options: TabOptions @Composable get() = TabOptions( diff --git a/app/src/main/java/xyz/wingio/dimett/ui/screens/feed/FeedScreen.kt b/app/src/main/java/xyz/wingio/dimett/ui/screens/feed/FeedScreen.kt index 4a4ec8b..8934ce3 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/screens/feed/FeedScreen.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/screens/feed/FeedScreen.kt @@ -39,6 +39,9 @@ import xyz.wingio.dimett.ui.widgets.posts.Post import xyz.wingio.dimett.utils.TabOptions import xyz.wingio.dimett.utils.getString +/** + * Shows the home timeline for the current user + */ class FeedTab : Tab { override val options: TabOptions @Composable get() = TabOptions( @@ -48,17 +51,14 @@ class FeedTab : Tab { ) @Composable - override fun Content() = Screen() - - @Composable - private fun Screen( - viewModel: FeedViewModel = getScreenModel() - ) { + override fun Content(){ + val viewModel: FeedViewModel = getScreenModel() val posts = viewModel.posts.collectAsLazyPagingItems() val refreshing = posts.loadState.refresh == LoadState.Loading val pullRefreshState = rememberPullRefreshState(refreshing = refreshing, onRefresh = { posts.refresh() }) + // UI has to be wrapped in a Box in order to display the RefreshIndicator Box( modifier = Modifier .fillMaxSize() @@ -69,6 +69,7 @@ class FeedTab : Tab { modifier = Modifier.fillMaxSize() ) { if (posts.itemCount == 0 && !refreshing) { + // Display this when no posts could be loaded item { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -96,7 +97,7 @@ class FeedTab : Tab { contentType = posts.itemContentType() ) { post -> posts[post]?.let { - val realPost = viewModel.modifiedPosts[it.id] ?: it + val realPost = viewModel.modifiedPosts[it.id] ?: it // A version of the post with local changes (such as boost or favorited status) Post( post = realPost, onAvatarClick = { authorId -> @@ -106,7 +107,7 @@ class FeedTab : Tab { }, onBoostClick = { postId -> - viewModel.toggleBoost(postId, realPost.hasBoosted ?: false, posts) + viewModel.toggleBoost(postId, realPost.hasBoosted ?: false) }, onFavoriteClick = { postId -> viewModel.toggleFavorite(postId, realPost.favorited ?: false) @@ -124,6 +125,7 @@ class FeedTab : Tab { } } if (posts.loadState.append is LoadState.Error) { + // Display at the bottom of the feed when receiving an error while trying to load more item { Box( contentAlignment = Alignment.Center, @@ -138,6 +140,7 @@ class FeedTab : Tab { } } if (posts.loadState.append == LoadState.Loading) { + // Show a little loading indicator if the user reaches the bottom before we could paginate item { Box( contentAlignment = Alignment.Center, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/screens/main/MainScreen.kt b/app/src/main/java/xyz/wingio/dimett/ui/screens/main/MainScreen.kt index c2ec2e6..7761b5b 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/screens/main/MainScreen.kt @@ -32,14 +32,14 @@ import xyz.wingio.dimett.ui.viewmodels.main.MainViewModel import xyz.wingio.dimett.utils.LocalPagerState import xyz.wingio.dimett.utils.RootTab +/** + * Hosts the Home, Explore, Inbox (Notifications), and Profile tabs + */ class MainScreen : Screen { - @Composable - override fun Content() = Screen() - @Composable @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) - private fun Screen() { + override fun Content() { val viewModel: MainViewModel = getScreenModel() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior() val coroutineScope = rememberCoroutineScope() @@ -47,8 +47,9 @@ class MainScreen : Screen { RootTab.entries.size } + // Navigate to the home tab when the back button is pressed BackHandler( - enabled = pagerState.currentPage != 0 + enabled = pagerState.currentPage != 0 // We don't want to do this on the home tab ) { coroutineScope.launch { pagerState.animateScrollToPage(0) @@ -56,10 +57,10 @@ class MainScreen : Screen { } CompositionLocalProvider( - LocalPagerState provides pagerState + LocalPagerState provides pagerState // Lets all children access the state used by the HorizontalPager ) { Scaffold( - bottomBar = { TabBar(pagerState, viewModel) }, + bottomBar = { TabBar(viewModel) }, topBar = { TopBar(RootTab.entries[pagerState.currentPage].tab, scrollBehavior) } ) { pv -> HorizontalPager( @@ -106,11 +107,11 @@ class MainScreen : Screen { @Composable @OptIn(ExperimentalFoundationApi::class) private fun TabBar( - pagerState: PagerState, viewModel: MainViewModel ) { - val tab = RootTab.entries[pagerState.currentPage].tab - val coroutineScope = rememberCoroutineScope() + val pagerState = LocalPagerState.current // This PagerState should be the one used for tabs + val tab = RootTab.entries[pagerState.currentPage].tab // Gets the currently selected/visible tab + val coroutineScope = rememberCoroutineScope() // Just required for certain animations NavigationBar { RootTab.entries.forEach { @@ -124,6 +125,7 @@ class MainScreen : Screen { }, icon = { if(it == RootTab.PROFILE && viewModel.account != null) { + // For the profile tab we want to display the current users avatar AsyncImage( model = viewModel.account!!.avatar, contentDescription = stringResource( @@ -131,10 +133,11 @@ class MainScreen : Screen { viewModel.account!!.username ), modifier = Modifier - .size(24.dp) + .size(24.dp) // About the same size that the other icons use .clip(CircleShape) ) } else { + // All others use an icon that changes depending on whether or not the tab is selected Icon( painter = it.tab.options.icon!!, contentDescription = it.tab.options.title diff --git a/app/src/main/java/xyz/wingio/dimett/ui/screens/notifications/NotificationsScreen.kt b/app/src/main/java/xyz/wingio/dimett/ui/screens/notifications/NotificationsScreen.kt index d569b4f..e5a09fa 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/screens/notifications/NotificationsScreen.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/screens/notifications/NotificationsScreen.kt @@ -24,6 +24,7 @@ import xyz.wingio.dimett.ui.components.Text import xyz.wingio.dimett.utils.TabOptions import xyz.wingio.dimett.utils.getString +// TODO: Implement class NotificationsTab : Tab { override val options: TabOptions @Composable get() = TabOptions( diff --git a/app/src/main/java/xyz/wingio/dimett/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/xyz/wingio/dimett/ui/screens/profile/ProfileScreen.kt index d680a59..f0dc243 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/screens/profile/ProfileScreen.kt @@ -25,6 +25,7 @@ import xyz.wingio.dimett.ui.components.Text import xyz.wingio.dimett.utils.TabOptions import xyz.wingio.dimett.utils.getString +// TODO: Implement open class ProfileScreen: Screen { @Composable @@ -51,6 +52,9 @@ open class ProfileScreen: Screen { } +/** + * Lets us use [ProfileScreen] as a tab + */ class ProfileTab : ProfileScreen(), Tab { override val options: TabOptions diff --git a/app/src/main/java/xyz/wingio/dimett/ui/theme/Color.kt b/app/src/main/java/xyz/wingio/dimett/ui/theme/Color.kt deleted file mode 100644 index f96b0f2..0000000 --- a/app/src/main/java/xyz/wingio/dimett/ui/theme/Color.kt +++ /dev/null @@ -1,11 +0,0 @@ -package xyz.wingio.dimett.ui.theme - -import androidx.compose.ui.graphics.Color - -val Purple80 = Color(0xFFD0BCFF) -val PurpleGrey80 = Color(0xFFCCC2DC) -val Pink80 = Color(0xFFEFB8C8) - -val Purple40 = Color(0xFF6650a4) -val PurpleGrey40 = Color(0xFF625b71) -val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/ui/theme/Theme.kt b/app/src/main/java/xyz/wingio/dimett/ui/theme/Theme.kt index ce75fc6..dc8f544 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/theme/Theme.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/theme/Theme.kt @@ -13,50 +13,34 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import com.google.accompanist.systemuicontroller.rememberSystemUiController -private val DarkColorScheme = darkColorScheme( - primary = Purple80, - secondary = PurpleGrey80, - tertiary = Pink80 -) - -private val LightColorScheme = lightColorScheme( - primary = Purple40, - secondary = PurpleGrey40, - tertiary = Pink40 - - /* Other default colors to override - background = Color(0xFFFFFBFE), - surface = Color(0xFFFFFBFE), - onPrimary = Color.White, - onSecondary = Color.White, - onTertiary = Color.White, - onBackground = Color(0xFF1C1B1F), - onSurface = Color(0xFF1C1B1F), - */ -) - +/** + * Wrapper around [MaterialTheme] that uses the correct color scheme automatically and sets up edge-to-edge + * + * @param darkTheme Whether or not to use the dark theme + * @param dynamicColor Whether or not to use dynamic theming (Only supported on Android 12+) + */ @Composable fun DimettTheme( darkTheme: Boolean = isSystemInDarkTheme(), - // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { val colorScheme = when { + // Make sure that even if the dynamicColor option is true it'll still require Android 12+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) } - darkTheme -> DarkColorScheme - else -> LightColorScheme + darkTheme -> darkColorScheme() + else -> lightColorScheme() } val sysUiController = rememberSystemUiController() SideEffect { sysUiController.apply { setSystemBarsColor( - color = Color.Transparent, + color = Color.Transparent, // Let components be visible under the nav and status bars darkIcons = !darkTheme, isNavigationBarContrastEnforced = true ) @@ -65,7 +49,6 @@ fun DimettTheme( MaterialTheme( colorScheme = colorScheme, - typography = Typography, content = content ) } \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/ui/theme/Type.kt b/app/src/main/java/xyz/wingio/dimett/ui/theme/Type.kt deleted file mode 100644 index 754a54a..0000000 --- a/app/src/main/java/xyz/wingio/dimett/ui/theme/Type.kt +++ /dev/null @@ -1,6 +0,0 @@ -package xyz.wingio.dimett.ui.theme - -import androidx.compose.material3.Typography - -// Set of Material typography styles to start with -val Typography = Typography() diff --git a/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/auth/LoginViewModel.kt b/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/auth/LoginViewModel.kt index d727622..021dd38 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/auth/LoginViewModel.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/auth/LoginViewModel.kt @@ -22,7 +22,9 @@ import xyz.wingio.dimett.rest.utils.ifSuccessful import xyz.wingio.dimett.ui.screens.main.MainScreen import xyz.wingio.dimett.utils.openCustomTab - +/** + * [ViewModel][ScreenModel] used by [xyz.wingio.dimett.ui.screens.auth.LoginScreen] + */ class LoginViewModel( private val mastodonRepository: MastodonRepository, private val instanceManager: InstanceManager, @@ -30,6 +32,7 @@ class LoginViewModel( private val preferenceManager: PreferenceManager ) : ScreenModel { + // The following are to be used in the UI var instance by mutableStateOf("") var didError by mutableStateOf(false) var nodeInfo by mutableStateOf(null as NodeInfo?) @@ -37,41 +40,51 @@ class LoginViewModel( var loginLoading by mutableStateOf(false) var nodeInfoLoading by mutableStateOf(false) + // Version of `instance` that requests are actually sent to, not to be shown in the ui private var instanceUrl: String? = null + // Whether or not the desired instance supports the Mastodon api, as that is all Dimett will support for now val instanceIsMastodon: Boolean get() = nodeInfo?.metadata?.features?.contains("mastodon_api") == true || nodeInfo?.software?.name == "mastodon" + /** + * Opens a Chrome custom tab with the OAuth authorization url for the desired instance + */ fun login(context: Context) { - if (instance.isEmpty()) return + if (instance.isEmpty()) return // In case the login button isn't disabled for whatever reason coroutineScope.launch(Dispatchers.IO) { verifyInstance()?.let { instanceUrl = it.url + // Construct a url according to https://docs.joinmastodon.org/client/authorized/#login val url = URLBuilder("https://${it.url}/oauth/authorize").also { url -> url.parameters.apply { append("client_id", it.clientId) - append("redirect_uri", "dimett://oauth") + append("redirect_uri", "dimett://oauth") // Dimett should be the only app that supports this scheme append("response_type", "code") append("force_login", "true") } }.buildString() + "&scope=read+write+push" - context.openCustomTab(url, force = true) + context.openCustomTab(url, force = true) // We don't want this link opening up in a non-browser app } } } + /** + * Loads some basic information about the desired instance using /.well-known/nodeinfo + */ fun loadDetails() { - if (instance.isBlank()) return + if (instance.isBlank()) return // Can't load anything from a blank url val url = fixUrl(instance) - nodeInfoLoading = true + nodeInfoLoading = true // Let the UI know that we're loading an instance's information coroutineScope.launch(Dispatchers.IO) { + // Initial request is to /.well-known/nodeinfo, this only gives us the link to the actual node info mastodonRepository.getNodeInfoLocation(url).fold( success = { - it.links.firstOrNull()?.let { link -> + it.links.firstOrNull()?.let { link -> // We only need the first link mastodonRepository.getNodeInfo(link.href).fold( success = { node -> - nodeInfo = node + nodeInfo = node // Let the user know that we found this instance nodeInfoLoading = false }, fail = { @@ -87,53 +100,63 @@ class LoginViewModel( } } + /** + * Creates the OAuth application on this instance, required to properly login + * --- + * *All created OAuth apps are saved to a local db* + */ private suspend fun verifyInstance(): Instance? { val url = fixUrl(instance) - if (instanceManager.exists(url)) return instanceManager[url] + if (instanceManager.exists(url)) return instanceManager[url] // Use the already existing OAuth app if it exists + var instance: Instance? = null mastodonRepository.createApp(url).fold( success = { app -> instance = instanceManager.addInstance( - url, - app.clientId!!, - app.clientSecret!!, - nodeInfo?.metadata?.features ?: emptyList() + url = url, + clientId = app.clientId!!, + clientSecret = app.clientSecret!!, + features = nodeInfo?.metadata?.features ?: emptyList() ) didError = false }, fail = { - didError = true + didError = true // Update the user if something goes wrong } ) return instance } + /** + * Handles all incoming intents for the LoginScreen, most importantly it handles the OAuth callback + */ fun handleIntent(intent: Intent, navigator: Navigator) { - val data = intent.data ?: return - if (data.scheme != "dimett") return // only respond to dimett:// - val code = data.getQueryParameter("code") ?: return + val data = intent.data ?: return // If the intent contains no data then it can be discarded + if (data.scheme != "dimett") return // Only respond to dimett:// + val code = data.getQueryParameter("code") ?: return // This is necessary to generate the authorization token val url = instanceUrl ?: return coroutineScope.launch { - val instance = instanceManager[url] ?: return@launch + val instance = instanceManager[url] ?: return@launch // Make sure we have an OAuth app for this instance + + loginLoading = true // Let the user know we're trying to log in - loginLoading = true mastodonRepository.getToken( instanceUrl = instance.url, clientId = instance.clientId, clientSecret = instance.clientSecret, code = code ).ifSuccessful { token -> - mastodonRepository.verifyCredentials(instance.url, token.accessToken) + mastodonRepository.verifyCredentials(instance.url, token.accessToken) // Fetch the current user in order to see if the OAuth flow was successful .ifSuccessful { user -> accountManager.addAccount( user = user, - token = token.accessToken, + token = token.accessToken, // Now all future requests can be authenticated for the user!! :) instance = instance.url ) - preferenceManager.currentAccount = user.id - navigator.replaceAll(MainScreen()) + preferenceManager.currentAccount = user.id // Switch to the newly added account + navigator.replaceAll(MainScreen()) // Replace the login screen with the main screen, don't let users shoot themselves in the foot } } @@ -142,14 +165,24 @@ class LoginViewModel( } } + /** + * Returns a more standard url string + * - Removes the http(s) scheme + * - Extracts the domain from a user@domain pair + * - Trims non-ascii characters + */ private fun fixUrl(url: String): String { - var s = url.replaceFirst("http://", "") - s = s.replaceFirst("https://", "") - val at = s.lastIndexOf('@') - if (at != -1) { - s = s.substring(at + 1) - } - return s.trim { it <= ' ' } + return url + .replaceFirst("http://", "") + .replaceFirst("https://", "") + .let { + // Only use the destination part of the url (The example.com from user@example.com) + val at = it.lastIndexOf('@') + if (at != -1) { + it.substring(at + 1) + } else it + } + .trim { it <= ' ' } // Trim off any non-ascii characters } } \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/feed/FeedViewModel.kt b/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/feed/FeedViewModel.kt index d34d649..812c4d7 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/feed/FeedViewModel.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/feed/FeedViewModel.kt @@ -6,7 +6,6 @@ import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.paging.cachedIn -import androidx.paging.compose.LazyPagingItems import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.coroutineScope import kotlinx.coroutines.launch @@ -15,21 +14,27 @@ import xyz.wingio.dimett.rest.dto.post.Post import xyz.wingio.dimett.rest.utils.ApiResponse import xyz.wingio.dimett.rest.utils.fold +/** + * [ViewModel][ScreenModel] used by [xyz.wingio.dimett.ui.screens.feed.FeedTab] + */ class FeedViewModel( - private val repo: MastodonRepository + private val mastodonRepository: MastodonRepository ) : ScreenModel { + // Client side modifications (such as favorited status) val modifiedPosts = mutableStateMapOf() + // Used for request pagination (infinite scroll) + // TODO: Possibly extract logic so that it can be reused with other requests val posts = Pager(PagingConfig(pageSize = 20)) { object : PagingSource() { override suspend fun load(params: LoadParams): LoadResult { - val next = params.key + val next = params.key // Should be the last post's id - return when (val response = repo.getFeed(max = next)) { + return when (val response = mastodonRepository.getFeed(max = next)) { is ApiResponse.Success -> { val nextKey = response.data.lastOrNull()?.id - if (next == null) modifiedPosts.clear() + if (next == null) modifiedPosts.clear() // Reset modifications when refreshing LoadResult.Page( data = response.data, @@ -59,11 +64,14 @@ class FeedViewModel( state.closestPageToPosition(it)?.prevKey } } - }.flow.cachedIn(coroutineScope) + }.flow.cachedIn(coroutineScope) // Binds requests to the lifecycle of this ScreenModel + /** + * (Un)favorite a post based on whether it was previously favorited + */ fun toggleFavorite(id: String, favorited: Boolean) { coroutineScope.launch { - val res = if (favorited) repo.unfavoritePost(id) else repo.favoritePost(id) + val res = if (favorited) mastodonRepository.unfavoritePost(id) else mastodonRepository.favoritePost(id) res.fold( success = { modifiedPosts[id] = it @@ -72,9 +80,12 @@ class FeedViewModel( } } - fun toggleBoost(id: String, boosted: Boolean, items: LazyPagingItems) { + /** + * (Un)boost a post based on whether it was previously boosted + */ + fun toggleBoost(id: String, boosted: Boolean) { coroutineScope.launch { - val res = if (boosted) repo.unboostPost(id) else repo.boostPost(id) + val res = if (boosted) mastodonRepository.unboostPost(id) else mastodonRepository.boostPost(id) res.fold( success = { if (boosted) diff --git a/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/main/MainViewModel.kt b/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/main/MainViewModel.kt index a57af35..964030e 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/main/MainViewModel.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/viewmodels/main/MainViewModel.kt @@ -4,11 +4,17 @@ import cafe.adriel.voyager.core.model.ScreenModel import xyz.wingio.dimett.domain.db.entities.Account import xyz.wingio.dimett.domain.manager.AccountManager +/** + * [ViewModel][ScreenModel] used by [xyz.wingio.dimett.ui.screens.main.MainScreen] + */ class MainViewModel( - private val accounts: AccountManager + private val accountManager: AccountManager ) : ScreenModel { + /** + * The currently logged in account + */ val account: Account? - get() = accounts.current + get() = accountManager.current } \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/Attachments.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/Attachments.kt index dfe30f7..97b7a54 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/Attachments.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/Attachments.kt @@ -16,8 +16,11 @@ import androidx.compose.ui.unit.dp import com.google.accompanist.pager.HorizontalPagerIndicator import xyz.wingio.dimett.rest.dto.post.MediaAttachment -@OptIn(ExperimentalFoundationApi::class) +/** + * Just shows [SingleAttachment] if [attachments] only contains one item, otherwise uses a [HorizontalPager] + */ @Composable +@OptIn(ExperimentalFoundationApi::class) fun Attachments( attachments: List ) { diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/AudioAttachment.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/AudioAttachment.kt index ae024cb..a881a53 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/AudioAttachment.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/AudioAttachment.kt @@ -15,13 +15,17 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import xyz.wingio.dimett.rest.dto.post.MediaAttachment +/** + * For audio attachments we just use the [MediaControls] + */ +// TODO: Make more distinct player for audio @Composable @OptIn(UnstableApi::class) fun AudioAttachment( attachment: MediaAttachment ) { val context = LocalContext.current - val player = remember(context) { + val player = remember(context, attachment) { val tSelector = DefaultTrackSelector(context) ExoPlayer.Builder(context) .setTrackSelector(tSelector) diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/MediaControls.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/MediaControls.kt index b11b3ec..36bb169 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/MediaControls.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/MediaControls.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -31,25 +32,38 @@ import kotlinx.coroutines.delay import xyz.wingio.dimett.R import kotlin.time.Duration.Companion.seconds +/** + * Displays controls such as a play/pause button and a seekbar + * + * @param player [ExoPlayer] instance getting controlled + */ @Composable fun MediaControls( player: ExoPlayer ) { + // Whether or not the user is actively using the seekbar var seeking by remember(player) { mutableStateOf(false) } + + // Whether or not the video/audio is playing var playing by remember(player) { mutableStateOf(player.isPlaying) } + + // The length of the video/audio var duration by remember(player) { - mutableStateOf(player.duration) + mutableLongStateOf(player.duration) } + + // Current position in the video/audio var position by remember(player) { - mutableStateOf(player.currentPosition) + mutableLongStateOf(player.currentPosition) } DisposableEffect(player) { val listener = object : Player.Listener { + // Updates all our state variables override fun onIsPlayingChanged(isPlaying: Boolean) { playing = isPlaying duration = player.duration @@ -58,10 +72,12 @@ fun MediaControls( } player.addListener(listener) - onDispose { player.removeListener(listener) } + onDispose { player.removeListener(listener) } // Remove the listener when the component leaves the scope } + if (playing) { LaunchedEffect(Unit) { + // We kinda have to do this bc ExoPlayer doesn't provide a listener for player position while (!seeking) { position = player.currentPosition delay(1.seconds) @@ -82,7 +98,7 @@ fun MediaControls( player.pause() else { if (player.currentPosition >= player.duration) - player.seekTo(0) + player.seekTo(0) // Replay the media if its finished playing player.play() } } diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/PlayButtonOverlay.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/PlayButtonOverlay.kt index fe94736..87988c0 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/PlayButtonOverlay.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/PlayButtonOverlay.kt @@ -19,6 +19,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +/** + * Adds an overlay on videos and gifs with the videos preview image, disappears after clicking + * + * @param previewUrl Url to the video/gifs preview image + * @param contentDescription Content description of the video/gif + * @param onPlay Called when the component is clicked + */ @Composable fun PlayButtonOverlay( previewUrl: String, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleGifAttachment.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleGifAttachment.kt index 4dc51ee..82bd857 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleGifAttachment.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleGifAttachment.kt @@ -38,6 +38,9 @@ import androidx.media3.ui.PlayerView import xyz.wingio.dimett.rest.dto.post.MediaAttachment import xyz.wingio.dimett.ui.components.Text +/** + * Displays a silent looping video, as GIF is kind of a bad format + */ @Composable @OptIn(androidx.media3.common.util.UnstableApi::class) fun SingleGifAttachment( @@ -48,7 +51,7 @@ fun SingleGifAttachment( mutableStateOf(false) } - val player = remember(context) { + val player = remember(context, attachment) { ExoPlayer.Builder(context).build().apply { val dataSourceFactory = DefaultDataSource.Factory(context) val source = ProgressiveMediaSource.Factory(dataSourceFactory) @@ -66,7 +69,8 @@ fun SingleGifAttachment( ) { val height = with(LocalDensity.current) { ((9f / 16f) * constraints.maxWidth).toDp() - } + } // Calculates the height so that the gif is always 16:9 + Box( modifier = Modifier .shadow(3.dp, RoundedCornerShape(12.dp)) @@ -76,16 +80,17 @@ fun SingleGifAttachment( ) { Box { DisposableEffect( - AndroidView(factory = { - PlayerView(it).apply { + // As far as I know there isn't an existing compose wrapper for ExoPlayer + AndroidView(factory = { ctx -> + PlayerView(ctx).apply { hideController() - useController = false + useController = false // Gifs don't need :) this.player = player resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM layoutParams = FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT + /* width = */ FrameLayout.LayoutParams.MATCH_PARENT, + /* height = */ FrameLayout.LayoutParams.WRAP_CONTENT ) } }) diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleImageAttachment.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleImageAttachment.kt index 2eb15e6..f6e5781 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleImageAttachment.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/SingleImageAttachment.kt @@ -1,5 +1,6 @@ package xyz.wingio.dimett.ui.widgets.attachments +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxWidth @@ -11,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity @@ -23,6 +25,9 @@ import com.ondev.imageblurkt_lib.IBlurModel import com.ondev.imageblurkt_lib.R import xyz.wingio.dimett.rest.dto.post.MediaAttachment +/** + * Displays an image (with blurhash if supported) + */ @OptIn(ExperimentalCoilApi::class) @Composable fun SingleImageAttachment( @@ -33,11 +38,12 @@ fun SingleImageAttachment( ) { val height = with(LocalDensity.current) { ((9f / 16f) * constraints.maxWidth).toDp() - } + } // Make sure the image is always 16:9 + Box( modifier = Modifier - .shadow(3.dp, RoundedCornerShape(12.dp)) - .clip(RoundedCornerShape(12.dp)) + .shadow(elevation = 3.dp, shape = RoundedCornerShape(12.dp), clip = true) + .background(Color.Black) .fillMaxWidth() .heightIn(max = height) ) { diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/VideoAttachment.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/VideoAttachment.kt index 0cc0fb8..6ef1f48 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/VideoAttachment.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/attachments/VideoAttachment.kt @@ -37,18 +37,25 @@ import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import xyz.wingio.dimett.rest.dto.post.MediaAttachment +/** + * Displays a basic video player + */ @Composable @OptIn(androidx.media3.common.util.UnstableApi::class) fun VideoAttachment(attachment: MediaAttachment) { val context = LocalContext.current + + // Whether or not the MediaControls component is visible var showControls by remember { mutableStateOf(false) } + + // Whether or not the video is currently loading var loading by remember { mutableStateOf(false) } - val player = remember(context) { + val player = remember(context, attachment) { ExoPlayer.Builder(context).build().apply { val dataSourceFactory = DefaultDataSource.Factory(context) val source = ProgressiveMediaSource.Factory(dataSourceFactory) diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/auth/InstancePreview.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/auth/InstancePreview.kt index cbb6d81..1f02895 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/auth/InstancePreview.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/auth/InstancePreview.kt @@ -26,6 +26,12 @@ import androidx.compose.ui.unit.dp import xyz.wingio.dimett.R import xyz.wingio.dimett.rest.dto.meta.NodeInfo +/** + * Displays some information about an instance + * + * @param url Used in place of the instance name if it doesn't exist + * @param nodeInfo Standard metadata for ActivityPub instances, this is where most of the displayed information is from + */ @Composable fun InstancePreview( url: String, @@ -43,13 +49,13 @@ fun InstancePreview( Box( modifier = Modifier - .width(350.dp) - .padding(bottom = iconSize / 2) + .width(350.dp) // Don't want this to be really wide + .padding(bottom = iconSize / 2) // Account for the additional space taken up by the logo ) { ElevatedCard( modifier = Modifier .fillMaxWidth() - .offset(y = iconSize / 2) + .offset(y = iconSize / 2) // Centers the logo image on the top of the card ) { Column( verticalArrangement = Arrangement.spacedBy(3.dp), diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Card.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Card.kt index 6425db1..5aa9ae0 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Card.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Card.kt @@ -43,6 +43,9 @@ fun Card( } } +/** + * Standard link card, displays the thumbnail image, the title, and a description + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun LinkCard( @@ -51,8 +54,9 @@ fun LinkCard( val ctx = LocalContext.current val uriHandler = LocalUriHandler.current var size by remember { - mutableStateOf(Size.ORIGINAL) + mutableStateOf(Size.ORIGINAL) // Position is chosen based on thumbnail size } + val imageRequest = remember { ImageRequest.Builder(ctx) .error(R.drawable.img_preview_placeholder) @@ -65,7 +69,7 @@ fun LinkCard( } .build() } - val isBig = size.width.pxOrElse { 0 } > size.height.pxOrElse { 0 } + val isBig = size.width.pxOrElse { 0 } > size.height.pxOrElse { 0 } // Checks if the width is larger than the height (Is in landscape) val aspectRatio = size.width.pxOrElse { 0 }.toFloat() / size.height.pxOrElse { 0 }.toFloat() ElevatedCard( @@ -75,7 +79,7 @@ fun LinkCard( modifier = Modifier .fillMaxWidth() ) { - if (isBig) { + if (isBig) { // Landscape images get placed above the title and description AsyncImage( model = imageRequest, contentDescription = null, @@ -91,7 +95,7 @@ fun LinkCard( .fillMaxWidth() .heightIn(max = 85.dp) ) { - if (!isBig) { + if (!isBig) { // Image is displayed as a square on the side when not landscape AsyncImage( model = imageRequest, contentDescription = null, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Poll.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Poll.kt index 010a394..429b723 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Poll.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Poll.kt @@ -32,17 +32,36 @@ import xyz.wingio.dimett.ui.components.Text import xyz.wingio.dimett.utils.getString import xyz.wingio.dimett.utils.toEmojiMap +/** + * Allows users to vote for one or more options + */ @Composable @OptIn(ExperimentalFoundationApi::class) fun Poll( poll: Poll, onVote: (String, List) -> Unit = { _, _ -> } ) { - val emojiMap = poll.emojis.toEmojiMap() - val selected = remember { - val l = mutableStateListOf() - l.addAll(poll.ownVotes) - l + val emojiMap = remember(poll) { poll.emojis.toEmojiMap() } + val selected = remember(poll) { + mutableStateListOf().apply { + addAll(poll.ownVotes) + } + } + + /** + * Updates the currently selected options + */ + fun select(optionIndex: Int, voted: Boolean) { + if (!poll.multiple) { // Only select one item at a time + selected.clear() + selected.add(optionIndex) + } + + if (voted && poll.multiple) { + selected.remove(optionIndex) // Unselect when selected + } else { + selected.add(optionIndex) + } } Column( @@ -51,18 +70,6 @@ fun Poll( for (i in poll.options.indices) { val option = poll.options[i] val voted = selected.contains(i) - val vote = { - if (!poll.multiple) { - selected.clear() - selected.add(i) - } - - if (voted && poll.multiple) { - selected.remove(i) - } else { - selected.add(i) - } - } Row( verticalAlignment = Alignment.CenterVertically, @@ -70,7 +77,7 @@ fun Poll( .shadow(3.dp, RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp)) .clickable { - vote() + select(i, voted) } .background(MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)) .padding(12.dp) @@ -90,7 +97,7 @@ fun Poll( RadioButton( selected = voted, onClick = { - vote() + select(i, voted) }, modifier = Modifier .weight(0.25f) diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Post.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Post.kt index 024edb1..1cc51d7 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Post.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/Post.kt @@ -11,6 +11,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -29,8 +30,20 @@ import xyz.wingio.dimett.utils.shareText import xyz.wingio.dimett.utils.toEmojiMap import xyz.wingio.dimett.utils.toMentionMap -@Suppress("LocalVariableName") +/** + * Post displayed in a feed + * + * @param post Information about the post + * @param onAvatarClick Called when the authors avatar is clicked + * @param onMentionClick Called when any mention (@user or @user@domain) is clicked + * @param onHashtagClick Called when a hashtag (#tag) is clicked + * @param onReplyClick Called when the reply button is pressed + * @param onFavoriteClick Called when the favorite button is pressed + * @param onBoostClick Called when the boost button is pressed + * @param onVotedInPoll Called when the user submits their selection in a poll + */ @Composable +@Suppress("LocalVariableName") fun Post( post: Post, onAvatarClick: (String) -> Unit = {}, @@ -42,13 +55,15 @@ fun Post( onVotedInPoll: (String, List) -> Unit = { _, _ -> } ) { val ctx = LocalContext.current - val _post = post.boosted ?: post - val timeString = DateUtils.getRelativeTimeSpanString( - /* time = */ post.createdAt.toEpochMilliseconds(), - /* now = */ System.currentTimeMillis(), - /* minResolution = */ 0L, - /* flags = */ DateUtils.FORMAT_ABBREV_ALL - ).toString() + val _post = post.boosted ?: post // The actually displayed post, not the same if its a boost + val timeString = remember(post.createdAt) { + DateUtils.getRelativeTimeSpanString( + /* time = */ post.createdAt.toEpochMilliseconds(), + /* now = */ System.currentTimeMillis(), + /* minResolution = */ 0L, + /* flags = */ DateUtils.FORMAT_ABBREV_ALL + ).toString() + } Surface( modifier = Modifier.fillMaxWidth(), @@ -137,7 +152,7 @@ fun Post( } _post.card?.let { - if (_post.media.isEmpty()) { + if (_post.media.isEmpty()) { // We don't want to show both the media and card at the same time, too big Card( card = it ) diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostAuthor.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostAuthor.kt index bf6d37e..c2c09e5 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostAuthor.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostAuthor.kt @@ -24,6 +24,16 @@ import xyz.wingio.dimett.ast.render import xyz.wingio.dimett.ui.components.BadgedItem import xyz.wingio.dimett.ui.components.Text +/** + * Displays the avatar along with display name and username for a post's author + * + * @param avatarUrl Url pointing to the authors avatar + * @param displayName Authors display name + * @param acct The username of the author (@user or @user@example.social) + * @param emojis The emojis present in the display name + * @param bot Whether or not the author is an automated account + * @param onAvatarClick Callback for when the avatar is clicked + */ @Composable fun PostAuthor( avatarUrl: String, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostButtons.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostButtons.kt index e350e76..c370523 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostButtons.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostButtons.kt @@ -33,6 +33,19 @@ import xyz.wingio.dimett.R import xyz.wingio.dimett.ui.components.Text import xyz.wingio.dimett.utils.formatNumber +/** + * Set of buttons and their interaction counts + * + * @param replies Number of replies to the post + * @param favorites Number of people that favorited this post + * @param boosts Number of people that boosted this post + * @param boosted Whether the current user has boosted this post + * @param favorited Whether the current user has favorited this post + * @param onReplyClick Called when the reply button is pressed + * @param onFavoriteClick Called when the favorite button is pressed + * @param onBoostClick Called when the boost button is pressed + * @param onShareClick Called when the share button is pressed + */ @Composable @OptIn(ExperimentalLayoutApi::class) fun PostButtons( @@ -78,6 +91,14 @@ fun PostButtons( } } +/** + * Version of [TextButton][androidx.compose.material3.TextButton] with an icon + * + * @param icon Icon displayed next to the label + * @param contentDescription Describes the button for screen readers + * @param text Label to be displayed in the button + * @param onClick Called when the button is clicked + */ @Composable fun PostButton( icon: ImageVector, diff --git a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostInfoBar.kt b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostInfoBar.kt index d123993..608cdb3 100644 --- a/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostInfoBar.kt +++ b/app/src/main/java/xyz/wingio/dimett/ui/widgets/posts/PostInfoBar.kt @@ -18,6 +18,9 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import xyz.wingio.dimett.ui.components.Text +/** + * Displays some additional information about a post + */ @Composable fun PostInfoBar( icon: ImageVector, @@ -29,17 +32,16 @@ fun PostInfoBar( verticalAlignment = Alignment.CenterVertically ) { Spacer( - Modifier.width(10.dp) + Modifier.width(10.dp) // Push to the side a little bit to line the space up with the avatar in a post ) Icon( imageVector = icon, contentDescription = stringResource(iconDescription), modifier = Modifier.size(18.dp) ) - ProvideTextStyle(MaterialTheme.typography.labelSmall) { - Text( - text = text - ) - } + Text( + text = text, + style = MaterialTheme.typography.labelSmall + ) } } \ No newline at end of file diff --git a/app/src/main/java/xyz/wingio/dimett/utils/CustomTabUtils.kt b/app/src/main/java/xyz/wingio/dimett/utils/CustomTabUtils.kt index 5d4443e..613de8d 100644 --- a/app/src/main/java/xyz/wingio/dimett/utils/CustomTabUtils.kt +++ b/app/src/main/java/xyz/wingio/dimett/utils/CustomTabUtils.kt @@ -6,9 +6,14 @@ import android.content.Intent import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent +/** + * Cached result of [defaultBrowserPackage] + */ private var mDefaultBrowserPackage: String? = null -@Suppress("DEPRECATION") +/** + * Gets the package name for the default browser app on the users device + */ private val Context.defaultBrowserPackage: String? @SuppressLint("QueryPermissionsNeeded") get() { @@ -17,14 +22,20 @@ private val Context.defaultBrowserPackage: String? .queryIntentActivities( Intent(Intent.ACTION_VIEW).setData(Uri.parse("http://")), 0 - ) - .firstOrNull() - ?.activityInfo?.packageName + ) // All apps that can open `http://` uris (Usually only browsers) + .firstOrNull() // The default browser app is usually first + ?.activityInfo?.packageName // We only need the package name mDefaultBrowserPackage } else mDefaultBrowserPackage } +/** + * Open a Chrome custom tab + * + * @param url Url of the desired webpage + * @param force Whether or not to force a custom tab, avoids certain links being opened in a non browser app + */ fun Context.openCustomTab(url: String, force: Boolean) = CustomTabsIntent.Builder().build().run { if (force) intent.setPackage(defaultBrowserPackage) launchUrl(this@openCustomTab, Uri.parse(url)) diff --git a/app/src/main/java/xyz/wingio/dimett/utils/EmojiUtils.kt b/app/src/main/java/xyz/wingio/dimett/utils/EmojiUtils.kt index d915e9a..9597fc2 100644 --- a/app/src/main/java/xyz/wingio/dimett/utils/EmojiUtils.kt +++ b/app/src/main/java/xyz/wingio/dimett/utils/EmojiUtils.kt @@ -8,24 +8,42 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.regex.Pattern +/** + * Collection of utilities for dealing with emojis + */ object EmojiUtils : KoinComponent { + private val context: Context by inject() private val json: Json by inject() + /** + * Parses `assets/emoji.json` and returns a map of emoji codepoints to the Twemoji versions resource name located in `res/raw` + */ val emojis by lazy { - val _json = String(context.assets.open("emoji.json").readBytes()) - json.decodeFromString(_json).emoji + val _json = String(context.assets.open("emoji.json").readBytes()) // Read emoji.json to a String + json.decodeFromString(_json).emoji // Decode the string into a more useful format } + /** + * An autogenerated regex pattern matching every supported emoji + * + * @see [xyz.wingio.dimett.ast.addUnicodeEmojiRule] + */ val regex by lazy { "^(${ - emojis.keys.sortedByDescending { it.length }.joinToString("|") { emoji -> - Pattern.quote(emoji) - } + emojis.keys + .sortedByDescending { it.length } // Ensure that we match emoji with modifiers (such as skin tone) before the default version of the emoji + .joinToString("|") { emoji -> + Pattern.quote(emoji) + } })" } + } +/** + * Usable representation of `assets/emoji.json` + */ @Serializable data class EmojiJson( val emoji: Map diff --git a/app/src/main/java/xyz/wingio/dimett/utils/Logger.kt b/app/src/main/java/xyz/wingio/dimett/utils/Logger.kt index 8b982fb..34bc97d 100644 --- a/app/src/main/java/xyz/wingio/dimett/utils/Logger.kt +++ b/app/src/main/java/xyz/wingio/dimett/utils/Logger.kt @@ -1,24 +1,76 @@ package xyz.wingio.dimett.utils import android.util.Log +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import xyz.wingio.dimett.BuildConfig +/** + * More readable logger implementation + * + * @param tag Tag to be shown in logcat + */ class Logger( private val tag: String ) { + /** + * The lowest log level, usually used for very frequent logs + * + * @param msg The message to be printed + * @param throwable Optional error to be printed alongside the message + */ fun verbose(msg: String?, throwable: Throwable? = null) = log(msg, throwable, Level.VERBOSE) + + /** + * Second lowest log level, used for intentional debug messages + * + * @param msg The message to be printed + * @param throwable Optional error to be printed alongside the message + */ fun debug(msg: String?, throwable: Throwable? = null) = log(msg, throwable, Level.DEBUG) + + /** + * Middle log level, used for printing basic information + * + * @param msg The message to be printed + * @param throwable Optional error to be printed alongside the message + */ fun info(msg: String?, throwable: Throwable? = null) = log(msg, throwable, Level.INFO) + + /** + * Second highest log level, used when something could potentially be going wrong + * + * @param msg The message to be printed + * @param throwable Optional error to be printed alongside the message + */ fun warn(msg: String?, throwable: Throwable? = null) = log(msg, throwable, Level.WARN) + + /** + * Highest log level, used when something has gone wrong or an exception was thrown + * + * @param msg The message to be printed + * @param throwable Optional error to be printed alongside the message + */ fun error(msg: String?, throwable: Throwable? = null) = log(msg, throwable, Level.ERROR) - inline fun logSerializable(obj: T) = log( + /** + * Prints a JSON representation of an object + * + * @param obj The object to print, must be annotated with [Serializable] + */ + inline fun logSerializable(obj: @Serializable T) = log( msg = Json.Default.encodeToString(obj) ) + /** + * Logs a message at the specified [level] if the app's build is debuggable + * + * @param msg The message to be printed + * @param throwable Optional error to be printed alongside the message + * @param level Level to log this message at, see [Level] + */ fun log(msg: String?, throwable: Throwable? = null, level: Level = Level.INFO) { if (!BuildConfig.DEBUG) return when (level) { @@ -30,6 +82,9 @@ class Logger( } } + /** + * Levels used when logging messages + */ enum class Level { VERBOSE, DEBUG, diff --git a/app/src/main/java/xyz/wingio/dimett/utils/NavUtils.kt b/app/src/main/java/xyz/wingio/dimett/utils/NavUtils.kt index 471f7a7..78223e9 100644 --- a/app/src/main/java/xyz/wingio/dimett/utils/NavUtils.kt +++ b/app/src/main/java/xyz/wingio/dimett/utils/NavUtils.kt @@ -5,6 +5,7 @@ import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.pager.PagerState import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter @@ -18,6 +19,9 @@ import xyz.wingio.dimett.ui.screens.feed.FeedTab import xyz.wingio.dimett.ui.screens.profile.ProfileTab import xyz.wingio.dimett.ui.screens.notifications.NotificationsTab +/** + * Tabs that appear on the [main screen][xyz.wingio.dimett.ui.screens.main.MainScreen] + */ enum class RootTab(val tab: Tab) { FEED(FeedTab()), EXPLORE(ExploreTab()), @@ -25,17 +29,34 @@ enum class RootTab(val tab: Tab) { PROFILE(ProfileTab()), } +/** + * Safely navigate to the given [screen] from the root [Navigator] + * + * @param screen Where to navigate + */ tailrec fun Navigator.navigate(screen: Screen) { - return if (parent == null && items.firstOrNull { it.key == screen.key } == null) try { + return if ( + parent == null // Is the root navigator + && items.firstOrNull { it.key == screen.key } == null // Doesn't already have the screen in the navigation stack + ) try { push(screen) - } catch (_: Throwable) { - } + } catch (_: Throwable) {} else parent!!.navigate(screen) } +/** + * [CompositionLocal] instance for [PagerState] + */ @OptIn(ExperimentalFoundationApi::class) val LocalPagerState = compositionLocalOf { error("No PagerState provided") } +/** + * Convenience function for providing tab options with alternating selected and unselected icons + * + * @param name String resource id for the name of this tab + * @param icon Icon to be shown when the tab is not selected (Usually outlined) + * @param iconSelected Icon to be shown when the tab is selected (Usually filled) + */ @Composable @SuppressLint("ComposableNaming") @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/xyz/wingio/dimett/utils/TextUtils.kt b/app/src/main/java/xyz/wingio/dimett/utils/TextUtils.kt index 16d8fdf..559ab96 100644 --- a/app/src/main/java/xyz/wingio/dimett/utils/TextUtils.kt +++ b/app/src/main/java/xyz/wingio/dimett/utils/TextUtils.kt @@ -26,11 +26,16 @@ import xyz.wingio.dimett.ast.rendercontext.DefaultRenderContext import xyz.wingio.syntakts.Syntakts import xyz.wingio.syntakts.compose.rememberRendered +/** + * Required in order to support custom emotes and Twemoji + * + * @param textStyle Used for calculating the size of the emoji + */ @Composable fun inlineContent( textStyle: TextStyle = LocalTextStyle.current ): Map { - val emoteSize = remember(textStyle) { (textStyle.fontSize.value + 2f).sp } + val emoteSize = remember(textStyle) { (textStyle.fontSize.value + 2f).sp } // Make emojis a little bigger than the surrounding text val ctx = LocalContext.current return remember(emoteSize, ctx) { @@ -56,9 +61,16 @@ fun inlineContent( placeholderVerticalAlign = PlaceholderVerticalAlign.Center, ), ) { emoji -> - val emojiImage = BitmapDrawable(EmojiUtils.emojis[emoji]?.let { - ctx.getResId(it) - }?.let { ctx.resources.openRawResource(it) }).bitmap.asImageBitmap() + val emojiImage = BitmapDrawable( + /* res = */ ctx.resources, + /* is = */ EmojiUtils.emojis[emoji] + ?.let { rawResourceName -> + ctx.getResId(rawResourceName) + } + ?.let { rawResId -> + ctx.resources.openRawResource(rawResId) + } + ).bitmap.asImageBitmap() Image( bitmap = emojiImage, @@ -70,15 +82,25 @@ fun inlineContent( } } - +/** + * Retrieves a string resource and formats it + * + * @param string Resource id for the desired string + * @param args Formatting arguments + * --- + * Ex: "%1$s has favorited your post" with user.username + * @param syntakts The [Syntakts] instance used to render the text + * @param actionHandler Ran whenever any text is clicked + */ @Composable fun getString( @StringRes string: Int, vararg args: Any, syntakts: Syntakts = StringSyntakts, - actionHandler: (String) -> Unit = {} + actionHandler: (actionName: String) -> Unit = {} ): AnnotatedString { val _string = stringResource(string, *args) + return syntakts.rememberRendered( text = _string, context = DefaultRenderContext( diff --git a/app/src/main/java/xyz/wingio/dimett/utils/Utils.kt b/app/src/main/java/xyz/wingio/dimett/utils/Utils.kt index 806fda0..f53b18a 100644 --- a/app/src/main/java/xyz/wingio/dimett/utils/Utils.kt +++ b/app/src/main/java/xyz/wingio/dimett/utils/Utils.kt @@ -1,5 +1,6 @@ package xyz.wingio.dimett.utils +import android.annotation.SuppressLint import android.content.Context import android.content.Intent import android.icu.text.CompactDecimalFormat @@ -22,6 +23,9 @@ import kotlin.collections.forEach import kotlin.collections.mutableMapOf import kotlin.collections.set +/** + * Convert the html returned from the server into plain text that can be parsed on device + */ val String.plain: String get() = replace("
", "
 ") .replace("
", "
 ") @@ -31,6 +35,9 @@ val String.plain: String .trimEnd() .toString() +/** + * Converts the list to a map (shortcode: url) + */ fun List.toEmojiMap(): Map { val map = mutableMapOf() forEach { @@ -39,6 +46,9 @@ fun List.toEmojiMap(): Map { return map } +/** + * Converts the list to a map (username: id) + */ fun List.toMentionMap(): Map { val map = mutableMapOf() forEach { @@ -47,40 +57,68 @@ fun List.toMentionMap(): Map { return map } +/** + * Injects the default [Logger] + */ fun getLogger(): Logger = GlobalContext.get().get(named("default")) +/** + * Converts a string to an [AnnotatedString] + */ fun String.toAnnotatedString() = AnnotatedString(this) /** * Removes the mention for the replied user from the start of the text + * + * @param post The post to process */ fun processPostContent(post: Post): String { val repliedTo = post.mentions.firstOrNull { mention -> mention.id == post.userRepliedTo } + return post.content?.plain?.run { if (repliedTo != null) this - .replaceFirst("@${repliedTo.username} ", "") - .replaceFirst("@${repliedTo.acct}", "") + .replaceFirst("@${repliedTo.username} ", "") // @username + .replaceFirst("@${repliedTo.acct} ", "") // @username@instance.url else this } ?: "" } -fun Context.getResId(emojiCode: String) = - resources.getIdentifier(emojiCode, "raw", BuildConfig.APPLICATION_ID) +/** + * Get the resource id for a raw resource from its name + * + * @param resName Name of the raw resource (without the file extension) + */ +@SuppressLint("DiscouragedApi") +fun Context.getResId(resName: String) = + resources.getIdentifier(resName, "raw", BuildConfig.APPLICATION_ID) +/** + * Converts a number into a more compact form (1,121 -> 1.1k) + * + * @param number The number to format + */ fun formatNumber(number: Int): String = CompactDecimalFormat.getInstance(Locale.getDefault(), CompactDecimalFormat.CompactStyle.SHORT) .format(number) +/** + * Opens the share dialog with the specified [string] + * + * @param string The text to share, such as a link + */ fun Context.shareText(string: String) = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra(Intent.EXTRA_TEXT, string) startActivity(Intent.createChooser(this, getString(R.string.action_share))) } +/** + * Runs the [block] on the main (UI) thread + */ fun mainThread(block: () -> Unit) { Handler(Looper.getMainLooper()).post(block) } \ No newline at end of file