feat(android): album/photo attribute-actions and device gallery sync fixes
Album panel and photo sheet with attributes/actions for every album (server and device), local album rename/cover/delete, EXIF attribute settings, and a unified device-gallery sync check: - add repository "Атрибуты и действия" panel (swipe left) on server and device albums: cover, info, navigation, rename/delete; album settings screen; photo sheet (swipe up) with info, tags and actions; EXIF attribute settings backed by profile preferences - local albums use local metadata and actions: app-only display name and cover (new device_album_meta table), delete removes photos from the phone via the system delete consent - unified mode x state policy (GallerySyncPolicy/SyncCheck) runs on start and on album open: upload/offload in move, restore originals in copy/off - fix move->copy restore, offload of any linked-but-local original, provenance recovery that no longer overwrites the album mode, serialized reconcile and duplicate-upload suppression; prompt for "all files" access on start - album card caption: name (with linked server name when different) and storage icons (memory card / cloud) with item count Sync delta specs into android-client and archive the change add-mobile-album-photo-actions. Verified: unit/Robolectric suite green, :app:assembleDebug, :app:lint, and the DeviceSyncE2ETest instrumented suite on the emulator (7/7).
This commit is contained in:
@@ -211,6 +211,101 @@ class DeviceSyncE2ETest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingFromMoveToCopyRestoresTheOriginal() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "MoveToCopy ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
|
||||
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.MOVE)
|
||||
assertTrue(container.syncCoordinator.reconcile(bucket))
|
||||
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
|
||||
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
|
||||
|
||||
// Switch to copies and run the check: the original comes back.
|
||||
container.syncRepository.link(bucket, "MC", serverAlbum, SyncMode.COPY)
|
||||
container.syncCheck.checkGallery(bucket)
|
||||
assertTrue(
|
||||
"original should be restored on move→copy",
|
||||
waitForState(container, bucket, key, LocalAssetState.LOCAL),
|
||||
)
|
||||
|
||||
// The restored original must not be re-uploaded as a duplicate.
|
||||
container.syncCoordinator.reconcile(bucket)
|
||||
delay(2_000)
|
||||
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unlinkedAlbumWithPreviewsRestoresOriginals() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "UnlinkRestore ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
|
||||
container.syncRepository.link(bucket, "U", serverAlbum, SyncMode.MOVE)
|
||||
assertTrue(container.syncCoordinator.reconcile(bucket))
|
||||
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
|
||||
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
|
||||
|
||||
// Unlink (mode off): previews must still be restored, without touching the server.
|
||||
container.syncRepository.unlink(bucket)
|
||||
container.syncCheck.checkGallery(bucket)
|
||||
assertTrue(
|
||||
"originals should be restored when no longer moving",
|
||||
waitForState(container, bucket, key, LocalAssetState.LOCAL),
|
||||
)
|
||||
assertEquals(1, E2E.listPhotos(token, serverAlbum).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkRebuildsLostIndexFromMarker() = runBlocking {
|
||||
val app = ApplicationProvider.getApplicationContext<GaloApplication>()
|
||||
val container = app.container
|
||||
E2E.configure(container)
|
||||
val token = E2E.registerAuthenticated(container)
|
||||
val serverAlbum = E2E.createAlbum(token, "LostIndex ${System.currentTimeMillis()}")
|
||||
|
||||
val (uri, bucket) = insertImage()
|
||||
val key = DeviceAssetEntity.key(bucket, ContentUris.parseId(uri))
|
||||
container.syncRepository.link(bucket, "L", serverAlbum, SyncMode.MOVE)
|
||||
assertTrue(container.syncCoordinator.reconcile(bucket))
|
||||
assertTrue("photo should reach the server", awaitPhotos(token, serverAlbum, minimum = 1) >= 1)
|
||||
assertTrue("asset should be offloaded", waitForState(container, bucket, key, LocalAssetState.PREVIEW))
|
||||
|
||||
// Simulate a lost index/link: the marker in the preview file is the only trail.
|
||||
container.syncRepository.forgetAlbum(bucket)
|
||||
container.syncCheck.checkGallery(bucket, recoverIndex = true)
|
||||
|
||||
val link = waitForLink(container, bucket)
|
||||
assertTrue("link should be rebuilt from the marker", link != null)
|
||||
assertEquals(SyncMode.MOVE, SyncMode.from(link?.mode))
|
||||
assertTrue(
|
||||
"an offloaded asset stays offloaded in move mode",
|
||||
waitForState(container, bucket, key, LocalAssetState.PREVIEW),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun waitForLink(
|
||||
container: com.galo.android.di.AppContainer,
|
||||
bucket: String,
|
||||
): com.galo.android.data.db.entity.SyncedAlbumEntity? {
|
||||
val deadline = System.currentTimeMillis() + 10_000
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val link = container.syncRepository.currentLink(bucket)
|
||||
if (link != null && link.serverAlbumId.isNotBlank()) return link
|
||||
delay(200)
|
||||
}
|
||||
return container.syncRepository.currentLink(bucket)
|
||||
}
|
||||
|
||||
private suspend fun waitForState(
|
||||
container: com.galo.android.di.AppContainer,
|
||||
bucket: String,
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.galo.android.data.db.dao.PhotoDao
|
||||
import com.galo.android.data.db.dao.SyncDao
|
||||
import com.galo.android.data.db.dao.UploadDao
|
||||
import com.galo.android.data.db.entity.AlbumEntity
|
||||
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.db.entity.PhotoEntity
|
||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||
@@ -21,11 +22,11 @@ import com.galo.android.data.db.entity.UploadJobEntity
|
||||
UploadJobEntity::class,
|
||||
SyncedAlbumEntity::class,
|
||||
DeviceAssetEntity::class,
|
||||
DeviceAlbumMetaEntity::class,
|
||||
],
|
||||
version = 4,
|
||||
version = 6,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class GaloDatabase : RoomDatabase() {
|
||||
)abstract class GaloDatabase : RoomDatabase() {
|
||||
|
||||
abstract fun albumDao(): AlbumDao
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.galo.android.data.db.dao
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Query
|
||||
import androidx.room.Upsert
|
||||
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -27,8 +28,25 @@ interface SyncDao {
|
||||
@Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId")
|
||||
suspend fun deleteSyncedAlbum(deviceAlbumId: String)
|
||||
|
||||
// --- Local album metadata (display name, cover) ------------------------
|
||||
|
||||
@Query("SELECT * FROM device_album_meta")
|
||||
fun observeAlbumMeta(): Flow<List<DeviceAlbumMetaEntity>>
|
||||
|
||||
@Query("SELECT * FROM device_album_meta WHERE deviceAlbumId = :deviceAlbumId")
|
||||
suspend fun getAlbumMeta(deviceAlbumId: String): DeviceAlbumMetaEntity?
|
||||
|
||||
@Upsert
|
||||
suspend fun upsertAlbumMeta(meta: DeviceAlbumMetaEntity)
|
||||
|
||||
@Query("DELETE FROM device_album_meta WHERE deviceAlbumId = :deviceAlbumId")
|
||||
suspend fun deleteAlbumMeta(deviceAlbumId: String)
|
||||
|
||||
// --- Device asset index and provenance ---------------------------------
|
||||
|
||||
@Query("SELECT DISTINCT deviceAlbumId FROM device_assets")
|
||||
suspend fun indexedAlbumIds(): List<String>
|
||||
|
||||
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
|
||||
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>>
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ data class PhotoEntity(
|
||||
val size: Long,
|
||||
val takenAt: String?,
|
||||
val caption: String,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val isPanorama: Boolean,
|
||||
val thumbUrl: String?,
|
||||
val mediumUrl: String?,
|
||||
@@ -28,4 +30,8 @@ data class PhotoEntity(
|
||||
val originalUrl: String?,
|
||||
val downloadUrl: String?,
|
||||
val createdAt: String?,
|
||||
val tagsJson: String = "[]",
|
||||
val exifJson: String = "{}",
|
||||
val uploaderName: String? = null,
|
||||
val uploaderAvatarUrl: String? = null,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,18 @@ data class SyncedAlbumEntity(
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Local, app-only metadata for a device album: a display name and a chosen
|
||||
* cover. Stored regardless of whether the album is linked to a server album.
|
||||
*/
|
||||
@Entity(tableName = "device_album_meta")
|
||||
data class DeviceAlbumMetaEntity(
|
||||
@PrimaryKey val deviceAlbumId: String,
|
||||
val displayName: String?,
|
||||
val coverUri: String?,
|
||||
val updatedAt: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* Local index of a device asset plus provenance: the server photo it was
|
||||
* uploaded as (when known) and the content hash used to match it.
|
||||
|
||||
@@ -2,12 +2,18 @@ package com.galo.android.data.mapper
|
||||
|
||||
import com.galo.android.data.db.entity.AlbumEntity
|
||||
import com.galo.android.data.db.entity.PhotoEntity
|
||||
import com.galo.android.data.network.GaloJson
|
||||
import com.galo.android.data.network.ObjectUrlResolver
|
||||
import com.galo.android.data.network.dto.AlbumDto
|
||||
import com.galo.android.data.network.dto.PhotoDto
|
||||
import com.galo.android.data.network.dto.TagDto
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.domain.model.Tag
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
// Remote DTO -> persisted entity (URLs kept raw so the cache survives a base-URL change).
|
||||
fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity(
|
||||
@@ -35,6 +41,8 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
|
||||
size = size,
|
||||
takenAt = takenAt,
|
||||
caption = caption,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
|
||||
thumbUrl = thumbUrl,
|
||||
mediumUrl = mediumUrl,
|
||||
@@ -43,6 +51,10 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
|
||||
originalUrl = originalUrl,
|
||||
downloadUrl = downloadUrl,
|
||||
createdAt = createdAt,
|
||||
tagsJson = encodeTags(tags),
|
||||
exifJson = encodeExif(exif),
|
||||
uploaderName = uploader?.name,
|
||||
uploaderAvatarUrl = uploader?.avatarUrl,
|
||||
)
|
||||
|
||||
// Persisted entity -> domain (URLs resolved against the current base URL).
|
||||
@@ -71,6 +83,8 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
||||
size = size,
|
||||
takenAt = takenAt,
|
||||
caption = caption,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
isPanorama = isPanorama,
|
||||
thumbUrl = resolver.resolve(thumbUrl),
|
||||
mediumUrl = resolver.resolve(mediumUrl),
|
||||
@@ -79,6 +93,10 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
||||
originalUrl = resolver.resolve(originalUrl),
|
||||
downloadUrl = resolver.resolve(downloadUrl),
|
||||
createdAt = createdAt,
|
||||
tags = decodeTags(tagsJson),
|
||||
exif = decodeExif(exifJson),
|
||||
uploaderName = uploaderName,
|
||||
uploaderAvatarUrl = resolver.resolve(uploaderAvatarUrl),
|
||||
)
|
||||
|
||||
fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
||||
@@ -93,6 +111,8 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
||||
size = size,
|
||||
takenAt = takenAt,
|
||||
caption = caption,
|
||||
latitude = latitude,
|
||||
longitude = longitude,
|
||||
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
|
||||
thumbUrl = resolver.resolve(thumbUrl),
|
||||
mediumUrl = resolver.resolve(mediumUrl),
|
||||
@@ -101,4 +121,31 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
||||
originalUrl = resolver.resolve(originalUrl),
|
||||
downloadUrl = resolver.resolve(downloadUrl),
|
||||
createdAt = createdAt,
|
||||
tags = tags.map { Tag(it.id, it.name) },
|
||||
exif = exif.mapValues { it.value.asExifString() },
|
||||
uploaderName = uploader?.name,
|
||||
uploaderAvatarUrl = resolver.resolve(uploader?.avatarUrl),
|
||||
)
|
||||
|
||||
private fun encodeTags(tags: List<TagDto>): String =
|
||||
runCatching { GaloJson.encodeToString(tags) }.getOrDefault("[]")
|
||||
|
||||
private fun decodeTags(json: String): List<Tag> =
|
||||
runCatching {
|
||||
GaloJson.decodeFromString<List<TagDto>>(json).map { Tag(it.id, it.name) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private fun encodeExif(exif: Map<String, JsonElement>): String =
|
||||
runCatching {
|
||||
GaloJson.encodeToString(exif.mapValues { it.value.asExifString() })
|
||||
}.getOrDefault("{}")
|
||||
|
||||
private fun decodeExif(json: String): Map<String, String> =
|
||||
runCatching { GaloJson.decodeFromString<Map<String, String>>(json) }
|
||||
.getOrDefault(emptyMap())
|
||||
|
||||
private fun JsonElement.asExifString(): String =
|
||||
when (this) {
|
||||
is JsonPrimitive -> content
|
||||
else -> toString()
|
||||
}
|
||||
|
||||
@@ -95,6 +95,19 @@ class MediaStoreDeviceSource(private val context: Context) {
|
||||
fun items(albumId: String): List<DeviceMedia> =
|
||||
allMedia().filter { it.albumId == albumId }
|
||||
|
||||
/**
|
||||
* System consent to delete media the app does not own. Launching the returned
|
||||
* sender (after user confirmation) removes the items from MediaStore.
|
||||
*/
|
||||
fun deleteConsent(uris: List<Uri>): android.content.IntentSender? =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R && uris.isNotEmpty()) {
|
||||
runCatching {
|
||||
MediaStore.createDeleteRequest(context.contentResolver, uris).intentSender
|
||||
}.getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
private fun allMedia(): List<DeviceMedia> =
|
||||
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
|
||||
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.galo.android.data.network
|
||||
|
||||
import com.galo.android.data.network.dto.AddIdentifierRequest
|
||||
import com.galo.android.data.network.dto.AddTagRequest
|
||||
import com.galo.android.data.network.dto.AlbumDto
|
||||
import com.galo.android.data.network.dto.AlbumEnvelopeDto
|
||||
import com.galo.android.data.network.dto.AlbumListDto
|
||||
@@ -12,12 +13,23 @@ import com.galo.android.data.network.dto.DeleteAccountRequest
|
||||
import com.galo.android.data.network.dto.HealthDto
|
||||
import com.galo.android.data.network.dto.LoginRequest
|
||||
import com.galo.android.data.network.dto.LogoutRequest
|
||||
import com.galo.android.data.network.dto.MemberDto
|
||||
import com.galo.android.data.network.dto.MemberListDto
|
||||
import com.galo.android.data.network.dto.PhotoDto
|
||||
import com.galo.android.data.network.dto.PhotoEnvelopeDto
|
||||
import com.galo.android.data.network.dto.PhotoListDto
|
||||
import com.galo.android.data.network.dto.PreferencesDto
|
||||
import com.galo.android.data.network.dto.PreferencesEnvelopeDto
|
||||
import com.galo.android.data.network.dto.RegisterRequest
|
||||
import com.galo.android.data.network.dto.RenamePhotoRequest
|
||||
import com.galo.android.data.network.dto.ShareLinkDto
|
||||
import com.galo.android.data.network.dto.ShareLinkEnvelopeDto
|
||||
import com.galo.android.data.network.dto.ShareLinkListDto
|
||||
import com.galo.android.data.network.dto.ShareLinkRequest
|
||||
import com.galo.android.data.network.dto.TagDto
|
||||
import com.galo.android.data.network.dto.TagListDto
|
||||
import com.galo.android.data.network.dto.TokenEnvelopeDto
|
||||
import com.galo.android.data.network.dto.UpdateAlbumRequest
|
||||
import com.galo.android.data.network.dto.UpdateProfileRequest
|
||||
import com.galo.android.data.network.dto.UploadResponseDto
|
||||
import com.galo.android.data.network.dto.UserDto
|
||||
@@ -76,6 +88,15 @@ class GaloApi(
|
||||
override suspend fun me(): UserDto =
|
||||
get("api/auth/me").decode<UserEnvelopeDto>().user
|
||||
|
||||
/** Full account payload including saved preferences and their `photo_info`. */
|
||||
suspend fun meEnvelope(): UserEnvelopeDto = get("api/auth/me").decode<UserEnvelopeDto>()
|
||||
|
||||
/** Persists preferences (e.g. which EXIF attributes are visible and their labels). */
|
||||
suspend fun savePreferences(preferences: PreferencesDto): PreferencesDto =
|
||||
patchJson("api/auth/me/preferences", json.encodeToString(preferences))
|
||||
.decode<PreferencesEnvelopeDto>()
|
||||
.preferences
|
||||
|
||||
override suspend fun updateProfile(name: String): UserDto =
|
||||
patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name)))
|
||||
.decode<UserEnvelopeDto>()
|
||||
@@ -127,6 +148,10 @@ class GaloApi(
|
||||
suspend fun album(albumId: String): AlbumDto =
|
||||
get("api/albums/$albumId").decode<AlbumEnvelopeDto>().album
|
||||
|
||||
/** Album plus the caller's role on it. */
|
||||
suspend fun albumEnvelope(albumId: String): AlbumEnvelopeDto =
|
||||
get("api/albums/$albumId").decode()
|
||||
|
||||
/** Creates a server album and returns it. */
|
||||
suspend fun createAlbum(title: String): AlbumDto =
|
||||
postJson("api/albums", json.encodeToString(CreateAlbumRequest(title)))
|
||||
@@ -183,6 +208,70 @@ class GaloApi(
|
||||
postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId)))
|
||||
}
|
||||
|
||||
/** Copies photos to another album in one batch. */
|
||||
suspend fun copyPhotos(photoIds: List<String>, albumId: String) {
|
||||
postJson("api/photo-batch/copy", json.encodeToString(BatchRequest(photoIds, albumId)))
|
||||
}
|
||||
|
||||
/** Updates album attributes (title, description, cover). Omitted fields are left unchanged. */
|
||||
suspend fun updateAlbum(
|
||||
albumId: String,
|
||||
title: String? = null,
|
||||
description: String? = null,
|
||||
coverPhotoId: String? = null,
|
||||
clearCover: Boolean = false,
|
||||
): AlbumDto {
|
||||
val body = UpdateAlbumRequest(
|
||||
title = title,
|
||||
description = description,
|
||||
coverPhotoId = coverPhotoId,
|
||||
clearCover = if (clearCover) true else null,
|
||||
)
|
||||
return patchJson("api/albums/$albumId", json.encodeToString(body))
|
||||
.decode<AlbumEnvelopeDto>()
|
||||
.album
|
||||
}
|
||||
|
||||
/** Deletes an album; its photos are moved to the server trash. */
|
||||
suspend fun deleteAlbum(albumId: String) {
|
||||
execute(Request.Builder().url(api.endpoint("api/albums/$albumId")).delete().build())
|
||||
}
|
||||
|
||||
suspend fun members(albumId: String): List<MemberDto> =
|
||||
get("api/albums/$albumId/members").decode<MemberListDto>().members
|
||||
|
||||
suspend fun shareLinks(albumId: String): List<ShareLinkDto> =
|
||||
get("api/albums/$albumId/share").decode<ShareLinkListDto>().shareLinks
|
||||
|
||||
suspend fun createShareLink(albumId: String, expiresIn: String = "168h"): ShareLinkDto =
|
||||
postJson("api/albums/$albumId/share", json.encodeToString(ShareLinkRequest(expiresIn)))
|
||||
.decode<ShareLinkEnvelopeDto>()
|
||||
.shareLink
|
||||
|
||||
suspend fun revokeShareLink(albumId: String, linkId: String) {
|
||||
execute(
|
||||
Request.Builder().url(api.endpoint("api/albums/$albumId/share/$linkId")).delete().build(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Adds a label to a photo and returns the updated photo. */
|
||||
suspend fun addTag(photoId: String, name: String): PhotoDto =
|
||||
postJson("api/photos/$photoId/tags", json.encodeToString(AddTagRequest(name)))
|
||||
.decode<PhotoEnvelopeDto>()
|
||||
.photo
|
||||
|
||||
/** Removes a label from a photo and returns the updated photo. */
|
||||
suspend fun removeTag(photoId: String, tagId: String): PhotoDto =
|
||||
execute(
|
||||
Request.Builder().url(api.endpoint("api/photos/$photoId/tags/$tagId")).delete().build(),
|
||||
).decode<PhotoEnvelopeDto>().photo
|
||||
|
||||
/** Lists the user's labels, optionally filtered by a query. */
|
||||
suspend fun tags(query: String = ""): List<TagDto> {
|
||||
val suffix = if (query.isBlank()) "" else "?q=" + urlEncode(query)
|
||||
return get("api/tags$suffix").decode<TagListDto>().tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Control sums of an album's photos keyed by checksum, following pagination.
|
||||
* Used by the device-album sync to avoid re-uploading existing content.
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.galo.android.data.network.dto
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
||||
@Serializable
|
||||
data class HealthDto(
|
||||
@@ -37,6 +38,7 @@ data class UserDto(
|
||||
@Serializable
|
||||
data class UserEnvelopeDto(
|
||||
val user: UserDto = UserDto(),
|
||||
val preferences: PreferencesDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -141,7 +143,11 @@ data class PhotoDto(
|
||||
val size: Long = 0,
|
||||
@SerialName("taken_at") val takenAt: String? = null,
|
||||
val caption: String = "",
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val tags: List<TagDto> = emptyList(),
|
||||
val exif: Map<String, JsonElement> = emptyMap(),
|
||||
val uploader: UserDto? = null,
|
||||
@SerialName("thumb_url") val thumbUrl: String? = null,
|
||||
@SerialName("medium_url") val mediumUrl: String? = null,
|
||||
@SerialName("preview_url") val previewUrl: String? = null,
|
||||
@@ -183,3 +189,74 @@ data class ErrorBodyDto(
|
||||
val code: String = "internal_error",
|
||||
val message: String = "internal server error",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UpdateAlbumRequest(
|
||||
val title: String? = null,
|
||||
val description: String? = null,
|
||||
@SerialName("cover_photo_id") val coverPhotoId: String? = null,
|
||||
@SerialName("clear_cover") val clearCover: Boolean? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ShareLinkRequest(
|
||||
@SerialName("expires_in") val expiresIn: String = "168h",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ShareLinkDto(
|
||||
val id: String = "",
|
||||
@SerialName("album_id") val albumId: String = "",
|
||||
val token: String = "",
|
||||
@SerialName("expires_at") val expiresAt: String? = null,
|
||||
@SerialName("revoked_at") val revokedAt: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ShareLinkEnvelopeDto(
|
||||
@SerialName("share_link") val shareLink: ShareLinkDto = ShareLinkDto(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ShareLinkListDto(
|
||||
@SerialName("share_links") val shareLinks: List<ShareLinkDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MemberDto(
|
||||
@SerialName("user_id") val userId: String = "",
|
||||
val role: String = "",
|
||||
val user: UserDto = UserDto(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MemberListDto(
|
||||
val members: List<MemberDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TagListDto(
|
||||
val tags: List<TagDto> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class AddTagRequest(
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PhotoInfoPrefsDto(
|
||||
val visible: List<String> = emptyList(),
|
||||
val labels: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PreferencesDto(
|
||||
@SerialName("photo_info") val photoInfo: PhotoInfoPrefsDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PreferencesEnvelopeDto(
|
||||
val preferences: PreferencesDto = PreferencesDto(),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
package com.galo.android.data.repo
|
||||
|
||||
import com.galo.android.data.db.dao.SyncDao
|
||||
import com.galo.android.data.db.entity.DeviceAlbumMetaEntity
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||
import com.galo.android.data.media.MediaStoreDeviceSource
|
||||
import com.galo.android.domain.model.DeviceAlbum
|
||||
import com.galo.android.domain.model.DeviceAlbumMeta
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
/** Device albums, their links to server albums and their asset index. */
|
||||
class SyncRepository(
|
||||
@@ -22,6 +25,9 @@ class SyncRepository(
|
||||
|
||||
suspend fun assets(deviceAlbumId: String): List<DeviceAssetEntity> = dao.getAssets(deviceAlbumId)
|
||||
|
||||
/** Device albums that have a local asset index (possibly partial/unlinked). */
|
||||
suspend fun indexedAlbumIds(): List<String> = dao.indexedAlbumIds()
|
||||
|
||||
suspend fun getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey)
|
||||
|
||||
suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? =
|
||||
@@ -48,6 +54,61 @@ class SyncRepository(
|
||||
|
||||
suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId)
|
||||
|
||||
// --- Local album metadata (display name, cover) ------------------------
|
||||
|
||||
/** App-only metadata for device albums, keyed by device album id. */
|
||||
fun observeAlbumMeta(): Flow<Map<String, DeviceAlbumMeta>> =
|
||||
dao.observeAlbumMeta().map { list ->
|
||||
list.associate { it.deviceAlbumId to DeviceAlbumMeta(it.deviceAlbumId, it.displayName, it.coverUri) }
|
||||
}
|
||||
|
||||
suspend fun albumMeta(deviceAlbumId: String): DeviceAlbumMeta? =
|
||||
dao.getAlbumMeta(deviceAlbumId)?.let {
|
||||
DeviceAlbumMeta(it.deviceAlbumId, it.displayName, it.coverUri)
|
||||
}
|
||||
|
||||
/** Sets the app-only display name for a device album. */
|
||||
suspend fun setAlbumName(deviceAlbumId: String, name: String?) {
|
||||
val current = dao.getAlbumMeta(deviceAlbumId)
|
||||
dao.upsertAlbumMeta(
|
||||
DeviceAlbumMetaEntity(
|
||||
deviceAlbumId = deviceAlbumId,
|
||||
displayName = name?.trim()?.takeIf { it.isNotEmpty() },
|
||||
coverUri = current?.coverUri,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** Sets (or clears, with null) the chosen cover URI for a device album. */
|
||||
suspend fun setAlbumCover(deviceAlbumId: String, coverUri: String?) {
|
||||
val current = dao.getAlbumMeta(deviceAlbumId)
|
||||
dao.upsertAlbumMeta(
|
||||
DeviceAlbumMetaEntity(
|
||||
deviceAlbumId = deviceAlbumId,
|
||||
displayName = current?.displayName,
|
||||
coverUri = coverUri,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Forgets a device album locally: drops its link, asset index and app-only
|
||||
* metadata. Does not touch media on the device.
|
||||
*/
|
||||
suspend fun forgetAlbum(deviceAlbumId: String) {
|
||||
dao.clearAlbum(deviceAlbumId)
|
||||
dao.deleteAlbumMeta(deviceAlbumId)
|
||||
dao.deleteSyncedAlbum(deviceAlbumId)
|
||||
}
|
||||
|
||||
/** System consent to delete the given device media (see [MediaStoreDeviceSource.deleteConsent]). */
|
||||
fun deleteConsent(uris: List<android.net.Uri>) = deviceSource.deleteConsent(uris)
|
||||
|
||||
fun deviceItemUris(albumId: String): List<android.net.Uri> =
|
||||
deviceSource.items(albumId).map { android.net.Uri.parse(it.uri) }
|
||||
|
||||
/** Records the server photo created for a device asset (called after upload). */
|
||||
suspend fun recordUploaded(
|
||||
deviceAlbumId: String,
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.media.PreviewGenerator
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -50,12 +51,18 @@ class OffloadManager(
|
||||
mutex.withLock {
|
||||
runCatching {
|
||||
val uri = Uri.parse(asset.uri)
|
||||
val serverAlbumId = dao.getSyncedAlbum(asset.deviceAlbumId)?.serverAlbumId
|
||||
val link = dao.getSyncedAlbum(asset.deviceAlbumId)
|
||||
val serverAlbumId = link?.serverAlbumId
|
||||
val syncMode = link?.mode
|
||||
if (kindOf(asset.mimeType) == MediaKind.VIDEO) {
|
||||
offloadVideo(asset, uri, serverAlbumId)
|
||||
offloadVideo(asset, uri, serverAlbumId, syncMode)
|
||||
} else {
|
||||
offloadPhoto(asset, uri, serverAlbumId)
|
||||
offloadPhoto(asset, uri, serverAlbumId, syncMode)
|
||||
}
|
||||
}.onSuccess {
|
||||
android.util.Log.i(TAG, "offload ${asset.assetKey}: $it")
|
||||
}.onFailure {
|
||||
android.util.Log.w(TAG, "offload ${asset.assetKey} failed", it)
|
||||
}.getOrDefault(OffloadResult.Failed)
|
||||
}
|
||||
}
|
||||
@@ -76,7 +83,12 @@ class OffloadManager(
|
||||
null
|
||||
}
|
||||
|
||||
private suspend fun offloadPhoto(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult {
|
||||
private suspend fun offloadPhoto(
|
||||
asset: DeviceAssetEntity,
|
||||
uri: Uri,
|
||||
serverAlbumId: String?,
|
||||
syncMode: String?,
|
||||
): OffloadResult {
|
||||
val preview = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
|
||||
val sourceExif = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) }
|
||||
@@ -84,7 +96,7 @@ class OffloadManager(
|
||||
|
||||
if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed
|
||||
ProvenanceStamp.copyDateTaken(sourceExif, preview)
|
||||
ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId))
|
||||
ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId, syncMode))
|
||||
|
||||
return try {
|
||||
writeInPlace(uri, preview)
|
||||
@@ -97,10 +109,15 @@ class OffloadManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun offloadVideo(asset: DeviceAssetEntity, uri: Uri, serverAlbumId: String?): OffloadResult {
|
||||
private suspend fun offloadVideo(
|
||||
asset: DeviceAssetEntity,
|
||||
uri: Uri,
|
||||
serverAlbumId: String?,
|
||||
syncMode: String?,
|
||||
): OffloadResult {
|
||||
val poster = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
|
||||
if (!PreviewGenerator.generate(context, uri, MediaKind.VIDEO, poster)) return OffloadResult.Failed
|
||||
ProvenanceStamp.write(poster, provenanceOf(asset, serverAlbumId))
|
||||
ProvenanceStamp.write(poster, provenanceOf(asset, serverAlbumId, syncMode))
|
||||
val relativePath = relativePathOf(uri)
|
||||
|
||||
return try {
|
||||
@@ -152,12 +169,18 @@ class OffloadManager(
|
||||
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||
}.getOrNull().orEmpty().ifBlank { "DCIM/" }
|
||||
|
||||
private fun provenanceOf(asset: DeviceAssetEntity, serverAlbumId: String?) = Provenance(
|
||||
private fun provenanceOf(
|
||||
asset: DeviceAssetEntity,
|
||||
serverAlbumId: String?,
|
||||
syncMode: String?,
|
||||
) = Provenance(
|
||||
serverAlbumId = serverAlbumId,
|
||||
serverPhotoId = asset.serverPhotoId,
|
||||
originalHash = asset.contentHash,
|
||||
deviceAlbumId = asset.deviceAlbumId,
|
||||
mode = LocalAssetState.PREVIEW.name,
|
||||
// The album's synchronization mode (not the asset state) — recovery reads
|
||||
// this back to rebuild the link after a reinstall.
|
||||
mode = syncMode ?: SyncMode.MOVE.name,
|
||||
assetKey = asset.assetKey,
|
||||
)
|
||||
|
||||
@@ -167,4 +190,8 @@ class OffloadManager(
|
||||
|
||||
private fun kindOf(mimeType: String?): MediaKind =
|
||||
if (mimeType?.startsWith("video/") == true) MediaKind.VIDEO else MediaKind.IMAGE
|
||||
|
||||
private companion object {
|
||||
const val TAG = "OffloadManager"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ class RestoreManager(
|
||||
/** Restoring must fit in the free space; otherwise it is deferred. */
|
||||
fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean =
|
||||
availableBytes >= requiredBytes
|
||||
|
||||
/** Assets whose originals live on the server and should be brought back. */
|
||||
fun restoreTargets(assets: List<DeviceAssetEntity>): List<DeviceAssetEntity> =
|
||||
assets.filter {
|
||||
it.serverPhotoId != null && it.state == LocalAssetState.PREVIEW.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun restore(assets: List<DeviceAssetEntity>): RestoreReport = withContext(Dispatchers.IO) {
|
||||
@@ -52,6 +58,7 @@ class RestoreManager(
|
||||
for (asset in assets) {
|
||||
val photoId = asset.serverPhotoId ?: continue
|
||||
val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue
|
||||
if (data.isEmpty()) continue
|
||||
if (!canRestore(data.size.toLong(), availableBytes())) {
|
||||
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
|
||||
}
|
||||
@@ -62,6 +69,8 @@ class RestoreManager(
|
||||
bytes += data.size
|
||||
} catch (e: SecurityException) {
|
||||
needsConsent++
|
||||
} catch (e: Exception) {
|
||||
// Leave the asset as is and continue with the rest.
|
||||
}
|
||||
}
|
||||
RestoreReport(restored, bytes, needsConsent, outOfSpace = false)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.galo.android.data.sync
|
||||
|
||||
import com.galo.android.data.repo.SyncRepository
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import com.galo.android.domain.sync.GallerySyncPolicy
|
||||
|
||||
/**
|
||||
* The unified per-gallery check and fix, run on app start and when a device album
|
||||
* is opened. It makes the on-device content match the album's mode for every
|
||||
* combination (see [GallerySyncPolicy]): it rebuilds provenance from markers when
|
||||
* asked, restores originals for copy/off albums that hold previews, then
|
||||
* reconciles (upload / link / offload).
|
||||
*/
|
||||
class SyncCheck(
|
||||
private val repository: SyncRepository,
|
||||
private val recovery: SyncRecovery,
|
||||
private val restoreManager: RestoreManager,
|
||||
private val coordinator: SyncCoordinator,
|
||||
) {
|
||||
|
||||
suspend fun checkGallery(deviceAlbumId: String, recoverIndex: Boolean = false) {
|
||||
if (recoverIndex) runCatching { recovery.recover(deviceAlbumId) }
|
||||
|
||||
val mode = repository.currentLink(deviceAlbumId)
|
||||
?.let { SyncMode.from(it.mode) } ?: SyncMode.DISABLED
|
||||
val assets = runCatching { repository.assets(deviceAlbumId) }.getOrDefault(emptyList())
|
||||
|
||||
// Bring originals back where the mode expects them locally (copy/off with
|
||||
// previews left over from move, possibly with a lost index).
|
||||
val restore = assets.filter { entity ->
|
||||
GallerySyncPolicy.decide(
|
||||
mode = mode,
|
||||
state = LocalAssetState.from(entity.state),
|
||||
hasServerPhoto = entity.serverPhotoId != null,
|
||||
) == GallerySyncPolicy.LocalFix.RESTORE
|
||||
}
|
||||
if (restore.isNotEmpty()) runCatching { restoreManager.restore(restore) }
|
||||
|
||||
// Upload what is missing and offload what is already on the server (move).
|
||||
runCatching { coordinator.reconcile(deviceAlbumId) }
|
||||
}
|
||||
|
||||
suspend fun checkAll(deviceAlbumIds: Collection<String>, recoverIndex: Boolean = true) {
|
||||
deviceAlbumIds.forEach { checkGallery(it, recoverIndex) }
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import com.galo.android.domain.sync.SyncPolicy
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/**
|
||||
* Reconciles a device album with its server album. Mode A (DISABLED) is local
|
||||
@@ -19,13 +21,16 @@ class SyncCoordinator(
|
||||
private val offloader: AssetOffloader,
|
||||
) {
|
||||
|
||||
/** Serializes reconciles so rapid triggers never double-upload or double-offload. */
|
||||
private val mutex = Mutex()
|
||||
|
||||
/** @return true when the server was contacted, false for a local-only album. */
|
||||
suspend fun reconcile(deviceAlbumId: String): Boolean {
|
||||
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false
|
||||
suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock {
|
||||
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false
|
||||
val mode = SyncMode.from(link.mode)
|
||||
if (!SyncPolicy.contactsServer(mode)) return false
|
||||
if (!SyncPolicy.contactsServer(mode)) return@withLock false
|
||||
// A mode can be chosen before a server album; nothing to sync until then.
|
||||
if (link.serverAlbumId.isBlank()) return false
|
||||
if (link.serverAlbumId.isBlank()) return@withLock false
|
||||
|
||||
val serverChecksums = remote.serverChecksums(link.serverAlbumId)
|
||||
val livePhotoIds = serverChecksums.values.toSet()
|
||||
@@ -41,20 +46,40 @@ class SyncCoordinator(
|
||||
// Mode C: assets already on the server (e.g. after switching from copies)
|
||||
// are replaced with their offloaded versions, right away.
|
||||
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets)
|
||||
return true
|
||||
return@withLock true
|
||||
}
|
||||
|
||||
/**
|
||||
* Offloads assets that are already uploaded but still have their original
|
||||
* locally. When the OS requires consent, the asset is marked as awaiting
|
||||
* freeing so the album screen can request it.
|
||||
* Reconciles every given device album, so a startup/background pass covers all
|
||||
* galleries (including partially synced ones). @return true when any album
|
||||
* contacted the server.
|
||||
*/
|
||||
suspend fun reconcileAll(deviceAlbumIds: Collection<String>): Boolean {
|
||||
var any = false
|
||||
for (id in deviceAlbumIds) {
|
||||
any = reconcile(id) || any
|
||||
}
|
||||
return any
|
||||
}
|
||||
|
||||
/**
|
||||
* Offloads assets that are already on the server but still have their original
|
||||
* locally (mode C). Retries assets left as "awaiting free" from a previous pass,
|
||||
* so freeing completes automatically on a later run without any prompt.
|
||||
*/
|
||||
private suspend fun offloadUploaded(deviceAlbumId: String, localAssets: List<LocalAsset>) {
|
||||
val localKeys = localAssets.mapTo(HashSet()) { it.assetKey }
|
||||
for (entity in dao.getAssets(deviceAlbumId)) {
|
||||
if (entity.serverPhotoId == null) continue
|
||||
if (entity.assetKey !in localKeys) continue
|
||||
if (entity.state != LocalAssetState.UPLOADED.name) continue
|
||||
// Anything linked to a server photo that still has its original on the
|
||||
// device (uploaded, awaiting free, or otherwise) is offloaded; only the
|
||||
// already-offloaded preview/server-only states are skipped.
|
||||
if (entity.state == LocalAssetState.PREVIEW.name ||
|
||||
entity.state == LocalAssetState.SERVER_ONLY.name
|
||||
) {
|
||||
continue
|
||||
}
|
||||
val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue
|
||||
when (result) {
|
||||
is OffloadResult.Done, OffloadResult.Failed -> Unit
|
||||
@@ -82,7 +107,11 @@ class SyncCoordinator(
|
||||
record(deviceAlbumId, action.asset, action.photoId, mode)
|
||||
|
||||
is SyncAction.Upload ->
|
||||
uploadQueue.enqueue(deviceAlbumId, serverAlbumId, action.asset, mode)
|
||||
// Skip if an upload for this asset is already queued/running, so
|
||||
// overlapping passes do not duplicate it.
|
||||
if (!uploadQueue.isPending(action.asset.assetKey)) {
|
||||
uploadQueue.enqueue(deviceAlbumId, serverAlbumId, action.asset, mode)
|
||||
}
|
||||
|
||||
is SyncAction.RenameOnServer -> {
|
||||
remote.renamePhoto(action.photoId, action.newName)
|
||||
|
||||
@@ -49,12 +49,17 @@ class SyncRecovery(
|
||||
),
|
||||
)
|
||||
marker.serverAlbumId?.let { serverAlbumId ->
|
||||
val existing = dao.getSyncedAlbum(deviceAlbumId)
|
||||
val markerMode = marker.mode?.let { raw -> SyncMode.parseOrNull(raw)?.name }
|
||||
dao.upsertSyncedAlbum(
|
||||
SyncedAlbumEntity(
|
||||
deviceAlbumId = deviceAlbumId,
|
||||
deviceAlbumName = media.albumName,
|
||||
serverAlbumId = serverAlbumId,
|
||||
mode = marker.mode ?: SyncMode.MOVE.name,
|
||||
deviceAlbumName = existing?.deviceAlbumName?.ifBlank { null } ?: media.albumName,
|
||||
serverAlbumId = existing?.serverAlbumId?.takeIf { it.isNotBlank() } ?: serverAlbumId,
|
||||
// Keep an existing link's mode; only fall back to the marker
|
||||
// when creating a link from scratch. The marker must carry a
|
||||
// real sync mode, never an asset state.
|
||||
mode = existing?.mode ?: markerMode ?: SyncMode.MOVE.name,
|
||||
updatedAt = System.currentTimeMillis(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -11,6 +11,9 @@ interface SyncUploadQueue {
|
||||
asset: LocalAsset,
|
||||
mode: SyncMode,
|
||||
)
|
||||
|
||||
/** True when this asset already has a queued/running upload. */
|
||||
suspend fun isPending(assetKey: String): Boolean
|
||||
}
|
||||
|
||||
/** [SyncUploadQueue] backed by the existing [UploadRepository]/WorkManager queue. */
|
||||
@@ -37,4 +40,6 @@ class RepositorySyncUploadQueue(
|
||||
wifiOnly = wifiOnly(),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun isPending(assetKey: String): Boolean = uploads.isPending(assetKey)
|
||||
}
|
||||
|
||||
@@ -26,14 +26,27 @@ class SyncWorker(
|
||||
override suspend fun doWork(): Result {
|
||||
val app = applicationContext as? GaloApplication ?: return Result.failure()
|
||||
val container = app.container
|
||||
val links = runCatching { container.syncRepository.observeLinks().first() }
|
||||
val sync = container.syncRepository
|
||||
val links = runCatching { sync.observeLinks().first() }
|
||||
.getOrElse { return Result.retry() }
|
||||
|
||||
val linkedIds = links.map { it.deviceAlbumId }
|
||||
val indexedIds = runCatching { sync.indexedAlbumIds() }.getOrDefault(emptyList())
|
||||
|
||||
// Check and fix every gallery we know about (linked now or with a local
|
||||
// index). recoverIndex rebuilds provenance from markers, covering a lost
|
||||
// index and partial syncs.
|
||||
val toCheck = (linkedIds + indexedIds).toSet()
|
||||
var failed = false
|
||||
for (link in links) {
|
||||
runCatching { container.syncCoordinator.reconcile(link.deviceAlbumId) }
|
||||
for (id in toCheck) {
|
||||
runCatching { container.syncCheck.checkGallery(id, recoverIndex = true) }
|
||||
.onFailure { failed = true }
|
||||
}
|
||||
|
||||
// Resume unfinished uploads so partially transferred galleries continue.
|
||||
runCatching { container.uploadRepository.requeuePending() }
|
||||
.onFailure { failed = true }
|
||||
|
||||
return if (failed) Result.retry() else Result.success()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,10 @@ class UploadRepository(
|
||||
|
||||
suspend fun getById(id: String): UploadJobEntity? = dao.getById(id)
|
||||
|
||||
/** True when a sync upload for this asset is already queued or running. */
|
||||
suspend fun isPending(assetKey: String): Boolean =
|
||||
dao.getPending().any { it.assetKey == assetKey }
|
||||
|
||||
suspend fun updateState(
|
||||
id: String,
|
||||
status: UploadStatus,
|
||||
|
||||
@@ -27,6 +27,7 @@ import com.galo.android.data.sync.DeviceMediaObserver
|
||||
import com.galo.android.data.sync.OffloadManager
|
||||
import com.galo.android.data.sync.RepositorySyncUploadQueue
|
||||
import com.galo.android.data.sync.RestoreManager
|
||||
import com.galo.android.data.sync.SyncCheck
|
||||
import com.galo.android.data.sync.SyncCoordinator
|
||||
import com.galo.android.data.sync.SyncRecovery
|
||||
import com.galo.android.data.sync.SyncRemote
|
||||
@@ -107,17 +108,24 @@ class AppContainer(context: Context) {
|
||||
|
||||
val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource)
|
||||
|
||||
/** Unified per-gallery check and fix (startup + album open). */
|
||||
val syncCheck = SyncCheck(syncRepository, syncRecovery, restoreManager, syncCoordinator)
|
||||
|
||||
fun start() {
|
||||
scope.launch {
|
||||
settingsRepository.flow.collect { settings ->
|
||||
val first = settingsState == null
|
||||
settingsState = settings
|
||||
runCatching { SyncScheduler.ensurePeriodic(appContext, settings.wifiOnlyUploads) }
|
||||
// Run the full synchronization check once the base URL is known, so
|
||||
// the startup pass can actually reach the server (all galleries,
|
||||
// including partially synced ones).
|
||||
if (first) {
|
||||
runCatching { uploadRepository.requeuePending() }
|
||||
runCatching { SyncScheduler.runNow(appContext) }
|
||||
}
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
runCatching { uploadRepository.requeuePending() }
|
||||
}
|
||||
runCatching { SyncScheduler.runNow(appContext) }
|
||||
runCatching {
|
||||
DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.galo.android.domain.attributes
|
||||
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.DeviceAlbum
|
||||
|
||||
/**
|
||||
* Resolved attributes shown by the album "Атрибуты и действия" panel.
|
||||
*
|
||||
* A server album is backed by the server; a device album is local (its metadata
|
||||
* and actions are local), optionally linked to a server album for the
|
||||
* members/share navigation.
|
||||
*/
|
||||
data class AlbumAttributes(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val coverUrl: String?,
|
||||
val photosCount: Int,
|
||||
val membersCount: Int,
|
||||
/** Server album id when linked, else null. */
|
||||
val serverAlbumId: String?,
|
||||
/** Caller's role on the linked/server album (owner/admin/member/viewer), or null. */
|
||||
val role: String?,
|
||||
/** True for device albums. */
|
||||
val isLocal: Boolean,
|
||||
/** Navigation rows: enabled only when a server album backs them. */
|
||||
val membersEnabled: Boolean,
|
||||
val shareEnabled: Boolean,
|
||||
val settingsEnabled: Boolean,
|
||||
val canRename: Boolean,
|
||||
val canDelete: Boolean,
|
||||
) {
|
||||
val canEdit: Boolean get() = role == "owner" || role == "admin"
|
||||
}
|
||||
|
||||
/** Where the album panel's attributes come from. */
|
||||
sealed interface AlbumAttributeSource {
|
||||
/** A server album, opened directly. */
|
||||
data class Server(val album: Album, val role: String) : AlbumAttributeSource
|
||||
|
||||
/**
|
||||
* A device album. [linkedServer] is the server album it syncs with, when any;
|
||||
* [coverUrl] is the resolved cover (chosen local cover or first item).
|
||||
*/
|
||||
data class Local(
|
||||
val device: DeviceAlbum,
|
||||
val title: String,
|
||||
val coverUrl: String?,
|
||||
val linkedServer: Album?,
|
||||
val role: String?,
|
||||
) : AlbumAttributeSource
|
||||
}
|
||||
|
||||
object AlbumAttributesResolver {
|
||||
|
||||
fun resolve(source: AlbumAttributeSource): AlbumAttributes = when (source) {
|
||||
is AlbumAttributeSource.Server -> fromServer(source.album, source.role)
|
||||
|
||||
is AlbumAttributeSource.Local -> {
|
||||
val linked = source.linkedServer
|
||||
val canEditLinked = source.role == "owner" || source.role == "admin"
|
||||
AlbumAttributes(
|
||||
title = source.title,
|
||||
description = "",
|
||||
coverUrl = source.coverUrl,
|
||||
photosCount = source.device.itemCount,
|
||||
membersCount = linked?.membersCount ?: 0,
|
||||
serverAlbumId = linked?.id,
|
||||
role = source.role,
|
||||
isLocal = true,
|
||||
membersEnabled = linked != null,
|
||||
shareEnabled = linked != null && canEditLinked,
|
||||
settingsEnabled = true,
|
||||
canRename = true,
|
||||
canDelete = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fromServer(album: Album, role: String): AlbumAttributes {
|
||||
val canEdit = role == "owner" || role == "admin"
|
||||
return AlbumAttributes(
|
||||
title = album.title,
|
||||
description = album.description,
|
||||
coverUrl = album.coverMediumUrl ?: album.coverThumbUrl,
|
||||
photosCount = album.photosCount,
|
||||
membersCount = album.membersCount,
|
||||
serverAlbumId = album.id,
|
||||
role = role,
|
||||
isLocal = false,
|
||||
membersEnabled = true,
|
||||
shareEnabled = canEdit,
|
||||
settingsEnabled = canEdit,
|
||||
canRename = canEdit,
|
||||
canDelete = canEdit,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.galo.android.domain.attributes
|
||||
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
|
||||
/**
|
||||
* Where the photo "Атрибуты и действия" sheet gets its data.
|
||||
*
|
||||
* A device media item linked to a server photo is server-backed (rich attributes
|
||||
* and actions); otherwise it is local (device properties and local actions).
|
||||
*/
|
||||
sealed interface PhotoAttributeSource {
|
||||
/** Server photo id backing the sheet. */
|
||||
data class Server(val photoId: String) : PhotoAttributeSource
|
||||
|
||||
/** A device media item with no matching server photo. */
|
||||
data class Local(val media: DeviceMedia) : PhotoAttributeSource
|
||||
}
|
||||
|
||||
object PhotoAttributeResolver {
|
||||
|
||||
/** [serverPhotoId] is the photo this device media maps to, when known. */
|
||||
fun resolve(media: DeviceMedia, serverPhotoId: String?): PhotoAttributeSource =
|
||||
if (serverPhotoId.isNullOrBlank()) {
|
||||
PhotoAttributeSource.Local(media)
|
||||
} else {
|
||||
PhotoAttributeSource.Server(serverPhotoId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.galo.android.domain.exif
|
||||
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
/** EXIF attribute labels, mirroring the web catalog (`frontend/src/lib/exif.ts`). */
|
||||
val EXIF_LABELS: Map<String, String> = mapOf(
|
||||
"Make" to "Производитель",
|
||||
"Model" to "Модель",
|
||||
"LensMake" to "Производитель объектива",
|
||||
"LensModel" to "Объектив",
|
||||
"ExposureTime" to "Выдержка",
|
||||
"ShutterSpeedValue" to "Выдержка (APEX)",
|
||||
"FNumber" to "Диафрагма",
|
||||
"ApertureValue" to "Диафрагма (APEX)",
|
||||
"MaxApertureValue" to "Макс. диафрагма",
|
||||
"FocalLength" to "Фокусное расстояние",
|
||||
"FocalLengthIn35mmFilm" to "Фокусное (35 мм)",
|
||||
"ISOSpeedRatings" to "ISO",
|
||||
"PhotographicSensitivity" to "ISO",
|
||||
"ExposureProgram" to "Программа съёмки",
|
||||
"ExposureMode" to "Режим экспозиции",
|
||||
"ExposureBiasValue" to "Экспокоррекция",
|
||||
"BrightnessValue" to "Яркость (APEX)",
|
||||
"MeteringMode" to "Режим замера",
|
||||
"LightSource" to "Источник света",
|
||||
"Flash" to "Вспышка",
|
||||
"WhiteBalance" to "Баланс белого",
|
||||
"Orientation" to "Ориентация",
|
||||
"Software" to "ПО",
|
||||
"ColorSpace" to "Цветовое пространство",
|
||||
"SceneCaptureType" to "Тип сцены",
|
||||
"Contrast" to "Контраст",
|
||||
"Saturation" to "Насыщенность",
|
||||
"Sharpness" to "Резкость",
|
||||
"CustomRendered" to "Обработка",
|
||||
"GainControl" to "Усиление",
|
||||
"SensingMethod" to "Метод съёмки",
|
||||
"SubjectDistanceRange" to "Дистанция",
|
||||
"DigitalZoomRatio" to "Цифровой зум",
|
||||
"CompressedBitsPerPixel" to "Сжатие (бит/пиксель)",
|
||||
"ImageDescription" to "Описание",
|
||||
)
|
||||
|
||||
/** Attributes excluded from the derived list (already shown elsewhere or noise). */
|
||||
val EXIF_SKIP: Set<String> = setOf(
|
||||
"GPSLatitude", "GPSLongitude", "GPSLatitudeRef", "GPSLongitudeRef",
|
||||
"GPSAltitude", "GPSAltitudeRef", "GPSVersionID", "GPSDateStamp",
|
||||
"GPSTimeStamp", "GPSProcessingMethod", "GPSAreaInformation", "GPSImgDirection",
|
||||
"GPSImgDirectionRef", "GPSDestBearing", "GPSDestBearingRef", "GPSSpeed",
|
||||
"GPSSpeedRef", "GPSTrack", "GPSTrackRef", "GPSMapDatum", "GPSDifferential",
|
||||
"DateTime", "DateTimeDigitized", "DateTimeOriginal", "SubSecTime",
|
||||
"SubSecTimeDigitized", "SubSecTimeOriginal", "ExifVersion", "FlashpixVersion",
|
||||
"InteroperabilityVersion", "InteroperabilityIndex", "ExifTag", "GPSTag",
|
||||
"InteroperabilityTag", "ExifIFDPointer", "GPSInfoIFDPointer",
|
||||
"ComponentsConfiguration", "MakerNote", "UserComment", "FileSource",
|
||||
"SceneType", "PhotometricInterpretation", "YCbCrPositioning", "ResolutionUnit",
|
||||
"BitsPerSample", "XResolution", "YResolution", "ImageWidth", "ImageLength",
|
||||
"PixelXDimension", "PixelYDimension",
|
||||
)
|
||||
|
||||
/** Value dictionaries for enum-like attributes, mirroring web `EXIF_ENUMS`. */
|
||||
val EXIF_ENUMS: Map<String, Map<Int, String>> = mapOf(
|
||||
"Orientation" to mapOf(
|
||||
1 to "Горизонтальная", 2 to "Зеркальная (гориз.)", 3 to "Повёрнута на 180°",
|
||||
4 to "Зеркальная (верт.)", 5 to "Зеркальная + 90° CW", 6 to "Повёрнута на 90° CW",
|
||||
7 to "Зеркальная + 90° CCW", 8 to "Повёрнута на 90° CCW",
|
||||
),
|
||||
"ExposureProgram" to mapOf(
|
||||
0 to "Не задано", 1 to "Ручная", 2 to "Программная", 3 to "Приоритет диафрагмы",
|
||||
4 to "Приоритет выдержки", 5 to "Творческая", 6 to "Спортивная", 7 to "Портрет",
|
||||
8 to "Пейзаж",
|
||||
),
|
||||
"ExposureMode" to mapOf(0 to "Авто", 1 to "Ручной", 2 to "Авто-брекетинг"),
|
||||
"MeteringMode" to mapOf(
|
||||
0 to "Неизвестно", 1 to "Средневзвешенный", 2 to "Центрально-взвешенный",
|
||||
3 to "Точечный", 4 to "Многозонный", 5 to "Матричный", 6 to "Частичный",
|
||||
255 to "Другое",
|
||||
),
|
||||
"LightSource" to mapOf(
|
||||
0 to "Неизвестно", 1 to "Дневной свет", 2 to "Люминесцентный",
|
||||
3 to "Лампа накаливания", 4 to "Вспышка", 9 to "Хорошая погода", 10 to "Облачно",
|
||||
11 to "Тень",
|
||||
),
|
||||
"Flash" to mapOf(
|
||||
0 to "Не сработала", 1 to "Сработала", 9 to "Сработала (принуд.)",
|
||||
16 to "Отключена", 24 to "Не сработала (авто)", 25 to "Сработала (авто)",
|
||||
),
|
||||
"WhiteBalance" to mapOf(0 to "Авто", 1 to "Ручной"),
|
||||
"SceneCaptureType" to mapOf(
|
||||
0 to "Стандартный", 1 to "Пейзаж", 2 to "Портрет", 3 to "Ночной",
|
||||
),
|
||||
"Contrast" to mapOf(0 to "Обычный", 1 to "Мягкий", 2 to "Жёсткий"),
|
||||
"Saturation" to mapOf(0 to "Обычная", 1 to "Низкая", 2 to "Высокая"),
|
||||
"Sharpness" to mapOf(0 to "Обычная", 1 to "Мягкая", 2 to "Жёсткая"),
|
||||
"CustomRendered" to mapOf(0 to "Обычная", 1 to "Особая"),
|
||||
"GainControl" to mapOf(
|
||||
0 to "Нет", 1 to "Малое усиление", 2 to "Большое усиление", 3 to "Низкое усиление",
|
||||
4 to "Высокое усиление",
|
||||
),
|
||||
"SensingMethod" to mapOf(
|
||||
1 to "Не задано", 2 to "Однокристальный", 3 to "Двухчиповый", 4 to "Трёхчиповый",
|
||||
5 to "Цветной последовательный", 7 to "Трёхлинейный", 8 to "Цветной последовательный",
|
||||
),
|
||||
"SubjectDistanceRange" to mapOf(0 to "Неизвестно", 1 to "Макро", 2 to "Близко", 3 to "Далеко"),
|
||||
"ColorSpace" to mapOf(1 to "sRGB", 65535 to "Некалиброванное"),
|
||||
)
|
||||
|
||||
/** Base attribute set shown before the user saves their own selection. */
|
||||
val EXIF_BASE: List<String> = listOf(
|
||||
"Make",
|
||||
"Model",
|
||||
"LensModel",
|
||||
"ExposureTime",
|
||||
"FNumber",
|
||||
"ISOSpeedRatings",
|
||||
"FocalLength",
|
||||
"FocalLengthIn35mmFilm",
|
||||
)
|
||||
|
||||
/** All known attribute keys, mirroring web `EXIF_CATALOG`. */
|
||||
val EXIF_CATALOG: List<String> = EXIF_LABELS.keys.toList()
|
||||
|
||||
data class ExifRow(val key: String, val label: String, val value: String)
|
||||
|
||||
private val ruCollator: Collator = Collator.getInstance(Locale("ru"))
|
||||
|
||||
/** Formats a raw EXIF value for display, mirroring web `formatExifValue`. */
|
||||
fun formatExifValue(key: String, value: String?): String {
|
||||
if (value == null) return ""
|
||||
val n = value.toDoubleOrNull()
|
||||
if (key == "ExposureTime" && n != null && n > 0) {
|
||||
return if (n >= 1) "${jsNumber(n)} с" else "1/${Math.round(1 / n)}"
|
||||
}
|
||||
if ((key == "FNumber" || key == "ApertureValue" || key == "MaxApertureValue") && n != null) {
|
||||
return "f/${jsNumber(n)}"
|
||||
}
|
||||
if ((key == "FocalLength" || key == "FocalLengthIn35mmFilm") && n != null) {
|
||||
return "${jsNumber(n)} мм"
|
||||
}
|
||||
if (key == "ExposureBiasValue" && n != null) {
|
||||
return "${if (n > 0) "+" else ""}${jsNumber(n)} EV"
|
||||
}
|
||||
val enums = EXIF_ENUMS[key]
|
||||
if (enums != null && n != null) {
|
||||
val label = enums[n.toInt()]
|
||||
if (label != null) return label
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** Renders a number like JavaScript's `${n}`: integral values lose the `.0`. */
|
||||
private fun jsNumber(n: Double): String =
|
||||
if (n.isFinite() && n == Math.floor(n)) n.toLong().toString() else n.toString()
|
||||
|
||||
/** Builds the visible attribute rows for a photo, mirroring web `buildExifRows`. */
|
||||
fun buildExifRows(
|
||||
exif: Map<String, String>,
|
||||
labels: Map<String, String> = emptyMap(),
|
||||
): List<ExifRow> =
|
||||
exif.entries
|
||||
.filter { (k, v) -> k !in EXIF_SKIP && v.isNotBlank() }
|
||||
.map { (k, v) -> ExifRow(k, labels[k] ?: EXIF_LABELS[k] ?: k, formatExifValue(k, v)) }
|
||||
.sortedWith { a, b -> ruCollator.compare(a.label, b.label) }
|
||||
@@ -1,6 +1,6 @@
|
||||
package com.galo.android.domain.model
|
||||
|
||||
/** An album of the device (a MediaStore bucket). */
|
||||
/** A device album of the device (a MediaStore bucket). */
|
||||
data class DeviceAlbum(
|
||||
val id: String,
|
||||
val name: String,
|
||||
@@ -9,6 +9,13 @@ data class DeviceAlbum(
|
||||
val relativePath: String,
|
||||
)
|
||||
|
||||
/** App-only metadata for a device album: display name override and chosen cover. */
|
||||
data class DeviceAlbumMeta(
|
||||
val deviceAlbumId: String,
|
||||
val displayName: String?,
|
||||
val coverUri: String?,
|
||||
)
|
||||
|
||||
/** A single media item of the device found in a [DeviceAlbum]. */
|
||||
data class DeviceMedia(
|
||||
val id: Long,
|
||||
|
||||
@@ -63,6 +63,8 @@ data class Photo(
|
||||
val size: Long,
|
||||
val takenAt: String?,
|
||||
val caption: String,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val isPanorama: Boolean,
|
||||
val thumbUrl: String?,
|
||||
val mediumUrl: String?,
|
||||
@@ -71,9 +73,19 @@ data class Photo(
|
||||
val originalUrl: String?,
|
||||
val downloadUrl: String?,
|
||||
val createdAt: String?,
|
||||
val tags: List<Tag> = emptyList(),
|
||||
val exif: Map<String, String> = emptyMap(),
|
||||
val uploaderName: String? = null,
|
||||
val uploaderAvatarUrl: String? = null,
|
||||
)
|
||||
|
||||
data class Page<T>(
|
||||
val items: List<T>,
|
||||
val nextCursor: String?,
|
||||
)
|
||||
|
||||
/** A user-scoped label attached to a photo. */
|
||||
data class Tag(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,10 @@ enum class SyncMode {
|
||||
companion object {
|
||||
fun from(raw: String?): SyncMode =
|
||||
entries.firstOrNull { it.name == raw } ?: DISABLED
|
||||
|
||||
/** Parses a stored mode, returning null for anything that is not a mode. */
|
||||
fun parseOrNull(raw: String?): SyncMode? =
|
||||
entries.firstOrNull { it.name == raw }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.galo.android.domain.sync
|
||||
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
|
||||
/**
|
||||
* What the synchronization check should do with one device asset, given its mode
|
||||
* and recorded state. Makes the whole matrix explicit so no combination (for
|
||||
* example copy mode holding offloaded previews) is left unhandled.
|
||||
*/
|
||||
object GallerySyncPolicy {
|
||||
|
||||
/** The local fix to apply to an asset (upload/link is done by the reconciler). */
|
||||
enum class LocalFix { UPLOAD, OFFLOAD, RESTORE, NONE }
|
||||
|
||||
fun decide(mode: SyncMode, state: LocalAssetState, hasServerPhoto: Boolean): LocalFix = when (state) {
|
||||
// Known on the server but never on this device: nothing local to fix.
|
||||
LocalAssetState.SERVER_ONLY -> LocalFix.NONE
|
||||
|
||||
// Only a preview locally: restore in copy/off, keep as-is in move.
|
||||
LocalAssetState.PREVIEW ->
|
||||
if (mode == SyncMode.MOVE) LocalFix.NONE else LocalFix.RESTORE
|
||||
|
||||
// Original still local, already on the server: offload in move, keep otherwise.
|
||||
LocalAssetState.UPLOADED -> offloadIfMove(mode, hasServerPhoto)
|
||||
|
||||
// Uploaded and waiting to free: same as uploaded for fixing purposes.
|
||||
LocalAssetState.AWAITING_FREE -> offloadIfMove(mode, hasServerPhoto)
|
||||
|
||||
// Original local, not yet on the server.
|
||||
LocalAssetState.LOCAL -> when {
|
||||
hasServerPhoto && mode == SyncMode.MOVE -> LocalFix.OFFLOAD
|
||||
hasServerPhoto -> LocalFix.NONE
|
||||
mode == SyncMode.DISABLED -> LocalFix.NONE
|
||||
else -> LocalFix.UPLOAD
|
||||
}
|
||||
}
|
||||
|
||||
private fun offloadIfMove(mode: SyncMode, hasServerPhoto: Boolean): LocalFix =
|
||||
if (mode == SyncMode.MOVE && hasServerPhoto) LocalFix.OFFLOAD else LocalFix.NONE
|
||||
}
|
||||
@@ -2,11 +2,22 @@ package com.galo.android.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
@@ -15,10 +26,13 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.data.prefs.SettingsState
|
||||
import com.galo.android.domain.media.AllFilesAccess
|
||||
import com.galo.android.ui.album.AlbumScreen
|
||||
import com.galo.android.ui.album.AlbumSettingsScreen
|
||||
import com.galo.android.ui.albums.AlbumsScreen
|
||||
import com.galo.android.ui.auth.AuthScreen
|
||||
import com.galo.android.ui.devicealbum.DeviceAlbumScreen
|
||||
import com.galo.android.ui.photo.AttributeSettingsScreen
|
||||
import com.galo.android.ui.profile.ProfileScreen
|
||||
import com.galo.android.ui.setup.SetupScreen
|
||||
import com.galo.android.ui.upload.UploadScreen
|
||||
@@ -31,11 +45,15 @@ object Routes {
|
||||
const val UPLOADS = "uploads"
|
||||
const val PROFILE = "profile"
|
||||
const val ALBUM = "album/{albumId}"
|
||||
const val ALBUM_SETTINGS = "album/{albumId}/settings"
|
||||
const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}"
|
||||
const val VIEWER = "album/{albumId}/viewer?index={index}"
|
||||
const val ATTRIBUTE_SETTINGS = "attributeSettings"
|
||||
|
||||
fun album(albumId: String) = "album/$albumId"
|
||||
|
||||
fun albumSettings(albumId: String) = "album/$albumId/settings"
|
||||
|
||||
fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId"
|
||||
|
||||
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
|
||||
@@ -57,6 +75,22 @@ fun GaloNavHost(app: GaloApplication) {
|
||||
}
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
// On launch (once the app is operational) ask for the "all files" access that
|
||||
// makes moving originals to the server work without per-item prompts.
|
||||
val context = LocalContext.current
|
||||
var allFilesPrompt by remember { mutableStateOf(false) }
|
||||
val allFilesLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult(),
|
||||
) {
|
||||
allFilesPrompt = !AllFilesAccess.isGranted()
|
||||
}
|
||||
val operational = current.baseUrl.isNotBlank() &&
|
||||
!container.tokenStore.accessToken().isNullOrBlank()
|
||||
LaunchedEffect(operational) {
|
||||
if (operational && !AllFilesAccess.isGranted()) allFilesPrompt = true
|
||||
}
|
||||
|
||||
val start = when {
|
||||
current.baseUrl.isBlank() -> Routes.SETUP
|
||||
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
|
||||
@@ -114,6 +148,12 @@ fun GaloNavHost(app: GaloApplication) {
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(Routes.ATTRIBUTE_SETTINGS) {
|
||||
AttributeSettingsScreen(
|
||||
app = app,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ALBUM,
|
||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||
@@ -125,6 +165,18 @@ fun GaloNavHost(app: GaloApplication) {
|
||||
onOpenViewer = { index ->
|
||||
navController.navigate(Routes.viewer(albumId, index))
|
||||
},
|
||||
onOpenSettings = { navController.navigate(Routes.albumSettings(albumId)) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Routes.ALBUM_SETTINGS,
|
||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||
) { entry ->
|
||||
val albumId = entry.arguments?.getString("albumId").orEmpty()
|
||||
AlbumSettingsScreen(
|
||||
app = app,
|
||||
albumId = albumId,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -136,6 +188,7 @@ fun GaloNavHost(app: GaloApplication) {
|
||||
DeviceAlbumScreen(
|
||||
app = app,
|
||||
deviceAlbumId = deviceAlbumId,
|
||||
onOpenServerSettings = { id -> navController.navigate(Routes.albumSettings(id)) },
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
@@ -156,7 +209,43 @@ fun GaloNavHost(app: GaloApplication) {
|
||||
albumId = albumId,
|
||||
initialIndex = index,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenAttributeSettings = { navController.navigate(Routes.ATTRIBUTE_SETTINGS) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (allFilesPrompt) {
|
||||
AllFilesAccessDialog(
|
||||
onEnable = {
|
||||
runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) }
|
||||
},
|
||||
onDismiss = { allFilesPrompt = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const val TAG_ALL_FILES_PROMPT_DIALOG = "all_files_prompt_dialog"
|
||||
const val TAG_ALL_FILES_PROMPT_ENABLE = "all_files_prompt_enable"
|
||||
|
||||
/** Startup prompt to grant the "all files" access needed to free space on move. */
|
||||
@Composable
|
||||
fun AllFilesAccessDialog(onEnable: () -> Unit, onDismiss: () -> Unit) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_ALL_FILES_PROMPT_DIALOG),
|
||||
title = { Text("Доступ к файлам") },
|
||||
text = {
|
||||
Text(
|
||||
"Чтобы переносить фото на сервер и освобождать место на телефоне, " +
|
||||
"разрешите galo доступ «Все файлы».",
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onEnable,
|
||||
modifier = Modifier.testTag(TAG_ALL_FILES_PROMPT_ENABLE),
|
||||
) { Text("Включить") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Позже") } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,28 +8,36 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -40,13 +48,24 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.domain.attributes.AlbumAttributeSource
|
||||
import com.galo.android.domain.attributes.AlbumAttributesResolver
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.ui.common.GaloAsyncImage
|
||||
import com.galo.android.ui.panel.AlbumAttributesPanel
|
||||
import com.galo.android.ui.panel.EndDrawerScaffold
|
||||
|
||||
const val TAG_ALBUM_GRID = "album_grid"
|
||||
const val TAG_ALBUM_OFFLINE = "album_offline"
|
||||
const val TAG_PHOTO_PREFIX = "photo_"
|
||||
const val TAG_ALBUM_RENAME_DIALOG = "album_rename_dialog"
|
||||
const val TAG_ALBUM_RENAME_FIELD = "album_rename_field"
|
||||
const val TAG_ALBUM_RENAME_CONFIRM = "album_rename_confirm"
|
||||
const val TAG_ALBUM_DELETE_DIALOG = "album_delete_dialog"
|
||||
const val TAG_ALBUM_DELETE_CONFIRM = "album_delete_confirm"
|
||||
const val TAG_COVER_PICKER = "cover_picker"
|
||||
const val TAG_COVER_OPTION_PREFIX = "cover_option_"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -54,6 +73,7 @@ fun AlbumScreen(
|
||||
app: GaloApplication,
|
||||
albumId: String,
|
||||
onOpenViewer: (Int) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val factory = viewModelFactory {
|
||||
@@ -62,6 +82,15 @@ fun AlbumScreen(
|
||||
val viewModel: AlbumViewModel = viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
var panelOpen by remember { mutableStateOf(false) }
|
||||
var renameOpen by remember { mutableStateOf(false) }
|
||||
var deleteOpen by remember { mutableStateOf(false) }
|
||||
var coverPickOpen by remember { mutableStateOf(false) }
|
||||
|
||||
val attributes = state.album?.let {
|
||||
AlbumAttributesResolver.resolve(AlbumAttributeSource.Server(it, state.role))
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -79,47 +108,67 @@ fun AlbumScreen(
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
if (state.offline) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
|
||||
) {
|
||||
Text(
|
||||
text = state.error ?: "Офлайн-режим",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
EndDrawerScaffold(
|
||||
open = panelOpen,
|
||||
onOpenChange = { panelOpen = it },
|
||||
modifier = Modifier.padding(padding),
|
||||
drawerContent = {
|
||||
if (attributes != null) {
|
||||
AlbumAttributesPanel(
|
||||
attributes = attributes,
|
||||
onPickCover = { coverPickOpen = true },
|
||||
onResetCover = viewModel::resetCover,
|
||||
onOpenMembers = onOpenSettings,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onOpenShare = onOpenSettings,
|
||||
onRename = { renameOpen = true },
|
||||
onDelete = { deleteOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
when {
|
||||
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("В альбоме пока нет медиа")
|
||||
}
|
||||
|
||||
else -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID),
|
||||
) {
|
||||
itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo ->
|
||||
PhotoCell(photo = photo, onClick = { onOpenViewer(index) })
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.offline) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.errorContainer,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
|
||||
) {
|
||||
Text(
|
||||
text = state.error ?: "Офлайн-режим",
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
if (!state.endReached) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
|
||||
}
|
||||
when {
|
||||
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
|
||||
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("В альбоме пока нет медиа")
|
||||
}
|
||||
|
||||
else -> LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
contentPadding = PaddingValues(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID),
|
||||
) {
|
||||
itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo ->
|
||||
PhotoCell(photo = photo, onClick = { onOpenViewer(index) })
|
||||
}
|
||||
if (!state.endReached) {
|
||||
item(span = { GridItemSpan(maxLineSpan) }) {
|
||||
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
|
||||
Box(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +176,129 @@ fun AlbumScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renameOpen) {
|
||||
RenameAlbumDialog(
|
||||
initial = state.album?.title.orEmpty(),
|
||||
onConfirm = {
|
||||
viewModel.rename(it)
|
||||
renameOpen = false
|
||||
},
|
||||
onDismiss = { renameOpen = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (deleteOpen) {
|
||||
DeleteAlbumDialog(
|
||||
title = state.album?.title.orEmpty(),
|
||||
onConfirm = {
|
||||
deleteOpen = false
|
||||
viewModel.delete(onDeleted = onBack)
|
||||
},
|
||||
onDismiss = { deleteOpen = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (coverPickOpen) {
|
||||
CoverPickerDialog(
|
||||
photos = state.photos,
|
||||
onPick = {
|
||||
viewModel.setCover(it)
|
||||
coverPickOpen = false
|
||||
},
|
||||
onDismiss = { coverPickOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenameAlbumDialog(
|
||||
initial: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_ALBUM_RENAME_DIALOG),
|
||||
title = { Text("Переименовать альбом") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
label = { Text("Название") },
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_RENAME_FIELD),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.testTag(TAG_ALBUM_RENAME_CONFIRM),
|
||||
) { Text("Сохранить") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeleteAlbumDialog(
|
||||
title: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_ALBUM_DELETE_DIALOG),
|
||||
title = { Text("Удалить альбом?") },
|
||||
text = { Text("«$title» и его фото будут перемещены в корзину.") },
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.testTag(TAG_ALBUM_DELETE_CONFIRM),
|
||||
) { Text("Удалить") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CoverPickerDialog(
|
||||
photos: List<Photo>,
|
||||
onPick: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_COVER_PICKER),
|
||||
title = { Text("Выбрать обложку") },
|
||||
text = {
|
||||
if (photos.isEmpty()) {
|
||||
Text("В альбоме нет фото")
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.heightIn(max = 360.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items(photos, key = { it.id }) { photo ->
|
||||
GaloAsyncImage(
|
||||
url = photo.thumbUrl ?: photo.mediumUrl ?: photo.previewUrl,
|
||||
contentDescription = photo.originalFilename,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.testTag(TAG_COVER_OPTION_PREFIX + photo.id)
|
||||
.clickable { onPick(photo.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.galo.android.ui.album
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AddLink
|
||||
import androidx.compose.material.icons.filled.LinkOff
|
||||
import androidx.compose.material.icons.filled.Save
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.galo.android.GaloApplication
|
||||
|
||||
const val TAG_SETTINGS_TITLE_FIELD = "settings_title_field"
|
||||
const val TAG_SETTINGS_DESC_FIELD = "settings_desc_field"
|
||||
const val TAG_SETTINGS_SAVE = "settings_save"
|
||||
const val TAG_SETTINGS_SAVED = "settings_saved"
|
||||
const val TAG_SETTINGS_MEMBER_PREFIX = "settings_member_"
|
||||
const val TAG_SETTINGS_LINK_CREATE = "settings_link_create"
|
||||
const val TAG_SETTINGS_LINK_PREFIX = "settings_link_"
|
||||
const val TAG_SETTINGS_LINK_REVOKE_PREFIX = "settings_link_revoke_"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlbumSettingsScreen(
|
||||
app: GaloApplication,
|
||||
albumId: String,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val factory = viewModelFactory {
|
||||
initializer { AlbumSettingsViewModel(app.container, albumId) }
|
||||
}
|
||||
val viewModel: AlbumSettingsViewModel = viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
AlbumSettingsContent(
|
||||
state = state,
|
||||
onSave = viewModel::save,
|
||||
onCreateLink = viewModel::createLink,
|
||||
onRevokeLink = viewModel::revokeLink,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AlbumSettingsContent(
|
||||
state: AlbumSettingsViewModel.UiState,
|
||||
onSave: (String, String) -> Unit,
|
||||
onCreateLink: (String) -> Unit,
|
||||
onRevokeLink: (String) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
var title by remember(state.title) { mutableStateOf(state.title) }
|
||||
var description by remember(state.description) { mutableStateOf(state.description) }
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Настройки") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Альбом", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = title,
|
||||
onValueChange = { title = it },
|
||||
label = { Text("Название") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_SETTINGS_TITLE_FIELD),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = description,
|
||||
onValueChange = { description = it },
|
||||
label = { Text("Описание") },
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_SETTINGS_DESC_FIELD),
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(
|
||||
onClick = { onSave(title, description) },
|
||||
modifier = Modifier.testTag(TAG_SETTINGS_SAVE),
|
||||
) {
|
||||
Icon(Icons.Filled.Save, contentDescription = null)
|
||||
Text(" Сохранить")
|
||||
}
|
||||
if (state.saved) {
|
||||
Text(
|
||||
"Сохранено",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.testTag(TAG_SETTINGS_SAVED),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Участники", style = MaterialTheme.typography.titleMedium)
|
||||
state.members.forEach { member ->
|
||||
Text(
|
||||
text = "${member.user.name} · ${member.role}",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TAG_SETTINGS_MEMBER_PREFIX + member.userId),
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider()
|
||||
Text("Ссылки для просмотра", style = MaterialTheme.typography.titleMedium)
|
||||
TextButton(
|
||||
onClick = { onCreateLink("168h") },
|
||||
modifier = Modifier.testTag(TAG_SETTINGS_LINK_CREATE),
|
||||
) {
|
||||
Icon(Icons.Filled.AddLink, contentDescription = null)
|
||||
Text(" Создать ссылку")
|
||||
}
|
||||
state.shareLinks.forEach { link ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_SETTINGS_LINK_PREFIX + link.id),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "/share/${link.token}",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { onRevokeLink(link.id) },
|
||||
modifier = Modifier.testTag(TAG_SETTINGS_LINK_REVOKE_PREFIX + link.id),
|
||||
) {
|
||||
Icon(Icons.Filled.LinkOff, contentDescription = null)
|
||||
Text(" Отозвать")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.galo.android.ui.album
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.data.network.dto.MemberDto
|
||||
import com.galo.android.data.network.dto.ShareLinkDto
|
||||
import com.galo.android.di.AppContainer
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AlbumSettingsViewModel(
|
||||
private val container: AppContainer,
|
||||
private val albumId: String,
|
||||
) : ViewModel() {
|
||||
|
||||
data class UiState(
|
||||
val title: String = "",
|
||||
val description: String = "",
|
||||
val members: List<MemberDto> = emptyList(),
|
||||
val shareLinks: List<ShareLinkDto> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val saved: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
val album = container.api.album(albumId)
|
||||
val members = container.api.members(albumId)
|
||||
val links = container.api.shareLinks(albumId)
|
||||
Triple(album, members, links)
|
||||
}.onSuccess { (album, members, links) ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
title = album.title,
|
||||
description = album.description,
|
||||
members = members,
|
||||
shareLinks = links,
|
||||
loading = false,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
_state.update { it.copy(loading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun save(title: String, description: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
container.api.updateAlbum(albumId, title = title.trim(), description = description)
|
||||
}.onSuccess { album ->
|
||||
_state.update {
|
||||
it.copy(title = album.title, description = album.description, saved = true, error = null)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
_state.update { it.copy(error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createLink(expiresIn: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.createShareLink(albumId, expiresIn) }
|
||||
.onSuccess { link ->
|
||||
_state.update { it.copy(shareLinks = it.shareLinks + link, error = null) }
|
||||
}
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun revokeLink(id: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.revokeShareLink(albumId, id) }
|
||||
.onSuccess {
|
||||
_state.update { s -> s.copy(shareLinks = s.shareLinks.filterNot { it.id == id }) }
|
||||
}
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class AlbumViewModel(
|
||||
|
||||
data class UiState(
|
||||
val album: Album? = null,
|
||||
val role: String = "",
|
||||
val photos: List<Photo> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val loadingMore: Boolean = false,
|
||||
@@ -40,6 +41,7 @@ class AlbumViewModel(
|
||||
)
|
||||
|
||||
private val status = MutableStateFlow(Status())
|
||||
private val role = MutableStateFlow("")
|
||||
|
||||
/** Watermark for `?since=` backfill; advanced as events are applied. */
|
||||
private var lastSync: String = Instant.now().toString()
|
||||
@@ -48,9 +50,11 @@ class AlbumViewModel(
|
||||
container.albumRepository.observeAlbum(albumId),
|
||||
container.photoRepository.observePhotos(albumId),
|
||||
status,
|
||||
) { album, photos, s ->
|
||||
role,
|
||||
) { album, photos, s, r ->
|
||||
UiState(
|
||||
album = album,
|
||||
role = r,
|
||||
photos = photos,
|
||||
loading = !s.loaded,
|
||||
loadingMore = s.loadingMore,
|
||||
@@ -61,11 +65,63 @@ class AlbumViewModel(
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
|
||||
|
||||
init {
|
||||
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
|
||||
viewModelScope.launch { loadAlbum() }
|
||||
loadNextPage()
|
||||
observeEvents()
|
||||
}
|
||||
|
||||
private suspend fun loadAlbum() {
|
||||
container.albumRepository.loadAlbum(albumId)
|
||||
runCatching { container.api.albumEnvelope(albumId).role }
|
||||
.getOrNull()
|
||||
?.let { role.value = it }
|
||||
}
|
||||
|
||||
/** Renames the album (title). */
|
||||
fun rename(title: String) {
|
||||
val trimmed = title.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
container.api.updateAlbum(albumId, title = trimmed)
|
||||
container.albumRepository.loadAlbum(albumId)
|
||||
}.onFailure { e ->
|
||||
status.update { it.copy(error = (e as? Exception)?.message ?: "Ошибка переименования") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets a photo as the album cover. */
|
||||
fun setCover(photoId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
container.api.updateAlbum(albumId, coverPhotoId = photoId)
|
||||
container.albumRepository.loadAlbum(albumId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the album cover. */
|
||||
fun resetCover() {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
container.api.updateAlbum(albumId, clearCover = true)
|
||||
container.albumRepository.loadAlbum(albumId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes the album; [onDeleted] runs after the server confirms. */
|
||||
fun delete(onDeleted: () -> Unit) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.deleteAlbum(albumId) }
|
||||
.onSuccess { onDeleted() }
|
||||
.onFailure { e ->
|
||||
status.update { it.copy(error = (e as? Exception)?.message ?: "Ошибка удаления") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Live updates while the screen is alive; reconnect backfills missed changes. */
|
||||
private fun observeEvents() {
|
||||
viewModelScope.launch {
|
||||
@@ -120,7 +176,7 @@ class AlbumViewModel(
|
||||
|
||||
fun refresh() {
|
||||
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
|
||||
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
|
||||
viewModelScope.launch { loadAlbum() }
|
||||
loadNextPage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.SdCard
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -50,9 +53,9 @@ const val TAG_ALBUMS_OFFLINE = "albums_offline"
|
||||
const val TAG_ALBUMS_AVATAR = "albums_avatar"
|
||||
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
|
||||
const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
|
||||
const val TAG_ALBUM_SOURCE_PREFIX = "album_source_"
|
||||
const val TAG_ALBUM_LINK_PREFIX = "album_link_"
|
||||
const val TAG_ALBUM_MODE_PREFIX = "album_mode_"
|
||||
const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_"
|
||||
const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_"
|
||||
const val TAG_ALBUM_COUNT_PREFIX = "album_count_"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -166,68 +169,71 @@ fun AlbumsContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** Where a device album's media live. */
|
||||
private enum class StoragePlacement { DEVICE, DEVICE_AND_CLOUD, CLOUD }
|
||||
|
||||
@Composable
|
||||
private fun AlbumCardBody(
|
||||
id: String,
|
||||
title: String,
|
||||
counter: String,
|
||||
source: String,
|
||||
link: String?,
|
||||
key: String,
|
||||
mode: String? = null,
|
||||
placement: StoragePlacement,
|
||||
count: String,
|
||||
arrowTo: String?,
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = counter,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
shape = MaterialTheme.shapes.small,
|
||||
modifier = Modifier.testTag(TAG_ALBUM_SOURCE_PREFIX + key),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (arrowTo != null) {
|
||||
Text(
|
||||
text = source,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
if (mode != null) {
|
||||
Text(
|
||||
text = mode,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
text = "→ $arrowTo",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(start = 6.dp)
|
||||
.testTag(TAG_ALBUM_MODE_PREFIX + key),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (link != null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (placement != StoragePlacement.CLOUD) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.SdCard,
|
||||
contentDescription = "Карта памяти",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(16.dp).testTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + id),
|
||||
)
|
||||
}
|
||||
if (placement != StoragePlacement.DEVICE) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Cloud,
|
||||
contentDescription = "Облако",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(start = if (placement == StoragePlacement.CLOUD) 0.dp else 4.dp)
|
||||
.size(16.dp)
|
||||
.testTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + id),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = link,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
text = count,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.padding(top = 2.dp)
|
||||
.testTag(TAG_ALBUM_LINK_PREFIX + key),
|
||||
modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit) { val album = item.album
|
||||
private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit) {
|
||||
val album = item.album
|
||||
val key = TAG_ALBUM_CARD_PREFIX + album.id
|
||||
Card(
|
||||
modifier = Modifier
|
||||
@@ -245,12 +251,11 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
)
|
||||
AlbumCardBody(
|
||||
id = album.id,
|
||||
title = album.title,
|
||||
counter = "${album.photosCount} фото · ${album.membersCount} участн.",
|
||||
source = "Сервер",
|
||||
link = item.linkedDeviceNames.takeIf { it.isNotEmpty() }
|
||||
?.joinToString(prefix = "источник: "),
|
||||
key = key,
|
||||
placement = StoragePlacement.CLOUD,
|
||||
count = "${album.photosCount} фото",
|
||||
arrowTo = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -260,6 +265,7 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
|
||||
private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) {
|
||||
val album: DeviceAlbum = item.album
|
||||
val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id
|
||||
val arrowTo = item.linkedServerTitle?.takeIf { it != album.name }
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -276,19 +282,19 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () ->
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
)
|
||||
AlbumCardBody(
|
||||
id = album.id,
|
||||
title = album.name,
|
||||
counter = "${album.itemCount} эл.",
|
||||
source = "Телефон",
|
||||
link = item.linkedServerTitle?.let { "→ $it" },
|
||||
key = key,
|
||||
mode = albumModeLabel(item.mode),
|
||||
placement = placementOf(item.mode),
|
||||
count = "${album.itemCount} эл.",
|
||||
arrowTo = arrowTo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun albumModeLabel(mode: SyncMode): String = when (mode) {
|
||||
SyncMode.DISABLED -> "синхр. выкл."
|
||||
SyncMode.COPY -> "копии"
|
||||
SyncMode.MOVE -> "перенос"
|
||||
/** Storage icon placement for a device album: where its media live. */
|
||||
private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) {
|
||||
SyncMode.DISABLED -> StoragePlacement.DEVICE
|
||||
SyncMode.COPY -> StoragePlacement.DEVICE_AND_CLOUD
|
||||
SyncMode.MOVE -> StoragePlacement.CLOUD
|
||||
}
|
||||
|
||||
@@ -41,21 +41,31 @@ class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
val state: StateFlow<UiState> = combine(
|
||||
container.albumRepository.observeAlbums(),
|
||||
container.syncRepository.observeLinks(),
|
||||
combine(deviceAlbums, deviceAccess) { devices, access -> devices to access },
|
||||
combine(deviceAlbums, deviceAccess, container.syncRepository.observeAlbumMeta()) { devices, access, meta ->
|
||||
Triple(devices, access, meta)
|
||||
},
|
||||
status,
|
||||
avatar,
|
||||
) { albums, links, device, s, a ->
|
||||
val withMeta = device.first.map { d ->
|
||||
val m = device.third[d.id]
|
||||
d.copy(
|
||||
name = m?.displayName?.takeIf { it.isNotBlank() } ?: d.name,
|
||||
coverUri = m?.coverUri ?: d.coverUri,
|
||||
)
|
||||
}
|
||||
val items = AlbumListBuilder.build(
|
||||
albums = albums,
|
||||
links = links.map { link ->
|
||||
AlbumLink(
|
||||
deviceAlbumId = link.deviceAlbumId,
|
||||
deviceAlbumName = link.deviceAlbumName,
|
||||
deviceAlbumName = device.third[link.deviceAlbumId]?.displayName
|
||||
?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName,
|
||||
serverAlbumId = link.serverAlbumId,
|
||||
mode = SyncMode.from(link.mode),
|
||||
)
|
||||
},
|
||||
devices = device.first,
|
||||
devices = withMeta,
|
||||
)
|
||||
|
||||
UiState(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.galo.android.ui.devicealbum
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.IntentSenderRequest
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
@@ -64,12 +66,26 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.domain.attributes.AlbumAttributeSource
|
||||
import com.galo.android.domain.attributes.AlbumAttributesResolver
|
||||
import com.galo.android.domain.attributes.PhotoAttributeResolver
|
||||
import com.galo.android.domain.attributes.PhotoAttributeSource
|
||||
import com.galo.android.domain.exif.EXIF_BASE
|
||||
import com.galo.android.domain.media.AllFilesAccess
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.DeviceAlbum
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import com.galo.android.ui.album.RenameAlbumDialog
|
||||
import com.galo.android.ui.common.GaloAsyncImage
|
||||
import com.galo.android.ui.panel.AlbumAttributesPanel
|
||||
import com.galo.android.ui.panel.EndDrawerScaffold
|
||||
import com.galo.android.ui.photo.DeletePhotoDialog
|
||||
import com.galo.android.ui.photo.PhotoAttributesSheet
|
||||
import com.galo.android.ui.photo.localPhotoSheetModel
|
||||
import com.galo.android.ui.photo.serverPhotoSheetModel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
const val TAG_DEVICE_GRID = "device_album_grid"
|
||||
@@ -103,6 +119,7 @@ const val TAG_CREATE_TARGET = "device_create_target"
|
||||
fun DeviceAlbumScreen(
|
||||
app: GaloApplication,
|
||||
deviceAlbumId: String,
|
||||
onOpenServerSettings: (String) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val factory = viewModelFactory {
|
||||
@@ -110,7 +127,14 @@ fun DeviceAlbumScreen(
|
||||
}
|
||||
val viewModel: DeviceAlbumViewModel = viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val role by viewModel.role.collectAsStateWithLifecycle()
|
||||
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
|
||||
val meta by viewModel.albumMeta.collectAsStateWithLifecycle()
|
||||
var viewerIndex by remember { mutableStateOf<Int?>(null) }
|
||||
var deletePhotoId by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val localName = meta?.displayName?.takeIf { it.isNotBlank() } ?: state.name
|
||||
val localCoverUrl = meta?.coverUri
|
||||
|
||||
val context = LocalContext.current
|
||||
var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) }
|
||||
@@ -119,6 +143,11 @@ fun DeviceAlbumScreen(
|
||||
) {
|
||||
allFilesGranted = AllFilesAccess.isGranted()
|
||||
}
|
||||
val deleteLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.StartIntentSenderForResult(),
|
||||
) {
|
||||
viewModel.refreshAfterDelete()
|
||||
}
|
||||
LifecycleResumeEffect(Unit) {
|
||||
allFilesGranted = AllFilesAccess.isGranted()
|
||||
onPauseOrDispose { }
|
||||
@@ -130,23 +159,97 @@ fun DeviceAlbumScreen(
|
||||
items = state.items,
|
||||
initialIndex = openIndex,
|
||||
onBack = { viewerIndex = null },
|
||||
serverPhotoByMediaId = state.serverPhotoByMediaId,
|
||||
sheet = { media ->
|
||||
when (val source = PhotoAttributeResolver.resolve(media, state.serverPhotoByMediaId[media.id])) {
|
||||
is PhotoAttributeSource.Server -> {
|
||||
var photo by remember(source.photoId) { mutableStateOf<Photo?>(null) }
|
||||
LaunchedEffect(source.photoId) { photo = viewModel.serverPhoto(source.photoId) }
|
||||
photo?.let { p ->
|
||||
var showAll by remember(p.id) { mutableStateOf(false) }
|
||||
var tagQuery by remember(p.id) { mutableStateOf("") }
|
||||
var suggestions by remember(p.id) { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(tagQuery) {
|
||||
if (tagQuery.isNotBlank()) suggestions = viewModel.tagSuggestions(tagQuery)
|
||||
}
|
||||
val model = serverPhotoSheetModel(
|
||||
photo = p,
|
||||
role = role,
|
||||
visible = prefs?.visible?.toSet() ?: EXIF_BASE.toSet(),
|
||||
labels = prefs?.labels.orEmpty(),
|
||||
showAll = showAll,
|
||||
)
|
||||
PhotoAttributesSheet(
|
||||
model = model,
|
||||
onDownload = {
|
||||
p.downloadUrl?.let { url ->
|
||||
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) }
|
||||
}
|
||||
},
|
||||
onSetCover = { if (state.serverAlbumId != null) viewModel.setServerCover(p.id) },
|
||||
onDelete = { deletePhotoId = p.id },
|
||||
onAddTag = { viewModel.addServerTag(p.id, it) },
|
||||
onRemoveTag = { viewModel.removeServerTag(p.id, it) },
|
||||
tagSuggestions = suggestions.filter { s -> model.tags.none { it.equals(s, true) } },
|
||||
onShowAll = { showAll = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is PhotoAttributeSource.Local -> {
|
||||
PhotoAttributesSheet(
|
||||
model = localPhotoSheetModel(media),
|
||||
onShare = {
|
||||
val share = Intent(Intent.ACTION_SEND).apply {
|
||||
type = media.mimeType ?: "image/*"
|
||||
putExtra(Intent.EXTRA_STREAM, Uri.parse(media.uri))
|
||||
}
|
||||
runCatching { context.startActivity(Intent.createChooser(share, null)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
originalSourceProvider = { mediaId -> viewModel.originalSource(mediaId) },
|
||||
)
|
||||
} else {
|
||||
DeviceAlbumContent(
|
||||
state = state,
|
||||
role = role,
|
||||
localName = localName,
|
||||
localCoverUrl = localCoverUrl,
|
||||
onBack = onBack,
|
||||
onMode = viewModel::setMode,
|
||||
onTarget = viewModel::setTarget,
|
||||
onRestoreOriginals = viewModel::restoreOriginals,
|
||||
onOpen = { index -> viewerIndex = index },
|
||||
onCreateAlbum = viewModel::createAlbum,
|
||||
onRenameAlbum = viewModel::renameAlbum,
|
||||
onSetCover = viewModel::setCover,
|
||||
onResetCover = { viewModel.setCover(null) },
|
||||
onDeleteAlbum = {
|
||||
viewModel.deleteAlbum { sender ->
|
||||
deleteLauncher.launch(IntentSenderRequest.Builder(sender).build())
|
||||
}
|
||||
},
|
||||
onOpenServerSettings = { state.serverAlbumId?.let(onOpenServerSettings) },
|
||||
allFilesAccessGranted = allFilesGranted,
|
||||
onRequestAllFilesAccess = {
|
||||
runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
deletePhotoId?.let { id ->
|
||||
DeletePhotoDialog(
|
||||
title = state.items.firstOrNull { state.serverPhotoByMediaId[it.id] == id }?.displayName ?: "",
|
||||
onConfirm = {
|
||||
viewModel.deleteServerPhoto(id)
|
||||
deletePhotoId = null
|
||||
},
|
||||
onDismiss = { deletePhotoId = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -159,6 +262,14 @@ fun DeviceAlbumContent(
|
||||
onRestoreOriginals: () -> Unit,
|
||||
onOpen: (Int) -> Unit,
|
||||
onCreateAlbum: (String) -> Unit,
|
||||
role: String = "",
|
||||
localName: String? = null,
|
||||
localCoverUrl: String? = null,
|
||||
onRenameAlbum: (String) -> Unit = {},
|
||||
onSetCover: (String?) -> Unit = {},
|
||||
onResetCover: () -> Unit = {},
|
||||
onDeleteAlbum: () -> Unit = {},
|
||||
onOpenServerSettings: () -> Unit = {},
|
||||
allFilesAccessGranted: Boolean = true,
|
||||
onRequestAllFilesAccess: () -> Unit = {},
|
||||
) {
|
||||
@@ -245,6 +356,28 @@ fun DeviceAlbumContent(
|
||||
)
|
||||
}
|
||||
|
||||
val device = DeviceAlbum(
|
||||
id = state.name,
|
||||
name = state.name.ifBlank { "Альбом телефона" },
|
||||
itemCount = state.items.size,
|
||||
coverUri = state.items.firstOrNull()?.uri,
|
||||
relativePath = "",
|
||||
)
|
||||
val linkedServer = state.serverAlbums.firstOrNull { it.id == state.serverAlbumId }
|
||||
val attributes = AlbumAttributesResolver.resolve(
|
||||
AlbumAttributeSource.Local(
|
||||
device = device,
|
||||
title = localName?.takeIf { it.isNotBlank() } ?: device.name,
|
||||
coverUrl = localCoverUrl ?: device.coverUri,
|
||||
linkedServer = linkedServer,
|
||||
role = role,
|
||||
),
|
||||
)
|
||||
var panelOpen by remember { mutableStateOf(false) }
|
||||
var renameOpen by remember { mutableStateOf(false) }
|
||||
var deleteOpen by remember { mutableStateOf(false) }
|
||||
var coverPickOpen by remember { mutableStateOf(false) }
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
drawerContent = {
|
||||
@@ -273,7 +406,24 @@ fun DeviceAlbumContent(
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
EndDrawerScaffold(
|
||||
open = panelOpen,
|
||||
onOpenChange = { panelOpen = it },
|
||||
modifier = Modifier.padding(padding),
|
||||
drawerContent = {
|
||||
AlbumAttributesPanel(
|
||||
attributes = attributes,
|
||||
onPickCover = { coverPickOpen = true },
|
||||
onResetCover = onResetCover,
|
||||
onOpenMembers = onOpenServerSettings,
|
||||
onOpenSettings = { scope.launch { drawerState.open() } },
|
||||
onOpenShare = onOpenServerSettings,
|
||||
onRename = { renameOpen = true },
|
||||
onDelete = { deleteOpen = true },
|
||||
)
|
||||
},
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
if (state.items.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("В альбоме нет медиа")
|
||||
@@ -296,8 +446,112 @@ fun DeviceAlbumContent(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renameOpen) {
|
||||
RenameAlbumDialog(
|
||||
initial = attributes.title,
|
||||
onConfirm = {
|
||||
onRenameAlbum(it)
|
||||
renameOpen = false
|
||||
},
|
||||
onDismiss = { renameOpen = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (deleteOpen) {
|
||||
DeleteLocalAlbumDialog(
|
||||
title = attributes.title,
|
||||
itemCount = state.items.size,
|
||||
onConfirm = {
|
||||
deleteOpen = false
|
||||
onDeleteAlbum()
|
||||
},
|
||||
onDismiss = { deleteOpen = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (coverPickOpen) {
|
||||
LocalCoverPickerDialog(
|
||||
items = state.items,
|
||||
onPick = {
|
||||
onSetCover(it)
|
||||
coverPickOpen = false
|
||||
},
|
||||
onDismiss = { coverPickOpen = false },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const val TAG_LOCAL_DELETE_DIALOG = "local_delete_dialog"
|
||||
const val TAG_LOCAL_DELETE_CONFIRM = "local_delete_confirm"
|
||||
const val TAG_LOCAL_COVER_PICKER = "local_cover_picker"
|
||||
const val TAG_LOCAL_COVER_OPTION_PREFIX = "local_cover_option_"
|
||||
|
||||
/** Confirms deleting a device album's photos from the phone itself. */
|
||||
@Composable
|
||||
fun DeleteLocalAlbumDialog(
|
||||
title: String,
|
||||
itemCount: Int,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_LOCAL_DELETE_DIALOG),
|
||||
title = { Text("Удалить альбом?") },
|
||||
text = {
|
||||
Text("«$title» и $itemCount фото будут удалены с телефона. Действие необратимо.")
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = onConfirm,
|
||||
modifier = Modifier.testTag(TAG_LOCAL_DELETE_CONFIRM),
|
||||
) { Text("Удалить с телефона", color = MaterialTheme.colorScheme.error) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Picks a cover from the device album's own media. */
|
||||
@Composable
|
||||
fun LocalCoverPickerDialog(
|
||||
items: List<DeviceMedia>,
|
||||
onPick: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_LOCAL_COVER_PICKER),
|
||||
title = { Text("Выбрать обложку") },
|
||||
text = {
|
||||
if (items.isEmpty()) {
|
||||
Text("В альбоме нет медиа")
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.heightIn(max = 360.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
items(items, key = { it.id }) { media ->
|
||||
GaloAsyncImage(
|
||||
url = media.uri,
|
||||
contentDescription = media.displayName,
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.testTag(TAG_LOCAL_COVER_OPTION_PREFIX + media.id)
|
||||
.clickable { onPick(media.uri) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
+190
-10
@@ -6,15 +6,23 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.data.mapper.toDomain
|
||||
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
|
||||
import com.galo.android.data.sync.RestoreManager
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.DeviceAlbumMeta
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import com.galo.android.data.sync.OffloadResult
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -44,6 +52,21 @@ class DeviceAlbumViewModel(
|
||||
|
||||
private val pendingMode = MutableStateFlow<SyncMode?>(null)
|
||||
|
||||
private val _role = MutableStateFlow("")
|
||||
|
||||
/** Caller's role on the linked server album (empty when unlinked). */
|
||||
val role: StateFlow<String> = _role.asStateFlow()
|
||||
|
||||
private val _preferences = MutableStateFlow<PhotoInfoPrefsDto?>(null)
|
||||
|
||||
/** Saved photo-attribute preferences (visibility + labels). */
|
||||
val preferences: StateFlow<PhotoInfoPrefsDto?> = _preferences.asStateFlow()
|
||||
|
||||
/** App-only metadata (display name, chosen cover) for this device album. */
|
||||
val albumMeta: StateFlow<DeviceAlbumMeta?> = container.syncRepository.observeAlbumMeta()
|
||||
.map { it[deviceAlbumId] }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
val state: StateFlow<UiState> = combine(
|
||||
container.albumRepository.observeAlbums(),
|
||||
container.syncRepository.observeLinks(),
|
||||
@@ -88,23 +111,179 @@ class DeviceAlbumViewModel(
|
||||
viewModelScope.launch {
|
||||
runCatching { container.albumRepository.refresh() }
|
||||
}
|
||||
viewModelScope.launch { refreshRole() }
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.meEnvelope().preferences?.photoInfo }
|
||||
.getOrNull()
|
||||
?.let { _preferences.value = it }
|
||||
}
|
||||
// Check and fix this gallery on open (see [SyncCheck]): upload/offload for
|
||||
// move, restore originals for copy/off when previews are left over.
|
||||
viewModelScope.launch { container.syncCheck.checkGallery(deviceAlbumId) }
|
||||
}
|
||||
|
||||
/** Loads the caller's role on the linked server album, for panel gating. */
|
||||
private suspend fun refreshRole() {
|
||||
val id = state.value.serverAlbumId
|
||||
_role.value = if (id.isNullOrBlank()) {
|
||||
""
|
||||
} else {
|
||||
runCatching { container.api.albumEnvelope(id).role }.getOrDefault("") ?: ""
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames the linked server album. */
|
||||
fun renameServerAlbum(title: String) { val id = state.value.serverAlbumId ?: return
|
||||
val trimmed = title.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
container.api.updateAlbum(id, title = trimmed)
|
||||
container.albumRepository.loadAlbum(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets a linked-server-album photo as its cover. */
|
||||
fun setServerCover(photoId: String) {
|
||||
val id = state.value.serverAlbumId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
container.api.updateAlbum(id, coverPhotoId = photoId)
|
||||
container.albumRepository.loadAlbum(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears the linked server album's cover. */
|
||||
fun resetServerCover() {
|
||||
val id = state.value.serverAlbumId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
container.api.updateAlbum(id, clearCover = true)
|
||||
container.albumRepository.loadAlbum(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes the linked server album; [onDeleted] runs after the server confirms. */
|
||||
fun deleteServerAlbum(onDeleted: () -> Unit) {
|
||||
val id = state.value.serverAlbumId ?: return
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.deleteAlbum(id) }
|
||||
.onSuccess {
|
||||
unlink()
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Snapshot of the linked server album's photos, for the cover picker. */
|
||||
suspend fun serverAlbumPhotos(): List<Photo> {
|
||||
val id = state.value.serverAlbumId ?: return emptyList()
|
||||
return runCatching { container.photoRepository.observePhotos(id).first() }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
/** Fetches a server photo by id (for the sheet of a mapped device item). */
|
||||
suspend fun serverPhoto(photoId: String): Photo? {
|
||||
val dto = runCatching { container.api.photo(photoId) }.getOrNull() ?: return null
|
||||
return dto.toDomain(container.apiClient.objectUrls)
|
||||
}
|
||||
|
||||
suspend fun tagSuggestions(query: String): List<String> =
|
||||
runCatching { container.api.tags(query).map { it.name } }.getOrDefault(emptyList())
|
||||
|
||||
fun deleteServerPhoto(photoId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.deletePhoto(photoId) }
|
||||
.onSuccess { container.photoRepository.remove(photoId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addServerTag(photoId: String, name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.addTag(photoId, trimmed) }
|
||||
.onSuccess { container.photoRepository.upsert(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeServerTag(photoId: String, tagName: String) {
|
||||
viewModelScope.launch {
|
||||
val tagId = runCatching { container.api.photo(photoId).tags }
|
||||
.getOrDefault(emptyList())
|
||||
.firstOrNull { it.name.equals(tagName, ignoreCase = true) }
|
||||
?.id ?: return@launch
|
||||
runCatching { container.api.removeTag(photoId, tagId) }
|
||||
.onSuccess { container.photoRepository.upsert(it) }
|
||||
}
|
||||
}
|
||||
|
||||
// --- Local (device) album attributes and actions -----------------------
|
||||
|
||||
/** Sets the app-only display name for this device album. */
|
||||
fun renameAlbum(name: String) {
|
||||
viewModelScope.launch { container.syncRepository.setAlbumName(deviceAlbumId, name) }
|
||||
}
|
||||
|
||||
/** Sets (or clears, with null) the chosen cover for this device album. */
|
||||
fun setCover(uri: String?) {
|
||||
viewModelScope.launch { container.syncRepository.setAlbumCover(deviceAlbumId, uri) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every photo of this device album. Media the app does not own needs
|
||||
* the system delete consent, surfaced through [onConsent].
|
||||
*/
|
||||
fun deleteAlbum(onConsent: (IntentSender) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val uris = container.syncRepository.deviceItemUris(deviceAlbumId)
|
||||
if (uris.isEmpty()) {
|
||||
finishDeleteAlbum()
|
||||
return@launch
|
||||
}
|
||||
val sender = container.syncRepository.deleteConsent(uris)
|
||||
if (sender != null) onConsent(sender) else finishDeleteAlbum()
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-reads the album after a delete consent and forgets it if now empty. */
|
||||
fun refreshAfterDelete() {
|
||||
viewModelScope.launch {
|
||||
load()
|
||||
if (local.value.items.isEmpty()) container.syncRepository.forgetAlbum(deviceAlbumId)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun finishDeleteAlbum() {
|
||||
container.syncRepository.forgetAlbum(deviceAlbumId)
|
||||
load()
|
||||
}
|
||||
|
||||
fun setMode(mode: SyncMode) {
|
||||
pendingMode.value = mode
|
||||
if (mode == SyncMode.DISABLED) {
|
||||
// Turning sync off clears the link to the server album.
|
||||
viewModelScope.launch { container.syncRepository.unlink(deviceAlbumId) }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
if (mode == SyncMode.DISABLED) {
|
||||
// Turning sync off clears the link to the server album.
|
||||
container.syncRepository.unlink(deviceAlbumId)
|
||||
return@launch
|
||||
}
|
||||
// Persist the chosen mode even before a server album is picked, so the
|
||||
// choice survives leaving and reopening the album.
|
||||
val target = state.value.serverAlbumId
|
||||
?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId
|
||||
?: ""
|
||||
container.syncRepository.link(
|
||||
deviceAlbumId = deviceAlbumId,
|
||||
deviceAlbumName = local.value.name,
|
||||
serverAlbumId = state.value.serverAlbumId ?: "",
|
||||
serverAlbumId = target,
|
||||
mode = mode,
|
||||
)
|
||||
if (state.value.serverAlbumId != null) reconcileNow()
|
||||
refreshRole()
|
||||
// Nothing to sync until a server album is chosen.
|
||||
if (target.isNotBlank()) reconcileNow()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +295,7 @@ class DeviceAlbumViewModel(
|
||||
serverAlbumId = serverAlbumId,
|
||||
mode = pendingMode.value ?: state.value.mode,
|
||||
)
|
||||
refreshRole()
|
||||
reconcileNow()
|
||||
}
|
||||
}
|
||||
@@ -133,6 +313,7 @@ class DeviceAlbumViewModel(
|
||||
serverAlbumId = album.id,
|
||||
mode = pendingMode.value ?: state.value.mode,
|
||||
)
|
||||
refreshRole()
|
||||
reconcileNow()
|
||||
}
|
||||
}
|
||||
@@ -170,9 +351,8 @@ class DeviceAlbumViewModel(
|
||||
/** Returns originals to the device for every offloaded (preview-only) asset. */
|
||||
fun restoreOriginals() {
|
||||
viewModelScope.launch {
|
||||
val assets = container.syncRepository.assets(deviceAlbumId)
|
||||
.filter { it.state == LocalAssetState.PREVIEW.name }
|
||||
container.restoreManager.restore(assets)
|
||||
val targets = RestoreManager.restoreTargets(container.syncRepository.assets(deviceAlbumId))
|
||||
if (targets.isNotEmpty()) container.restoreManager.restore(targets)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.galo.android.ui.devicealbum
|
||||
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
@@ -10,18 +12,22 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
@@ -37,6 +43,8 @@ fun DeviceViewerScreen(
|
||||
items: List<DeviceMedia>,
|
||||
initialIndex: Int,
|
||||
onBack: () -> Unit,
|
||||
serverPhotoByMediaId: Map<Long, String> = emptyMap(),
|
||||
sheet: (@Composable (DeviceMedia) -> Unit)? = null,
|
||||
originalSourceProvider: suspend (Long) -> OriginalSource? = { null },
|
||||
) {
|
||||
if (items.isEmpty()) {
|
||||
@@ -49,6 +57,20 @@ fun DeviceViewerScreen(
|
||||
val current = items.getOrNull(pagerState.currentPage)
|
||||
var zoomed by remember(current?.id) { mutableStateOf(false) }
|
||||
|
||||
var sheetOpen by remember(current?.id) { mutableStateOf(false) }
|
||||
var dragUp by remember(current?.id) { mutableFloatStateOf(0f) }
|
||||
val sheetGesture = if (sheet != null) {
|
||||
Modifier.pointerInput(current?.id, zoomed) {
|
||||
val canOpen = current != null && !zoomed && current.kind != MediaKind.VIDEO
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { dragUp = 0f },
|
||||
onDragEnd = { if (canOpen && dragUp < -80f) sheetOpen = true },
|
||||
) { _, dragAmount -> dragUp += dragAmount }
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -64,6 +86,7 @@ fun DeviceViewerScreen(
|
||||
},
|
||||
) { padding ->
|
||||
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
Box(Modifier.fillMaxSize().then(sheetGesture)) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = !zoomed,
|
||||
@@ -94,6 +117,16 @@ fun DeviceViewerScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetOpen && sheet != null && current != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { sheetOpen = false },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
sheet(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.galo.android.ui.panel
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddLink
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Group
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Wallpaper
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.galo.android.domain.attributes.AlbumAttributes
|
||||
import com.galo.android.ui.common.GaloAsyncImage
|
||||
|
||||
const val TAG_ALBUM_PANEL = "album_panel"
|
||||
const val TAG_PANEL_COVER_PICK = "panel_cover_pick"
|
||||
const val TAG_PANEL_COVER_RESET = "panel_cover_reset"
|
||||
const val TAG_PANEL_RENAME = "panel_rename"
|
||||
const val TAG_PANEL_DELETE = "panel_delete"
|
||||
const val TAG_PANEL_MEMBERS = "panel_members"
|
||||
const val TAG_PANEL_SETTINGS = "panel_settings"
|
||||
const val TAG_PANEL_SHARE = "panel_share"
|
||||
|
||||
/**
|
||||
* The "Атрибуты и действия" album panel, mirroring the web album panel. It is
|
||||
* shown for every album; navigation rows appear always but are enabled only when
|
||||
* a server album backs them, and cover/actions depend on local vs server.
|
||||
*/
|
||||
@Composable
|
||||
fun AlbumAttributesPanel(
|
||||
attributes: AlbumAttributes,
|
||||
onPickCover: () -> Unit,
|
||||
onResetCover: () -> Unit,
|
||||
onOpenMembers: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenShare: () -> Unit,
|
||||
onRename: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.testTag(TAG_ALBUM_PANEL)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text("Атрибуты и действия", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
PanelSection("Обложка") {
|
||||
GaloAsyncImage(
|
||||
url = attributes.coverUrl,
|
||||
contentDescription = attributes.title,
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
|
||||
)
|
||||
if (attributes.isLocal || attributes.canEdit) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = onPickCover,
|
||||
modifier = Modifier.testTag(TAG_PANEL_COVER_PICK),
|
||||
) {
|
||||
Icon(Icons.Filled.Wallpaper, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(" Выбрать обложку")
|
||||
}
|
||||
if (attributes.coverUrl != null) {
|
||||
TextButton(
|
||||
onClick = onResetCover,
|
||||
modifier = Modifier.testTag(TAG_PANEL_COVER_RESET),
|
||||
) {
|
||||
Text("Сбросить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PanelSection("Информация") {
|
||||
Text(attributes.title, style = MaterialTheme.typography.titleMedium)
|
||||
if (attributes.description.isNotBlank()) {
|
||||
Text(attributes.description, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(
|
||||
text = if (attributes.isLocal && attributes.serverAlbumId == null) {
|
||||
"${attributes.photosCount} элементов"
|
||||
} else {
|
||||
"${attributes.photosCount} фото · ${attributes.membersCount} участников"
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
PanelSection("Навигация") {
|
||||
PanelNavRow(
|
||||
Icons.Filled.Group, "Участники", TAG_PANEL_MEMBERS,
|
||||
onClick = onOpenMembers, enabled = attributes.membersEnabled,
|
||||
)
|
||||
PanelNavRow(
|
||||
Icons.Filled.Settings, "Настройки", TAG_PANEL_SETTINGS,
|
||||
onClick = onOpenSettings, enabled = attributes.settingsEnabled,
|
||||
)
|
||||
PanelNavRow(
|
||||
Icons.Filled.AddLink, "Ссылки для просмотра", TAG_PANEL_SHARE,
|
||||
onClick = onOpenShare, enabled = attributes.shareEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
if (attributes.canRename || attributes.canDelete) {
|
||||
PanelSection("Действия") {
|
||||
if (attributes.canRename) {
|
||||
PanelNavRow(Icons.Filled.Edit, "Переименовать", TAG_PANEL_RENAME, onRename)
|
||||
}
|
||||
if (attributes.canDelete) {
|
||||
PanelNavRow(Icons.Filled.Delete, "Удалить", TAG_PANEL_DELETE, onDelete, danger = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelSection(title: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
HorizontalDivider()
|
||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelNavRow(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
tag: String,
|
||||
onClick: () -> Unit,
|
||||
enabled: Boolean = true,
|
||||
danger: Boolean = false,
|
||||
) {
|
||||
TextButton(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth().testTag(tag),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
label,
|
||||
color = if (danger) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.galo.android.ui.panel
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val TAG_END_DRAWER_SCRIM = "end_drawer_scrim"
|
||||
|
||||
/**
|
||||
* A right-anchored ("end") drawer. Material3 only ships a start-anchored
|
||||
* `ModalNavigationDrawer`, so this draws the panel on the right and opens it on a
|
||||
* right-to-left swipe over the content. Rightward drags are left unconsumed so a
|
||||
* start-anchored drawer (e.g. the device sync settings) keeps working.
|
||||
*/
|
||||
@Composable
|
||||
fun EndDrawerScaffold(
|
||||
open: Boolean,
|
||||
onOpenChange: (Boolean) -> Unit,
|
||||
drawerWidth: Dp = 320.dp,
|
||||
modifier: Modifier = Modifier,
|
||||
drawerContent: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
val widthPx = with(density) { drawerWidth.toPx() }
|
||||
val offset = remember { Animatable(widthPx) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(open, widthPx) {
|
||||
offset.animateTo(if (open) 0f else widthPx)
|
||||
}
|
||||
|
||||
// While the panel is open the scrim and panel sit above the content, so a
|
||||
// rightward swipe must be caught here and close the panel. Consuming it also
|
||||
// stops a start-anchored drawer behind from opening (e.g. sync settings).
|
||||
val closeDrag = Modifier.pointerInput(widthPx) {
|
||||
var total = 0f
|
||||
detectHorizontalDragGestures(
|
||||
onDragStart = { total = 0f },
|
||||
onDragEnd = { if (total > 40f) onOpenChange(false) },
|
||||
) { change, dragAmount ->
|
||||
total += dragAmount
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier.fillMaxSize()) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInput(widthPx) {
|
||||
detectHorizontalDragGestures(
|
||||
onDragEnd = { onOpenChange(offset.value < widthPx / 2f) },
|
||||
) { change, dragAmount ->
|
||||
val next = (offset.value + dragAmount).coerceIn(0f, widthPx)
|
||||
// Follow leftward drags (revealing the panel) or any drag while open;
|
||||
// leave rightward drags to a start-anchored drawer.
|
||||
if (next < offset.value || offset.value < widthPx) {
|
||||
change.consume()
|
||||
scope.launch { offset.snapTo(next) }
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
content()
|
||||
}
|
||||
|
||||
if (open) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.testTag(TAG_END_DRAWER_SCRIM)
|
||||
.background(Color.Black.copy(alpha = 0.4f))
|
||||
.then(closeDrag)
|
||||
.clickable { onOpenChange(false) },
|
||||
)
|
||||
Surface(
|
||||
tonalElevation = 3.dp,
|
||||
shadowElevation = 8.dp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.fillMaxHeight()
|
||||
.width(drawerWidth)
|
||||
.offset { IntOffset(offset.value.roundToInt(), 0) }
|
||||
.then(closeDrag),
|
||||
) {
|
||||
drawerContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.domain.exif.EXIF_CATALOG
|
||||
import com.galo.android.domain.exif.EXIF_LABELS
|
||||
|
||||
const val TAG_ATTR_ROW_PREFIX = "attr_row_"
|
||||
const val TAG_ATTR_CHECK_PREFIX = "attr_check_"
|
||||
const val TAG_ATTR_LABEL_PREFIX = "attr_label_"
|
||||
const val TAG_ATTR_ALL = "attr_all"
|
||||
const val TAG_ATTR_NONE = "attr_none"
|
||||
const val TAG_ATTR_SAVE = "attr_save"
|
||||
const val TAG_ATTR_RESET = "attr_reset"
|
||||
const val TAG_ATTR_SAVED = "attr_saved"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttributeSettingsScreen(
|
||||
app: GaloApplication,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val factory = viewModelFactory {
|
||||
initializer { AttributeSettingsViewModel(app.container) }
|
||||
}
|
||||
val viewModel: AttributeSettingsViewModel = viewModel(factory = factory)
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
AttributeSettingsContent(
|
||||
state = state,
|
||||
onToggle = viewModel::toggle,
|
||||
onSetAll = viewModel::setAll,
|
||||
onLabel = viewModel::setLabel,
|
||||
onSave = viewModel::save,
|
||||
onReset = viewModel::reset,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AttributeSettingsContent(
|
||||
state: AttributeSettingsViewModel.UiState,
|
||||
onToggle: (String) -> Unit,
|
||||
onSetAll: (Boolean) -> Unit,
|
||||
onLabel: (String, String) -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onReset: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Настройки атрибутов") },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onSave, modifier = Modifier.testTag(TAG_ATTR_SAVE)) {
|
||||
Text("Сохранить")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton(onClick = { onSetAll(true) }, modifier = Modifier.testTag(TAG_ATTR_ALL)) {
|
||||
Text("Отметить все")
|
||||
}
|
||||
TextButton(onClick = { onSetAll(false) }, modifier = Modifier.testTag(TAG_ATTR_NONE)) {
|
||||
Text("Снять все")
|
||||
}
|
||||
TextButton(onClick = onReset, modifier = Modifier.testTag(TAG_ATTR_RESET)) {
|
||||
Text("Сбросить")
|
||||
}
|
||||
if (state.saved) {
|
||||
Text(
|
||||
"Сохранено",
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.testTag(TAG_ATTR_SAVED),
|
||||
)
|
||||
}
|
||||
}
|
||||
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
items(EXIF_CATALOG, key = { it }) { key ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.testTag(TAG_ATTR_ROW_PREFIX + key),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Checkbox(
|
||||
checked = key in state.visible,
|
||||
onCheckedChange = { onToggle(key) },
|
||||
modifier = Modifier.testTag(TAG_ATTR_CHECK_PREFIX + key),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.labels[key] ?: "",
|
||||
onValueChange = { onLabel(key, it) },
|
||||
label = { Text(EXIF_LABELS[key] ?: key) },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TAG_ATTR_LABEL_PREFIX + key),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
|
||||
import com.galo.android.data.network.dto.PreferencesDto
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.exif.EXIF_BASE
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AttributeSettingsViewModel(
|
||||
private val container: AppContainer,
|
||||
) : ViewModel() {
|
||||
|
||||
data class UiState(
|
||||
val visible: Set<String> = EXIF_BASE.toSet(),
|
||||
val labels: Map<String, String> = emptyMap(),
|
||||
val loading: Boolean = true,
|
||||
val saved: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(UiState())
|
||||
val state: StateFlow<UiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.meEnvelope().preferences?.photoInfo }
|
||||
.onSuccess { info ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
visible = info?.visible?.toSet() ?: EXIF_BASE.toSet(),
|
||||
labels = info?.labels.orEmpty(),
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { e -> _state.update { it.copy(loading = false, error = e.message) } }
|
||||
}
|
||||
}
|
||||
|
||||
fun toggle(key: String) {
|
||||
_state.update { s ->
|
||||
val next = s.visible.toMutableSet()
|
||||
if (!next.add(key)) next.remove(key)
|
||||
s.copy(visible = next, saved = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAll(visible: Boolean) {
|
||||
_state.update { s ->
|
||||
s.copy(
|
||||
visible = if (visible) com.galo.android.domain.exif.EXIF_CATALOG.toSet() else emptySet(),
|
||||
saved = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLabel(key: String, value: String) {
|
||||
_state.update { s ->
|
||||
val labels = s.labels.toMutableMap()
|
||||
if (value.isBlank()) labels.remove(key) else labels[key] = value
|
||||
s.copy(labels = labels, saved = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun save() {
|
||||
persist(PreferencesDto(PhotoInfoPrefsDto(visible = _state.value.visible.toList(), labels = _state.value.labels)))
|
||||
}
|
||||
|
||||
/** Removes the saved preferences, restoring the base attribute set. */
|
||||
fun reset() {
|
||||
persist(PreferencesDto(photoInfo = null))
|
||||
}
|
||||
|
||||
private fun persist(preferences: PreferencesDto) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.savePreferences(preferences) }
|
||||
.onSuccess { saved ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
visible = saved.photoInfo?.visible?.toSet() ?: EXIF_BASE.toSet(),
|
||||
labels = saved.photoInfo?.labels.orEmpty(),
|
||||
saved = true,
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Label
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material.icons.filled.Wallpaper
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
const val TAG_PHOTO_SHEET = "photo_sheet"
|
||||
const val TAG_SHEET_TITLE = "sheet_title"
|
||||
const val TAG_SHEET_SETTINGS = "sheet_settings"
|
||||
const val TAG_SHEET_MORE = "sheet_more"
|
||||
const val TAG_SHEET_TAG_FIELD = "sheet_tag_field"
|
||||
const val TAG_SHEET_TAG_SUGGESTION_PREFIX = "sheet_tag_suggestion_"
|
||||
const val TAG_SHEET_TAG_CHIP_PREFIX = "sheet_tag_chip_"
|
||||
const val TAG_SHEET_TAG_REMOVE_PREFIX = "sheet_tag_remove_"
|
||||
const val TAG_SHEET_OPEN = "sheet_open"
|
||||
const val TAG_SHEET_DOWNLOAD = "sheet_download"
|
||||
const val TAG_SHEET_MOVE = "sheet_move"
|
||||
const val TAG_SHEET_COPY = "sheet_copy"
|
||||
const val TAG_SHEET_COVER = "sheet_cover"
|
||||
const val TAG_SHEET_DELETE = "sheet_delete"
|
||||
const val TAG_SHEET_SHARE = "sheet_share"
|
||||
|
||||
/**
|
||||
* The "Атрибуты и действия" sheet, mirroring the web photo panel. Sections appear
|
||||
* based on the model; each action row appears only when its callback is provided.
|
||||
*/
|
||||
@Composable
|
||||
fun PhotoAttributesSheet(
|
||||
model: PhotoSheetModel,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpen: (() -> Unit)? = null,
|
||||
onRename: (() -> Unit)? = null,
|
||||
onDownload: (() -> Unit)? = null,
|
||||
onMove: (() -> Unit)? = null,
|
||||
onCopy: (() -> Unit)? = null,
|
||||
onSetCover: (() -> Unit)? = null,
|
||||
onDelete: (() -> Unit)? = null,
|
||||
onShare: (() -> Unit)? = null,
|
||||
onOpenAttributeSettings: (() -> Unit)? = null,
|
||||
onAddTag: ((String) -> Unit)? = null,
|
||||
onRemoveTag: ((String) -> Unit)? = null,
|
||||
tagSuggestions: List<String> = emptyList(),
|
||||
onShowAll: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.testTag(TAG_PHOTO_SHEET)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text("Атрибуты и действия", style = MaterialTheme.typography.titleMedium)
|
||||
|
||||
// Title (rename affordance).
|
||||
if (onRename != null) {
|
||||
TextButton(onClick = onRename, modifier = Modifier.testTag(TAG_SHEET_TITLE)) {
|
||||
Text(model.title, style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
} else {
|
||||
Text(model.title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.testTag(TAG_SHEET_TITLE))
|
||||
}
|
||||
|
||||
PanelSection("Информация") {
|
||||
if (model.uploaderName != null) MetaRow("Автор", model.uploaderName)
|
||||
MetaRow("Файл", formatSize(model.size))
|
||||
model.mimeType?.let { MetaRow("Тип", it) }
|
||||
if (model.width > 0 && model.height > 0) {
|
||||
MetaRow("Разрешение", "${model.width}×${model.height}")
|
||||
}
|
||||
model.exifRows.forEach { row -> MetaRow(row.label, row.value) }
|
||||
if (model.hiddenAttributeCount > 0 && onShowAll != null) {
|
||||
TextButton(onClick = onShowAll, modifier = Modifier.testTag(TAG_SHEET_MORE)) {
|
||||
Text("…")
|
||||
}
|
||||
}
|
||||
model.takenAt?.let { MetaRow("Снято", it) }
|
||||
if (model.latitude != null && model.longitude != null) {
|
||||
MetaRow("Координаты", "${model.latitude}, ${model.longitude}")
|
||||
}
|
||||
model.createdAt?.let { MetaRow("Загружено", it) }
|
||||
}
|
||||
|
||||
if (onAddTag != null || onRemoveTag != null) {
|
||||
PanelSection("Метки") {
|
||||
if (model.tags.isNotEmpty()) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
model.tags.forEach { name ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Filled.Label, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(" $name", modifier = Modifier.weight(1f))
|
||||
if (onRemoveTag != null) {
|
||||
IconButtonTag(
|
||||
icon = Icons.Filled.Close,
|
||||
description = "Убрать метку $name",
|
||||
tag = TAG_SHEET_TAG_REMOVE_PREFIX + name,
|
||||
onClick = { onRemoveTag(name) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onAddTag != null) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
placeholder = { Text("Добавить метку…") },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Text),
|
||||
keyboardActions = KeyboardActions(onDone = {
|
||||
if (query.isNotBlank()) {
|
||||
onAddTag(query)
|
||||
query = ""
|
||||
}
|
||||
}),
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_SHEET_TAG_FIELD),
|
||||
)
|
||||
tagSuggestions.forEach { suggestion ->
|
||||
TextButton(
|
||||
onClick = {
|
||||
onAddTag(suggestion)
|
||||
query = ""
|
||||
},
|
||||
modifier = Modifier.testTag(TAG_SHEET_TAG_SUGGESTION_PREFIX + suggestion),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(" $suggestion")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onOpen != null || onDownload != null || onMove != null || onCopy != null ||
|
||||
onSetCover != null || onDelete != null || onShare != null
|
||||
) {
|
||||
PanelSection("Действия") {
|
||||
ActionRow(Icons.Filled.Image, "Открыть (просмотр)", TAG_SHEET_OPEN, onOpen)
|
||||
ActionRow(Icons.Filled.Download, "Скачать", TAG_SHEET_DOWNLOAD, onDownload)
|
||||
ActionRow(Icons.Filled.ArrowBack, "Переместить", TAG_SHEET_MOVE, onMove)
|
||||
ActionRow(Icons.Filled.ContentCopy, "Копировать", TAG_SHEET_COPY, onCopy)
|
||||
ActionRow(Icons.Filled.Wallpaper, "Установить как обложку", TAG_SHEET_COVER, onSetCover)
|
||||
ActionRow(Icons.Filled.Share, "Поделиться", TAG_SHEET_SHARE, onShare)
|
||||
if (onDelete != null) {
|
||||
TextButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_SHEET_DELETE),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text("Удалить", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (onOpenAttributeSettings != null) {
|
||||
ActionRow(Icons.Filled.Settings, "Настройки атрибутов", TAG_SHEET_SETTINGS, onOpenAttributeSettings)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PanelSection(title: String, content: @Composable () -> Unit) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
HorizontalDivider()
|
||||
Text(title, style = MaterialTheme.typography.titleSmall)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MetaRow(label: String, value: String) {
|
||||
Row(modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
label,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(value, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionRow(icon: ImageVector, label: String, tag: String, onClick: (() -> Unit)?) {
|
||||
if (onClick == null) return
|
||||
TextButton(onClick = onClick, modifier = Modifier.fillMaxWidth().testTag(tag)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Text(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun IconButtonTag(
|
||||
icon: ImageVector,
|
||||
description: String,
|
||||
tag: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
TextButton(onClick = onClick, modifier = Modifier.testTag(tag)) {
|
||||
Icon(icon, contentDescription = description, modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatSize(bytes: Long): String {
|
||||
if (bytes <= 0) return "—"
|
||||
val units = listOf("Б", "КБ", "МБ", "ГБ")
|
||||
var v = bytes.toDouble()
|
||||
var i = 0
|
||||
while (v >= 1024 && i < units.size - 1) {
|
||||
v /= 1024
|
||||
i++
|
||||
}
|
||||
return if (i > 0 && v < 10) "${String.format(java.util.Locale.US, "%.1f", v)} ${units[i]}" else "${v.toLong()} ${units[i]}"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.galo.android.domain.model.Album
|
||||
|
||||
const val TAG_PHOTO_RENAME_DIALOG = "photo_rename_dialog"
|
||||
const val TAG_PHOTO_RENAME_FIELD = "photo_rename_field"
|
||||
const val TAG_PHOTO_RENAME_CONFIRM = "photo_rename_confirm"
|
||||
const val TAG_PHOTO_DELETE_DIALOG = "photo_delete_dialog"
|
||||
const val TAG_PHOTO_DELETE_CONFIRM = "photo_delete_confirm"
|
||||
const val TAG_ALBUM_PICKER = "album_picker"
|
||||
const val TAG_ALBUM_PICKER_OPTION_PREFIX = "album_picker_option_"
|
||||
|
||||
@Composable
|
||||
fun RenamePhotoDialog(
|
||||
initial: String,
|
||||
onConfirm: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
var text by remember { mutableStateOf(initial) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_PHOTO_RENAME_DIALOG),
|
||||
title = { Text("Переименовать фото") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
singleLine = true,
|
||||
label = { Text("Имя файла") },
|
||||
modifier = Modifier.fillMaxWidth().testTag(TAG_PHOTO_RENAME_FIELD),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onConfirm(text) },
|
||||
enabled = text.isNotBlank(),
|
||||
modifier = Modifier.testTag(TAG_PHOTO_RENAME_CONFIRM),
|
||||
) { Text("Сохранить") }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeletePhotoDialog(
|
||||
title: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_PHOTO_DELETE_DIALOG),
|
||||
title = { Text("Удалить фото?") },
|
||||
text = { Text("«$title» будет перемещено в корзину.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm, modifier = Modifier.testTag(TAG_PHOTO_DELETE_CONFIRM)) {
|
||||
Text("Удалить")
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
|
||||
/** Picks a destination album for a move or copy. */
|
||||
@Composable
|
||||
fun AlbumPickerDialog(
|
||||
title: String,
|
||||
albums: List<Album>,
|
||||
onPick: (String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
modifier = Modifier.testTag(TAG_ALBUM_PICKER),
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.heightIn(max = 320.dp)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
if (albums.isEmpty()) {
|
||||
Text("Нет других альбомов")
|
||||
}
|
||||
albums.forEach { album ->
|
||||
TextButton(
|
||||
onClick = { onPick(album.id) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.testTag(TAG_ALBUM_PICKER_OPTION_PREFIX + album.id),
|
||||
) {
|
||||
Text(album.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text("Отмена") } },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import com.galo.android.domain.exif.ExifRow
|
||||
import com.galo.android.domain.exif.buildExifRows
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
import com.galo.android.domain.model.Photo
|
||||
|
||||
/**
|
||||
* Everything the photo "Атрибуты и действия" sheet renders, already resolved
|
||||
* (server photo or local device media). Actions are optional callbacks on the
|
||||
* sheet composable; a null callback hides its row.
|
||||
*/
|
||||
data class PhotoSheetModel(
|
||||
val title: String,
|
||||
val uploaderName: String? = null,
|
||||
val size: Long = 0,
|
||||
val mimeType: String? = null,
|
||||
val width: Int = 0,
|
||||
val height: Int = 0,
|
||||
val takenAt: String? = null,
|
||||
val latitude: Double? = null,
|
||||
val longitude: Double? = null,
|
||||
val createdAt: String? = null,
|
||||
val exifRows: List<ExifRow> = emptyList(),
|
||||
val hiddenAttributeCount: Int = 0,
|
||||
val tags: List<String> = emptyList(),
|
||||
val isServer: Boolean = true,
|
||||
val canRename: Boolean = false,
|
||||
val canSetCover: Boolean = false,
|
||||
val canDelete: Boolean = false,
|
||||
)
|
||||
|
||||
/** Rows for the current photo; when [showAll] the hidden attributes are included too. */
|
||||
fun serverPhotoSheetModel(
|
||||
photo: Photo,
|
||||
role: String,
|
||||
visible: Set<String>,
|
||||
labels: Map<String, String>,
|
||||
showAll: Boolean,
|
||||
): PhotoSheetModel {
|
||||
val allRows = buildExifRows(photo.exif, labels)
|
||||
val shown = if (showAll) allRows else allRows.filter { it.key in visible }
|
||||
val canEdit = role == "owner" || role == "admin"
|
||||
return PhotoSheetModel(
|
||||
title = photo.originalFilename,
|
||||
uploaderName = photo.uploaderName,
|
||||
size = photo.size,
|
||||
mimeType = photo.mimeType,
|
||||
width = photo.width,
|
||||
height = photo.height,
|
||||
takenAt = photo.takenAt,
|
||||
latitude = photo.latitude,
|
||||
longitude = photo.longitude,
|
||||
createdAt = photo.createdAt,
|
||||
exifRows = shown,
|
||||
hiddenAttributeCount = allRows.size - shown.size,
|
||||
tags = photo.tags.map { it.name },
|
||||
isServer = true,
|
||||
canRename = true,
|
||||
canSetCover = canEdit,
|
||||
canDelete = canEdit,
|
||||
)
|
||||
}
|
||||
|
||||
/** Local device media that has no matching server photo. */
|
||||
fun localPhotoSheetModel(media: DeviceMedia): PhotoSheetModel = PhotoSheetModel(
|
||||
title = media.displayName,
|
||||
uploaderName = null,
|
||||
size = media.size,
|
||||
mimeType = media.mimeType,
|
||||
width = media.width,
|
||||
height = media.height,
|
||||
takenAt = media.dateTaken?.toString(),
|
||||
createdAt = media.dateModified.toString(),
|
||||
exifRows = emptyList(),
|
||||
hiddenAttributeCount = 0,
|
||||
tags = emptyList(),
|
||||
isServer = false,
|
||||
canRename = false,
|
||||
canSetCover = false,
|
||||
canDelete = false,
|
||||
)
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.galo.android.ui.viewer
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.foundation.gestures.detectVerticalDragGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,26 +15,38 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.lifecycle.viewmodel.initializer
|
||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||
import com.galo.android.GaloApplication
|
||||
import com.galo.android.domain.exif.EXIF_BASE
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.ui.photo.AlbumPickerDialog
|
||||
import com.galo.android.ui.photo.DeletePhotoDialog
|
||||
import com.galo.android.ui.photo.PhotoAttributesSheet
|
||||
import com.galo.android.ui.photo.RenamePhotoDialog
|
||||
import com.galo.android.ui.photo.serverPhotoSheetModel
|
||||
|
||||
const val TAG_VIEWER_PAGER = "viewer_pager"
|
||||
|
||||
@@ -42,13 +57,92 @@ fun ViewerScreen(
|
||||
albumId: String,
|
||||
initialIndex: Int,
|
||||
onBack: () -> Unit,
|
||||
onOpenAttributeSettings: () -> Unit = {},
|
||||
) {
|
||||
val factory = viewModelFactory {
|
||||
initializer { ViewerViewModel(app.container, albumId) }
|
||||
}
|
||||
val viewModel: ViewerViewModel = viewModel(factory = factory)
|
||||
val photos by viewModel.photos.collectAsStateWithLifecycle()
|
||||
ViewerContent(photos = photos, initialIndex = initialIndex, onBack = onBack)
|
||||
val role by viewModel.role.collectAsStateWithLifecycle()
|
||||
val prefs by viewModel.preferences.collectAsStateWithLifecycle()
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle()
|
||||
|
||||
val context = LocalContext.current
|
||||
var renameFor by remember { mutableStateOf<Photo?>(null) }
|
||||
var deleteFor by remember { mutableStateOf<Photo?>(null) }
|
||||
var transferFor by remember { mutableStateOf<Pair<Photo, Boolean>?>(null) }
|
||||
|
||||
ViewerContent(
|
||||
photos = photos,
|
||||
initialIndex = initialIndex,
|
||||
onBack = onBack,
|
||||
sheet = { photo ->
|
||||
val visible = prefs?.visible?.toSet() ?: EXIF_BASE.toSet()
|
||||
val labels = prefs?.labels.orEmpty()
|
||||
var showAll by remember(photo.id) { mutableStateOf(false) }
|
||||
var tagQuery by remember(photo.id) { mutableStateOf("") }
|
||||
var suggestions by remember(photo.id) { mutableStateOf<List<String>>(emptyList()) }
|
||||
LaunchedEffect(tagQuery) {
|
||||
if (tagQuery.isNotBlank()) suggestions = viewModel.tagSuggestions(tagQuery)
|
||||
}
|
||||
val model = serverPhotoSheetModel(photo, role, visible, labels, showAll)
|
||||
PhotoAttributesSheet(
|
||||
model = model,
|
||||
onRename = { renameFor = photo },
|
||||
onDownload = {
|
||||
photo.downloadUrl?.let { url ->
|
||||
runCatching {
|
||||
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
}
|
||||
},
|
||||
onMove = { transferFor = photo to false },
|
||||
onCopy = { transferFor = photo to true },
|
||||
onSetCover = { viewModel.setCover(albumId, photo.id) },
|
||||
onDelete = { deleteFor = photo },
|
||||
onAddTag = { viewModel.addTag(photo.id, it) },
|
||||
onRemoveTag = { viewModel.removeTag(photo.id, it) },
|
||||
tagSuggestions = suggestions.filter { s -> model.tags.none { it.equals(s, true) } },
|
||||
onOpenAttributeSettings = onOpenAttributeSettings,
|
||||
onShowAll = { showAll = true },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
renameFor?.let { photo ->
|
||||
RenamePhotoDialog(
|
||||
initial = photo.originalFilename,
|
||||
onConfirm = {
|
||||
viewModel.rename(photo.id, it)
|
||||
renameFor = null
|
||||
},
|
||||
onDismiss = { renameFor = null },
|
||||
)
|
||||
}
|
||||
|
||||
deleteFor?.let { photo ->
|
||||
DeletePhotoDialog(
|
||||
title = photo.originalFilename,
|
||||
onConfirm = {
|
||||
viewModel.delete(photo.id)
|
||||
deleteFor = null
|
||||
},
|
||||
onDismiss = { deleteFor = null },
|
||||
)
|
||||
}
|
||||
|
||||
transferFor?.let { (photo, isCopy) ->
|
||||
AlbumPickerDialog(
|
||||
title = if (isCopy) "Копировать в альбом" else "Переместить в альбом",
|
||||
albums = albums.filter { it.id != albumId },
|
||||
onPick = { target ->
|
||||
if (isCopy) viewModel.copy(photo.id, target) else viewModel.move(photo.id, target)
|
||||
transferFor = null
|
||||
},
|
||||
onDismiss = { transferFor = null },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@@ -57,6 +151,7 @@ fun ViewerContent(
|
||||
photos: List<Photo>,
|
||||
initialIndex: Int,
|
||||
onBack: () -> Unit,
|
||||
sheet: (@Composable (Photo) -> Unit)? = null,
|
||||
) {
|
||||
if (photos.isEmpty()) {
|
||||
Scaffold(
|
||||
@@ -90,6 +185,23 @@ fun ViewerContent(
|
||||
var zoomed by remember(current?.id) { mutableStateOf(false) }
|
||||
val pagerScrollEnabled = current?.isPanorama != true && !zoomed
|
||||
|
||||
// Swipe up opens the attributes/actions sheet, except while zoomed, on a
|
||||
// panorama, or on video (where the gesture belongs to the media).
|
||||
var sheetOpen by remember(current?.id) { mutableStateOf(false) }
|
||||
var dragUp by remember(current?.id) { mutableFloatStateOf(0f) }
|
||||
val sheetGesture = if (sheet != null) {
|
||||
Modifier.pointerInput(current?.id, zoomed) {
|
||||
val canOpen = current != null && !zoomed && !current.isPanorama &&
|
||||
current.kind != MediaKind.VIDEO
|
||||
detectVerticalDragGestures(
|
||||
onDragStart = { dragUp = 0f },
|
||||
onDragEnd = { if (canOpen && dragUp < -80f) sheetOpen = true },
|
||||
) { _, dragAmount -> dragUp += dragAmount }
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -111,21 +223,32 @@ fun ViewerContent(
|
||||
color = Color.Black,
|
||||
modifier = Modifier.fillMaxSize().padding(padding),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = pagerScrollEnabled,
|
||||
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
|
||||
key = { index -> photos.getOrNull(index)?.id ?: index },
|
||||
) { page ->
|
||||
val photo = photos.getOrNull(page) ?: return@HorizontalPager
|
||||
MediaPage(
|
||||
photo = photo,
|
||||
active = pagerState.currentPage == page,
|
||||
onZoomChanged = { zoomed = it },
|
||||
)
|
||||
Box(Modifier.fillMaxSize().then(sheetGesture)) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
userScrollEnabled = pagerScrollEnabled,
|
||||
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
|
||||
key = { index -> photos.getOrNull(index)?.id ?: index },
|
||||
) { page ->
|
||||
val photo = photos.getOrNull(page) ?: return@HorizontalPager
|
||||
MediaPage(
|
||||
photo = photo,
|
||||
active = pagerState.currentPage == page,
|
||||
onZoomChanged = { zoomed = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sheetOpen && sheet != null && current != null) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { sheetOpen = false },
|
||||
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||
) {
|
||||
sheet(current)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -2,10 +2,15 @@ package com.galo.android.ui.viewer
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
|
||||
import com.galo.android.di.AppContainer
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.domain.model.Tag
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -17,6 +22,15 @@ class ViewerViewModel(
|
||||
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
private val _role = MutableStateFlow("")
|
||||
val role: StateFlow<String> = _role.asStateFlow()
|
||||
|
||||
private val _preferences = MutableStateFlow<PhotoInfoPrefsDto?>(null)
|
||||
val preferences: StateFlow<PhotoInfoPrefsDto?> = _preferences.asStateFlow()
|
||||
|
||||
val albums: StateFlow<List<Album>> = container.albumRepository.observeAlbums()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
init {
|
||||
// Cold start (e.g. restored route): make sure the cache is populated.
|
||||
viewModelScope.launch {
|
||||
@@ -24,5 +38,77 @@ class ViewerViewModel(
|
||||
container.photoRepository.loadNextPage(albumId, cursor = null)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.albumEnvelope(albumId).role }
|
||||
.getOrNull()
|
||||
?.let { _role.value = it }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.meEnvelope().preferences?.photoInfo }
|
||||
.getOrNull()
|
||||
?.let { _preferences.value = it }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun tagSuggestions(query: String): List<String> =
|
||||
runCatching { container.api.tags(query).map { it.name } }.getOrDefault(emptyList())
|
||||
|
||||
fun rename(photoId: String, name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.renamePhoto(photoId, trimmed) }
|
||||
.onSuccess { container.photoRepository.upsert(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun delete(photoId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.deletePhoto(photoId) }
|
||||
.onSuccess { container.photoRepository.remove(photoId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun move(photoId: String, targetAlbumId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.movePhotos(listOf(photoId), targetAlbumId) }
|
||||
.onSuccess { container.photoRepository.remove(photoId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun copy(photoId: String, targetAlbumId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.copyPhotos(listOf(photoId), targetAlbumId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setCover(targetAlbumId: String, photoId: String) {
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.updateAlbum(targetAlbumId, coverPhotoId = photoId) }
|
||||
}
|
||||
}
|
||||
|
||||
fun addTag(photoId: String, name: String) {
|
||||
val trimmed = name.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
viewModelScope.launch {
|
||||
runCatching { container.api.addTag(photoId, trimmed) }
|
||||
.onSuccess { container.photoRepository.upsert(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun removeTag(photoId: String, tagName: String) {
|
||||
viewModelScope.launch {
|
||||
val tag = photos.value.firstOrNull { it.id == photoId }
|
||||
?.tags?.firstOrNull { it.name.equals(tagName, ignoreCase = true) }
|
||||
?: Tag(id = "", name = tagName)
|
||||
if (tag.id.isBlank()) return@launch
|
||||
runCatching { container.api.removeTag(photoId, tag.id) }
|
||||
.onSuccess { container.photoRepository.upsert(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun download(photo: Photo, onOpen: (String) -> Unit) {
|
||||
photo.downloadUrl?.let(onOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.galo.android.data.network
|
||||
|
||||
import com.galo.android.data.auth.InMemoryTokenStore
|
||||
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
|
||||
import com.galo.android.data.network.dto.PreferencesDto
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/** Request-level coverage for the album/tag/preferences operations added for the panels. */
|
||||
class GaloApiTest {
|
||||
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var api: GaloApi
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer()
|
||||
server.start()
|
||||
val client = ApiClient(
|
||||
tokenStore = InMemoryTokenStore(),
|
||||
baseUrlProvider = { server.url("/").toString() },
|
||||
insecureAllowedProvider = { true },
|
||||
)
|
||||
api = GaloApi(client)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `meEnvelope reads saved photo_info preferences`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"user":{"id":"u1","name":"Ann"},"preferences":{"photo_info":{"visible":["ISO"]}}}""",
|
||||
),
|
||||
)
|
||||
|
||||
val envelope = api.meEnvelope()
|
||||
|
||||
assertEquals(listOf("ISO"), envelope.preferences?.photoInfo?.visible)
|
||||
assertEquals("/api/auth/me", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `savePreferences patches preferences`() = runTest {
|
||||
server.enqueue(MockResponse().setBody("""{"preferences":{"photo_info":{"visible":["Make"]}}}"""))
|
||||
|
||||
val saved = api.savePreferences(
|
||||
PreferencesDto(photoInfo = PhotoInfoPrefsDto(visible = listOf("Make"))),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Make"), saved.photoInfo?.visible)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("PATCH", request.method)
|
||||
assertEquals("/api/auth/me/preferences", request.path)
|
||||
assertTrue(request.body.readUtf8().contains("\"visible\":[\"Make\"]"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateAlbum sends title and cover, omitting clear_cover`() = runTest {
|
||||
server.enqueue(MockResponse().setBody("""{"album":{"id":"a1","title":"New"}}"""))
|
||||
|
||||
val album = api.updateAlbum("a1", title = "New", description = "d", coverPhotoId = "p1")
|
||||
|
||||
assertEquals("New", album.title)
|
||||
val request = server.takeRequest()
|
||||
assertEquals("PATCH", request.method)
|
||||
assertEquals("/api/albums/a1", request.path)
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains("\"title\":\"New\""))
|
||||
assertTrue(body.contains("\"cover_photo_id\":\"p1\""))
|
||||
assertFalse(body.contains("clear_cover"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateAlbum can clear the cover`() = runTest {
|
||||
server.enqueue(MockResponse().setBody("""{"album":{"id":"a1"}}"""))
|
||||
|
||||
api.updateAlbum("a1", clearCover = true)
|
||||
|
||||
val body = server.takeRequest().body.readUtf8()
|
||||
assertTrue(body.contains("\"clear_cover\":true"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `deleteAlbum issues DELETE`() = runTest {
|
||||
server.enqueue(MockResponse().setBody(""))
|
||||
|
||||
api.deleteAlbum("a1")
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("DELETE", request.method)
|
||||
assertEquals("/api/albums/a1", request.path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `members lists album participants`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"""{"members":[{"user_id":"u1","role":"owner","user":{"id":"u1","name":"Ann"}}]}""",
|
||||
),
|
||||
)
|
||||
|
||||
val members = api.members("a1")
|
||||
|
||||
assertEquals("owner", members.single().role)
|
||||
assertEquals("Ann", members.single().user.name)
|
||||
assertEquals("/api/albums/a1/members", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `share links are listed created and revoked`() = runTest {
|
||||
server.enqueue(MockResponse().setBody("""{"share_links":[{"id":"l1","token":"tok"}]}"""))
|
||||
assertEquals("l1", api.shareLinks("a1").single().id)
|
||||
assertEquals("/api/albums/a1/share", server.takeRequest().path)
|
||||
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(201).setBody("""{"share_link":{"id":"l2","token":"tok2"}}"""),
|
||||
)
|
||||
assertEquals("l2", api.createShareLink("a1", expiresIn = "24h").id)
|
||||
val create = server.takeRequest()
|
||||
assertEquals("POST", create.method)
|
||||
assertTrue(create.body.readUtf8().contains("\"expires_in\":\"24h\""))
|
||||
|
||||
server.enqueue(MockResponse().setBody(""))
|
||||
api.revokeShareLink("a1", "l2")
|
||||
assertEquals("DELETE", server.takeRequest().method)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copyPhotos posts a copy batch`() = runTest {
|
||||
server.enqueue(MockResponse().setBody("""{"photos":[]}"""))
|
||||
|
||||
api.copyPhotos(listOf("p1", "p2"), "a2")
|
||||
|
||||
val request = server.takeRequest()
|
||||
assertEquals("/api/photo-batch/copy", request.path)
|
||||
val body = request.body.readUtf8()
|
||||
assertTrue(body.contains("\"ids\":[\"p1\",\"p2\"]"))
|
||||
assertTrue(body.contains("\"album_id\":\"a2\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `addTag removeTag and tags use the tag endpoints`() = runTest {
|
||||
server.enqueue(
|
||||
MockResponse().setBody("""{"photo":{"id":"p1","tags":[{"id":"t1","name":"sun"}]}}"""),
|
||||
)
|
||||
assertEquals("sun", api.addTag("p1", "sun").tags.single().name)
|
||||
assertEquals("/api/photos/p1/tags", server.takeRequest().path)
|
||||
|
||||
server.enqueue(MockResponse().setBody("""{"photo":{"id":"p1","tags":[]}}"""))
|
||||
assertTrue(api.removeTag("p1", "t1").tags.isEmpty())
|
||||
assertEquals("/api/photos/p1/tags/t1", server.takeRequest().path)
|
||||
|
||||
server.enqueue(MockResponse().setBody("""{"tags":[{"id":"t2","name":"sunset"}]}"""))
|
||||
assertEquals("sunset", api.tags("sun").single().name)
|
||||
assertEquals("/api/tags?q=sun", server.takeRequest().path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.galo.android.data.network.dto
|
||||
|
||||
import com.galo.android.data.mapper.toDomain
|
||||
import com.galo.android.data.network.GaloJson
|
||||
import com.galo.android.data.network.ObjectUrlResolver
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Deserialization of the rich photo payload used by the attributes/actions sheet. */
|
||||
class PhotoDtoTest {
|
||||
|
||||
private val resolver = ObjectUrlResolver { "https://photos.example.com" }
|
||||
|
||||
private val json = """
|
||||
{
|
||||
"id": "p1",
|
||||
"album_id": "a1",
|
||||
"original_filename": "IMG_0001.jpg",
|
||||
"kind": "image",
|
||||
"mime_type": "image/jpeg",
|
||||
"width": 4000,
|
||||
"height": 3000,
|
||||
"size": 1234567,
|
||||
"taken_at": "2024-05-01T10:00:00Z",
|
||||
"caption": "",
|
||||
"tags": [{"id": "t1", "name": "pano360"}],
|
||||
"exif": {"Make": "Canon", "FNumber": 2.8, "ExposureTime": "1/250"},
|
||||
"uploader": {"id": "u1", "name": "Ann", "avatar_url": "/api/avatars/u1/v2"},
|
||||
"download_url": "/api/photos/p1/download"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `decodes exif tags uploader and download url`() {
|
||||
val dto = GaloJson.decodeFromString<PhotoDto>(json)
|
||||
|
||||
assertEquals("Canon", dto.exif["Make"]!!.toString().trim('"'))
|
||||
assertEquals("pano360", dto.tags.single().name)
|
||||
assertEquals("Ann", dto.uploader?.name)
|
||||
assertEquals("/api/photos/p1/download", dto.downloadUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maps rich fields into the domain photo`() {
|
||||
val photo = GaloJson.decodeFromString<PhotoDto>(json).toDomain(resolver)
|
||||
|
||||
assertEquals("Canon", photo.exif["Make"])
|
||||
assertEquals("2.8", photo.exif["FNumber"])
|
||||
assertEquals("1/250", photo.exif["ExposureTime"])
|
||||
assertEquals(listOf("pano360"), photo.tags.map { it.name })
|
||||
assertTrue(photo.isPanorama)
|
||||
assertEquals("Ann", photo.uploaderName)
|
||||
assertEquals(
|
||||
"https://photos.example.com/api/avatars/u1/v2",
|
||||
photo.uploaderAvatarUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `photo without exif decodes to empty attributes`() {
|
||||
val minimal = """{"id":"p2","album_id":"a1","original_filename":"x.jpg","kind":"image"}"""
|
||||
val photo = GaloJson.decodeFromString<PhotoDto>(minimal).toDomain(resolver)
|
||||
|
||||
assertTrue(photo.exif.isEmpty())
|
||||
assertTrue(photo.tags.isEmpty())
|
||||
assertEquals(null, photo.uploaderName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.galo.android.data.network.dto
|
||||
|
||||
import com.galo.android.data.network.GaloJson
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** The account payload carries saved preferences, including photo attribute settings. */
|
||||
class UserPreferencesDtoTest {
|
||||
|
||||
@Test
|
||||
fun `decodes photo_info preferences from the account payload`() {
|
||||
val json = """
|
||||
{
|
||||
"user": {"id": "u1", "name": "Ann"},
|
||||
"preferences": {
|
||||
"photo_info": {
|
||||
"visible": ["Make", "Model"],
|
||||
"labels": {"Make": "Камера", "Model": "Модель"}
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val envelope = GaloJson.decodeFromString<UserEnvelopeDto>(json)
|
||||
|
||||
assertEquals(listOf("Make", "Model"), envelope.preferences?.photoInfo?.visible)
|
||||
assertEquals("Камера", envelope.preferences?.photoInfo?.labels?.get("Make"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `account payload without preferences decodes to null`() {
|
||||
val envelope = GaloJson.decodeFromString<UserEnvelopeDto>("""{"user":{"id":"u1","name":"Ann"}}""")
|
||||
|
||||
assertEquals(null, envelope.preferences)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preferences round-trip keeps photo_info shape`() {
|
||||
val dto = PreferencesDto(
|
||||
photoInfo = PhotoInfoPrefsDto(visible = listOf("ISO"), labels = mapOf("ISO" to "ISO")),
|
||||
)
|
||||
val encoded = GaloJson.encodeToString(PreferencesDto.serializer(), dto)
|
||||
|
||||
assertEquals(
|
||||
dto,
|
||||
GaloJson.decodeFromString<PreferencesDto>(encoded),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.galo.android.data.sync
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ProvenanceStampTest {
|
||||
|
||||
@Test
|
||||
fun `marker round-trips the sync mode`() {
|
||||
val marker = Provenance(
|
||||
serverAlbumId = "srv1",
|
||||
serverPhotoId = "p1",
|
||||
originalHash = "h",
|
||||
deviceAlbumId = "b1",
|
||||
mode = "MOVE",
|
||||
assetKey = "b1/1",
|
||||
)
|
||||
|
||||
val decoded = ProvenanceStamp.decode(ProvenanceStamp.encode(marker))
|
||||
|
||||
assertEquals(marker, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a legacy marker may carry a non-mode value`() {
|
||||
// Older builds wrote the asset state here; decoding must still work and
|
||||
// the caller decides it is not a valid mode.
|
||||
val legacy = "galo|album=srv1|photo=p1|hash=h|device=b1|mode=PREVIEW|asset=b1/1"
|
||||
|
||||
assertEquals("PREVIEW", ProvenanceStamp.decode(legacy)?.mode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `bad markers decode to null`() {
|
||||
assertNull(ProvenanceStamp.decode(null))
|
||||
assertNull(ProvenanceStamp.decode("not-galo"))
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,33 @@
|
||||
package com.galo.android.data.sync
|
||||
|
||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RestoreManagerTest {
|
||||
|
||||
private fun asset(
|
||||
key: String,
|
||||
serverPhotoId: String? = "p1",
|
||||
state: LocalAssetState = LocalAssetState.PREVIEW,
|
||||
) = DeviceAssetEntity(
|
||||
assetKey = key,
|
||||
deviceAlbumId = "b1",
|
||||
mediaStoreId = 1L,
|
||||
uri = "content://media/external/images/media/1",
|
||||
displayName = "a.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
size = 1L,
|
||||
dateModified = 1L,
|
||||
contentHash = "h",
|
||||
serverPhotoId = serverPhotoId,
|
||||
uploadedAt = 1L,
|
||||
state = state.name,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `restoring fits when there is enough space`() {
|
||||
assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100))
|
||||
@@ -16,4 +38,16 @@ class RestoreManagerTest {
|
||||
fun `restoring is deferred when space is short`() {
|
||||
assertFalse(RestoreManager.canRestore(requiredBytes = 500, availableBytes = 100))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `restore targets are previews that have a server photo`() {
|
||||
val preview = asset("b1/1")
|
||||
val local = asset("b1/2", state = LocalAssetState.LOCAL)
|
||||
val noServer = asset("b1/3", serverPhotoId = null)
|
||||
val awaiting = asset("b1/4", state = LocalAssetState.AWAITING_FREE)
|
||||
|
||||
val targets = RestoreManager.restoreTargets(listOf(preview, local, noServer, awaiting))
|
||||
|
||||
assertEquals(listOf("b1/1"), targets.map { it.assetKey })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class SyncCoordinatorTest {
|
||||
|
||||
private class FakeQueue : SyncUploadQueue {
|
||||
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
|
||||
val pending = mutableSetOf<String>()
|
||||
override suspend fun enqueue(
|
||||
deviceAlbumId: String,
|
||||
serverAlbumId: String,
|
||||
@@ -61,6 +62,8 @@ class SyncCoordinatorTest {
|
||||
) {
|
||||
enqueued += Triple(deviceAlbumId, serverAlbumId, asset)
|
||||
}
|
||||
|
||||
override suspend fun isPending(assetKey: String): Boolean = assetKey in pending
|
||||
}
|
||||
|
||||
private class FakeProvider : LocalAssetProvider {
|
||||
@@ -267,9 +270,75 @@ class SyncCoordinatorTest {
|
||||
assertEquals(LocalAssetState.AWAITING_FREE.name, db.syncDao().getAsset("b1/1")?.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `awaiting-free assets are retried on a later pass without a prompt`() = runBlocking {
|
||||
link(SyncMode.MOVE)
|
||||
remote.serverMap = mapOf("h" to "p1")
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"h", "p1", 1L, LocalAssetState.AWAITING_FREE.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
provider.items = listOf(asset("b1/1", hash = "h"))
|
||||
offloader.result = OffloadResult.Done
|
||||
|
||||
coordinator.reconcile("b1")
|
||||
|
||||
assertEquals(listOf("b1/1"), offloader.offloaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a linked asset still on the device is offloaded in move mode`() = runBlocking {
|
||||
link(SyncMode.MOVE)
|
||||
remote.serverMap = mapOf("h" to "p1")
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"h", "p1", 1L, LocalAssetState.LOCAL.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
provider.items = listOf(asset("b1/1", hash = "h"))
|
||||
offloader.result = OffloadResult.Done
|
||||
|
||||
coordinator.reconcile("b1")
|
||||
|
||||
assertEquals(listOf("b1/1"), offloader.offloaded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move delegates to the server`() = runBlocking {
|
||||
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
|
||||
assertEquals(listOf(listOf("p1", "p2") to "srv2"), remote.moves)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reconcileAll reconciles every listed gallery including partial ones`() = runBlocking {
|
||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b1", "Camera", "srv1", SyncMode.COPY.name, 1L))
|
||||
db.syncDao().upsertSyncedAlbum(SyncedAlbumEntity("b2", "Trips", "srv2", SyncMode.COPY.name, 1L))
|
||||
provider.items = listOf(asset("x/1", hash = "h2"))
|
||||
|
||||
val contacted = coordinator.reconcileAll(listOf("b1", "b2", "missing"))
|
||||
|
||||
assertTrue(contacted)
|
||||
assertEquals(setOf("b1", "b2"), queue.enqueued.map { it.first }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `indexed album ids list galleries with local provenance`() = runBlocking {
|
||||
db.syncDao().upsertAssets(
|
||||
listOf(
|
||||
DeviceAssetEntity(
|
||||
"b1/1", "b1", 1L, "u", "a.jpg", "image/jpeg", 1L, 1L,
|
||||
"h", "p1", 1L, LocalAssetState.UPLOADED.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("b1"), db.syncDao().indexedAlbumIds())
|
||||
}
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.galo.android.domain.attributes
|
||||
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.DeviceAlbum
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AlbumAttributesResolverTest {
|
||||
|
||||
private fun serverAlbum(id: String, title: String) = Album(
|
||||
id = id,
|
||||
title = title,
|
||||
description = "server description",
|
||||
ownerId = "u1",
|
||||
coverPhotoId = "p1",
|
||||
coverThumbUrl = "https://host/thumb",
|
||||
coverMediumUrl = null,
|
||||
photosCount = 7,
|
||||
membersCount = 3,
|
||||
updatedAt = null,
|
||||
)
|
||||
|
||||
private fun deviceAlbum(id: String, name: String) = DeviceAlbum(
|
||||
id = id,
|
||||
name = name,
|
||||
itemCount = 12,
|
||||
coverUri = "content://media/$id/1",
|
||||
relativePath = "DCIM/$name/",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `server album uses server attributes and enables everything by role`() {
|
||||
val attributes = AlbumAttributesResolver.resolve(
|
||||
AlbumAttributeSource.Server(serverAlbum("a1", "Summer"), role = "owner"),
|
||||
)
|
||||
|
||||
assertEquals("Summer", attributes.title)
|
||||
assertEquals("server description", attributes.description)
|
||||
assertEquals("https://host/thumb", attributes.coverUrl)
|
||||
assertEquals(7, attributes.photosCount)
|
||||
assertEquals(3, attributes.membersCount)
|
||||
assertEquals("a1", attributes.serverAlbumId)
|
||||
assertTrue(attributes.canEdit)
|
||||
assertFalse(attributes.isLocal)
|
||||
assertTrue(attributes.membersEnabled)
|
||||
assertTrue(attributes.shareEnabled)
|
||||
assertTrue(attributes.canRename)
|
||||
assertTrue(attributes.canDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server viewer cannot rename, delete or manage links`() {
|
||||
val attributes = AlbumAttributesResolver.resolve(
|
||||
AlbumAttributeSource.Server(serverAlbum("a1", "Summer"), role = "viewer"),
|
||||
)
|
||||
|
||||
assertFalse(attributes.canEdit)
|
||||
assertTrue(attributes.membersEnabled)
|
||||
assertFalse(attributes.shareEnabled)
|
||||
assertFalse(attributes.canRename)
|
||||
assertFalse(attributes.canDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `linked local album uses local metadata and enables actions plus linked navigation`() {
|
||||
val attributes = AlbumAttributesResolver.resolve(
|
||||
AlbumAttributeSource.Local(
|
||||
device = deviceAlbum("d1", "Camera"),
|
||||
title = "Моё имя",
|
||||
coverUrl = "content://media/d1/9",
|
||||
linkedServer = serverAlbum("a9", "Trips"),
|
||||
role = "admin",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("Моё имя", attributes.title)
|
||||
assertEquals("content://media/d1/9", attributes.coverUrl)
|
||||
assertEquals(12, attributes.photosCount)
|
||||
assertEquals("a9", attributes.serverAlbumId)
|
||||
assertTrue(attributes.isLocal)
|
||||
assertTrue(attributes.membersEnabled)
|
||||
assertTrue(attributes.shareEnabled)
|
||||
assertTrue(attributes.canRename)
|
||||
assertTrue(attributes.canDelete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlinked local album shows disabled navigation and local actions`() {
|
||||
val attributes = AlbumAttributesResolver.resolve(
|
||||
AlbumAttributeSource.Local(
|
||||
device = deviceAlbum("d1", "Camera"),
|
||||
title = "Camera",
|
||||
coverUrl = null,
|
||||
linkedServer = null,
|
||||
role = null,
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("Camera", attributes.title)
|
||||
assertEquals("", attributes.description)
|
||||
assertNull(attributes.serverAlbumId)
|
||||
assertEquals(12, attributes.photosCount)
|
||||
assertTrue(attributes.isLocal)
|
||||
assertFalse(attributes.membersEnabled)
|
||||
assertFalse(attributes.shareEnabled)
|
||||
assertTrue(attributes.settingsEnabled)
|
||||
assertTrue(attributes.canRename)
|
||||
assertTrue(attributes.canDelete)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.galo.android.domain.attributes
|
||||
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PhotoAttributeResolverTest {
|
||||
|
||||
private fun media(id: Long) = DeviceMedia(
|
||||
id = id,
|
||||
uri = "content://media/external/images/media/$id",
|
||||
albumId = "b1",
|
||||
albumName = "Camera",
|
||||
displayName = "IMG_$id.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
size = 100L,
|
||||
width = 4000,
|
||||
height = 3000,
|
||||
dateModified = 5L,
|
||||
dateTaken = null,
|
||||
relativePath = "DCIM/Camera/",
|
||||
kind = MediaKind.IMAGE,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `mapped media resolves to the server photo`() {
|
||||
val source = PhotoAttributeResolver.resolve(media(1L), serverPhotoId = "p42")
|
||||
|
||||
assertTrue(source is PhotoAttributeSource.Server)
|
||||
assertEquals("p42", (source as PhotoAttributeSource.Server).photoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unmapped media resolves to the local item`() {
|
||||
val source = PhotoAttributeResolver.resolve(media(2L), serverPhotoId = null)
|
||||
|
||||
assertTrue(source is PhotoAttributeSource.Local)
|
||||
assertEquals(2L, (source as PhotoAttributeSource.Local).media.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank server id is treated as unmapped`() {
|
||||
val source = PhotoAttributeResolver.resolve(media(3L), serverPhotoId = " ")
|
||||
|
||||
assertTrue(source is PhotoAttributeSource.Local)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.galo.android.domain.exif
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** The catalog must stay in sync with the web catalog (`frontend/src/lib/exif.ts`). */
|
||||
class ExifCatalogTest {
|
||||
|
||||
@Test
|
||||
fun `base set matches the web default`() {
|
||||
assertEquals(
|
||||
listOf(
|
||||
"Make", "Model", "LensModel", "ExposureTime", "FNumber",
|
||||
"ISOSpeedRatings", "FocalLength", "FocalLengthIn35mmFilm",
|
||||
),
|
||||
EXIF_BASE,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `labels match the web catalog`() {
|
||||
assertEquals("Производитель", EXIF_LABELS["Make"])
|
||||
assertEquals("Модель", EXIF_LABELS["Model"])
|
||||
assertEquals("Объектив", EXIF_LABELS["LensModel"])
|
||||
assertEquals("Выдержка", EXIF_LABELS["ExposureTime"])
|
||||
assertEquals("Диафрагма", EXIF_LABELS["FNumber"])
|
||||
assertEquals("ISO", EXIF_LABELS["ISOSpeedRatings"])
|
||||
assertEquals("Фокусное расстояние", EXIF_LABELS["FocalLength"])
|
||||
assertEquals(35, EXIF_CATALOG.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formats exposure aperture focal and enums`() {
|
||||
assertEquals("1/250", formatExifValue("ExposureTime", "0.004"))
|
||||
assertEquals("2 с", formatExifValue("ExposureTime", "2"))
|
||||
assertEquals("f/2.8", formatExifValue("FNumber", "2.8"))
|
||||
assertEquals("35 мм", formatExifValue("FocalLength", "35"))
|
||||
assertEquals("+1 EV", formatExifValue("ExposureBiasValue", "1.0"))
|
||||
assertEquals("Авто", formatExifValue("WhiteBalance", "0"))
|
||||
assertEquals("canon", formatExifValue("Make", "canon"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `builds rows skipping gps and technical tags and sorting by label`() {
|
||||
val rows = buildExifRows(
|
||||
mapOf(
|
||||
"Make" to "Canon",
|
||||
"GPSLatitude" to "55.7",
|
||||
"ImageWidth" to "4000",
|
||||
"Model" to "R5",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("Model", "Make"), rows.map { it.key })
|
||||
assertFalse(rows.any { it.key == "GPSLatitude" })
|
||||
assertTrue(rows.all { it.value.isNotBlank() || it.key == "Make" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom labels override the catalog`() {
|
||||
val rows = buildExifRows(mapOf("Make" to "Canon"), labels = mapOf("Make" to "Камера"))
|
||||
|
||||
assertEquals("Камера", rows.single().label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.galo.android.domain.model
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SyncModeTest {
|
||||
|
||||
@Test
|
||||
fun `from returns disabled for unknown values`() {
|
||||
assertEquals(SyncMode.DISABLED, SyncMode.from("PREVIEW"))
|
||||
assertEquals(SyncMode.DISABLED, SyncMode.from(null))
|
||||
assertEquals(SyncMode.COPY, SyncMode.from("COPY"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseOrNull keeps only real modes`() {
|
||||
assertEquals(SyncMode.MOVE, SyncMode.parseOrNull("MOVE"))
|
||||
assertEquals(SyncMode.COPY, SyncMode.parseOrNull("COPY"))
|
||||
assertEquals(SyncMode.DISABLED, SyncMode.parseOrNull("DISABLED"))
|
||||
assertNull(SyncMode.parseOrNull("PREVIEW"))
|
||||
assertNull(SyncMode.parseOrNull(null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.galo.android.domain.sync
|
||||
|
||||
import com.galo.android.domain.model.LocalAssetState
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import com.galo.android.domain.sync.GallerySyncPolicy.LocalFix
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/** Full mode × state × linked matrix — every combination must be handled. */
|
||||
class GallerySyncPolicyTest {
|
||||
|
||||
private fun decide(mode: SyncMode, state: LocalAssetState, linked: Boolean) =
|
||||
GallerySyncPolicy.decide(mode, state, hasServerPhoto = linked)
|
||||
|
||||
@Test
|
||||
fun `server-only is never fixed locally`() {
|
||||
for (mode in SyncMode.entries) {
|
||||
for (linked in listOf(true, false)) {
|
||||
assertEquals(LocalFix.NONE, decide(mode, LocalAssetState.SERVER_ONLY, linked))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preview is restored unless in move mode`() {
|
||||
assertEquals(LocalFix.RESTORE, decide(SyncMode.COPY, LocalAssetState.PREVIEW, true))
|
||||
assertEquals(LocalFix.RESTORE, decide(SyncMode.DISABLED, LocalAssetState.PREVIEW, true))
|
||||
assertEquals(LocalFix.RESTORE, decide(SyncMode.COPY, LocalAssetState.PREVIEW, false))
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.MOVE, LocalAssetState.PREVIEW, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uploaded and awaiting-free are offloaded only in move mode`() {
|
||||
for (state in listOf(LocalAssetState.UPLOADED, LocalAssetState.AWAITING_FREE)) {
|
||||
assertEquals(LocalFix.OFFLOAD, decide(SyncMode.MOVE, state, true))
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.MOVE, state, false))
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.COPY, state, true))
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.DISABLED, state, true))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local uploads when a server mode is chosen and not yet linked`() {
|
||||
assertEquals(LocalFix.UPLOAD, decide(SyncMode.COPY, LocalAssetState.LOCAL, false))
|
||||
assertEquals(LocalFix.UPLOAD, decide(SyncMode.MOVE, LocalAssetState.LOCAL, false))
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.DISABLED, LocalAssetState.LOCAL, false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local already linked is offloaded in move and kept otherwise`() {
|
||||
assertEquals(LocalFix.OFFLOAD, decide(SyncMode.MOVE, LocalAssetState.LOCAL, true))
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.COPY, LocalAssetState.LOCAL, true))
|
||||
assertEquals(LocalFix.NONE, decide(SyncMode.DISABLED, LocalAssetState.LOCAL, true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every combination yields a defined action`() {
|
||||
for (mode in SyncMode.entries) {
|
||||
for (state in LocalAssetState.entries) {
|
||||
for (linked in listOf(true, false)) {
|
||||
val fix = decide(mode, state, linked)
|
||||
// Sanity: restore never happens in move, offload never outside move.
|
||||
if (mode == SyncMode.MOVE) {
|
||||
assert(fix != LocalFix.RESTORE)
|
||||
}
|
||||
if (mode != SyncMode.MOVE) {
|
||||
assert(fix != LocalFix.OFFLOAD)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.galo.android.ui
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AllFilesAccessDialogTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `enable launches the settings intent`() {
|
||||
var enabled = false
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AllFilesAccessDialog(onEnable = { enabled = true }, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_ALL_FILES_PROMPT_DIALOG).assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_ALL_FILES_PROMPT_ENABLE).performClick()
|
||||
|
||||
assertTrue(enabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `later dismisses the prompt`() {
|
||||
var dismissed = false
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AllFilesAccessDialog(onEnable = {}, onDismiss = { dismissed = true })
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Позже").performClick()
|
||||
|
||||
assertTrue(dismissed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.galo.android.ui.album
|
||||
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AlbumDialogsTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun `rename dialog confirms with the edited title`() {
|
||||
var confirmed: String? = null
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
RenameAlbumDialog(initial = "Старое", onConfirm = { confirmed = it }, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextClearance()
|
||||
compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextInput("Новое")
|
||||
compose.onNodeWithTag(TAG_ALBUM_RENAME_CONFIRM).performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals("Новое", confirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename is disabled for a blank title`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
RenameAlbumDialog(initial = " ", onConfirm = {}, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_ALBUM_RENAME_CONFIRM).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete dialog confirms deletion`() {
|
||||
var confirmed = false
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
DeleteAlbumDialog(title = "Отпуск", onConfirm = { confirmed = true }, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_ALBUM_DELETE_CONFIRM).performClick()
|
||||
|
||||
assertTrue(confirmed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.galo.android.ui.album
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.galo.android.data.network.dto.MemberDto
|
||||
import com.galo.android.data.network.dto.ShareLinkDto
|
||||
import com.galo.android.data.network.dto.UserDto
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AlbumSettingsContentTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun render(
|
||||
state: AlbumSettingsViewModel.UiState,
|
||||
onSave: (String, String) -> Unit = { _, _ -> },
|
||||
onCreateLink: (String) -> Unit = {},
|
||||
onRevokeLink: (String) -> Unit = {},
|
||||
) {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AlbumSettingsContent(
|
||||
state = state,
|
||||
onSave = onSave,
|
||||
onCreateLink = onCreateLink,
|
||||
onRevokeLink = onRevokeLink,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saving passes the edited title and description`() {
|
||||
var saved: Pair<String, String>? = null
|
||||
render(
|
||||
AlbumSettingsViewModel.UiState(title = "Старое", description = "desc"),
|
||||
onSave = { t, d -> saved = t to d },
|
||||
)
|
||||
|
||||
compose.onNodeWithTag(TAG_SETTINGS_TITLE_FIELD).performTextClearance()
|
||||
compose.onNodeWithTag(TAG_SETTINGS_TITLE_FIELD).performTextInput("Новое")
|
||||
compose.onNodeWithTag(TAG_SETTINGS_SAVE).performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertEquals("Новое" to "desc", saved)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved indicator appears after a save`() {
|
||||
render(AlbumSettingsViewModel.UiState(title = "A", description = "", saved = true))
|
||||
|
||||
compose.onNodeWithTag(TAG_SETTINGS_SAVED).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `members are listed`() {
|
||||
render(
|
||||
AlbumSettingsViewModel.UiState(
|
||||
members = listOf(MemberDto(userId = "u1", role = "owner", user = UserDto(id = "u1", name = "Ann"))),
|
||||
),
|
||||
)
|
||||
|
||||
compose.onNodeWithTag(TAG_SETTINGS_MEMBER_PREFIX + "u1").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `links can be created and revoked`() {
|
||||
var created = false
|
||||
var revoked: String? = null
|
||||
render(
|
||||
AlbumSettingsViewModel.UiState(
|
||||
shareLinks = listOf(ShareLinkDto(id = "l1", token = "tok")),
|
||||
),
|
||||
onCreateLink = { created = true },
|
||||
onRevokeLink = { revoked = it },
|
||||
)
|
||||
|
||||
compose.onNodeWithTag(TAG_SETTINGS_LINK_CREATE).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_SETTINGS_LINK_REVOKE_PREFIX + "l1").performScrollTo().performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertTrue(created)
|
||||
assertEquals("l1", revoked)
|
||||
}
|
||||
}
|
||||
@@ -72,11 +72,13 @@ class AlbumsContentTest {
|
||||
compose.onNodeWithTag(TAG_ALBUMS_GRID).assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
|
||||
compose.onNodeWithText("Summer").assertIsDisplayed()
|
||||
compose.onNodeWithText("4 фото · 2 участн.").assertIsDisplayed()
|
||||
compose.onNodeWithText("4 фото").assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "a1", useUnmergedTree = true).assertExists()
|
||||
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "a1", useUnmergedTree = true).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `mixed list shows device album with source and link`() {
|
||||
fun `mixed list shows device album with storage and link`() {
|
||||
val items = AlbumListBuilder.build(
|
||||
albums = listOf(album("a1", "Отпуск")),
|
||||
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
|
||||
@@ -88,10 +90,27 @@ class AlbumsContentTest {
|
||||
compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed()
|
||||
// The linked server album is collapsed into the device card, not shown twice.
|
||||
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist()
|
||||
compose.onNodeWithText("Телефон").assertIsDisplayed()
|
||||
compose.onNodeWithText("9 эл.").assertIsDisplayed()
|
||||
compose.onNodeWithText("Camera").assertIsDisplayed()
|
||||
compose.onNodeWithText("→ Отпуск").assertIsDisplayed()
|
||||
compose.onNodeWithText("копии").assertIsDisplayed()
|
||||
compose.onNodeWithText("9 эл.").assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists()
|
||||
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device album caption uses storage icons and hides matching server name`() {
|
||||
val items = AlbumListBuilder.build(
|
||||
albums = listOf(album("a1", "Отпуск")),
|
||||
links = listOf(AlbumLink("b1", "Отпуск", "a1", SyncMode.DISABLED)),
|
||||
devices = listOf(device("b1", "Отпуск", 5)),
|
||||
)
|
||||
compose.setContent {
|
||||
screen(AlbumsViewModel.UiState(items = items, deviceAccess = true))
|
||||
}
|
||||
compose.onNodeWithText("5 эл.").assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_ALBUM_STORAGE_DEVICE_PREFIX + "b1", useUnmergedTree = true).assertExists()
|
||||
compose.onNodeWithTag(TAG_ALBUM_STORAGE_CLOUD_PREFIX + "b1", useUnmergedTree = true).assertDoesNotExist()
|
||||
compose.onNodeWithText("→ Отпуск").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.galo.android.ui.devicealbum
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||
import androidx.compose.ui.test.assertTextContains
|
||||
import androidx.compose.ui.test.hasTestTag
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
@@ -10,10 +11,21 @@ import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollToNode
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.test.swipeRight
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.SyncMode
|
||||
import com.galo.android.ui.album.TAG_ALBUM_RENAME_CONFIRM
|
||||
import com.galo.android.ui.album.TAG_ALBUM_RENAME_FIELD
|
||||
import com.galo.android.ui.panel.TAG_ALBUM_PANEL
|
||||
import com.galo.android.ui.panel.TAG_PANEL_DELETE
|
||||
import com.galo.android.ui.panel.TAG_PANEL_RENAME
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -78,6 +90,12 @@ class DeviceAlbumContentTest {
|
||||
onMode: (SyncMode) -> Unit = {},
|
||||
onTarget: (String) -> Unit = {},
|
||||
onCreateAlbum: (String) -> Unit = {},
|
||||
role: String = "",
|
||||
localName: String? = null,
|
||||
localCoverUrl: String? = null,
|
||||
onDeleteAlbum: () -> Unit = {},
|
||||
onRenameAlbum: (String) -> Unit = {},
|
||||
onSetCover: (String?) -> Unit = {},
|
||||
) {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
DeviceAlbumContent(
|
||||
@@ -88,6 +106,12 @@ class DeviceAlbumContentTest {
|
||||
onRestoreOriginals = {},
|
||||
onOpen = {},
|
||||
onCreateAlbum = onCreateAlbum,
|
||||
role = role,
|
||||
localName = localName,
|
||||
localCoverUrl = localCoverUrl,
|
||||
onDeleteAlbum = onDeleteAlbum,
|
||||
onRenameAlbum = onRenameAlbum,
|
||||
onSetCover = onSetCover,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -168,6 +192,45 @@ class DeviceAlbumContentTest {
|
||||
fun `mode action for the same mode does nothing`() {
|
||||
assertEquals(null, modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.COPY))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all mode transition variants are routed correctly`() {
|
||||
// Off → copy/move without a target: pick one first.
|
||||
assertEquals(
|
||||
ModeAction.PickTarget(SyncMode.COPY),
|
||||
modeAction(SyncMode.DISABLED, hasTarget = false, target = SyncMode.COPY),
|
||||
)
|
||||
assertEquals(
|
||||
ModeAction.PickTarget(SyncMode.MOVE),
|
||||
modeAction(SyncMode.DISABLED, hasTarget = false, target = SyncMode.MOVE),
|
||||
)
|
||||
// Off → copy with a target: confirm.
|
||||
assertEquals(
|
||||
ModeAction.Confirm(SyncMode.COPY),
|
||||
modeAction(SyncMode.DISABLED, hasTarget = true, target = SyncMode.COPY),
|
||||
)
|
||||
// Copy → move: confirm.
|
||||
assertEquals(
|
||||
ModeAction.Confirm(SyncMode.MOVE),
|
||||
modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.MOVE),
|
||||
)
|
||||
// Move → copy/off: warn and offer to restore the originals.
|
||||
assertEquals(
|
||||
ModeAction.LeaveC(SyncMode.COPY),
|
||||
modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.COPY),
|
||||
)
|
||||
assertEquals(
|
||||
ModeAction.LeaveC(SyncMode.DISABLED),
|
||||
modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.DISABLED),
|
||||
)
|
||||
// Copy → off: applies directly.
|
||||
assertEquals(
|
||||
ModeAction.Apply(SyncMode.DISABLED),
|
||||
modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.DISABLED),
|
||||
)
|
||||
// Same mode: nothing.
|
||||
assertEquals(null, modeAction(SyncMode.MOVE, hasTarget = true, target = SyncMode.MOVE))
|
||||
}
|
||||
@Test
|
||||
fun `target picker lists albums and picks one`() {
|
||||
var picked: String? = null
|
||||
@@ -275,4 +338,107 @@ class DeviceAlbumContentTest {
|
||||
compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick()
|
||||
assertEquals(SyncMode.MOVE, chosen)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swipe left opens the attributes panel`() {
|
||||
compose.setContent {
|
||||
content(
|
||||
DeviceAlbumViewModel.UiState(
|
||||
name = "Camera",
|
||||
items = listOf(media(1), media(2)),
|
||||
serverAlbumId = "s1",
|
||||
serverAlbums = listOf(album("s1", "Отпуск")),
|
||||
),
|
||||
role = "owner",
|
||||
)
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sync settings drawer opens independently of the panel`() {
|
||||
compose.setContent {
|
||||
content(
|
||||
DeviceAlbumViewModel.UiState(
|
||||
name = "Camera",
|
||||
items = listOf(media(1)),
|
||||
serverAlbumId = "s1",
|
||||
serverAlbums = listOf(album("s1", "Отпуск")),
|
||||
),
|
||||
role = "owner",
|
||||
)
|
||||
}
|
||||
|
||||
openSettings()
|
||||
|
||||
compose.onNodeWithTag(TAG_DEVICE_SETTINGS).assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swiping right on the open panel closes it instead of opening sync settings`() {
|
||||
compose.setContent {
|
||||
content(
|
||||
DeviceAlbumViewModel.UiState(
|
||||
name = "Camera",
|
||||
items = listOf(media(1), media(2)),
|
||||
serverAlbumId = "s1",
|
||||
serverAlbums = listOf(album("s1", "Отпуск")),
|
||||
),
|
||||
role = "owner",
|
||||
)
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertIsDisplayed()
|
||||
|
||||
compose.onNodeWithTag(TAG_ALBUM_PANEL).performTouchInput { swipeRight() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(TAG_ALBUM_PANEL).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_DEVICE_SETTINGS).assertIsNotDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local panel rename reports the new name`() {
|
||||
var name: String? = null
|
||||
compose.setContent {
|
||||
content(
|
||||
DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1))),
|
||||
onRenameAlbum = { name = it },
|
||||
)
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag(TAG_PANEL_RENAME).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextClearance()
|
||||
compose.onNodeWithTag(TAG_ALBUM_RENAME_FIELD).performTextInput("Поездка")
|
||||
compose.onNodeWithTag(TAG_ALBUM_RENAME_CONFIRM).performClick()
|
||||
|
||||
assertEquals("Поездка", name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local panel delete asks confirmation then deletes locally`() {
|
||||
var deleted = false
|
||||
compose.setContent {
|
||||
content(
|
||||
DeviceAlbumViewModel.UiState(name = "Camera", items = listOf(media(1), media(2))),
|
||||
onDeleteAlbum = { deleted = true },
|
||||
)
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_DEVICE_GRID).performTouchInput { swipeLeft() }
|
||||
compose.waitForIdle()
|
||||
compose.onNodeWithTag(TAG_PANEL_DELETE).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_LOCAL_DELETE_CONFIRM).performClick()
|
||||
|
||||
assertTrue(deleted)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.galo.android.ui.devicealbum
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import com.galo.android.domain.model.DeviceMedia
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class DeviceViewerSheetTest {
|
||||
|
||||
private val sheetTag = "device_sheet_content"
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun media(id: Long, kind: MediaKind = MediaKind.IMAGE) = DeviceMedia(
|
||||
id = id,
|
||||
uri = "content://media/external/images/media/$id",
|
||||
albumId = "b1",
|
||||
albumName = "Camera",
|
||||
displayName = "IMG_$id.jpg",
|
||||
mimeType = "image/jpeg",
|
||||
size = 100L,
|
||||
width = 4000,
|
||||
height = 3000,
|
||||
dateModified = 5L,
|
||||
dateTaken = null,
|
||||
relativePath = "DCIM/Camera/",
|
||||
kind = kind,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `swipe up opens the sheet in the device viewer`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
DeviceViewerScreen(
|
||||
items = listOf(media(1)),
|
||||
initialIndex = 0,
|
||||
onBack = {},
|
||||
sheet = { Text("SHEET", modifier = Modifier.testTag(sheetTag)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(sheetTag).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_DEVICE_VIEWER_PAGER).performTouchInput { swipeUp() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(sheetTag).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swipe up does not open the sheet on a video`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
DeviceViewerScreen(
|
||||
items = listOf(media(2, MediaKind.VIDEO)),
|
||||
initialIndex = 0,
|
||||
onBack = {},
|
||||
sheet = { Text("SHEET", modifier = Modifier.testTag(sheetTag)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_DEVICE_VIEWER_PAGER).performTouchInput { swipeUp() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(sheetTag).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package com.galo.android.ui.panel
|
||||
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.galo.android.domain.attributes.AlbumAttributes
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AlbumAttributesPanelTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun serverAttributes(role: String) = AlbumAttributes(
|
||||
title = "Summer",
|
||||
description = "desc",
|
||||
coverUrl = "https://host/cover",
|
||||
photosCount = 4,
|
||||
membersCount = 2,
|
||||
serverAlbumId = "a1",
|
||||
role = role,
|
||||
isLocal = false,
|
||||
membersEnabled = true,
|
||||
shareEnabled = role == "owner" || role == "admin",
|
||||
settingsEnabled = role == "owner" || role == "admin",
|
||||
canRename = role == "owner" || role == "admin",
|
||||
canDelete = role == "owner" || role == "admin",
|
||||
)
|
||||
|
||||
private fun localAttributes(linked: Boolean) = AlbumAttributes(
|
||||
title = "Camera",
|
||||
description = "",
|
||||
coverUrl = "content://media/d1/1",
|
||||
photosCount = 9,
|
||||
membersCount = if (linked) 2 else 0,
|
||||
serverAlbumId = if (linked) "s1" else null,
|
||||
role = if (linked) "owner" else null,
|
||||
isLocal = true,
|
||||
membersEnabled = linked,
|
||||
shareEnabled = linked,
|
||||
settingsEnabled = true,
|
||||
canRename = true,
|
||||
canDelete = true,
|
||||
)
|
||||
|
||||
private fun render(
|
||||
attributes: AlbumAttributes,
|
||||
onPickCover: () -> Unit = {},
|
||||
onRename: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
) {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AlbumAttributesPanel(
|
||||
attributes = attributes,
|
||||
onPickCover = onPickCover,
|
||||
onResetCover = {},
|
||||
onOpenMembers = {},
|
||||
onOpenSettings = {},
|
||||
onOpenShare = {},
|
||||
onRename = onRename,
|
||||
onDelete = onDelete,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server owner sees cover controls navigation and actions`() {
|
||||
render(serverAttributes("owner"))
|
||||
|
||||
compose.onNodeWithTag(TAG_PANEL_COVER_PICK).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_COVER_RESET).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_MEMBERS).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_SETTINGS).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_SHARE).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_RENAME).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_DELETE).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `server viewer has no cover controls and disabled editable navigation`() {
|
||||
render(serverAttributes("viewer"))
|
||||
|
||||
compose.onNodeWithTag(TAG_PANEL_COVER_PICK).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_PANEL_MEMBERS).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_SETTINGS).assertIsNotEnabled()
|
||||
compose.onNodeWithTag(TAG_PANEL_SHARE).assertIsNotEnabled()
|
||||
compose.onNodeWithTag(TAG_PANEL_RENAME).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_PANEL_DELETE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unlinked local album shows cover and actions with disabled navigation`() {
|
||||
render(localAttributes(linked = false))
|
||||
|
||||
compose.onNodeWithTag(TAG_PANEL_COVER_PICK).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_MEMBERS).assertIsNotEnabled()
|
||||
compose.onNodeWithTag(TAG_PANEL_SHARE).assertIsNotEnabled()
|
||||
compose.onNodeWithTag(TAG_PANEL_SETTINGS).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_RENAME).assertExists()
|
||||
compose.onNodeWithTag(TAG_PANEL_DELETE).assertExists()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `action rows invoke their callbacks`() {
|
||||
var picked = false
|
||||
var renamed = false
|
||||
var deleted = false
|
||||
render(
|
||||
localAttributes(linked = false),
|
||||
onPickCover = { picked = true },
|
||||
onRename = { renamed = true },
|
||||
onDelete = { deleted = true },
|
||||
)
|
||||
|
||||
compose.onNodeWithTag(TAG_PANEL_COVER_PICK).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_PANEL_RENAME).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_PANEL_DELETE).performScrollTo().performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertTrue(picked)
|
||||
assertTrue(renamed)
|
||||
assertTrue(deleted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package com.galo.android.ui.panel
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.click
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.swipeLeft
|
||||
import androidx.compose.ui.test.swipeRight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class EndDrawerScaffoldTest {
|
||||
|
||||
private val contentTag = "content"
|
||||
private val panelTag = "panel"
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun render(open: MutableState<Boolean>) {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
EndDrawerScaffold(
|
||||
open = open.value,
|
||||
onOpenChange = { open.value = it },
|
||||
drawerWidth = 120.dp,
|
||||
drawerContent = { Text("PANEL", modifier = Modifier.testTag(panelTag)) },
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().testTag(contentTag))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swipe left opens the end drawer`() {
|
||||
val open = mutableStateOf(false)
|
||||
render(open)
|
||||
|
||||
compose.onNodeWithTag(panelTag).assertDoesNotExist()
|
||||
compose.onNodeWithTag(contentTag).performTouchInput { swipeLeft() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(panelTag).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tapping the scrim closes the end drawer`() {
|
||||
render(mutableStateOf(true))
|
||||
|
||||
compose.onNodeWithTag(panelTag).assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_END_DRAWER_SCRIM).performTouchInput { click(Offset(8f, 8f)) }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(panelTag).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swipe right on the panel closes it`() {
|
||||
render(mutableStateOf(true))
|
||||
|
||||
compose.onNodeWithTag(panelTag).assertIsDisplayed()
|
||||
compose.onNodeWithTag(panelTag).performTouchInput { swipeRight() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(panelTag).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class AttributeSettingsContentTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun render(
|
||||
state: AttributeSettingsViewModel.UiState,
|
||||
onToggle: (String) -> Unit = {},
|
||||
onSetAll: (Boolean) -> Unit = {},
|
||||
onSave: () -> Unit = {},
|
||||
onReset: () -> Unit = {},
|
||||
) {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AttributeSettingsContent(
|
||||
state = state,
|
||||
onToggle = onToggle,
|
||||
onSetAll = onSetAll,
|
||||
onLabel = { _, _ -> },
|
||||
onSave = onSave,
|
||||
onReset = onReset,
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all and none report their target state`() {
|
||||
var all: Boolean? = null
|
||||
render(AttributeSettingsViewModel.UiState(), onSetAll = { all = it })
|
||||
|
||||
compose.onNodeWithTag(TAG_ATTR_ALL).performClick()
|
||||
assertEquals(true, all)
|
||||
compose.onNodeWithTag(TAG_ATTR_NONE).performClick()
|
||||
assertEquals(false, all)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save and reset report`() {
|
||||
var saved = false
|
||||
var reset = false
|
||||
render(
|
||||
AttributeSettingsViewModel.UiState(),
|
||||
onSave = { saved = true },
|
||||
onReset = { reset = true },
|
||||
)
|
||||
|
||||
compose.onNodeWithTag(TAG_ATTR_SAVE).performClick()
|
||||
compose.onNodeWithTag(TAG_ATTR_RESET).performClick()
|
||||
|
||||
assertTrue(saved)
|
||||
assertTrue(reset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggling a row reports its key`() {
|
||||
var toggled: String? = null
|
||||
render(AttributeSettingsViewModel.UiState(), onToggle = { toggled = it })
|
||||
|
||||
compose.onNodeWithTag(TAG_ATTR_CHECK_PREFIX + "Make").performClick()
|
||||
|
||||
assertEquals("Make", toggled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saved indicator appears`() {
|
||||
render(AttributeSettingsViewModel.UiState(saved = true))
|
||||
|
||||
compose.onNodeWithTag(TAG_ATTR_SAVED).assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import com.galo.android.domain.exif.ExifRow
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class PhotoAttributesSheetTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun model(
|
||||
tags: List<String> = emptyList(),
|
||||
hidden: Int = 0,
|
||||
) = PhotoSheetModel(
|
||||
title = "IMG_0001.jpg",
|
||||
uploaderName = "Ann",
|
||||
size = 2048,
|
||||
mimeType = "image/jpeg",
|
||||
width = 4000,
|
||||
height = 3000,
|
||||
takenAt = "2024-05-01",
|
||||
createdAt = "2024-05-02",
|
||||
exifRows = listOf(ExifRow("Make", "Производитель", "Canon")),
|
||||
hiddenAttributeCount = hidden,
|
||||
tags = tags,
|
||||
isServer = true,
|
||||
canRename = true,
|
||||
canSetCover = true,
|
||||
canDelete = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `info section shows metadata and exif`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
PhotoAttributesSheet(model = model())
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Ann").assertIsDisplayed()
|
||||
compose.onNodeWithText("Canon").assertIsDisplayed()
|
||||
compose.onNodeWithText("4000×3000").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `more expands the hidden attributes`() {
|
||||
var expanded = false
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
PhotoAttributesSheet(model = model(hidden = 2), onShowAll = { expanded = true })
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_SHEET_MORE).assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_SHEET_MORE).performClick()
|
||||
|
||||
assertTrue(expanded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing a tag reports its name`() {
|
||||
var removed: String? = null
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
PhotoAttributesSheet(
|
||||
model = model(tags = listOf("sun")),
|
||||
onRemoveTag = { removed = it },
|
||||
onAddTag = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_SHEET_TAG_REMOVE_PREFIX + "sun").performScrollTo().performClick()
|
||||
|
||||
assertEquals("sun", removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adding a tag from a suggestion reports it`() {
|
||||
var added: String? = null
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
PhotoAttributesSheet(
|
||||
model = model(),
|
||||
onAddTag = { added = it },
|
||||
tagSuggestions = listOf("beach"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_SHEET_TAG_SUGGESTION_PREFIX + "beach").performScrollTo().performClick()
|
||||
|
||||
assertEquals("beach", added)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each action row invokes its callback`() {
|
||||
var download = false
|
||||
var move = false
|
||||
var copy = false
|
||||
var cover = false
|
||||
var delete = false
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
PhotoAttributesSheet(
|
||||
model = model(),
|
||||
onDownload = { download = true },
|
||||
onMove = { move = true },
|
||||
onCopy = { copy = true },
|
||||
onSetCover = { cover = true },
|
||||
onDelete = { delete = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_SHEET_MOVE).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_SHEET_COPY).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_SHEET_COVER).performScrollTo().performClick()
|
||||
compose.onNodeWithTag(TAG_SHEET_DELETE).performScrollTo().performClick()
|
||||
compose.waitForIdle()
|
||||
|
||||
assertTrue(download)
|
||||
assertTrue(move)
|
||||
assertTrue(copy)
|
||||
assertTrue(cover)
|
||||
assertTrue(delete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `local media offers local actions only`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
PhotoAttributesSheet(
|
||||
model = PhotoSheetModel(title = "IMG.jpg", isServer = false),
|
||||
onShare = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_SHEET_SHARE).performScrollTo().assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_SHEET_DOWNLOAD).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_SHEET_DELETE).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.galo.android.ui.photo
|
||||
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextClearance
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import com.galo.android.domain.model.Album
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class PhotoDialogsTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun album(id: String, title: String) = Album(
|
||||
id = id,
|
||||
title = title,
|
||||
description = "",
|
||||
ownerId = "u1",
|
||||
coverPhotoId = null,
|
||||
coverThumbUrl = null,
|
||||
coverMediumUrl = null,
|
||||
photosCount = 0,
|
||||
membersCount = 0,
|
||||
updatedAt = null,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `rename dialog confirms the edited name`() {
|
||||
var confirmed: String? = null
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
RenamePhotoDialog(initial = "old.jpg", onConfirm = { confirmed = it }, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_PHOTO_RENAME_FIELD).performTextClearance()
|
||||
compose.onNodeWithTag(TAG_PHOTO_RENAME_FIELD).performTextInput("new.jpg")
|
||||
compose.onNodeWithTag(TAG_PHOTO_RENAME_CONFIRM).performClick()
|
||||
|
||||
assertEquals("new.jpg", confirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rename is disabled for a blank name`() {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
RenamePhotoDialog(initial = "", onConfirm = {}, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_PHOTO_RENAME_CONFIRM).assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `delete dialog confirms deletion`() {
|
||||
var confirmed = false
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
DeletePhotoDialog(title = "IMG.jpg", onConfirm = { confirmed = true }, onDismiss = {})
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_PHOTO_DELETE_CONFIRM).performClick()
|
||||
|
||||
assertTrue(confirmed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `album picker reports the chosen album`() {
|
||||
var picked: String? = null
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
AlbumPickerDialog(
|
||||
title = "Переместить в альбом",
|
||||
albums = listOf(album("a2", "Работа")),
|
||||
onPick = { picked = it },
|
||||
onDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithTag(TAG_ALBUM_PICKER_OPTION_PREFIX + "a2").performClick()
|
||||
|
||||
assertEquals("a2", picked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.galo.android.ui.viewer
|
||||
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTouchInput
|
||||
import androidx.compose.ui.test.pinch
|
||||
import androidx.compose.ui.test.swipeUp
|
||||
import com.galo.android.domain.model.MediaKind
|
||||
import com.galo.android.domain.model.Photo
|
||||
import com.galo.android.ui.theme.GaloTheme
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class ViewerSheetTest {
|
||||
|
||||
private val sheetTag = "sheet_content"
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
private fun photo(
|
||||
id: String,
|
||||
name: String,
|
||||
kind: MediaKind = MediaKind.IMAGE,
|
||||
isPanorama: Boolean = false,
|
||||
) = Photo(
|
||||
id = id,
|
||||
albumId = "a1",
|
||||
originalFilename = name,
|
||||
kind = kind,
|
||||
mimeType = null,
|
||||
width = 100,
|
||||
height = 100,
|
||||
durationMs = 0,
|
||||
size = 0,
|
||||
takenAt = null,
|
||||
caption = "",
|
||||
isPanorama = isPanorama,
|
||||
thumbUrl = null,
|
||||
mediumUrl = null,
|
||||
previewUrl = null,
|
||||
playbackUrl = null,
|
||||
originalUrl = null,
|
||||
downloadUrl = null,
|
||||
createdAt = null,
|
||||
)
|
||||
|
||||
private fun render(photo: Photo) {
|
||||
compose.setContent {
|
||||
GaloTheme(dynamicColor = false) {
|
||||
ViewerContent(
|
||||
photos = listOf(photo),
|
||||
initialIndex = 0,
|
||||
onBack = {},
|
||||
sheet = { Text("SHEET", modifier = Modifier.testTag(sheetTag)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swipe up opens the attributes sheet`() {
|
||||
render(photo("p1", "first.jpg"))
|
||||
|
||||
compose.onNodeWithTag(sheetTag).assertDoesNotExist()
|
||||
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeUp() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(sheetTag).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swipe up does not open the sheet on a panorama`() {
|
||||
render(photo("p1", "pano.jpg", isPanorama = true))
|
||||
|
||||
compose.onNodeWithText("pano.jpg").assertIsDisplayed()
|
||||
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeUp() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(sheetTag).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `swipe up does not open the sheet when zoomed`() {
|
||||
render(photo("p1", "first.jpg"))
|
||||
|
||||
compose.onNodeWithTag(TAG_ZOOMABLE_IMAGE).performTouchInput {
|
||||
pinch(
|
||||
start0 = center + Offset(-40f, 0f),
|
||||
end0 = center + Offset(-160f, 0f),
|
||||
start1 = center + Offset(40f, 0f),
|
||||
end1 = center + Offset(160f, 0f),
|
||||
)
|
||||
}
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(TAG_VIEWER_PAGER).performTouchInput { swipeUp() }
|
||||
compose.waitForIdle()
|
||||
|
||||
compose.onNodeWithTag(sheetTag).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user