M12f: S-16 lost version read-only, S-22 versions from /health, settle start without a partner (D-109 to D-111)
- Rejection.lost: RejectedVersion (the pushed entity from sync_rejections.rejected_payload as a ledger row, account, payer, split, notes, deleted); S-16 "Öppna" shows it read-only in a dialog with "Visa nuvarande version" (S-8/S-10); without one it opens the current version as before. Ledger rows take a null onClick (read-only). - AboutState.schemaVersion; AboutRepositoryImpl calls E-1 /health once per collection when online; header fallback. S-22 shows "1.1.0 (schema 3)". - SettlementRepository.start/recompute return null without a session or partner; save is Invalid(payer); no checkNotNull/requireNotNull in :data. - :feature:home screenshot harness: one more frame and an idle looper before each capture; the committed sv_dark, 200 % and dialog goldens were one variant stale (light instead of dark, en instead of sv_200). - Tests: lost version (stale, refused deletion, unreadable payload), /health online and offline, settle without a partner, category reorder PATCHes, recurring toWire splits, §9.2 effect cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
@@ -26,7 +26,10 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
@@ -63,6 +66,22 @@ import se.hagstedt.kvitt.model.LedgerRow
|
||||
import se.hagstedt.kvitt.model.Rejection
|
||||
import se.hagstedt.kvitt.model.SettlementId
|
||||
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.semantics.heading
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import se.hagstedt.kvitt.design.component.FactRow
|
||||
import se.hagstedt.kvitt.design.component.KvittBadge
|
||||
import se.hagstedt.kvitt.design.component.SegmentedItemScope
|
||||
import se.hagstedt.kvitt.design.component.defaultModeLabel
|
||||
import se.hagstedt.kvitt.design.component.personName
|
||||
import se.hagstedt.kvitt.design.component.splitModeLabel
|
||||
import se.hagstedt.kvitt.design.format.MoneyFormatter
|
||||
import se.hagstedt.kvitt.design.KvittElevation
|
||||
import se.hagstedt.kvitt.model.RejectedVersion
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import kotlin.time.Instant
|
||||
import se.hagstedt.kvitt.core.design.R as DesignR
|
||||
|
||||
@@ -74,6 +93,8 @@ data class SyncStatusActions(
|
||||
val onSyncNow: () -> Unit = {},
|
||||
val onDismiss: (String) -> Unit = {},
|
||||
val onOpenDateSettings: () -> Unit = {},
|
||||
val onOpenRejection: (String) -> Unit = {},
|
||||
val onCloseRejection: () -> Unit = {},
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -97,6 +118,8 @@ fun SyncStatusScreen(
|
||||
onOpenSettlement = onOpenSettlement,
|
||||
onSyncNow = viewModel::syncNow,
|
||||
onDismiss = viewModel::dismiss,
|
||||
onOpenRejection = viewModel::openRejection,
|
||||
onCloseRejection = viewModel::closeRejection,
|
||||
onOpenDateSettings = {
|
||||
try {
|
||||
context.startActivity(Intent(Settings.ACTION_DATE_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
|
||||
@@ -125,6 +148,7 @@ fun SyncStatusContent(ui: SyncStatusUi, actions: SyncStatusActions, modifier: Mo
|
||||
}
|
||||
ProvideHousehold(ready.me, ready.partner) {
|
||||
SyncStatusList(ready, actions, padding)
|
||||
ready.opened?.let { opened -> opened.lost?.let { LostVersionDialog(opened, it, ready, actions) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,9 +294,8 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
|
||||
}
|
||||
val time = DateFormatter.listDateTime(Instant.parse(rejection.rejectedAt), clock.zone, clock.today, kvittLanguage())
|
||||
val reasonLine = reason + stringResource(DesignR.string.common_separator_dot) + time
|
||||
val open = {
|
||||
if (settlement) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
|
||||
}
|
||||
// D-109: the lost version read-only first; without one (it cannot be shown) the current version directly.
|
||||
val open = { if (rejection.lost != null) actions.onOpenRejection(rejection.mutationId) else openCurrent(rejection, actions) }
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -299,3 +322,95 @@ private fun RejectionCard(rejection: Rejection, ui: SyncStatusUi.Ready, actions:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S-8 or S-10 for the rejected entity: the server's version, or its deleted or missing state. */
|
||||
private fun openCurrent(rejection: Rejection, actions: SyncStatusActions) {
|
||||
if (rejection.entityType == EntityType.SETTLEMENT) actions.onOpenSettlement(SettlementId(rejection.entityId)) else actions.onOpenExpense(ExpenseId(rejection.entityId))
|
||||
}
|
||||
|
||||
/**
|
||||
* The lost local version of a rejected change, read-only (§13.3 S-16, D-109): "Din version", the ledger row it would
|
||||
* have been (no tap), the expense's account, payer, split and notes, or the payment's note; "Du tog bort den här"
|
||||
* when the refused change was a deletion. "Visa nuvarande version" opens S-8 / S-10 with the server's version.
|
||||
*/
|
||||
@Composable
|
||||
private fun LostVersionDialog(rejection: Rejection, lost: RejectedVersion, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
// The dialog window re-provides its own configuration and density; carry the screen's (language, font scale),
|
||||
// which are the same on a phone and differ only where a preview or test overrides them.
|
||||
val configuration = LocalConfiguration.current
|
||||
val density = LocalDensity.current
|
||||
Dialog(onDismissRequest = actions.onCloseRejection) {
|
||||
CompositionLocalProvider(LocalConfiguration provides configuration, LocalDensity provides density) {
|
||||
LostVersionCard(rejection, lost, ui, actions)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LostVersionCard(rejection: Rejection, lost: RejectedVersion, ui: SyncStatusUi.Ready, actions: SyncStatusActions) {
|
||||
val scheme = MaterialTheme.colorScheme
|
||||
Surface(shape = KvittTheme.shapes.card, color = scheme.surfaceContainerHigh, shadowElevation = KvittElevation.overlay) {
|
||||
Column(
|
||||
Modifier.verticalScroll(rememberScrollState()).padding(KvittDimens.dialogPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(KvittDimens.dialogGap),
|
||||
) {
|
||||
Text(stringResource(R.string.home_sync_lost_title), Modifier.semantics { heading() }, style = MaterialTheme.typography.headlineSmall)
|
||||
Text(stringResource(R.string.home_sync_lost_body), style = MaterialTheme.typography.bodyMedium, color = scheme.onSurfaceVariant)
|
||||
if (lost.deleted) KvittBadge(stringResource(R.string.home_sync_lost_deleted), icon = KvittIcon.Delete)
|
||||
LostFacts(lost, ui, if (lost.deleted) Modifier.alpha(KvittDimens.DELETED_ALPHA) else Modifier)
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s2, Alignment.End)) {
|
||||
KvittButton(stringResource(DesignR.string.common_close), actions.onCloseRejection, kind = ButtonKind.Text)
|
||||
KvittButton(
|
||||
stringResource(R.string.home_sync_lost_show_current),
|
||||
{
|
||||
actions.onCloseRejection()
|
||||
openCurrent(rejection, actions)
|
||||
},
|
||||
kind = ButtonKind.Tonal,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LostFacts(lost: RejectedVersion, ui: SyncStatusUi.Ready, modifier: Modifier) {
|
||||
val row = lost.row
|
||||
val facts = buildList<@Composable (SegmentedItemScope) -> Unit> {
|
||||
when (row) {
|
||||
is LedgerRow.ExpenseRow -> {
|
||||
add { item -> ExpenseRow(item, row, onClick = null) }
|
||||
lost.accountName?.let { name -> add { item -> FactRow(item, KvittIcon.Inventory2, stringResource(DesignR.string.editor_account), name) } }
|
||||
lost.payer?.let { payer -> add { item -> FactRow(item, KvittIcon.Person, stringResource(DesignR.string.editor_paid_by), personName(payer)) } }
|
||||
lost.mode?.let { mode -> add { item -> FactRow(item, KvittIcon.CallSplit, stringResource(DesignR.string.editor_split), lostModeLabel(mode, lost, ui)) } }
|
||||
if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
|
||||
}
|
||||
is LedgerRow.SettlementRow -> {
|
||||
add { item -> SettlementRow(item, row, stringResource(DesignR.string.ledger_settlement_label), onClick = null) }
|
||||
if (lost.notes.isNotEmpty()) add { item -> FactRow(item, KvittIcon.Notes, stringResource(DesignR.string.editor_notes), lost.notes) }
|
||||
}
|
||||
}
|
||||
}
|
||||
SegmentedGroup(facts.size, modifier) { item -> facts[item.index](item) }
|
||||
}
|
||||
|
||||
/** "Kontots standard 50/50" / "Egen fördelning 70/30" / "Exakta belopp" / "Allt på Anna", as on S-8. */
|
||||
@Composable
|
||||
private fun lostModeLabel(mode: SplitMode, lost: RejectedVersion, ui: SyncStatusUi.Ready): String {
|
||||
val language = kvittLanguage()
|
||||
val mineBp = lost.shares.firstOrNull { it.userId == ui.me.id }?.shareBp
|
||||
val ratio = mineBp?.let { stringResource(DesignR.string.common_ratio, MoneyFormatter.basisPoints(it, language), MoneyFormatter.basisPoints(BP_TOTAL - it, language)) }
|
||||
return when (mode) {
|
||||
SplitMode.DEFAULT -> if (ratio != null) stringResource(DesignR.string.editor_split_mode_default, ratio) else splitModeLabel(mode, defaultModeLabel(HALF_PERCENT))
|
||||
SplitMode.RATIO -> splitModeLabel(mode, "") + (ratio?.let { " $it" } ?: "")
|
||||
SplitMode.EXACT -> splitModeLabel(mode, "")
|
||||
SplitMode.FULL -> {
|
||||
val bearer = lost.shares.firstOrNull { it.shareOre > 0 }?.userId
|
||||
if (bearer == ui.me.id) stringResource(DesignR.string.editor_split_full_on_you) else stringResource(DesignR.string.editor_split_full_on, ui.partner?.displayName.orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val BP_TOTAL = 10_000
|
||||
private const val HALF_PERCENT = 50
|
||||
|
||||
@@ -3,12 +3,14 @@ package se.hagstedt.kvitt.feature.home
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.model.ConnectivityRepository
|
||||
import se.hagstedt.kvitt.model.Rejection
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import se.hagstedt.kvitt.model.SyncRepository
|
||||
@@ -22,9 +24,9 @@ sealed interface SyncStatusUi {
|
||||
|
||||
/**
|
||||
* @property me the viewer and [partner] the other person: `stale` names the rejection's actor, or the partner when
|
||||
* it has none (§14.4.6).
|
||||
* it has none (§14.4.6). [opened] is the rejection whose lost version the read-only sheet shows (D-109).
|
||||
*/
|
||||
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?) : SyncStatusUi {
|
||||
data class Ready(val status: SyncStatusState, val online: Boolean, val me: User, val partner: User?, val opened: Rejection? = null) : SyncStatusUi {
|
||||
/** The last attempt failed while online ("Synkningen misslyckades"); offline wins, as on the pill (§3.6). */
|
||||
val failed: Boolean get() = online && status.summary.lastAttemptFailed
|
||||
|
||||
@@ -44,16 +46,32 @@ class SyncStatusViewModel @Inject constructor(
|
||||
connectivity: ConnectivityRepository,
|
||||
session: SessionRepository,
|
||||
) : ViewModel() {
|
||||
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session) { status, online, s ->
|
||||
if (s is SessionState.Paired) SyncStatusUi.Ready(status, online, s.me, s.partner) else SyncStatusUi.Starting
|
||||
private val openedId = MutableStateFlow<String?>(null)
|
||||
|
||||
val ui: StateFlow<SyncStatusUi> = combine(sync.status(), connectivity.online, session.session, openedId) { status, online, s, opened ->
|
||||
if (s is SessionState.Paired) {
|
||||
SyncStatusUi.Ready(status, online, s.me, s.partner, status.rejections.firstOrNull { it.mutationId == opened })
|
||||
} else {
|
||||
SyncStatusUi.Starting
|
||||
}
|
||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, SyncStatusUi.Starting)
|
||||
|
||||
/** "Öppna" on a rejection with a lost version: the read-only sheet (S-16, D-109). */
|
||||
fun openRejection(mutationId: String) {
|
||||
openedId.value = mutationId
|
||||
}
|
||||
|
||||
fun closeRejection() {
|
||||
openedId.value = null
|
||||
}
|
||||
|
||||
fun syncNow() {
|
||||
viewModelScope.launch { sync.syncNow() }
|
||||
}
|
||||
|
||||
/** "Ta bort från listan": works offline (design-spec S-16). */
|
||||
fun dismiss(mutationId: String) {
|
||||
if (openedId.value == mutationId) openedId.value = null
|
||||
viewModelScope.launch { sync.dismiss(mutationId) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
<string name="home_sync_status_dismiss">Ta bort från listan</string>
|
||||
<!-- opens the rejected change read-only -->
|
||||
<string name="home_sync_status_open">Öppna</string>
|
||||
<string name="home_sync_lost_title">Din version</string>
|
||||
<string name="home_sync_lost_body">Så här såg det ut på telefonen. Det sparades inte och kan inte ändras här.</string>
|
||||
<string name="home_sync_lost_deleted">Du tog bort den här</string>
|
||||
<string name="home_sync_lost_show_current">Visa nuvarande version</string>
|
||||
<!-- §14.4.6 -->
|
||||
<string name="home_sync_reject_stale">Din ändring ersattes av en nyare ändring från %1$s</string>
|
||||
<!-- sv genitive as onboarding_profile_joining -->
|
||||
|
||||
@@ -40,6 +40,10 @@
|
||||
<string name="home_sync_status_dismiss">Remove from list</string>
|
||||
<!-- opens the rejected change read-only -->
|
||||
<string name="home_sync_status_open">Open</string>
|
||||
<string name="home_sync_lost_title">Your version</string>
|
||||
<string name="home_sync_lost_body">This is how it looked on your phone. It was not saved and cannot be changed here.</string>
|
||||
<string name="home_sync_lost_deleted">You deleted this</string>
|
||||
<string name="home_sync_lost_show_current">Show current version</string>
|
||||
<!-- §14.4.6 -->
|
||||
<string name="home_sync_reject_stale">Your change was replaced by a newer change from %1$s</string>
|
||||
<!-- sv genitive as onboarding_profile_joining -->
|
||||
|
||||
@@ -23,10 +23,12 @@ fun homeState(scenario: FakeScenario, act: (HomeViewModel) -> Unit = {}): HomeUi
|
||||
vm.ui.value
|
||||
}
|
||||
|
||||
/** Sync status with [scenario]. */
|
||||
fun syncState(scenario: FakeScenario): SyncStatusUi = withMain {
|
||||
/** Sync status with [scenario] after [act] on its ViewModel. */
|
||||
fun syncState(scenario: FakeScenario, act: (SyncStatusViewModel) -> Unit = {}): SyncStatusUi = withMain {
|
||||
val fakes = FakeRepositories(scenario)
|
||||
SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session).ui.value
|
||||
val vm = SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session)
|
||||
act(vm)
|
||||
vm.ui.value
|
||||
}
|
||||
|
||||
/** Every state of S-4, S-16 and S-17 (design-spec §4 and PLAN-M7b §5) × light/dark × sv/en. */
|
||||
@@ -56,6 +58,9 @@ val homeStates: List<ScreenState> = listOf(
|
||||
sync("S-16-sync-error", HomeScenarios.pending.copy(lastAttemptFailed = true)),
|
||||
sync("S-16-sync-offline", HomeScenarios.syncOffline),
|
||||
sync("S-16-sync-long", HomeScenarios.longSync, largeFont = true),
|
||||
sync("S-16-sync-lost", HomeScenarios.syncLoaded, largeFont = true) { it.openRejection("m-1") },
|
||||
sync("S-16-sync-lost-payment", HomeScenarios.allRejections) { it.openRejection("m-3") },
|
||||
sync("S-16-sync-lost-deleted", HomeScenarios.allRejections) { it.openRejection("m-5") },
|
||||
)
|
||||
|
||||
private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (HomeViewModel) -> Unit = {}): ScreenState {
|
||||
@@ -63,8 +68,8 @@ private fun home(name: String, scenario: FakeScenario, largeFont: Boolean = fals
|
||||
return ScreenState(name, largeFont) { HomeContent(ui, HomeActions()) }
|
||||
}
|
||||
|
||||
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false): ScreenState {
|
||||
val ui = syncState(scenario)
|
||||
private fun sync(name: String, scenario: FakeScenario, largeFont: Boolean = false, act: (SyncStatusViewModel) -> Unit = {}): ScreenState {
|
||||
val ui = syncState(scenario, act)
|
||||
return ScreenState(name, largeFont) { SyncStatusContent(ui, SyncStatusActions()) }
|
||||
}
|
||||
|
||||
@@ -93,7 +98,7 @@ class HomeScreenshotTest(private val state: String) {
|
||||
"S-4-home-syncing", "S-4-home-empty", "S-4-home-loading", "S-4-home-offline", "S-4-home-only-archived", "S-4-home-long",
|
||||
"S-17-update-banner", "S-17-update-permission", "S-17-update", "S-17-update-progress", "S-17-update-failed",
|
||||
"S-16-sync", "S-16-sync-empty", "S-16-sync-rejections", "S-16-sync-clock", "S-16-sync-running", "S-16-sync-error",
|
||||
"S-16-sync-offline", "S-16-sync-long",
|
||||
"S-16-sync-offline", "S-16-sync-long", "S-16-sync-lost", "S-16-sync-lost-payment", "S-16-sync-lost-deleted",
|
||||
).map { arrayOf<Any>(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,4 +163,19 @@ class SyncStatusViewModelTest {
|
||||
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
|
||||
assertTrue((vm.ui.value as SyncStatusUi.Ready).status.rejections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun openingARejectionShowsItsLostVersion_andCloseOrDismissHidesIt() {
|
||||
val fakes = FakeRepositories(HomeScenarios.syncLoaded)
|
||||
val vm = withMain { SyncStatusViewModel(fakes.sync, fakes.connectivity, fakes.session) }
|
||||
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
|
||||
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
|
||||
val opened = (vm.ui.value as SyncStatusUi.Ready).opened
|
||||
assertEquals(HomeScenarios.lostBabette, opened?.lost)
|
||||
withMain { vm.closeRejection() }
|
||||
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
|
||||
withMain { vm.openRejection(HomeScenarios.staleRejection.mutationId) }
|
||||
withMain { vm.dismiss(HomeScenarios.staleRejection.mutationId) }
|
||||
assertEquals(null, (vm.ui.value as SyncStatusUi.Ready).opened)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package se.hagstedt.kvitt.feature.home
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.os.LocaleList
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -22,6 +23,7 @@ import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.datetime.TimeZone
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import se.hagstedt.kvitt.design.KvittTheme
|
||||
import se.hagstedt.kvitt.design.component.KvittClock
|
||||
import se.hagstedt.kvitt.design.component.LocalKvittClock
|
||||
@@ -75,6 +77,11 @@ object ScreenSnapshots {
|
||||
variants.forEach { variant ->
|
||||
current = variant
|
||||
compose.mainClock.advanceTimeBy(SETTLE_MILLIS)
|
||||
// The screen (and a dialog window: S-16's lost version, S-17's install dialog) draw in a later frame; one
|
||||
// more frame and an idle main looper, so the capture shows this variant and not the previous one.
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
compose.mainClock.advanceTimeByFrame()
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
captureScreenRoboImage("src/test/screenshots/${name}_${variant.suffix}.png")
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |