Merge pull request #4414 from element-hq/feature/bma/openTxtDocument
Open txt document inside the application
This commit is contained in:
commit
94eb54abcf
27 changed files with 427 additions and 139 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
* Please see LICENSE files in the repository root for full details.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
plugins {
|
plugins {
|
||||||
id("io.element.android-library")
|
id("io.element.android-compose-library")
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.viewfolder.api
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
fun interface TextFileViewer {
|
||||||
|
@Composable
|
||||||
|
fun Render(
|
||||||
|
lines: ImmutableList<String>,
|
||||||
|
modifier: Modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.viewfolder.impl
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
|
import io.element.android.features.viewfolder.impl.file.ColorationMode
|
||||||
|
import io.element.android.features.viewfolder.impl.file.FileContent
|
||||||
|
import io.element.android.libraries.di.AppScope
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
class DefaultTextFileViewer @Inject constructor() : TextFileViewer {
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
lines: ImmutableList<String>,
|
||||||
|
modifier: Modifier
|
||||||
|
) {
|
||||||
|
FileContent(
|
||||||
|
lines = lines,
|
||||||
|
colorationMode = ColorationMode.None,
|
||||||
|
modifier = modifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,152 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.features.viewfolder.impl.file
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.element.android.compound.theme.ElementTheme
|
||||||
|
import io.element.android.libraries.androidutils.system.copyToClipboard
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun FileContent(
|
||||||
|
lines: ImmutableList<String>,
|
||||||
|
colorationMode: ColorationMode,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
if (lines.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Spacer(Modifier.size(80.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(CommonStrings.common_empty_file),
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
itemsIndexed(
|
||||||
|
items = lines,
|
||||||
|
) { index, line ->
|
||||||
|
LineRow(
|
||||||
|
lineNumber = index + 1,
|
||||||
|
line = line,
|
||||||
|
colorationMode = colorationMode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LineRow(
|
||||||
|
lineNumber: Int,
|
||||||
|
line: String,
|
||||||
|
colorationMode: ColorationMode,
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = {
|
||||||
|
context.copyToClipboard(
|
||||||
|
text = line,
|
||||||
|
toastMessage = context.getString(CommonStrings.common_line_copied_to_clipboard),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(min = 36.dp)
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
text = "$lineNumber",
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
color = ElementTheme.colors.textSecondary,
|
||||||
|
style = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
)
|
||||||
|
val color = ElementTheme.colors.textSecondary
|
||||||
|
val width = 0.5.dp.value
|
||||||
|
Text(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.drawWithContent {
|
||||||
|
// Using .height(IntrinsicSize.Min) on the Row does not work well inside LazyColumn
|
||||||
|
drawLine(
|
||||||
|
color = color,
|
||||||
|
start = Offset(0f, 0f),
|
||||||
|
end = Offset(0f, size.height),
|
||||||
|
strokeWidth = width
|
||||||
|
)
|
||||||
|
drawContent()
|
||||||
|
}
|
||||||
|
.padding(horizontal = 4.dp),
|
||||||
|
text = line,
|
||||||
|
color = line.toColor(colorationMode),
|
||||||
|
style = ElementTheme.typography.fontBodyMdRegular
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a line to a color.
|
||||||
|
* Ex for logcat:
|
||||||
|
* `01-23 13:14:50.740 25818 25818 D org.matrix.rust.sdk: elementx: SyncIndicator = Hide | RustRoomListService.kt:81`
|
||||||
|
* ^ use this char to determine the color
|
||||||
|
* Ex for Rust logs:
|
||||||
|
* `2024-01-26T10:22:26.947416Z WARN elementx: Restore with non-empty map | MatrixClientsHolder.kt:68`
|
||||||
|
* ^ use this char to determine the color, see [LogLevel]
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun String.toColor(colorationMode: ColorationMode): Color {
|
||||||
|
return when (colorationMode) {
|
||||||
|
ColorationMode.Logcat -> when (getOrNull(31)) {
|
||||||
|
'D' -> colorDebug
|
||||||
|
'I' -> colorInfo
|
||||||
|
'W' -> colorWarning
|
||||||
|
'E' -> colorError
|
||||||
|
'A' -> colorError
|
||||||
|
else -> ElementTheme.colors.textPrimary
|
||||||
|
}
|
||||||
|
ColorationMode.RustLogs -> when (getOrNull(32)) {
|
||||||
|
'E' -> ElementTheme.colors.textPrimary
|
||||||
|
'G' -> colorDebug
|
||||||
|
'O' -> colorInfo
|
||||||
|
'N' -> colorWarning
|
||||||
|
'R' -> colorError
|
||||||
|
else -> ElementTheme.colors.textPrimary
|
||||||
|
}
|
||||||
|
ColorationMode.None -> ElementTheme.colors.textPrimary
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val colorDebug = Color(0xFF299999)
|
||||||
|
private val colorInfo = Color(0xFFABC023)
|
||||||
|
private val colorWarning = Color(0xFFBBB529)
|
||||||
|
private val colorError = Color(0xFFFF6B68)
|
||||||
|
|
@ -7,32 +7,16 @@
|
||||||
|
|
||||||
package io.element.android.features.viewfolder.impl.file
|
package io.element.android.features.viewfolder.impl.file
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.libraries.androidutils.system.copyToClipboard
|
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
import io.element.android.libraries.designsystem.components.async.AsyncFailure
|
||||||
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
import io.element.android.libraries.designsystem.components.async.AsyncLoading
|
||||||
|
|
@ -46,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
@ -114,125 +97,6 @@ fun ViewFileView(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FileContent(
|
|
||||||
lines: ImmutableList<String>,
|
|
||||||
colorationMode: ColorationMode,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier
|
|
||||||
) {
|
|
||||||
if (lines.isEmpty()) {
|
|
||||||
item {
|
|
||||||
Spacer(Modifier.size(80.dp))
|
|
||||||
Text(
|
|
||||||
text = "Empty file",
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
itemsIndexed(
|
|
||||||
items = lines,
|
|
||||||
) { index, line ->
|
|
||||||
LineRow(
|
|
||||||
lineNumber = index + 1,
|
|
||||||
line = line,
|
|
||||||
colorationMode = colorationMode,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun LineRow(
|
|
||||||
lineNumber: Int,
|
|
||||||
line: String,
|
|
||||||
colorationMode: ColorationMode,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = {
|
|
||||||
context.copyToClipboard(
|
|
||||||
line,
|
|
||||||
"Line copied to clipboard",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(min = 36.dp)
|
|
||||||
.padding(horizontal = 4.dp),
|
|
||||||
text = "$lineNumber",
|
|
||||||
textAlign = TextAlign.End,
|
|
||||||
color = ElementTheme.colors.textSecondary,
|
|
||||||
style = ElementTheme.typography.fontBodyMdMedium,
|
|
||||||
)
|
|
||||||
val color = ElementTheme.colors.textSecondary
|
|
||||||
val width = 0.5.dp.value
|
|
||||||
Text(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.drawWithContent {
|
|
||||||
// Using .height(IntrinsicSize.Min) on the Row does not work well inside LazyColumn
|
|
||||||
drawLine(
|
|
||||||
color = color,
|
|
||||||
start = Offset(0f, 0f),
|
|
||||||
end = Offset(0f, size.height),
|
|
||||||
strokeWidth = width
|
|
||||||
)
|
|
||||||
drawContent()
|
|
||||||
}
|
|
||||||
.padding(horizontal = 4.dp),
|
|
||||||
text = line,
|
|
||||||
color = line.toColor(colorationMode),
|
|
||||||
style = ElementTheme.typography.fontBodyMdRegular
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert a line to a color.
|
|
||||||
* Ex for logcat:
|
|
||||||
* `01-23 13:14:50.740 25818 25818 D org.matrix.rust.sdk: elementx: SyncIndicator = Hide | RustRoomListService.kt:81`
|
|
||||||
* ^ use this char to determine the color
|
|
||||||
* Ex for Rust logs:
|
|
||||||
* `2024-01-26T10:22:26.947416Z WARN elementx: Restore with non-empty map | MatrixClientsHolder.kt:68`
|
|
||||||
* ^ use this char to determine the color, see [LogLevel]
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
private fun String.toColor(colorationMode: ColorationMode): Color {
|
|
||||||
return when (colorationMode) {
|
|
||||||
ColorationMode.Logcat -> when (getOrNull(31)) {
|
|
||||||
'D' -> colorDebug
|
|
||||||
'I' -> colorInfo
|
|
||||||
'W' -> colorWarning
|
|
||||||
'E' -> colorError
|
|
||||||
'A' -> colorError
|
|
||||||
else -> ElementTheme.colors.textPrimary
|
|
||||||
}
|
|
||||||
ColorationMode.RustLogs -> when (getOrNull(32)) {
|
|
||||||
'E' -> ElementTheme.colors.textPrimary
|
|
||||||
'G' -> colorDebug
|
|
||||||
'O' -> colorInfo
|
|
||||||
'N' -> colorWarning
|
|
||||||
'R' -> colorError
|
|
||||||
else -> ElementTheme.colors.textPrimary
|
|
||||||
}
|
|
||||||
ColorationMode.None -> ElementTheme.colors.textPrimary
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val colorDebug = Color(0xFF299999)
|
|
||||||
private val colorInfo = Color(0xFFABC023)
|
|
||||||
private val colorWarning = Color(0xFFBBB529)
|
|
||||||
private val colorError = Color(0xFFFF6B68)
|
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview {
|
internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview {
|
||||||
|
|
|
||||||
|
|
@ -156,3 +156,24 @@ fun aVoiceMediaInfo(
|
||||||
waveform = waveForm,
|
waveform = waveForm,
|
||||||
duration = duration,
|
duration = duration,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun aTxtMediaInfo(
|
||||||
|
filename: String = "a text file.txt",
|
||||||
|
caption: String? = null,
|
||||||
|
senderName: String? = null,
|
||||||
|
dateSent: String? = null,
|
||||||
|
dateSentFull: String? = null,
|
||||||
|
): MediaInfo = MediaInfo(
|
||||||
|
filename = filename,
|
||||||
|
caption = caption,
|
||||||
|
mimeType = MimeTypes.PlainText,
|
||||||
|
formattedFileSize = "2kB",
|
||||||
|
fileExtension = "txt",
|
||||||
|
senderId = UserId("@alice:server.org"),
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatar = null,
|
||||||
|
dateSent = dateSent,
|
||||||
|
dateSentFull = dateSentFull,
|
||||||
|
waveform = null,
|
||||||
|
duration = null,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ dependencies {
|
||||||
implementation(libs.vanniktech.blurhash)
|
implementation(libs.vanniktech.blurhash)
|
||||||
implementation(libs.telephoto.flick)
|
implementation(libs.telephoto.flick)
|
||||||
|
|
||||||
|
implementation(projects.features.viewfolder.api)
|
||||||
implementation(projects.libraries.androidutils)
|
implementation(projects.libraries.androidutils)
|
||||||
implementation(projects.libraries.architecture)
|
implementation(projects.libraries.architecture)
|
||||||
implementation(projects.libraries.core)
|
implementation(projects.libraries.core)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.squareup.anvil.annotations.ContributesBinding
|
import com.squareup.anvil.annotations.ContributesBinding
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
import io.element.android.libraries.mediaviewer.api.local.LocalMediaRenderer
|
||||||
|
|
@ -20,7 +21,9 @@ import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@ContributesBinding(AppScope::class)
|
@ContributesBinding(AppScope::class)
|
||||||
class DefaultLocalMediaRenderer @Inject constructor() : LocalMediaRenderer {
|
class DefaultLocalMediaRenderer @Inject constructor(
|
||||||
|
private val textFileViewer: TextFileViewer,
|
||||||
|
) : LocalMediaRenderer {
|
||||||
@Composable
|
@Composable
|
||||||
override fun Render(localMedia: LocalMedia) {
|
override fun Render(localMedia: LocalMedia) {
|
||||||
val localMediaViewState = rememberLocalMediaViewState(
|
val localMediaViewState = rememberLocalMediaViewState(
|
||||||
|
|
@ -33,6 +36,7 @@ class DefaultLocalMediaRenderer @Inject constructor() : LocalMediaRenderer {
|
||||||
bottomPaddingInPixels = 0,
|
bottomPaddingInPixels = 0,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
onClick = {}
|
onClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ package io.element.android.libraries.mediaviewer.impl.local
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage
|
||||||
|
|
@ -19,6 +20,7 @@ import io.element.android.libraries.mediaviewer.impl.local.audio.MediaAudioView
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.file.MediaFileView
|
import io.element.android.libraries.mediaviewer.impl.local.file.MediaFileView
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView
|
import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView
|
import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.local.txt.TextFileView
|
||||||
import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView
|
import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -26,6 +28,7 @@ fun LocalMediaView(
|
||||||
localMedia: LocalMedia?,
|
localMedia: LocalMedia?,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
textFileViewer: TextFileViewer,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isDisplayed: Boolean = true,
|
isDisplayed: Boolean = true,
|
||||||
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
localMediaViewState: LocalMediaViewState = rememberLocalMediaViewState(),
|
||||||
|
|
@ -46,6 +49,11 @@ fun LocalMediaView(
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
|
mimeType == MimeTypes.PlainText -> TextFileView(
|
||||||
|
localMedia = localMedia,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
mimeType == MimeTypes.Pdf -> MediaPdfView(
|
mimeType == MimeTypes.Pdf -> MediaPdfView(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = localMedia,
|
localMedia = localMedia,
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.roundToPx
|
import io.element.android.libraries.designsystem.text.roundToPx
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import me.saket.telephoto.zoomable.zoomable
|
import me.saket.telephoto.zoomable.zoomable
|
||||||
|
|
@ -126,7 +127,7 @@ private fun PdfPagesContentView(
|
||||||
) {
|
) {
|
||||||
// Add a fake item to the top so that the first item is not at the top of the screen.
|
// Add a fake item to the top so that the first item is not at the top of the screen.
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(80.dp))
|
Spacer(modifier = Modifier.height(topAppBarHeight))
|
||||||
}
|
}
|
||||||
items(pdfPages.size) { index ->
|
items(pdfPages.size) { index ->
|
||||||
val pdfPage = pdfPages[index]
|
val pdfPage = pdfPages[index]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.local.txt
|
||||||
|
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.persistentListOf
|
||||||
|
|
||||||
|
open class TextFileContentProvider : PreviewParameterProvider<AsyncData<ImmutableList<String>>> {
|
||||||
|
override val values: Sequence<AsyncData<ImmutableList<String>>>
|
||||||
|
get() = sequenceOf(
|
||||||
|
AsyncData.Uninitialized,
|
||||||
|
AsyncData.Loading(),
|
||||||
|
AsyncData.Success(persistentListOf("Hello, World!")),
|
||||||
|
AsyncData.Failure(Exception("Failed to load text")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
/*
|
||||||
|
* Copyright 2025 New Vector Ltd.
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||||
|
* Please see LICENSE files in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package io.element.android.libraries.mediaviewer.impl.local.txt
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
||||||
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
|
||||||
|
import io.element.android.libraries.mediaviewer.impl.viewer.topAppBarHeight
|
||||||
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextFileView(
|
||||||
|
localMedia: LocalMedia?,
|
||||||
|
textFileViewer: TextFileViewer,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val data = remember { mutableStateOf<AsyncData<ImmutableList<String>>>(AsyncData.Uninitialized) }
|
||||||
|
val context = LocalContext.current
|
||||||
|
LaunchedEffect(localMedia?.uri) {
|
||||||
|
data.value = AsyncData.Loading()
|
||||||
|
if (localMedia?.uri != null) {
|
||||||
|
// Load the file content
|
||||||
|
val result = runCatching {
|
||||||
|
context.contentResolver.openInputStream(localMedia.uri).use {
|
||||||
|
it?.bufferedReader()?.readLines()?.toList().orEmpty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data.value = if (result.isSuccess) {
|
||||||
|
AsyncData.Success(result.getOrNull().orEmpty().toImmutableList())
|
||||||
|
} else {
|
||||||
|
AsyncData.Failure(result.exceptionOrNull() ?: Exception("An error occurred"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextFileContentView(
|
||||||
|
data = data.value,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TextFileContentView(
|
||||||
|
data: AsyncData<ImmutableList<String>>,
|
||||||
|
textFileViewer: TextFileViewer,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (data) {
|
||||||
|
AsyncData.Uninitialized,
|
||||||
|
is AsyncData.Loading -> Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
is AsyncData.Failure -> Box(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(text = data.error.message ?: stringResource(id = CommonStrings.error_unknown))
|
||||||
|
}
|
||||||
|
is AsyncData.Success -> {
|
||||||
|
textFileViewer.Render(
|
||||||
|
lines = data.data,
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(top = topAppBarHeight),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewsDayNight
|
||||||
|
@Composable
|
||||||
|
internal fun TextFileContentViewPreview(
|
||||||
|
@PreviewParameter(TextFileContentProvider::class) text: AsyncData<ImmutableList<String>>,
|
||||||
|
) = ElementPreview {
|
||||||
|
TextFileContentView(
|
||||||
|
data = text,
|
||||||
|
textFileViewer = { lines, modifier ->
|
||||||
|
Text(
|
||||||
|
modifier = modifier,
|
||||||
|
text = lines.firstOrNull() ?: "File content"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.compound.theme.ForcedDarkElementTheme
|
import io.element.android.compound.theme.ForcedDarkElementTheme
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
import io.element.android.libraries.architecture.inputs
|
import io.element.android.libraries.architecture.inputs
|
||||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
|
@ -42,6 +43,7 @@ class MediaViewerNode @AssistedInject constructor(
|
||||||
coroutineDispatchers: CoroutineDispatchers,
|
coroutineDispatchers: CoroutineDispatchers,
|
||||||
systemClock: SystemClock,
|
systemClock: SystemClock,
|
||||||
pagerKeysHandler: PagerKeysHandler,
|
pagerKeysHandler: PagerKeysHandler,
|
||||||
|
private val textFileViewer: TextFileViewer,
|
||||||
) : Node(buildContext, plugins = plugins),
|
) : Node(buildContext, plugins = plugins),
|
||||||
MediaViewerNavigator {
|
MediaViewerNavigator {
|
||||||
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
private val inputs = inputs<MediaViewerEntryPoint.Params>()
|
||||||
|
|
@ -125,6 +127,7 @@ class MediaViewerNode @AssistedInject constructor(
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onBackClick = ::onDone
|
onBackClick = ::onDone
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
import io.element.android.libraries.mediaviewer.api.MediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aPdfMediaInfo
|
||||||
|
import io.element.android.libraries.mediaviewer.api.aTxtMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
import io.element.android.libraries.mediaviewer.api.anApkMediaInfo
|
||||||
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
|
import io.element.android.libraries.mediaviewer.api.anAudioMediaInfo
|
||||||
|
|
@ -159,6 +160,14 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
|
||||||
MediaViewerPageData.Failure(Exception("error"))
|
MediaViewerPageData.Failure(Exception("error"))
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
aMediaViewerState(
|
||||||
|
listOf(
|
||||||
|
aMediaViewerPageData(
|
||||||
|
downloadedMedia = AsyncData.Loading(),
|
||||||
|
mediaInfo = aTxtMediaInfo(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import io.element.android.compound.theme.ElementTheme
|
import io.element.android.compound.theme.ElementTheme
|
||||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||||
|
import io.element.android.features.viewfolder.api.TextFileViewer
|
||||||
import io.element.android.libraries.architecture.AsyncData
|
import io.element.android.libraries.architecture.AsyncData
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
|
||||||
|
|
@ -83,9 +84,12 @@ import me.saket.telephoto.zoomable.OverzoomEffect
|
||||||
import me.saket.telephoto.zoomable.ZoomSpec
|
import me.saket.telephoto.zoomable.ZoomSpec
|
||||||
import me.saket.telephoto.zoomable.rememberZoomableState
|
import me.saket.telephoto.zoomable.rememberZoomableState
|
||||||
|
|
||||||
|
val topAppBarHeight = 88.dp
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MediaViewerView(
|
fun MediaViewerView(
|
||||||
state: MediaViewerState,
|
state: MediaViewerState,
|
||||||
|
textFileViewer: TextFileViewer,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
|
@ -143,6 +147,7 @@ fun MediaViewerView(
|
||||||
showOverlay = showOverlay,
|
showOverlay = showOverlay,
|
||||||
bottomPaddingInPixels = bottomPaddingInPixels,
|
bottomPaddingInPixels = bottomPaddingInPixels,
|
||||||
data = dataForPage,
|
data = dataForPage,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
onDismiss = onBackClick,
|
onDismiss = onBackClick,
|
||||||
onRetry = {
|
onRetry = {
|
||||||
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
|
state.eventSink(MediaViewerEvents.LoadMedia(dataForPage))
|
||||||
|
|
@ -267,6 +272,7 @@ private fun MediaViewerPage(
|
||||||
showOverlay: Boolean,
|
showOverlay: Boolean,
|
||||||
bottomPaddingInPixels: Int,
|
bottomPaddingInPixels: Int,
|
||||||
data: MediaViewerPageData.MediaViewerData,
|
data: MediaViewerPageData.MediaViewerData,
|
||||||
|
textFileViewer: TextFileViewer,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
onDismissError: () -> Unit,
|
onDismissError: () -> Unit,
|
||||||
|
|
@ -316,6 +322,7 @@ private fun MediaViewerPage(
|
||||||
localMediaViewState = localMediaViewState,
|
localMediaViewState = localMediaViewState,
|
||||||
localMedia = downloadedMedia.dataOrNull(),
|
localMedia = downloadedMedia.dataOrNull(),
|
||||||
mediaInfo = data.mediaInfo,
|
mediaInfo = data.mediaInfo,
|
||||||
|
textFileViewer = textFileViewer,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (playableState is PlayableState.NotPlayable) {
|
if (playableState is PlayableState.NotPlayable) {
|
||||||
currentOnShowOverlayChange(!currentShowOverlay)
|
currentOnShowOverlayChange(!currentShowOverlay)
|
||||||
|
|
@ -563,6 +570,7 @@ private fun ErrorView(
|
||||||
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
|
internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark {
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
|
textFileViewer = { _, _ -> },
|
||||||
onBackClick = {}
|
onBackClick = {}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -252,6 +252,7 @@ private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setMedia
|
||||||
setContent {
|
setContent {
|
||||||
MediaViewerView(
|
MediaViewerView(
|
||||||
state = state,
|
state = state,
|
||||||
|
textFileViewer = { _, _ -> },
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,6 +160,7 @@
|
||||||
<string name="common_editing">"Editing"</string>
|
<string name="common_editing">"Editing"</string>
|
||||||
<string name="common_editing_caption">"Editing caption"</string>
|
<string name="common_editing_caption">"Editing caption"</string>
|
||||||
<string name="common_emote">"* %1$s %2$s"</string>
|
<string name="common_emote">"* %1$s %2$s"</string>
|
||||||
|
<string name="common_empty_file">"Empty file"</string>
|
||||||
<string name="common_encryption">"Encryption"</string>
|
<string name="common_encryption">"Encryption"</string>
|
||||||
<string name="common_encryption_enabled">"Encryption enabled"</string>
|
<string name="common_encryption_enabled">"Encryption enabled"</string>
|
||||||
<string name="common_enter_your_pin">"Enter your PIN"</string>
|
<string name="common_enter_your_pin">"Enter your PIN"</string>
|
||||||
|
|
@ -184,6 +185,7 @@ Reason: %1$s."</string>
|
||||||
<string name="common_invite_unknown_profile">"This Matrix ID can\'t be found, so the invite might not be received."</string>
|
<string name="common_invite_unknown_profile">"This Matrix ID can\'t be found, so the invite might not be received."</string>
|
||||||
<string name="common_leaving_room">"Leaving room"</string>
|
<string name="common_leaving_room">"Leaving room"</string>
|
||||||
<string name="common_light">"Light"</string>
|
<string name="common_light">"Light"</string>
|
||||||
|
<string name="common_line_copied_to_clipboard">"Line copied to clipboard"</string>
|
||||||
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
|
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
|
||||||
<string name="common_loading">"Loading…"</string>
|
<string name="common_loading">"Loading…"</string>
|
||||||
<string name="common_loading_more">"Loading more…"</string>
|
<string name="common_loading_more">"Loading more…"</string>
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ class KonsistClassNameTest {
|
||||||
.withoutName(
|
.withoutName(
|
||||||
"AspectRatioProvider",
|
"AspectRatioProvider",
|
||||||
"OverlapRatioProvider",
|
"OverlapRatioProvider",
|
||||||
|
"TextFileContentProvider",
|
||||||
)
|
)
|
||||||
.also {
|
.also {
|
||||||
// Check that classes are actually found
|
// Check that classes are actually found
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:615107c5e6d654779b1a8fb0ac3e5511f03ac0eac2a5c3ae0424972b92401ca2
|
||||||
|
size 5244
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:615107c5e6d654779b1a8fb0ac3e5511f03ac0eac2a5c3ae0424972b92401ca2
|
||||||
|
size 5244
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:783bfa52d591b0ae98ac55cf5da8c4a7d7a28275e2714ec7278d4a7947009f4d
|
||||||
|
size 6187
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c3e94d893ac1bc9fc13806556332f6639469abc620a7617a730a8bbc9153f826
|
||||||
|
size 7070
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a360e21538876df4d8aa1b4a3e95e4982df6307a69df4d887416cbbd76b8cd99
|
||||||
|
size 5250
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a360e21538876df4d8aa1b4a3e95e4982df6307a69df4d887416cbbd76b8cd99
|
||||||
|
size 5250
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e374bcd5e119bf944eeaf28876b7e902c1bbd2e6bbf84e0f30ace9732675b9ae
|
||||||
|
size 6142
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8bd12380891a5af74a827ce14393bb5f79d9019eec7653e10ecc2923cec3c2a9
|
||||||
|
size 6945
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:dec2e6f9dcfc2e92fef730a26599d6a4e2e09b6b9999dc912a82917f65908417
|
||||||
|
size 6798
|
||||||
Loading…
Add table
Add a link
Reference in a new issue