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:
+33
-1
@@ -100,6 +100,36 @@ While an album screen is active the client holds
|
|||||||
`GET /api/sse/albums/:id?token=` and backfills missed changes via
|
`GET /api/sse/albums/:id?token=` and backfills missed changes via
|
||||||
`GET /api/albums/:id/photos?since=` after a reconnect.
|
`GET /api/albums/:id/photos?since=` after a reconnect.
|
||||||
|
|
||||||
|
## Attributes and actions
|
||||||
|
|
||||||
|
The mobile client mirrors the web workspace's attribute/action management.
|
||||||
|
|
||||||
|
- **Album panel.** Swiping right-to-left on any album screen (server or device)
|
||||||
|
opens the «Атрибуты и действия» panel with the sections «Обложка», «Информация»,
|
||||||
|
«Навигация» and «Действия» (rename, delete). Cover and navigation are limited to
|
||||||
|
owner/admin. For a device album linked to a server album, the panel shows the
|
||||||
|
linked server album's real attributes; an unlinked device album shows local
|
||||||
|
values (cover, name, item count) without the navigation section.
|
||||||
|
- **Photo sheet.** Swiping up in full-screen viewing (server or device) opens the
|
||||||
|
«Атрибуты и действия» sheet with «Информация», «Метки» and «Действия» (download,
|
||||||
|
move, copy, set as cover, delete) plus rename by tapping the file name. The sheet
|
||||||
|
does not open while an image is zoomed, a panorama rotates, or video controls are
|
||||||
|
in use. For device media linked to a server photo, the sheet shows that photo's
|
||||||
|
server attributes and actions; otherwise it shows local properties.
|
||||||
|
- **Attribute settings.** The «Информация» section reaches a screen listing the EXIF
|
||||||
|
attributes with visibility checkboxes and label fields («отметить все»/«снять
|
||||||
|
все»); the choice is stored in the profile's `photo_info` preferences, using the
|
||||||
|
same shape as web, so the two clients stay in sync.
|
||||||
|
|
||||||
|
API operations backing these panels (all already served by the backend):
|
||||||
|
|
||||||
|
- `PATCH /api/albums/:id` (`title`, `description`, `cover_photo_id` / `clear_cover`),
|
||||||
|
`DELETE /api/albums/:id`, `GET /api/albums/:id/members`,
|
||||||
|
`GET|POST|DELETE /api/albums/:id/share`
|
||||||
|
- `POST /api/photo-batch/copy`, `POST|DELETE /api/photos/:id/tags`, `GET /api/tags?q=`
|
||||||
|
- `PATCH /api/auth/me/preferences`; account payload `GET /api/auth/me` and photo
|
||||||
|
payloads expose `preferences`, `exif`, `tags`, `uploader` and `download_url`.
|
||||||
|
|
||||||
## Device albums and synchronization
|
## Device albums and synchronization
|
||||||
|
|
||||||
The album list mixes the server's albums with the device's own albums (MediaStore
|
The album list mixes the server's albums with the device's own albums (MediaStore
|
||||||
@@ -152,7 +182,9 @@ Unit tests cover URL resolution, error mapping, token refresh, the offline
|
|||||||
cache/repository fallback, page merging, the object cache key, SSE event
|
cache/repository fallback, page merging, the object cache key, SSE event
|
||||||
parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric
|
parsing/reconnect, HEIC detection, and the panorama camera math. Robolectric
|
||||||
Compose tests cover the albums grid/offline banner, the viewer swipe navigation,
|
Compose tests cover the albums grid/offline banner, the viewer swipe navigation,
|
||||||
and upload statuses.
|
and upload statuses. Request-level tests cover the album/tag/preferences
|
||||||
|
operations behind the attribute/action panels, and DTO tests cover the rich photo
|
||||||
|
and account payloads (`exif`, `tags`, `uploader`, `preferences`).
|
||||||
|
|
||||||
Device-only checks (real server E2E, Wi-Fi-only gating, background continuation,
|
Device-only checks (real server E2E, Wi-Fi-only gating, background continuation,
|
||||||
HEIC conversion of a real asset) require a device or emulator.
|
HEIC conversion of a real asset) require a device or emulator.
|
||||||
|
|||||||
@@ -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(
|
private suspend fun waitForState(
|
||||||
container: com.galo.android.di.AppContainer,
|
container: com.galo.android.di.AppContainer,
|
||||||
bucket: String,
|
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.SyncDao
|
||||||
import com.galo.android.data.db.dao.UploadDao
|
import com.galo.android.data.db.dao.UploadDao
|
||||||
import com.galo.android.data.db.entity.AlbumEntity
|
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.DeviceAssetEntity
|
||||||
import com.galo.android.data.db.entity.PhotoEntity
|
import com.galo.android.data.db.entity.PhotoEntity
|
||||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||||
@@ -21,11 +22,11 @@ import com.galo.android.data.db.entity.UploadJobEntity
|
|||||||
UploadJobEntity::class,
|
UploadJobEntity::class,
|
||||||
SyncedAlbumEntity::class,
|
SyncedAlbumEntity::class,
|
||||||
DeviceAssetEntity::class,
|
DeviceAssetEntity::class,
|
||||||
|
DeviceAlbumMetaEntity::class,
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 6,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)abstract class GaloDatabase : RoomDatabase() {
|
||||||
abstract class GaloDatabase : RoomDatabase() {
|
|
||||||
|
|
||||||
abstract fun albumDao(): AlbumDao
|
abstract fun albumDao(): AlbumDao
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.galo.android.data.db.dao
|
|||||||
import androidx.room.Dao
|
import androidx.room.Dao
|
||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Upsert
|
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.DeviceAssetEntity
|
||||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -27,8 +28,25 @@ interface SyncDao {
|
|||||||
@Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId")
|
@Query("DELETE FROM synced_albums WHERE deviceAlbumId = :deviceAlbumId")
|
||||||
suspend fun deleteSyncedAlbum(deviceAlbumId: String)
|
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 ---------------------------------
|
// --- 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")
|
@Query("SELECT * FROM device_assets WHERE deviceAlbumId = :deviceAlbumId")
|
||||||
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>>
|
fun observeAssets(deviceAlbumId: String): Flow<List<DeviceAssetEntity>>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ data class PhotoEntity(
|
|||||||
val size: Long,
|
val size: Long,
|
||||||
val takenAt: String?,
|
val takenAt: String?,
|
||||||
val caption: String,
|
val caption: String,
|
||||||
|
val latitude: Double? = null,
|
||||||
|
val longitude: Double? = null,
|
||||||
val isPanorama: Boolean,
|
val isPanorama: Boolean,
|
||||||
val thumbUrl: String?,
|
val thumbUrl: String?,
|
||||||
val mediumUrl: String?,
|
val mediumUrl: String?,
|
||||||
@@ -28,4 +30,8 @@ data class PhotoEntity(
|
|||||||
val originalUrl: String?,
|
val originalUrl: String?,
|
||||||
val downloadUrl: String?,
|
val downloadUrl: String?,
|
||||||
val createdAt: 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,
|
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
|
* 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.
|
* 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.AlbumEntity
|
||||||
import com.galo.android.data.db.entity.PhotoEntity
|
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.ObjectUrlResolver
|
||||||
import com.galo.android.data.network.dto.AlbumDto
|
import com.galo.android.data.network.dto.AlbumDto
|
||||||
import com.galo.android.data.network.dto.PhotoDto
|
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.Album
|
||||||
import com.galo.android.domain.model.MediaKind
|
import com.galo.android.domain.model.MediaKind
|
||||||
import com.galo.android.domain.model.Photo
|
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).
|
// Remote DTO -> persisted entity (URLs kept raw so the cache survives a base-URL change).
|
||||||
fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity(
|
fun AlbumDto.toEntity(): AlbumEntity = AlbumEntity(
|
||||||
@@ -35,6 +41,8 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
|
|||||||
size = size,
|
size = size,
|
||||||
takenAt = takenAt,
|
takenAt = takenAt,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
|
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
|
||||||
thumbUrl = thumbUrl,
|
thumbUrl = thumbUrl,
|
||||||
mediumUrl = mediumUrl,
|
mediumUrl = mediumUrl,
|
||||||
@@ -43,6 +51,10 @@ fun PhotoDto.toEntity(): PhotoEntity = PhotoEntity(
|
|||||||
originalUrl = originalUrl,
|
originalUrl = originalUrl,
|
||||||
downloadUrl = downloadUrl,
|
downloadUrl = downloadUrl,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
|
tagsJson = encodeTags(tags),
|
||||||
|
exifJson = encodeExif(exif),
|
||||||
|
uploaderName = uploader?.name,
|
||||||
|
uploaderAvatarUrl = uploader?.avatarUrl,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Persisted entity -> domain (URLs resolved against the current base URL).
|
// Persisted entity -> domain (URLs resolved against the current base URL).
|
||||||
@@ -71,6 +83,8 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
|||||||
size = size,
|
size = size,
|
||||||
takenAt = takenAt,
|
takenAt = takenAt,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
isPanorama = isPanorama,
|
isPanorama = isPanorama,
|
||||||
thumbUrl = resolver.resolve(thumbUrl),
|
thumbUrl = resolver.resolve(thumbUrl),
|
||||||
mediumUrl = resolver.resolve(mediumUrl),
|
mediumUrl = resolver.resolve(mediumUrl),
|
||||||
@@ -79,6 +93,10 @@ fun PhotoEntity.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
|||||||
originalUrl = resolver.resolve(originalUrl),
|
originalUrl = resolver.resolve(originalUrl),
|
||||||
downloadUrl = resolver.resolve(downloadUrl),
|
downloadUrl = resolver.resolve(downloadUrl),
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
|
tags = decodeTags(tagsJson),
|
||||||
|
exif = decodeExif(exifJson),
|
||||||
|
uploaderName = uploaderName,
|
||||||
|
uploaderAvatarUrl = resolver.resolve(uploaderAvatarUrl),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
||||||
@@ -93,6 +111,8 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
|||||||
size = size,
|
size = size,
|
||||||
takenAt = takenAt,
|
takenAt = takenAt,
|
||||||
caption = caption,
|
caption = caption,
|
||||||
|
latitude = latitude,
|
||||||
|
longitude = longitude,
|
||||||
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
|
isPanorama = tags.any { MediaKind.isPanoTag(it.name) },
|
||||||
thumbUrl = resolver.resolve(thumbUrl),
|
thumbUrl = resolver.resolve(thumbUrl),
|
||||||
mediumUrl = resolver.resolve(mediumUrl),
|
mediumUrl = resolver.resolve(mediumUrl),
|
||||||
@@ -101,4 +121,31 @@ fun PhotoDto.toDomain(resolver: ObjectUrlResolver): Photo = Photo(
|
|||||||
originalUrl = resolver.resolve(originalUrl),
|
originalUrl = resolver.resolve(originalUrl),
|
||||||
downloadUrl = resolver.resolve(downloadUrl),
|
downloadUrl = resolver.resolve(downloadUrl),
|
||||||
createdAt = createdAt,
|
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> =
|
fun items(albumId: String): List<DeviceMedia> =
|
||||||
allMedia().filter { it.albumId == albumId }
|
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> =
|
private fun allMedia(): List<DeviceMedia> =
|
||||||
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
|
query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE) +
|
||||||
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO)
|
query(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.galo.android.data.network
|
package com.galo.android.data.network
|
||||||
|
|
||||||
import com.galo.android.data.network.dto.AddIdentifierRequest
|
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.AlbumDto
|
||||||
import com.galo.android.data.network.dto.AlbumEnvelopeDto
|
import com.galo.android.data.network.dto.AlbumEnvelopeDto
|
||||||
import com.galo.android.data.network.dto.AlbumListDto
|
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.HealthDto
|
||||||
import com.galo.android.data.network.dto.LoginRequest
|
import com.galo.android.data.network.dto.LoginRequest
|
||||||
import com.galo.android.data.network.dto.LogoutRequest
|
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.PhotoDto
|
||||||
import com.galo.android.data.network.dto.PhotoEnvelopeDto
|
import com.galo.android.data.network.dto.PhotoEnvelopeDto
|
||||||
import com.galo.android.data.network.dto.PhotoListDto
|
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.RegisterRequest
|
||||||
import com.galo.android.data.network.dto.RenamePhotoRequest
|
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.TokenEnvelopeDto
|
||||||
|
import com.galo.android.data.network.dto.UpdateAlbumRequest
|
||||||
import com.galo.android.data.network.dto.UpdateProfileRequest
|
import com.galo.android.data.network.dto.UpdateProfileRequest
|
||||||
import com.galo.android.data.network.dto.UploadResponseDto
|
import com.galo.android.data.network.dto.UploadResponseDto
|
||||||
import com.galo.android.data.network.dto.UserDto
|
import com.galo.android.data.network.dto.UserDto
|
||||||
@@ -76,6 +88,15 @@ class GaloApi(
|
|||||||
override suspend fun me(): UserDto =
|
override suspend fun me(): UserDto =
|
||||||
get("api/auth/me").decode<UserEnvelopeDto>().user
|
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 =
|
override suspend fun updateProfile(name: String): UserDto =
|
||||||
patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name)))
|
patchJson("api/auth/me", json.encodeToString(UpdateProfileRequest(name)))
|
||||||
.decode<UserEnvelopeDto>()
|
.decode<UserEnvelopeDto>()
|
||||||
@@ -127,6 +148,10 @@ class GaloApi(
|
|||||||
suspend fun album(albumId: String): AlbumDto =
|
suspend fun album(albumId: String): AlbumDto =
|
||||||
get("api/albums/$albumId").decode<AlbumEnvelopeDto>().album
|
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. */
|
/** Creates a server album and returns it. */
|
||||||
suspend fun createAlbum(title: String): AlbumDto =
|
suspend fun createAlbum(title: String): AlbumDto =
|
||||||
postJson("api/albums", json.encodeToString(CreateAlbumRequest(title)))
|
postJson("api/albums", json.encodeToString(CreateAlbumRequest(title)))
|
||||||
@@ -183,6 +208,70 @@ class GaloApi(
|
|||||||
postJson("api/photo-batch/move", json.encodeToString(BatchRequest(photoIds, albumId)))
|
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.
|
* Control sums of an album's photos keyed by checksum, following pagination.
|
||||||
* Used by the device-album sync to avoid re-uploading existing content.
|
* 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.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HealthDto(
|
data class HealthDto(
|
||||||
@@ -37,6 +38,7 @@ data class UserDto(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class UserEnvelopeDto(
|
data class UserEnvelopeDto(
|
||||||
val user: UserDto = UserDto(),
|
val user: UserDto = UserDto(),
|
||||||
|
val preferences: PreferencesDto? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -141,7 +143,11 @@ data class PhotoDto(
|
|||||||
val size: Long = 0,
|
val size: Long = 0,
|
||||||
@SerialName("taken_at") val takenAt: String? = null,
|
@SerialName("taken_at") val takenAt: String? = null,
|
||||||
val caption: String = "",
|
val caption: String = "",
|
||||||
|
val latitude: Double? = null,
|
||||||
|
val longitude: Double? = null,
|
||||||
val tags: List<TagDto> = emptyList(),
|
val tags: List<TagDto> = emptyList(),
|
||||||
|
val exif: Map<String, JsonElement> = emptyMap(),
|
||||||
|
val uploader: UserDto? = null,
|
||||||
@SerialName("thumb_url") val thumbUrl: String? = null,
|
@SerialName("thumb_url") val thumbUrl: String? = null,
|
||||||
@SerialName("medium_url") val mediumUrl: String? = null,
|
@SerialName("medium_url") val mediumUrl: String? = null,
|
||||||
@SerialName("preview_url") val previewUrl: String? = null,
|
@SerialName("preview_url") val previewUrl: String? = null,
|
||||||
@@ -183,3 +189,74 @@ data class ErrorBodyDto(
|
|||||||
val code: String = "internal_error",
|
val code: String = "internal_error",
|
||||||
val message: String = "internal server 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
|
package com.galo.android.data.repo
|
||||||
|
|
||||||
import com.galo.android.data.db.dao.SyncDao
|
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.DeviceAssetEntity
|
||||||
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
import com.galo.android.data.db.entity.SyncedAlbumEntity
|
||||||
import com.galo.android.data.media.MediaStoreDeviceSource
|
import com.galo.android.data.media.MediaStoreDeviceSource
|
||||||
import com.galo.android.domain.model.DeviceAlbum
|
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.LocalAssetState
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
/** Device albums, their links to server albums and their asset index. */
|
/** Device albums, their links to server albums and their asset index. */
|
||||||
class SyncRepository(
|
class SyncRepository(
|
||||||
@@ -22,6 +25,9 @@ class SyncRepository(
|
|||||||
|
|
||||||
suspend fun assets(deviceAlbumId: String): List<DeviceAssetEntity> = dao.getAssets(deviceAlbumId)
|
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 getAsset(assetKey: String): DeviceAssetEntity? = dao.getAsset(assetKey)
|
||||||
|
|
||||||
suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? =
|
suspend fun currentLink(deviceAlbumId: String): SyncedAlbumEntity? =
|
||||||
@@ -48,6 +54,61 @@ class SyncRepository(
|
|||||||
|
|
||||||
suspend fun unlink(deviceAlbumId: String) = dao.deleteSyncedAlbum(deviceAlbumId)
|
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). */
|
/** Records the server photo created for a device asset (called after upload). */
|
||||||
suspend fun recordUploaded(
|
suspend fun recordUploaded(
|
||||||
deviceAlbumId: String,
|
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.data.media.PreviewGenerator
|
||||||
import com.galo.android.domain.model.LocalAssetState
|
import com.galo.android.domain.model.LocalAssetState
|
||||||
import com.galo.android.domain.model.MediaKind
|
import com.galo.android.domain.model.MediaKind
|
||||||
|
import com.galo.android.domain.model.SyncMode
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
@@ -50,12 +51,18 @@ class OffloadManager(
|
|||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
runCatching {
|
runCatching {
|
||||||
val uri = Uri.parse(asset.uri)
|
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) {
|
if (kindOf(asset.mimeType) == MediaKind.VIDEO) {
|
||||||
offloadVideo(asset, uri, serverAlbumId)
|
offloadVideo(asset, uri, serverAlbumId, syncMode)
|
||||||
} else {
|
} 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)
|
}.getOrDefault(OffloadResult.Failed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,7 +83,12 @@ class OffloadManager(
|
|||||||
null
|
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 preview = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
|
||||||
val sourceExif = runCatching {
|
val sourceExif = runCatching {
|
||||||
context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) }
|
context.contentResolver.openInputStream(uri)?.use { ExifInterface(it) }
|
||||||
@@ -84,7 +96,7 @@ class OffloadManager(
|
|||||||
|
|
||||||
if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed
|
if (!PreviewGenerator.generate(context, uri, MediaKind.IMAGE, preview)) return OffloadResult.Failed
|
||||||
ProvenanceStamp.copyDateTaken(sourceExif, preview)
|
ProvenanceStamp.copyDateTaken(sourceExif, preview)
|
||||||
ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId))
|
ProvenanceStamp.write(preview, provenanceOf(asset, serverAlbumId, syncMode))
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
writeInPlace(uri, preview)
|
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)
|
val poster = PreviewGenerator.tempPreviewFile(context, asset.assetKey)
|
||||||
if (!PreviewGenerator.generate(context, uri, MediaKind.VIDEO, poster)) return OffloadResult.Failed
|
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)
|
val relativePath = relativePathOf(uri)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
@@ -152,12 +169,18 @@ class OffloadManager(
|
|||||||
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null }
|
||||||
}.getOrNull().orEmpty().ifBlank { "DCIM/" }
|
}.getOrNull().orEmpty().ifBlank { "DCIM/" }
|
||||||
|
|
||||||
private fun provenanceOf(asset: DeviceAssetEntity, serverAlbumId: String?) = Provenance(
|
private fun provenanceOf(
|
||||||
|
asset: DeviceAssetEntity,
|
||||||
|
serverAlbumId: String?,
|
||||||
|
syncMode: String?,
|
||||||
|
) = Provenance(
|
||||||
serverAlbumId = serverAlbumId,
|
serverAlbumId = serverAlbumId,
|
||||||
serverPhotoId = asset.serverPhotoId,
|
serverPhotoId = asset.serverPhotoId,
|
||||||
originalHash = asset.contentHash,
|
originalHash = asset.contentHash,
|
||||||
deviceAlbumId = asset.deviceAlbumId,
|
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,
|
assetKey = asset.assetKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -167,4 +190,8 @@ class OffloadManager(
|
|||||||
|
|
||||||
private fun kindOf(mimeType: String?): MediaKind =
|
private fun kindOf(mimeType: String?): MediaKind =
|
||||||
if (mimeType?.startsWith("video/") == true) MediaKind.VIDEO else MediaKind.IMAGE
|
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. */
|
/** Restoring must fit in the free space; otherwise it is deferred. */
|
||||||
fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean =
|
fun canRestore(requiredBytes: Long, availableBytes: Long): Boolean =
|
||||||
availableBytes >= requiredBytes
|
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) {
|
suspend fun restore(assets: List<DeviceAssetEntity>): RestoreReport = withContext(Dispatchers.IO) {
|
||||||
@@ -52,6 +58,7 @@ class RestoreManager(
|
|||||||
for (asset in assets) {
|
for (asset in assets) {
|
||||||
val photoId = asset.serverPhotoId ?: continue
|
val photoId = asset.serverPhotoId ?: continue
|
||||||
val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue
|
val data = runCatching { fetcher.fetch(photoId) }.getOrNull() ?: continue
|
||||||
|
if (data.isEmpty()) continue
|
||||||
if (!canRestore(data.size.toLong(), availableBytes())) {
|
if (!canRestore(data.size.toLong(), availableBytes())) {
|
||||||
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
|
return@withContext RestoreReport(restored, bytes, needsConsent, outOfSpace = true)
|
||||||
}
|
}
|
||||||
@@ -62,6 +69,8 @@ class RestoreManager(
|
|||||||
bytes += data.size
|
bytes += data.size
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
needsConsent++
|
needsConsent++
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Leave the asset as is and continue with the rest.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
RestoreReport(restored, bytes, needsConsent, outOfSpace = false)
|
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.LocalAssetState
|
||||||
import com.galo.android.domain.model.SyncMode
|
import com.galo.android.domain.model.SyncMode
|
||||||
import com.galo.android.domain.sync.SyncPolicy
|
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
|
* Reconciles a device album with its server album. Mode A (DISABLED) is local
|
||||||
@@ -19,13 +21,16 @@ class SyncCoordinator(
|
|||||||
private val offloader: AssetOffloader,
|
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. */
|
/** @return true when the server was contacted, false for a local-only album. */
|
||||||
suspend fun reconcile(deviceAlbumId: String): Boolean {
|
suspend fun reconcile(deviceAlbumId: String): Boolean = mutex.withLock {
|
||||||
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return false
|
val link = dao.getSyncedAlbum(deviceAlbumId) ?: return@withLock false
|
||||||
val mode = SyncMode.from(link.mode)
|
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.
|
// 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 serverChecksums = remote.serverChecksums(link.serverAlbumId)
|
||||||
val livePhotoIds = serverChecksums.values.toSet()
|
val livePhotoIds = serverChecksums.values.toSet()
|
||||||
@@ -41,20 +46,40 @@ class SyncCoordinator(
|
|||||||
// Mode C: assets already on the server (e.g. after switching from copies)
|
// Mode C: assets already on the server (e.g. after switching from copies)
|
||||||
// are replaced with their offloaded versions, right away.
|
// are replaced with their offloaded versions, right away.
|
||||||
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets)
|
if (mode == SyncMode.MOVE) offloadUploaded(deviceAlbumId, assets)
|
||||||
return true
|
return@withLock true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Offloads assets that are already uploaded but still have their original
|
* Reconciles every given device album, so a startup/background pass covers all
|
||||||
* locally. When the OS requires consent, the asset is marked as awaiting
|
* galleries (including partially synced ones). @return true when any album
|
||||||
* freeing so the album screen can request it.
|
* 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>) {
|
private suspend fun offloadUploaded(deviceAlbumId: String, localAssets: List<LocalAsset>) {
|
||||||
val localKeys = localAssets.mapTo(HashSet()) { it.assetKey }
|
val localKeys = localAssets.mapTo(HashSet()) { it.assetKey }
|
||||||
for (entity in dao.getAssets(deviceAlbumId)) {
|
for (entity in dao.getAssets(deviceAlbumId)) {
|
||||||
if (entity.serverPhotoId == null) continue
|
if (entity.serverPhotoId == null) continue
|
||||||
if (entity.assetKey !in localKeys) 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
|
val result = runCatching { offloader.offload(entity) }.getOrNull() ?: continue
|
||||||
when (result) {
|
when (result) {
|
||||||
is OffloadResult.Done, OffloadResult.Failed -> Unit
|
is OffloadResult.Done, OffloadResult.Failed -> Unit
|
||||||
@@ -82,7 +107,11 @@ class SyncCoordinator(
|
|||||||
record(deviceAlbumId, action.asset, action.photoId, mode)
|
record(deviceAlbumId, action.asset, action.photoId, mode)
|
||||||
|
|
||||||
is SyncAction.Upload ->
|
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 -> {
|
is SyncAction.RenameOnServer -> {
|
||||||
remote.renamePhoto(action.photoId, action.newName)
|
remote.renamePhoto(action.photoId, action.newName)
|
||||||
|
|||||||
@@ -49,12 +49,17 @@ class SyncRecovery(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
marker.serverAlbumId?.let { serverAlbumId ->
|
marker.serverAlbumId?.let { serverAlbumId ->
|
||||||
|
val existing = dao.getSyncedAlbum(deviceAlbumId)
|
||||||
|
val markerMode = marker.mode?.let { raw -> SyncMode.parseOrNull(raw)?.name }
|
||||||
dao.upsertSyncedAlbum(
|
dao.upsertSyncedAlbum(
|
||||||
SyncedAlbumEntity(
|
SyncedAlbumEntity(
|
||||||
deviceAlbumId = deviceAlbumId,
|
deviceAlbumId = deviceAlbumId,
|
||||||
deviceAlbumName = media.albumName,
|
deviceAlbumName = existing?.deviceAlbumName?.ifBlank { null } ?: media.albumName,
|
||||||
serverAlbumId = serverAlbumId,
|
serverAlbumId = existing?.serverAlbumId?.takeIf { it.isNotBlank() } ?: serverAlbumId,
|
||||||
mode = marker.mode ?: SyncMode.MOVE.name,
|
// 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(),
|
updatedAt = System.currentTimeMillis(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ interface SyncUploadQueue {
|
|||||||
asset: LocalAsset,
|
asset: LocalAsset,
|
||||||
mode: SyncMode,
|
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. */
|
/** [SyncUploadQueue] backed by the existing [UploadRepository]/WorkManager queue. */
|
||||||
@@ -37,4 +40,6 @@ class RepositorySyncUploadQueue(
|
|||||||
wifiOnly = wifiOnly(),
|
wifiOnly = wifiOnly(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun isPending(assetKey: String): Boolean = uploads.isPending(assetKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,14 +26,27 @@ class SyncWorker(
|
|||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val app = applicationContext as? GaloApplication ?: return Result.failure()
|
val app = applicationContext as? GaloApplication ?: return Result.failure()
|
||||||
val container = app.container
|
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() }
|
.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
|
var failed = false
|
||||||
for (link in links) {
|
for (id in toCheck) {
|
||||||
runCatching { container.syncCoordinator.reconcile(link.deviceAlbumId) }
|
runCatching { container.syncCheck.checkGallery(id, recoverIndex = true) }
|
||||||
.onFailure { failed = 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()
|
return if (failed) Result.retry() else Result.success()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ class UploadRepository(
|
|||||||
|
|
||||||
suspend fun getById(id: String): UploadJobEntity? = dao.getById(id)
|
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(
|
suspend fun updateState(
|
||||||
id: String,
|
id: String,
|
||||||
status: UploadStatus,
|
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.OffloadManager
|
||||||
import com.galo.android.data.sync.RepositorySyncUploadQueue
|
import com.galo.android.data.sync.RepositorySyncUploadQueue
|
||||||
import com.galo.android.data.sync.RestoreManager
|
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.SyncCoordinator
|
||||||
import com.galo.android.data.sync.SyncRecovery
|
import com.galo.android.data.sync.SyncRecovery
|
||||||
import com.galo.android.data.sync.SyncRemote
|
import com.galo.android.data.sync.SyncRemote
|
||||||
@@ -107,17 +108,24 @@ class AppContainer(context: Context) {
|
|||||||
|
|
||||||
val syncRecovery = SyncRecovery(appContext, database.syncDao(), deviceSource)
|
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() {
|
fun start() {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
settingsRepository.flow.collect { settings ->
|
settingsRepository.flow.collect { settings ->
|
||||||
|
val first = settingsState == null
|
||||||
settingsState = settings
|
settingsState = settings
|
||||||
runCatching { SyncScheduler.ensurePeriodic(appContext, settings.wifiOnlyUploads) }
|
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 {
|
runCatching {
|
||||||
DeviceMediaObserver(appContext) { runCatching { SyncScheduler.runNow(appContext) } }.start()
|
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
|
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(
|
data class DeviceAlbum(
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -9,6 +9,13 @@ data class DeviceAlbum(
|
|||||||
val relativePath: String,
|
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]. */
|
/** A single media item of the device found in a [DeviceAlbum]. */
|
||||||
data class DeviceMedia(
|
data class DeviceMedia(
|
||||||
val id: Long,
|
val id: Long,
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ data class Photo(
|
|||||||
val size: Long,
|
val size: Long,
|
||||||
val takenAt: String?,
|
val takenAt: String?,
|
||||||
val caption: String,
|
val caption: String,
|
||||||
|
val latitude: Double? = null,
|
||||||
|
val longitude: Double? = null,
|
||||||
val isPanorama: Boolean,
|
val isPanorama: Boolean,
|
||||||
val thumbUrl: String?,
|
val thumbUrl: String?,
|
||||||
val mediumUrl: String?,
|
val mediumUrl: String?,
|
||||||
@@ -71,9 +73,19 @@ data class Photo(
|
|||||||
val originalUrl: String?,
|
val originalUrl: String?,
|
||||||
val downloadUrl: String?,
|
val downloadUrl: String?,
|
||||||
val createdAt: 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>(
|
data class Page<T>(
|
||||||
val items: List<T>,
|
val items: List<T>,
|
||||||
val nextCursor: String?,
|
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 {
|
companion object {
|
||||||
fun from(raw: String?): SyncMode =
|
fun from(raw: String?): SyncMode =
|
||||||
entries.firstOrNull { it.name == raw } ?: DISABLED
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
@@ -15,10 +26,13 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.galo.android.GaloApplication
|
import com.galo.android.GaloApplication
|
||||||
import com.galo.android.data.prefs.SettingsState
|
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.AlbumScreen
|
||||||
|
import com.galo.android.ui.album.AlbumSettingsScreen
|
||||||
import com.galo.android.ui.albums.AlbumsScreen
|
import com.galo.android.ui.albums.AlbumsScreen
|
||||||
import com.galo.android.ui.auth.AuthScreen
|
import com.galo.android.ui.auth.AuthScreen
|
||||||
import com.galo.android.ui.devicealbum.DeviceAlbumScreen
|
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.profile.ProfileScreen
|
||||||
import com.galo.android.ui.setup.SetupScreen
|
import com.galo.android.ui.setup.SetupScreen
|
||||||
import com.galo.android.ui.upload.UploadScreen
|
import com.galo.android.ui.upload.UploadScreen
|
||||||
@@ -31,11 +45,15 @@ object Routes {
|
|||||||
const val UPLOADS = "uploads"
|
const val UPLOADS = "uploads"
|
||||||
const val PROFILE = "profile"
|
const val PROFILE = "profile"
|
||||||
const val ALBUM = "album/{albumId}"
|
const val ALBUM = "album/{albumId}"
|
||||||
|
const val ALBUM_SETTINGS = "album/{albumId}/settings"
|
||||||
const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}"
|
const val DEVICE_ALBUM = "deviceAlbum/{deviceAlbumId}"
|
||||||
const val VIEWER = "album/{albumId}/viewer?index={index}"
|
const val VIEWER = "album/{albumId}/viewer?index={index}"
|
||||||
|
const val ATTRIBUTE_SETTINGS = "attributeSettings"
|
||||||
|
|
||||||
fun album(albumId: String) = "album/$albumId"
|
fun album(albumId: String) = "album/$albumId"
|
||||||
|
|
||||||
|
fun albumSettings(albumId: String) = "album/$albumId/settings"
|
||||||
|
|
||||||
fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId"
|
fun deviceAlbum(deviceAlbumId: String) = "deviceAlbum/$deviceAlbumId"
|
||||||
|
|
||||||
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
|
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
|
||||||
@@ -57,6 +75,22 @@ fun GaloNavHost(app: GaloApplication) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val navController = rememberNavController()
|
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 {
|
val start = when {
|
||||||
current.baseUrl.isBlank() -> Routes.SETUP
|
current.baseUrl.isBlank() -> Routes.SETUP
|
||||||
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
|
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
|
||||||
@@ -114,6 +148,12 @@ fun GaloNavHost(app: GaloApplication) {
|
|||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
composable(Routes.ATTRIBUTE_SETTINGS) {
|
||||||
|
AttributeSettingsScreen(
|
||||||
|
app = app,
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
composable(
|
composable(
|
||||||
route = Routes.ALBUM,
|
route = Routes.ALBUM,
|
||||||
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
|
||||||
@@ -125,6 +165,18 @@ fun GaloNavHost(app: GaloApplication) {
|
|||||||
onOpenViewer = { index ->
|
onOpenViewer = { index ->
|
||||||
navController.navigate(Routes.viewer(albumId, 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() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -136,6 +188,7 @@ fun GaloNavHost(app: GaloApplication) {
|
|||||||
DeviceAlbumScreen(
|
DeviceAlbumScreen(
|
||||||
app = app,
|
app = app,
|
||||||
deviceAlbumId = deviceAlbumId,
|
deviceAlbumId = deviceAlbumId,
|
||||||
|
onOpenServerSettings = { id -> navController.navigate(Routes.albumSettings(id)) },
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -156,7 +209,43 @@ fun GaloNavHost(app: GaloApplication) {
|
|||||||
albumId = albumId,
|
albumId = albumId,
|
||||||
initialIndex = index,
|
initialIndex = index,
|
||||||
onBack = { navController.popBackStack() },
|
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.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
import androidx.compose.foundation.lazy.grid.GridItemSpan
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.galo.android.GaloApplication
|
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.MediaKind
|
||||||
import com.galo.android.domain.model.Photo
|
import com.galo.android.domain.model.Photo
|
||||||
import com.galo.android.ui.common.GaloAsyncImage
|
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_GRID = "album_grid"
|
||||||
const val TAG_ALBUM_OFFLINE = "album_offline"
|
const val TAG_ALBUM_OFFLINE = "album_offline"
|
||||||
const val TAG_PHOTO_PREFIX = "photo_"
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -54,6 +73,7 @@ fun AlbumScreen(
|
|||||||
app: GaloApplication,
|
app: GaloApplication,
|
||||||
albumId: String,
|
albumId: String,
|
||||||
onOpenViewer: (Int) -> Unit,
|
onOpenViewer: (Int) -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val factory = viewModelFactory {
|
val factory = viewModelFactory {
|
||||||
@@ -62,6 +82,15 @@ fun AlbumScreen(
|
|||||||
val viewModel: AlbumViewModel = viewModel(factory = factory)
|
val viewModel: AlbumViewModel = viewModel(factory = factory)
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -79,47 +108,67 @@ fun AlbumScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
|
EndDrawerScaffold(
|
||||||
if (state.offline) {
|
open = panelOpen,
|
||||||
Surface(
|
onOpenChange = { panelOpen = it },
|
||||||
color = MaterialTheme.colorScheme.errorContainer,
|
modifier = Modifier.padding(padding),
|
||||||
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
|
drawerContent = {
|
||||||
) {
|
if (attributes != null) {
|
||||||
Text(
|
AlbumAttributesPanel(
|
||||||
text = state.error ?: "Офлайн-режим",
|
attributes = attributes,
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
onPickCover = { coverPickOpen = true },
|
||||||
style = MaterialTheme.typography.bodySmall,
|
onResetCover = viewModel::resetCover,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
onOpenMembers = onOpenSettings,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
onOpenShare = onOpenSettings,
|
||||||
|
onRename = { renameOpen = true },
|
||||||
|
onDelete = { deleteOpen = true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
when {
|
) {
|
||||||
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
CircularProgressIndicator()
|
if (state.offline) {
|
||||||
}
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
|
||||||
Text("В альбоме пока нет медиа")
|
) {
|
||||||
}
|
Text(
|
||||||
|
text = state.error ?: "Офлайн-режим",
|
||||||
else -> LazyVerticalGrid(
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
columns = GridCells.Fixed(3),
|
style = MaterialTheme.typography.bodySmall,
|
||||||
contentPadding = PaddingValues(2.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.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) }) {
|
when {
|
||||||
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
|
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Box(
|
CircularProgressIndicator()
|
||||||
Modifier.fillMaxWidth().padding(16.dp),
|
}
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
|
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
|
@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(
|
data class UiState(
|
||||||
val album: Album? = null,
|
val album: Album? = null,
|
||||||
|
val role: String = "",
|
||||||
val photos: List<Photo> = emptyList(),
|
val photos: List<Photo> = emptyList(),
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val loadingMore: Boolean = false,
|
val loadingMore: Boolean = false,
|
||||||
@@ -40,6 +41,7 @@ class AlbumViewModel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
private val status = MutableStateFlow(Status())
|
private val status = MutableStateFlow(Status())
|
||||||
|
private val role = MutableStateFlow("")
|
||||||
|
|
||||||
/** Watermark for `?since=` backfill; advanced as events are applied. */
|
/** Watermark for `?since=` backfill; advanced as events are applied. */
|
||||||
private var lastSync: String = Instant.now().toString()
|
private var lastSync: String = Instant.now().toString()
|
||||||
@@ -48,9 +50,11 @@ class AlbumViewModel(
|
|||||||
container.albumRepository.observeAlbum(albumId),
|
container.albumRepository.observeAlbum(albumId),
|
||||||
container.photoRepository.observePhotos(albumId),
|
container.photoRepository.observePhotos(albumId),
|
||||||
status,
|
status,
|
||||||
) { album, photos, s ->
|
role,
|
||||||
|
) { album, photos, s, r ->
|
||||||
UiState(
|
UiState(
|
||||||
album = album,
|
album = album,
|
||||||
|
role = r,
|
||||||
photos = photos,
|
photos = photos,
|
||||||
loading = !s.loaded,
|
loading = !s.loaded,
|
||||||
loadingMore = s.loadingMore,
|
loadingMore = s.loadingMore,
|
||||||
@@ -61,11 +65,63 @@ class AlbumViewModel(
|
|||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
|
viewModelScope.launch { loadAlbum() }
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
observeEvents()
|
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. */
|
/** Live updates while the screen is alive; reconnect backfills missed changes. */
|
||||||
private fun observeEvents() {
|
private fun observeEvents() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -120,7 +176,7 @@ class AlbumViewModel(
|
|||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
|
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
|
||||||
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
|
viewModelScope.launch { loadAlbum() }
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,16 @@ import androidx.compose.foundation.layout.aspectRatio
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.Logout
|
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.CloudUpload
|
||||||
import androidx.compose.material.icons.filled.Refresh
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.filled.SdCard
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
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_ALBUMS_AVATAR = "albums_avatar"
|
||||||
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
|
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
|
||||||
const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
|
const val TAG_DEVICE_ALBUM_CARD_PREFIX = "device_album_card_"
|
||||||
const val TAG_ALBUM_SOURCE_PREFIX = "album_source_"
|
const val TAG_ALBUM_STORAGE_DEVICE_PREFIX = "album_storage_device_"
|
||||||
const val TAG_ALBUM_LINK_PREFIX = "album_link_"
|
const val TAG_ALBUM_STORAGE_CLOUD_PREFIX = "album_storage_cloud_"
|
||||||
const val TAG_ALBUM_MODE_PREFIX = "album_mode_"
|
const val TAG_ALBUM_COUNT_PREFIX = "album_count_"
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -166,68 +169,71 @@ fun AlbumsContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where a device album's media live. */
|
||||||
|
private enum class StoragePlacement { DEVICE, DEVICE_AND_CLOUD, CLOUD }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AlbumCardBody(
|
private fun AlbumCardBody(
|
||||||
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
counter: String,
|
placement: StoragePlacement,
|
||||||
source: String,
|
count: String,
|
||||||
link: String?,
|
arrowTo: String?,
|
||||||
key: String,
|
|
||||||
mode: String? = null,
|
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
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) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Surface(
|
Text(
|
||||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
text = title,
|
||||||
shape = MaterialTheme.shapes.small,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
modifier = Modifier.testTag(TAG_ALBUM_SOURCE_PREFIX + key),
|
maxLines = 1,
|
||||||
) {
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.weight(1f, fill = false),
|
||||||
|
)
|
||||||
|
if (arrowTo != null) {
|
||||||
Text(
|
Text(
|
||||||
text = source,
|
text = "→ $arrowTo",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (mode != null) {
|
|
||||||
Text(
|
|
||||||
text = mode,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier
|
maxLines = 1,
|
||||||
.padding(start = 6.dp)
|
overflow = TextOverflow.Ellipsis,
|
||||||
.testTag(TAG_ALBUM_MODE_PREFIX + key),
|
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(
|
||||||
text = link,
|
text = count,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
modifier = Modifier.padding(start = 6.dp).testTag(TAG_ALBUM_COUNT_PREFIX + id),
|
||||||
modifier = Modifier
|
|
||||||
.padding(top = 2.dp)
|
|
||||||
.testTag(TAG_ALBUM_LINK_PREFIX + key),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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
|
val key = TAG_ALBUM_CARD_PREFIX + album.id
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -245,12 +251,11 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
|
|||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium),
|
||||||
)
|
)
|
||||||
AlbumCardBody(
|
AlbumCardBody(
|
||||||
|
id = album.id,
|
||||||
title = album.title,
|
title = album.title,
|
||||||
counter = "${album.photosCount} фото · ${album.membersCount} участн.",
|
placement = StoragePlacement.CLOUD,
|
||||||
source = "Сервер",
|
count = "${album.photosCount} фото",
|
||||||
link = item.linkedDeviceNames.takeIf { it.isNotEmpty() }
|
arrowTo = null,
|
||||||
?.joinToString(prefix = "источник: "),
|
|
||||||
key = key,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -260,6 +265,7 @@ private fun ServerAlbumCard(item: AlbumListItem.ServerAlbum, onClick: () -> Unit
|
|||||||
private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) {
|
private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () -> Unit) {
|
||||||
val album: DeviceAlbum = item.album
|
val album: DeviceAlbum = item.album
|
||||||
val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id
|
val key = TAG_DEVICE_ALBUM_CARD_PREFIX + album.id
|
||||||
|
val arrowTo = item.linkedServerTitle?.takeIf { it != album.name }
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -276,19 +282,19 @@ private fun DeviceAlbumCard(item: AlbumListItem.DeviceAlbumItem, onClick: () ->
|
|||||||
.clip(MaterialTheme.shapes.medium),
|
.clip(MaterialTheme.shapes.medium),
|
||||||
)
|
)
|
||||||
AlbumCardBody(
|
AlbumCardBody(
|
||||||
|
id = album.id,
|
||||||
title = album.name,
|
title = album.name,
|
||||||
counter = "${album.itemCount} эл.",
|
placement = placementOf(item.mode),
|
||||||
source = "Телефон",
|
count = "${album.itemCount} эл.",
|
||||||
link = item.linkedServerTitle?.let { "→ $it" },
|
arrowTo = arrowTo,
|
||||||
key = key,
|
|
||||||
mode = albumModeLabel(item.mode),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun albumModeLabel(mode: SyncMode): String = when (mode) {
|
/** Storage icon placement for a device album: where its media live. */
|
||||||
SyncMode.DISABLED -> "синхр. выкл."
|
private fun placementOf(mode: SyncMode): StoragePlacement = when (mode) {
|
||||||
SyncMode.COPY -> "копии"
|
SyncMode.DISABLED -> StoragePlacement.DEVICE
|
||||||
SyncMode.MOVE -> "перенос"
|
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(
|
val state: StateFlow<UiState> = combine(
|
||||||
container.albumRepository.observeAlbums(),
|
container.albumRepository.observeAlbums(),
|
||||||
container.syncRepository.observeLinks(),
|
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,
|
status,
|
||||||
avatar,
|
avatar,
|
||||||
) { albums, links, device, s, a ->
|
) { 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(
|
val items = AlbumListBuilder.build(
|
||||||
albums = albums,
|
albums = albums,
|
||||||
links = links.map { link ->
|
links = links.map { link ->
|
||||||
AlbumLink(
|
AlbumLink(
|
||||||
deviceAlbumId = link.deviceAlbumId,
|
deviceAlbumId = link.deviceAlbumId,
|
||||||
deviceAlbumName = link.deviceAlbumName,
|
deviceAlbumName = device.third[link.deviceAlbumId]?.displayName
|
||||||
|
?.takeIf { it.isNotBlank() } ?: link.deviceAlbumName,
|
||||||
serverAlbumId = link.serverAlbumId,
|
serverAlbumId = link.serverAlbumId,
|
||||||
mode = SyncMode.from(link.mode),
|
mode = SyncMode.from(link.mode),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
devices = device.first,
|
devices = withMeta,
|
||||||
)
|
)
|
||||||
|
|
||||||
UiState(
|
UiState(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.galo.android.ui.devicealbum
|
package com.galo.android.ui.devicealbum
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.IntentSenderRequest
|
import androidx.activity.result.IntentSenderRequest
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.galo.android.GaloApplication
|
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.media.AllFilesAccess
|
||||||
import com.galo.android.domain.model.Album
|
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.DeviceMedia
|
||||||
import com.galo.android.domain.model.MediaKind
|
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.domain.model.SyncMode
|
||||||
|
import com.galo.android.ui.album.RenameAlbumDialog
|
||||||
import com.galo.android.ui.common.GaloAsyncImage
|
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
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
const val TAG_DEVICE_GRID = "device_album_grid"
|
const val TAG_DEVICE_GRID = "device_album_grid"
|
||||||
@@ -103,6 +119,7 @@ const val TAG_CREATE_TARGET = "device_create_target"
|
|||||||
fun DeviceAlbumScreen(
|
fun DeviceAlbumScreen(
|
||||||
app: GaloApplication,
|
app: GaloApplication,
|
||||||
deviceAlbumId: String,
|
deviceAlbumId: String,
|
||||||
|
onOpenServerSettings: (String) -> Unit = {},
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val factory = viewModelFactory {
|
val factory = viewModelFactory {
|
||||||
@@ -110,7 +127,14 @@ fun DeviceAlbumScreen(
|
|||||||
}
|
}
|
||||||
val viewModel: DeviceAlbumViewModel = viewModel(factory = factory)
|
val viewModel: DeviceAlbumViewModel = viewModel(factory = factory)
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
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 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
|
val context = LocalContext.current
|
||||||
var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) }
|
var allFilesGranted by remember { mutableStateOf(AllFilesAccess.isGranted()) }
|
||||||
@@ -119,6 +143,11 @@ fun DeviceAlbumScreen(
|
|||||||
) {
|
) {
|
||||||
allFilesGranted = AllFilesAccess.isGranted()
|
allFilesGranted = AllFilesAccess.isGranted()
|
||||||
}
|
}
|
||||||
|
val deleteLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartIntentSenderForResult(),
|
||||||
|
) {
|
||||||
|
viewModel.refreshAfterDelete()
|
||||||
|
}
|
||||||
LifecycleResumeEffect(Unit) {
|
LifecycleResumeEffect(Unit) {
|
||||||
allFilesGranted = AllFilesAccess.isGranted()
|
allFilesGranted = AllFilesAccess.isGranted()
|
||||||
onPauseOrDispose { }
|
onPauseOrDispose { }
|
||||||
@@ -130,23 +159,97 @@ fun DeviceAlbumScreen(
|
|||||||
items = state.items,
|
items = state.items,
|
||||||
initialIndex = openIndex,
|
initialIndex = openIndex,
|
||||||
onBack = { viewerIndex = null },
|
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) },
|
originalSourceProvider = { mediaId -> viewModel.originalSource(mediaId) },
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
DeviceAlbumContent(
|
DeviceAlbumContent(
|
||||||
state = state,
|
state = state,
|
||||||
|
role = role,
|
||||||
|
localName = localName,
|
||||||
|
localCoverUrl = localCoverUrl,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onMode = viewModel::setMode,
|
onMode = viewModel::setMode,
|
||||||
onTarget = viewModel::setTarget,
|
onTarget = viewModel::setTarget,
|
||||||
onRestoreOriginals = viewModel::restoreOriginals,
|
onRestoreOriginals = viewModel::restoreOriginals,
|
||||||
onOpen = { index -> viewerIndex = index },
|
onOpen = { index -> viewerIndex = index },
|
||||||
onCreateAlbum = viewModel::createAlbum,
|
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,
|
allFilesAccessGranted = allFilesGranted,
|
||||||
onRequestAllFilesAccess = {
|
onRequestAllFilesAccess = {
|
||||||
runCatching { allFilesLauncher.launch(AllFilesAccess.settingsIntent(context)) }
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -159,6 +262,14 @@ fun DeviceAlbumContent(
|
|||||||
onRestoreOriginals: () -> Unit,
|
onRestoreOriginals: () -> Unit,
|
||||||
onOpen: (Int) -> Unit,
|
onOpen: (Int) -> Unit,
|
||||||
onCreateAlbum: (String) -> 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,
|
allFilesAccessGranted: Boolean = true,
|
||||||
onRequestAllFilesAccess: () -> Unit = {},
|
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(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
drawerContent = {
|
drawerContent = {
|
||||||
@@ -273,7 +406,24 @@ fun DeviceAlbumContent(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { padding ->
|
) { 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()) {
|
if (state.items.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Text("В альбоме нет медиа")
|
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
|
@Composable
|
||||||
|
|||||||
+190
-10
@@ -6,15 +6,23 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.galo.android.di.AppContainer
|
import com.galo.android.di.AppContainer
|
||||||
import com.galo.android.data.db.entity.DeviceAssetEntity
|
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.Album
|
||||||
|
import com.galo.android.domain.model.DeviceAlbumMeta
|
||||||
import com.galo.android.domain.model.DeviceMedia
|
import com.galo.android.domain.model.DeviceMedia
|
||||||
import com.galo.android.domain.model.LocalAssetState
|
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.domain.model.SyncMode
|
||||||
import com.galo.android.data.sync.OffloadResult
|
import com.galo.android.data.sync.OffloadResult
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -44,6 +52,21 @@ class DeviceAlbumViewModel(
|
|||||||
|
|
||||||
private val pendingMode = MutableStateFlow<SyncMode?>(null)
|
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(
|
val state: StateFlow<UiState> = combine(
|
||||||
container.albumRepository.observeAlbums(),
|
container.albumRepository.observeAlbums(),
|
||||||
container.syncRepository.observeLinks(),
|
container.syncRepository.observeLinks(),
|
||||||
@@ -88,23 +111,179 @@ class DeviceAlbumViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching { container.albumRepository.refresh() }
|
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) {
|
fun setMode(mode: SyncMode) {
|
||||||
pendingMode.value = mode
|
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 {
|
viewModelScope.launch {
|
||||||
if (mode == SyncMode.DISABLED) {
|
// Persist the chosen mode even before a server album is picked, so the
|
||||||
// Turning sync off clears the link to the server album.
|
// choice survives leaving and reopening the album.
|
||||||
container.syncRepository.unlink(deviceAlbumId)
|
val target = state.value.serverAlbumId
|
||||||
return@launch
|
?: container.syncRepository.currentLink(deviceAlbumId)?.serverAlbumId
|
||||||
}
|
?: ""
|
||||||
container.syncRepository.link(
|
container.syncRepository.link(
|
||||||
deviceAlbumId = deviceAlbumId,
|
deviceAlbumId = deviceAlbumId,
|
||||||
deviceAlbumName = local.value.name,
|
deviceAlbumName = local.value.name,
|
||||||
serverAlbumId = state.value.serverAlbumId ?: "",
|
serverAlbumId = target,
|
||||||
mode = mode,
|
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,
|
serverAlbumId = serverAlbumId,
|
||||||
mode = pendingMode.value ?: state.value.mode,
|
mode = pendingMode.value ?: state.value.mode,
|
||||||
)
|
)
|
||||||
|
refreshRole()
|
||||||
reconcileNow()
|
reconcileNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,6 +313,7 @@ class DeviceAlbumViewModel(
|
|||||||
serverAlbumId = album.id,
|
serverAlbumId = album.id,
|
||||||
mode = pendingMode.value ?: state.value.mode,
|
mode = pendingMode.value ?: state.value.mode,
|
||||||
)
|
)
|
||||||
|
refreshRole()
|
||||||
reconcileNow()
|
reconcileNow()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,9 +351,8 @@ class DeviceAlbumViewModel(
|
|||||||
/** Returns originals to the device for every offloaded (preview-only) asset. */
|
/** Returns originals to the device for every offloaded (preview-only) asset. */
|
||||||
fun restoreOriginals() {
|
fun restoreOriginals() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val assets = container.syncRepository.assets(deviceAlbumId)
|
val targets = RestoreManager.restoreTargets(container.syncRepository.assets(deviceAlbumId))
|
||||||
.filter { it.state == LocalAssetState.PREVIEW.name }
|
if (targets.isNotEmpty()) container.restoreManager.restore(targets)
|
||||||
container.restoreManager.restore(assets)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package com.galo.android.ui.devicealbum
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.pager.HorizontalPager
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
@@ -10,18 +12,22 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import com.galo.android.domain.model.DeviceMedia
|
import com.galo.android.domain.model.DeviceMedia
|
||||||
import com.galo.android.domain.model.MediaKind
|
import com.galo.android.domain.model.MediaKind
|
||||||
@@ -37,6 +43,8 @@ fun DeviceViewerScreen(
|
|||||||
items: List<DeviceMedia>,
|
items: List<DeviceMedia>,
|
||||||
initialIndex: Int,
|
initialIndex: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
serverPhotoByMediaId: Map<Long, String> = emptyMap(),
|
||||||
|
sheet: (@Composable (DeviceMedia) -> Unit)? = null,
|
||||||
originalSourceProvider: suspend (Long) -> OriginalSource? = { null },
|
originalSourceProvider: suspend (Long) -> OriginalSource? = { null },
|
||||||
) {
|
) {
|
||||||
if (items.isEmpty()) {
|
if (items.isEmpty()) {
|
||||||
@@ -49,6 +57,20 @@ fun DeviceViewerScreen(
|
|||||||
val current = items.getOrNull(pagerState.currentPage)
|
val current = items.getOrNull(pagerState.currentPage)
|
||||||
var zoomed by remember(current?.id) { mutableStateOf(false) }
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -64,6 +86,7 @@ fun DeviceViewerScreen(
|
|||||||
},
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
|
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
Box(Modifier.fillMaxSize().then(sheetGesture)) {
|
||||||
HorizontalPager(
|
HorizontalPager(
|
||||||
state = pagerState,
|
state = pagerState,
|
||||||
userScrollEnabled = !zoomed,
|
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
|
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.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
@@ -12,26 +15,38 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
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.compose.ui.platform.testTag
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import androidx.lifecycle.viewmodel.initializer
|
import androidx.lifecycle.viewmodel.initializer
|
||||||
import androidx.lifecycle.viewmodel.viewModelFactory
|
import androidx.lifecycle.viewmodel.viewModelFactory
|
||||||
import com.galo.android.GaloApplication
|
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.MediaKind
|
||||||
import com.galo.android.domain.model.Photo
|
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"
|
const val TAG_VIEWER_PAGER = "viewer_pager"
|
||||||
|
|
||||||
@@ -42,13 +57,92 @@ fun ViewerScreen(
|
|||||||
albumId: String,
|
albumId: String,
|
||||||
initialIndex: Int,
|
initialIndex: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenAttributeSettings: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val factory = viewModelFactory {
|
val factory = viewModelFactory {
|
||||||
initializer { ViewerViewModel(app.container, albumId) }
|
initializer { ViewerViewModel(app.container, albumId) }
|
||||||
}
|
}
|
||||||
val viewModel: ViewerViewModel = viewModel(factory = factory)
|
val viewModel: ViewerViewModel = viewModel(factory = factory)
|
||||||
val photos by viewModel.photos.collectAsStateWithLifecycle()
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -57,6 +151,7 @@ fun ViewerContent(
|
|||||||
photos: List<Photo>,
|
photos: List<Photo>,
|
||||||
initialIndex: Int,
|
initialIndex: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
sheet: (@Composable (Photo) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
if (photos.isEmpty()) {
|
if (photos.isEmpty()) {
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -90,6 +185,23 @@ fun ViewerContent(
|
|||||||
var zoomed by remember(current?.id) { mutableStateOf(false) }
|
var zoomed by remember(current?.id) { mutableStateOf(false) }
|
||||||
val pagerScrollEnabled = current?.isPanorama != true && !zoomed
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
@@ -111,21 +223,32 @@ fun ViewerContent(
|
|||||||
color = Color.Black,
|
color = Color.Black,
|
||||||
modifier = Modifier.fillMaxSize().padding(padding),
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
) {
|
) {
|
||||||
HorizontalPager(
|
Box(Modifier.fillMaxSize().then(sheetGesture)) {
|
||||||
state = pagerState,
|
HorizontalPager(
|
||||||
userScrollEnabled = pagerScrollEnabled,
|
state = pagerState,
|
||||||
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
|
userScrollEnabled = pagerScrollEnabled,
|
||||||
key = { index -> photos.getOrNull(index)?.id ?: index },
|
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
|
||||||
) { page ->
|
key = { index -> photos.getOrNull(index)?.id ?: index },
|
||||||
val photo = photos.getOrNull(page) ?: return@HorizontalPager
|
) { page ->
|
||||||
MediaPage(
|
val photo = photos.getOrNull(page) ?: return@HorizontalPager
|
||||||
photo = photo,
|
MediaPage(
|
||||||
active = pagerState.currentPage == page,
|
photo = photo,
|
||||||
onZoomChanged = { zoomed = it },
|
active = pagerState.currentPage == page,
|
||||||
)
|
onZoomChanged = { zoomed = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (sheetOpen && sheet != null && current != null) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = { sheetOpen = false },
|
||||||
|
sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true),
|
||||||
|
) {
|
||||||
|
sheet(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ package com.galo.android.ui.viewer
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.galo.android.data.network.dto.PhotoInfoPrefsDto
|
||||||
import com.galo.android.di.AppContainer
|
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.Photo
|
||||||
|
import com.galo.android.domain.model.Tag
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -17,6 +22,15 @@ class ViewerViewModel(
|
|||||||
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
|
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
.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 {
|
init {
|
||||||
// Cold start (e.g. restored route): make sure the cache is populated.
|
// Cold start (e.g. restored route): make sure the cache is populated.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -24,5 +38,77 @@ class ViewerViewModel(
|
|||||||
container.photoRepository.loadNextPage(albumId, cursor = null)
|
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
|
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.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class RestoreManagerTest {
|
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
|
@Test
|
||||||
fun `restoring fits when there is enough space`() {
|
fun `restoring fits when there is enough space`() {
|
||||||
assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100))
|
assertTrue(RestoreManager.canRestore(requiredBytes = 100, availableBytes = 100))
|
||||||
@@ -16,4 +38,16 @@ class RestoreManagerTest {
|
|||||||
fun `restoring is deferred when space is short`() {
|
fun `restoring is deferred when space is short`() {
|
||||||
assertFalse(RestoreManager.canRestore(requiredBytes = 500, availableBytes = 100))
|
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 {
|
private class FakeQueue : SyncUploadQueue {
|
||||||
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
|
val enqueued = mutableListOf<Triple<String, String, LocalAsset>>()
|
||||||
|
val pending = mutableSetOf<String>()
|
||||||
override suspend fun enqueue(
|
override suspend fun enqueue(
|
||||||
deviceAlbumId: String,
|
deviceAlbumId: String,
|
||||||
serverAlbumId: String,
|
serverAlbumId: String,
|
||||||
@@ -61,6 +62,8 @@ class SyncCoordinatorTest {
|
|||||||
) {
|
) {
|
||||||
enqueued += Triple(deviceAlbumId, serverAlbumId, asset)
|
enqueued += Triple(deviceAlbumId, serverAlbumId, asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun isPending(assetKey: String): Boolean = assetKey in pending
|
||||||
}
|
}
|
||||||
|
|
||||||
private class FakeProvider : LocalAssetProvider {
|
private class FakeProvider : LocalAssetProvider {
|
||||||
@@ -267,9 +270,75 @@ class SyncCoordinatorTest {
|
|||||||
assertEquals(LocalAssetState.AWAITING_FREE.name, db.syncDao().getAsset("b1/1")?.state)
|
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
|
@Test
|
||||||
fun `move delegates to the server`() = runBlocking {
|
fun `move delegates to the server`() = runBlocking {
|
||||||
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
|
coordinator.moveToServerAlbum(listOf("p1", "p2"), "srv2")
|
||||||
assertEquals(listOf(listOf("p1", "p2") to "srv2"), remote.moves)
|
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_ALBUMS_GRID).assertIsDisplayed()
|
||||||
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
|
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertIsDisplayed()
|
||||||
compose.onNodeWithText("Summer").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
|
@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(
|
val items = AlbumListBuilder.build(
|
||||||
albums = listOf(album("a1", "Отпуск")),
|
albums = listOf(album("a1", "Отпуск")),
|
||||||
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
|
links = listOf(AlbumLink("b1", "Camera", "a1", SyncMode.COPY)),
|
||||||
@@ -88,10 +90,27 @@ class AlbumsContentTest {
|
|||||||
compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed()
|
compose.onNodeWithTag(TAG_DEVICE_ALBUM_CARD_PREFIX + "b1").assertIsDisplayed()
|
||||||
// The linked server album is collapsed into the device card, not shown twice.
|
// The linked server album is collapsed into the device card, not shown twice.
|
||||||
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist()
|
compose.onNodeWithTag(TAG_ALBUM_CARD_PREFIX + "a1").assertDoesNotExist()
|
||||||
compose.onNodeWithText("Телефон").assertIsDisplayed()
|
compose.onNodeWithText("Camera").assertIsDisplayed()
|
||||||
compose.onNodeWithText("9 эл.").assertIsDisplayed()
|
|
||||||
compose.onNodeWithText("→ Отпуск").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
|
@Test
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.galo.android.ui.devicealbum
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsNotDisplayed
|
||||||
import androidx.compose.ui.test.assertTextContains
|
import androidx.compose.ui.test.assertTextContains
|
||||||
import androidx.compose.ui.test.hasTestTag
|
import androidx.compose.ui.test.hasTestTag
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
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.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollToNode
|
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.Album
|
||||||
import com.galo.android.domain.model.DeviceMedia
|
import com.galo.android.domain.model.DeviceMedia
|
||||||
import com.galo.android.domain.model.MediaKind
|
import com.galo.android.domain.model.MediaKind
|
||||||
import com.galo.android.domain.model.SyncMode
|
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 com.galo.android.ui.theme.GaloTheme
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -78,6 +90,12 @@ class DeviceAlbumContentTest {
|
|||||||
onMode: (SyncMode) -> Unit = {},
|
onMode: (SyncMode) -> Unit = {},
|
||||||
onTarget: (String) -> Unit = {},
|
onTarget: (String) -> Unit = {},
|
||||||
onCreateAlbum: (String) -> Unit = {},
|
onCreateAlbum: (String) -> Unit = {},
|
||||||
|
role: String = "",
|
||||||
|
localName: String? = null,
|
||||||
|
localCoverUrl: String? = null,
|
||||||
|
onDeleteAlbum: () -> Unit = {},
|
||||||
|
onRenameAlbum: (String) -> Unit = {},
|
||||||
|
onSetCover: (String?) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
GaloTheme(dynamicColor = false) {
|
GaloTheme(dynamicColor = false) {
|
||||||
DeviceAlbumContent(
|
DeviceAlbumContent(
|
||||||
@@ -88,6 +106,12 @@ class DeviceAlbumContentTest {
|
|||||||
onRestoreOriginals = {},
|
onRestoreOriginals = {},
|
||||||
onOpen = {},
|
onOpen = {},
|
||||||
onCreateAlbum = onCreateAlbum,
|
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`() {
|
fun `mode action for the same mode does nothing`() {
|
||||||
assertEquals(null, modeAction(SyncMode.COPY, hasTarget = true, target = SyncMode.COPY))
|
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
|
@Test
|
||||||
fun `target picker lists albums and picks one`() {
|
fun `target picker lists albums and picks one`() {
|
||||||
var picked: String? = null
|
var picked: String? = null
|
||||||
@@ -275,4 +338,107 @@ class DeviceAlbumContentTest {
|
|||||||
compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick()
|
compose.onNodeWithTag(TAG_MODE_PREFIX + SyncMode.MOVE.name).performClick()
|
||||||
assertEquals(SyncMode.MOVE, chosen)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-13
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Design
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
См. `proposal.md` — зачем. Текущее состояние, влияющее на решение:
|
||||||
|
|
||||||
|
- `GaloApi` умеет только `albums list/get/create`, `photos list/get/upload`, `renamePhoto`,
|
||||||
|
`deletePhoto`, `movePhotos`. Доступны, но не задействованы: `PATCH/DELETE /albums/:id`,
|
||||||
|
`/albums/:id/members`, `/albums/:id/share`, `POST /photo-batch/copy`,
|
||||||
|
`/photos/:id/tags`, `/tags`, `PATCH /auth/me/preferences`.
|
||||||
|
- Сервер уже отдаёт в JSON фото `exif`, `tags`, `uploader`, `download_url`, а `/auth/me` —
|
||||||
|
`preferences`; `PhotoDto`/`UserDto` в Android этих полей не читают.
|
||||||
|
- `DeviceAlbumViewModel` держит `serverPhotoByMediaId` (сопоставление локального медиа
|
||||||
|
серверному фото), но `DeviceViewerScreen` его не получает.
|
||||||
|
- `DeviceAlbumScreen` уже использует `ModalNavigationDrawer` (стартовый/левый drawer) под
|
||||||
|
настройки синхронизации.
|
||||||
|
- Просмотр (`ViewerScreen`/`DeviceViewerScreen`) занят жестами: горизонтальный пейджер,
|
||||||
|
pinch-zoom, double-tap, панорама (drag по двум осям), плеер видео.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
- Единая панель «Атрибуты и действия» на всех экранах альбомов (свайп справа налево).
|
||||||
|
- Единый лист «Атрибуты и действия» в обоих просмотрах (свайп вверх).
|
||||||
|
- Резолвинг источника атрибутов: сервер / связанный сервер / локальные значения.
|
||||||
|
- Расширение клиентского API/DTO до паритета с web без изменений бэкенда.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
- Изменения бэкенда, web и схемы spec-ов (кроме этой delta).
|
||||||
|
- Новые серверные способы работы с медиа (всё уже есть в API).
|
||||||
|
- Пересмотр существующих жестов просмотра (пейджер, зум, панорама) и drawer'а синхронизации.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
### 1. Панель альбома = `ModalNavigationDrawer` с end-drawer
|
||||||
|
Свайп справа налево открывает правый (end) `ModalNavigationDrawer` на `AlbumScreen` и на
|
||||||
|
`DeviceAlbumScreen`. Так сохраняется симметрия: на device-экране левый drawer — синхронизация,
|
||||||
|
правый — атрибуты/действия; на серверном — только правый.
|
||||||
|
_Альтернатива_: bottom sheet на альбоме — не повторяет web-раскладку «правая панель» и хуже
|
||||||
|
для длинного списка атрибутов.
|
||||||
|
|
||||||
|
### 2. Один компонент панели; атрибуты локального альбома — локальные
|
||||||
|
Общий composable-контент панели принимает «модель атрибутов альбома», которую строит экран:
|
||||||
|
|
||||||
|
- серверный альбом → из `AlbumDto` (включая `role` из envelope);
|
||||||
|
- локальный альбом → локальные метаданные (`DeviceAlbum` + app-only метаданные), связанный
|
||||||
|
серверный альбом используется только как источник для навигации «Участники»/«Ссылки».
|
||||||
|
|
||||||
|
Секции панели показываются всегда; строки навигации, требующие серверной стороны, отображаются
|
||||||
|
неактивными, когда связи нет.
|
||||||
|
|
||||||
|
_Альтернатива_: три отдельные панели — дублирование и расхождение поведения.
|
||||||
|
|
||||||
|
### 2a. App-only метаданные локального альбома
|
||||||
|
Имя (отображаемое) и выбранная обложка локального альбома хранятся в новой Room-таблице
|
||||||
|
`device_album_meta` (ключ — id альбома-устройства). «Переименовать» пишет `displayName`,
|
||||||
|
«Выбрать обложку»/«Сбросить» — `coverUri`. Список альбомов и панель применяют их поверх
|
||||||
|
данных MediaStore.
|
||||||
|
|
||||||
|
_Альтернатива_: менять bucket в MediaStore — невозможно без прав и необратимо.
|
||||||
|
|
||||||
|
### 2b. Удаление локального альбома удаляет медиа телефона
|
||||||
|
«Удалить» собирает все URI медиа альбома и запрашивает системное согласие
|
||||||
|
(`MediaStore.createDeleteRequest`) через тот же механизм, что и разгрузка в режиме C. После
|
||||||
|
подтверждения система удаляет файлы; приложение забывает локальный индекс и метаданные
|
||||||
|
(`forgetAlbum`).
|
||||||
|
|
||||||
|
### 3. Лист фото = `ModalBottomSheet`, открытие по порогу свайпа вверх
|
||||||
|
`ViewerScreen` и `DeviceViewerScreen` добавляют `detectVerticalDragGestures` под условием:
|
||||||
|
не открывать при активном зуме, панораме и поверх контролов видео. В счёт: открывать при
|
||||||
|
`!zoomed && !isPanorama && kind != VIDEO` (для видео — через видимый drag-handle/кнопку).
|
||||||
|
_Альтернатива_: кнопка вместо жеста — расходится с исходным требованием (свайп вверх).
|
||||||
|
|
||||||
|
### 4. Резолвинг атрибутов/действий device-медиа
|
||||||
|
Если `serverPhotoByMediaId[mediaId]` есть — лист грузит серверное фото (`GET /photos/:id`) и
|
||||||
|
показывает серверные атрибуты/действия; иначе — локальную модель из `DeviceMedia`.
|
||||||
|
Сопоставление передаётся в `DeviceViewerScreen` из `DeviceAlbumViewModel`.
|
||||||
|
|
||||||
|
### 5. Расширение клиентского API/DTO (без бэкенда)
|
||||||
|
- `PhotoDto` + `Photo`: `exif`, `tags`, `uploader`, `download_url`, `likes_count`,
|
||||||
|
`comments_count` (используется то, что нужно листу).
|
||||||
|
- `UserDto`/`me()`: `preferences`.
|
||||||
|
- `GaloApi`: `updateAlbum` (title/description/cover/clear), `deleteAlbum`, `members`,
|
||||||
|
share-links, `copyPhotos`, `addTag`/`removeTag`/`tags`, `download` (через существующий
|
||||||
|
`download_url`), `savePreferences`.
|
||||||
|
- `ProfileApi` не трогаем; `preferences` добавляется в ответ `me()`.
|
||||||
|
|
||||||
|
### 6. Настройки атрибутов — новый экран, состояние в профиле
|
||||||
|
Переносим каталог `EXIF_LABELS`/`EXIF_BASE` (web `exif.ts`) в Android-ресурс/объект и храним
|
||||||
|
`photo_info.visible` + `photo_info.labels` в `preferences` через `PATCH /auth/me/preferences`
|
||||||
|
(тот же формат, что у web). _Альтернатива_: собственный формат — рассинхрон настроек между
|
||||||
|
web и мобильным для одного пользователя.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Конфликт жеста «вверх» с зумом/панорамой/плеером] → открывать лист только вне этих
|
||||||
|
состояний; для видео — не перехватывать жест, доступ через handle/кнопку.
|
||||||
|
- [Удаление/переименование локального альбома не имеет смысла и деструктивно] → действия
|
||||||
|
серверного уровня показываются только при связи и применяются к серверному альбому; без
|
||||||
|
связи раздел действий пуст.
|
||||||
|
- [Офлайн] → операции серверного уровня недоступны офлайн; используем уже существующее
|
||||||
|
офлайн-состояние экрана и показываем ошибку без падения.
|
||||||
|
- [Дублирование EXIF-каталога между web и Android] → переносим один в один и фиксируем
|
||||||
|
базовый набор; расхождение контролируется тестом.
|
||||||
|
- [Права доступа] → гейтим секции по `role` (владелец/админ/автор), как web.
|
||||||
|
|
||||||
|
### 7. Полная проверка синхронизации при запуске
|
||||||
|
`SyncWorker` реконсилирует все известные галереи (связанные плюс имеющие локальный индекс),
|
||||||
|
восстанавливает утраченные связи по меткам происхождения (случай частичной синхронизации) и
|
||||||
|
перевыставляет незавершённые загрузки (`requeuePending`). Запуск воркера происходит после
|
||||||
|
загрузки настроек (когда известен base URL), а смена режима на серверный сразу вызывает
|
||||||
|
`reconcile`. Та же проверка выполняется при открытии галереи. Разгрузка в режиме переноса
|
||||||
|
автоматическая: любой ассет, уже имеющий серверное фото и всё ещё хранящий оригинал на
|
||||||
|
устройстве, разгружается (включая повторные попытки и ассеты, оставшиеся «ожидающими
|
||||||
|
освобождения»). Обратный случай (переход из переноса в копии/выключено) возвращает оригиналы с
|
||||||
|
server и довосстанавливает незавершённый возврат при следующей проверке; восстановление не
|
||||||
|
обрывается на единичных ошибках. Решение по каждому ассету вынесено в политику
|
||||||
|
`GallerySyncPolicy` («режим × состояние × связанность»), которую применяет общая проверка
|
||||||
|
`SyncCheck` — так охвачены все сочетания, включая «копии с разгруженными файлами».
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Изменение аддитивное, бэкенд и данные не мигрируются. Выкатывается в составе обычного релиза
|
||||||
|
Android-клиента; откат — откат версии приложения. Пользовательские `preferences` не меняют
|
||||||
|
формат, поэтому web и мобильный остаются совместимы.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
Нет — все решения, влияющие на спек и декомпозицию, приняты выше.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
В web у альбомов и фото есть полноценное управление атрибутами и действиями (панель
|
||||||
|
обложки/информации/навигации, разделы «Действия», «Метки», «Информация» с настройками
|
||||||
|
EXIF-атрибутов). В Android-клиенте этого нет: экран альбома — только сетка, просмотр —
|
||||||
|
только изображение с навигацией, а `GaloApi` умеет лишь переименовать/удалить/переместить.
|
||||||
|
С телефона нельзя ни посмотреть свойства, ни выполнить действия «как в web».
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Новый **экранный жест**: свайп справа налево в любом альбоме (серверном и локальном)
|
||||||
|
открывает панель **«Атрибуты и действия»** (drawer справа).
|
||||||
|
- Панель альбома повторяет структуру web (`AlbumPanel` + экран настроек):
|
||||||
|
- Атрибуты: «Обложка» (превью, выбрать/сбросить), «Информация» (название, описание,
|
||||||
|
счётчики), «Навигация» (участники, настройки, ссылки для просмотра).
|
||||||
|
- Действия: «Переименовать», «Удалить».
|
||||||
|
- Источник данных: серверный альбом — данные сервера; локальный, связанный с серверным —
|
||||||
|
реальные атрибуты связанного серверного альбома; локальный без связи — локальные
|
||||||
|
значения (обложка/имя/счётчик), без раздела «Навигация».
|
||||||
|
- Новый **жест просмотра**: свайп вверх в полноэкранном просмотре (серверном и device)
|
||||||
|
открывает лист **«Атрибуты и действия»**, повторяющий web `PhotoPanel`:
|
||||||
|
- Атрибуты: «Информация» (автор, файл, тип, разрешение, EXIF, снято, координаты,
|
||||||
|
загружено) с настройками списка EXIF-атрибутов; «Метки».
|
||||||
|
- Действия: «Открыть (просмотр)» (из сетки), «Скачать», «Переместить», «Копировать»,
|
||||||
|
«Установить как обложку альбома», «Удалить»; переименование по тапу на имя.
|
||||||
|
- Для device-медиа: если элемент сопоставлен серверному фото — реальные серверные
|
||||||
|
атрибуты и действия; иначе локальные атрибуты и локальные действия.
|
||||||
|
- Расширение клиентского слоя Android: `GaloApi` + DTO получают операции альбома
|
||||||
|
(обновление/обложка/удаление/участники/share), копирование, метки, скачивание, `exif`
|
||||||
|
и `preferences`; новый экран «Настройки атрибутов» (видимость + подписи, в профиль).
|
||||||
|
- **Бэкенд не меняется**: все нужные endpoints уже есть.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
<!-- none -->
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `android-client`: добавляются требования к панели «Атрибуты и действия» на экранах
|
||||||
|
альбомов, к листу «Атрибуты и действия» в полноэкранном просмотре, к выбору источника
|
||||||
|
атрибутов для локальных альбомов/медиа и к поддерживающим операциям API/настройкам
|
||||||
|
атрибутов в профиле.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- `android/app/src/main/java/com/galo/android/`: `data/network/GaloApi.kt`, `.../dto/Dtos.kt`,
|
||||||
|
`domain/model/`, `ui/album/`, `ui/viewer/`, `ui/devicealbum/`, новые экраны настроек.
|
||||||
|
- Тесты Android (`android/app/src/androidTest`, `test`).
|
||||||
|
- Бэкенд, web и схема spec-ов не затрагиваются (кроме новой delta для `android-client`).
|
||||||
+266
@@ -0,0 +1,266 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Проверка доступа «Все файлы» при запуске
|
||||||
|
Приложение SHALL при запуске, когда оно уже настроено и авторизовано, проверять наличие доступа
|
||||||
|
«Все файлы» и при его отсутствии SHALL предлагать его включить, открывая системный экран настроек.
|
||||||
|
|
||||||
|
#### Scenario: Доступ не выдан
|
||||||
|
- **WHEN** приложение запущено и доступ «Все файлы» не выдан
|
||||||
|
- **THEN** показывается диалог с предложением включить доступ
|
||||||
|
|
||||||
|
#### Scenario: Включение доступа
|
||||||
|
- **WHEN** пользователь подтверждает
|
||||||
|
- **THEN** открывается системный экран выдачи доступа «Все файлы»
|
||||||
|
|
||||||
|
#### Scenario: Доступ уже выдан
|
||||||
|
- **WHEN** доступ уже выдан
|
||||||
|
- **THEN** диалог не показывается
|
||||||
|
|
||||||
|
### Requirement: Возврат оригиналов при выходе из переноса
|
||||||
|
При переключении альбома из режима переноса в «копии» или «выключено» приложение SHALL
|
||||||
|
возвращать оригиналы с сервера (когда пользователь выбирает возврат), а незавершённый возврат
|
||||||
|
SHALL довосстанавливаться при следующей проверке (при запуске и при открытии альбома).
|
||||||
|
|
||||||
|
#### Scenario: Перенос → копии
|
||||||
|
- **WHEN** пользователь переключает альбом из переноса в копии и подтверждает возврат оригиналов
|
||||||
|
- **THEN** оригиналы скачиваются с сервера и заменяют превью на устройстве
|
||||||
|
|
||||||
|
#### Scenario: Довосстановление
|
||||||
|
- **WHEN** альбом не в режиме переноса, но содержит превью с серверным фото
|
||||||
|
- **THEN** при запуске или открытии альбома оригиналы довосстанавливаются
|
||||||
|
|
||||||
|
#### Scenario: Перенос → выключено
|
||||||
|
- **WHEN** пользователь переключает перенос в выключено
|
||||||
|
- **THEN** предлагается вернуть оригиналы или оставить превью
|
||||||
|
|
||||||
|
### Requirement: Единая проверка соответствия режиму
|
||||||
|
При запуске и при открытии альбома приложение SHALL приводить локальное содержимое в
|
||||||
|
соответствие режиму для всех сочетаний состояния ассета: в «переносе» разгружать уже загруженные
|
||||||
|
оригиналы, а в «копиях»/«выключено» восстанавливать превью; отсутствующий локальный индекс SHALL
|
||||||
|
восстанавливаться по метке происхождения.
|
||||||
|
|
||||||
|
#### Scenario: Копии с разгруженными файлами
|
||||||
|
- **WHEN** альбом в режиме копий, а часть файлов разгружена (превью)
|
||||||
|
- **THEN** проверка восстанавливает оригиналы
|
||||||
|
|
||||||
|
#### Scenario: Перенос с неразгруженными файлами
|
||||||
|
- **WHEN** альбом в режиме переноса, файл есть на сервере, но оригинал ещё на телефоне
|
||||||
|
- **THEN** проверка разгружает оригинал
|
||||||
|
|
||||||
|
#### Scenario: Потерянный индекс
|
||||||
|
- **WHEN** у разгруженного медиа нет записи в локальном индексе
|
||||||
|
- **THEN** связь и происхождение восстанавливаются по метке в файле
|
||||||
|
|
||||||
|
#### Scenario: Выключенный режим
|
||||||
|
- **WHEN** альбом без синхронизации содержит превью
|
||||||
|
- **THEN** оригиналы восстанавливаются, а сервер не затрагивается
|
||||||
|
|
||||||
|
### Requirement: Отсутствие дублей и гонок при синхронизации
|
||||||
|
Проверка синхронизации SHALL выполняться последовательно (без одновременных проходов по одной
|
||||||
|
галерее) и SHALL NOT ставить повторную загрузку, пока по тому же элементу уже есть задание в
|
||||||
|
очереди. Метка происхождения в разгруженном файле SHALL хранить режим синхронизации, а не
|
||||||
|
состояние элемента, чтобы восстановление не меняло режим галереи.
|
||||||
|
|
||||||
|
#### Scenario: Повторная проверка во время загрузки
|
||||||
|
- **WHEN** проверка запускается снова, пока загрузка элемента ещё стоит в очереди
|
||||||
|
- **THEN** повторная загрузка не ставится
|
||||||
|
|
||||||
|
#### Scenario: Метка происхождения хранит режим
|
||||||
|
- **WHEN** разгруженный файл восстанавливается после переустановки
|
||||||
|
- **THEN** режим галереи берётся из метки как режим (перенос/копии), а не как состояние элемента
|
||||||
|
|
||||||
|
### Requirement: Подпись карточки альбома
|
||||||
|
Карточка альбома SHALL показывать в первой строке имя альбома, а для связанной пары — со
|
||||||
|
стрелкой имя связанного серверного альбома, и только когда имена различаются. Вторая строка
|
||||||
|
SHALL показывать значками расположение содержимого и число элементов: значок карты памяти для
|
||||||
|
локального без синхронизации, значки карты памяти и облака для режима копий, значок облака для
|
||||||
|
режима переноса; для серверного альбома — значок облака.
|
||||||
|
|
||||||
|
#### Scenario: Локальный без синхронизации
|
||||||
|
- **WHEN** показан локальный альбом без синхронизации
|
||||||
|
- **THEN** вторая строка показывает значок карты памяти и число элементов
|
||||||
|
|
||||||
|
#### Scenario: Режим копий
|
||||||
|
- **WHEN** локальный альбом синхронизируется в режиме копий
|
||||||
|
- **THEN** вторая строка показывает значки карты памяти и облака и число элементов
|
||||||
|
|
||||||
|
#### Scenario: Режим переноса
|
||||||
|
- **WHEN** локальный альбом синхронизируется в режиме переноса
|
||||||
|
- **THEN** вторая строка показывает значок облака и число элементов
|
||||||
|
|
||||||
|
#### Scenario: Разные имена связанной пары
|
||||||
|
- **WHEN** имя локального альбома не совпадает с именем связанного серверного
|
||||||
|
- **THEN** первая строка показывает «имя → имя серверного»
|
||||||
|
|
||||||
|
#### Scenario: Одинаковые имена
|
||||||
|
- **WHEN** имена совпадают
|
||||||
|
- **THEN** стрелка и имя серверного не показываются
|
||||||
|
|
||||||
|
### Requirement: Панель атрибутов и действий альбома
|
||||||
|
Приложение SHALL открывать панель «Атрибуты и действия» по свайпу справа налево на экране
|
||||||
|
любого альбома — серверного и локального. Панель SHALL повторять структуру web: разделы
|
||||||
|
«Обложка», «Информация», «Навигация» и раздел «Действия».
|
||||||
|
|
||||||
|
#### Scenario: Открытие свайпом
|
||||||
|
- **WHEN** пользователь свайпает справа налево в открытом альбоме
|
||||||
|
- **THEN** открывается панель «Атрибуты и действия»
|
||||||
|
|
||||||
|
#### Scenario: Структура панели
|
||||||
|
- **WHEN** панель открыта
|
||||||
|
- **THEN** показаны разделы «Обложка», «Информация», «Навигация» и «Действия»
|
||||||
|
|
||||||
|
#### Scenario: Закрытие панели
|
||||||
|
- **WHEN** пользователь закрывает панель (свайп в обратную сторону или касание вне панели)
|
||||||
|
- **THEN** панель скрывается, сетка медиа снова доступна
|
||||||
|
|
||||||
|
### Requirement: Источник атрибутов локального альбома
|
||||||
|
Для локального альбома приложение SHALL показывать ту же панель «Атрибуты и действия», что и
|
||||||
|
для серверного, а метаданные SHALL брать локально (имя, число элементов, локальная обложка,
|
||||||
|
режим синхронизации, связанный серверный альбом). Действия локального альбома SHALL
|
||||||
|
применяться локально.
|
||||||
|
|
||||||
|
#### Scenario: Локальные метаданные
|
||||||
|
- **WHEN** пользователь открывает панель локального альбома
|
||||||
|
- **THEN** раздел «Информация» показывает локальные имя, число элементов и обложку
|
||||||
|
|
||||||
|
#### Scenario: Неактивная навигация без связи
|
||||||
|
- **WHEN** локальный альбом не связан с серверным
|
||||||
|
- **THEN** строки «Участники» и «Ссылки для просмотра» показаны, но неактивны
|
||||||
|
|
||||||
|
#### Scenario: Активная навигация при связи
|
||||||
|
- **WHEN** локальный альбом связан с серверным
|
||||||
|
- **THEN** строки «Участники» и «Ссылки для просмотра» активны и относятся к связанному серверному альбому
|
||||||
|
|
||||||
|
### Requirement: Переименование и обложка локального альбома
|
||||||
|
Для локального альбома действие «Переименовать» SHALL менять отображаемое имя в приложении, не
|
||||||
|
изменяя медиа или bucket на устройстве, а «Выбрать обложку»/«Сбросить» SHALL назначать или
|
||||||
|
снимать обложку из медиа этого альбома. Имя и обложка SHALL храниться локально.
|
||||||
|
|
||||||
|
#### Scenario: Переименование
|
||||||
|
- **WHEN** пользователь вводит новое имя локального альбома
|
||||||
|
- **THEN** панель, заголовок и карточка в списке альбомов показывают новое имя
|
||||||
|
|
||||||
|
#### Scenario: Назначение и сброс обложки
|
||||||
|
- **WHEN** пользователь выбирает медиа обложкой или сбрасывает её
|
||||||
|
- **THEN** панель и карточка альбома отражают выбор
|
||||||
|
|
||||||
|
### Requirement: Удаление локального альбома с телефона
|
||||||
|
Действие «Удалить» локального альбома SHALL после подтверждения удалять все фото и видео
|
||||||
|
этого альбома с устройства, запрашивая при необходимости системное согласие.
|
||||||
|
|
||||||
|
#### Scenario: Подтверждение
|
||||||
|
- **WHEN** пользователь выбирает «Удалить» локального альбома
|
||||||
|
- **THEN** открывается диалог с предупреждением, что медиа будут удалены с телефона
|
||||||
|
|
||||||
|
#### Scenario: Удаление
|
||||||
|
- **WHEN** пользователь подтверждает удаление
|
||||||
|
- **THEN** медиа удаляются с устройства, а альбом исчезает из приложения
|
||||||
|
|
||||||
|
### Requirement: Атрибуты альбома
|
||||||
|
Панель SHALL показывать обложку с выбором и сбросом, информацию (название, описание, число
|
||||||
|
фото и участников) и переходы к участникам, настройкам и ссылкам для просмотра. Управление
|
||||||
|
обложкой и раздел «Навигация» SHALL быть доступны только владельцу и администратору.
|
||||||
|
|
||||||
|
#### Scenario: Управление обложкой
|
||||||
|
- **WHEN** владелец или администратор выбирает или сбрасывает обложку
|
||||||
|
- **THEN** изменение сохраняется на сервере и отражается в списке и панели
|
||||||
|
|
||||||
|
#### Scenario: Наблюдатель
|
||||||
|
- **WHEN** наблюдатель открывает панель
|
||||||
|
- **THEN** управление обложкой не показывается, а строки «Настройки» и «Ссылки для просмотра» неактивны
|
||||||
|
|
||||||
|
#### Scenario: Переход к настройкам
|
||||||
|
- **WHEN** владелец или администратор открывает настройки из раздела «Навигация»
|
||||||
|
- **THEN** доступны правка названия и описания и управление ссылками для просмотра
|
||||||
|
|
||||||
|
### Requirement: Действия с альбомом
|
||||||
|
Панель SHALL содержать действия «Переименовать» и «Удалить». Удаление SHALL подтверждаться
|
||||||
|
диалогом, а удалённый альбом SHALL помещать свои фото в корзину.
|
||||||
|
|
||||||
|
#### Scenario: Переименование
|
||||||
|
- **WHEN** владелец или администратор выбирает «Переименовать» и вводит новое название
|
||||||
|
- **THEN** название сохраняется на сервере и отражается в списке и заголовке
|
||||||
|
|
||||||
|
#### Scenario: Удаление с подтверждением
|
||||||
|
- **WHEN** владелец выбирает «Удалить» и подтверждает в диалоге
|
||||||
|
- **THEN** альбом удаляется, а его фото попадают в корзину
|
||||||
|
|
||||||
|
### Requirement: Лист атрибутов и действий фото
|
||||||
|
Полноэкранный просмотр SHALL открывать лист «Атрибуты и действия» по свайпу вверх как в
|
||||||
|
серверном, так и в device-просмотре. Лист SHALL повторять структуру web: разделы
|
||||||
|
«Информация», «Метки» и «Действия». При зуме изображения, панораме или активном управлении
|
||||||
|
видео свайп вверх SHALL NOT открывать лист.
|
||||||
|
|
||||||
|
#### Scenario: Открытие листа
|
||||||
|
- **WHEN** пользователь свайпает вверх во вписанном изображении
|
||||||
|
- **THEN** открывается лист «Атрибуты и действия» для текущего элемента
|
||||||
|
|
||||||
|
#### Scenario: Зум или панорама
|
||||||
|
- **WHEN** изображение увеличено или открыта панорама, и пользователь свайпает вверх
|
||||||
|
- **THEN** лист не открывается, жест обрабатывается изображением
|
||||||
|
|
||||||
|
#### Scenario: Видео
|
||||||
|
- **WHEN** открыт видеоэлемент, и пользователь взаимодействует с плеером
|
||||||
|
- **THEN** свайп вверх не мешает управлению воспроизведением
|
||||||
|
|
||||||
|
### Requirement: Атрибуты фото в листе
|
||||||
|
Раздел «Информация» SHALL показывать автора, размер файла, тип, разрешение, EXIF-атрибуты,
|
||||||
|
дату съёмки, координаты и дату загрузки. Раздел «Метки» SHALL позволять добавлять и снимать
|
||||||
|
метки с поиском по меткам пользователя и созданием новых.
|
||||||
|
|
||||||
|
#### Scenario: Информация
|
||||||
|
- **WHEN** лист открыт
|
||||||
|
- **THEN** раздел «Информация» показывает метаданные и EXIF текущего фото
|
||||||
|
|
||||||
|
#### Scenario: Метки
|
||||||
|
- **WHEN** пользователь добавляет или снимает метку в разделе «Метки»
|
||||||
|
- **THEN** изменение сразу отражается в просмотре без перезагрузки
|
||||||
|
|
||||||
|
### Requirement: Настройки списка атрибутов
|
||||||
|
Раздел «Информация» SHALL давать доступ к настройкам со списком EXIF-атрибутов, где
|
||||||
|
пользователь отмечает отображаемые атрибуты и задаёт их подписи, с действиями «отметить все»
|
||||||
|
и «снять все». Настройки SHALL применяться ко всем фото и сохраняться в профиле пользователя.
|
||||||
|
|
||||||
|
#### Scenario: Открытие настроек
|
||||||
|
- **WHEN** пользователь открывает настройки атрибутов
|
||||||
|
- **THEN** показан список EXIF-атрибутов с галочками видимости и полями подписей
|
||||||
|
|
||||||
|
#### Scenario: Сохранение настроек
|
||||||
|
- **WHEN** пользователь отмечает атрибуты, задаёт подписи и сохраняет
|
||||||
|
- **THEN** настройки сохраняются в профиле и применяются в разделе «Информация»
|
||||||
|
|
||||||
|
#### Scenario: Сброс настроек
|
||||||
|
- **WHEN** пользователь сбрасывает настройки
|
||||||
|
- **THEN** применяется базовый набор атрибутов с исходными подписями
|
||||||
|
|
||||||
|
### Requirement: Действия с фото в листе
|
||||||
|
Раздел «Действия» SHALL содержать «Скачать», «Переместить», «Копировать», «Установить как
|
||||||
|
обложку альбома» (по правам) и «Удалить»; из сетки также «Открыть (просмотр)». Удаление SHALL
|
||||||
|
подтверждаться диалогом. Переименование SHALL выполняться по тапу на имя файла.
|
||||||
|
|
||||||
|
#### Scenario: Состав действий
|
||||||
|
- **WHEN** лист открыт
|
||||||
|
- **THEN** показаны действия «Скачать», «Переместить», «Копировать», «Установить как обложку» и «Удалить» по правам
|
||||||
|
|
||||||
|
#### Scenario: Удаление фото
|
||||||
|
- **WHEN** пользователь выбирает «Удалить» и подтверждает
|
||||||
|
- **THEN** фото уходит в корзину, а просмотр переходит к соседнему элементу
|
||||||
|
|
||||||
|
#### Scenario: Переименование фото
|
||||||
|
- **WHEN** автор, владелец или администратор меняет имя файла на непустое
|
||||||
|
- **THEN** новое имя сохраняется и отражается в просмотре и сетке
|
||||||
|
|
||||||
|
### Requirement: Источник атрибутов device-медиа
|
||||||
|
Для device-медиа приложение SHALL показывать атрибуты и действия сопоставленного серверного
|
||||||
|
фото, когда сопоставление есть, и локальные атрибуты и действия (имя, размер, тип,
|
||||||
|
разрешение, дата, путь) при его отсутствии.
|
||||||
|
|
||||||
|
#### Scenario: Сопоставлено серверному фото
|
||||||
|
- **WHEN** device-медиа сопоставлено серверному фото
|
||||||
|
- **THEN** лист показывает серверные атрибуты и серверные действия этого фото
|
||||||
|
|
||||||
|
#### Scenario: Не сопоставлено
|
||||||
|
- **WHEN** сопоставления с серверным фото нет
|
||||||
|
- **THEN** лист показывает локальные атрибуты и локальные действия элемента
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Клиентский API и DTO
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить в `PhotoDto`/`Photo` поля `exif`, `tags`, `uploader`, `download_url` и разобрать их в маппере; проверить unit-тестом десериализации ответа `GET /photos/:id`
|
||||||
|
- [x] 1.2 Добавить `preferences` в ответ `me()` (`UserDto`/DTO профиля); проверить unit-тестом, что сохранённые `photo_info` читаются
|
||||||
|
- [x] 1.3 Добавить в `GaloApi` обновление альбома (`PATCH /albums/:id` с `title`/`description`/`cover_photo_id`/`clear_cover`), удаление (`DELETE`), список участников и share-ссылки; проверить unit-тестами запросов
|
||||||
|
- [x] 1.4 Добавить в `GaloApi` копирование (`POST /photo-batch/copy`), метки (`POST/DELETE /photos/:id/tags`, `GET /tags?q=`) и сохранение профиля (`PATCH /auth/me/preferences`); проверить unit-тестами
|
||||||
|
- [x] 1.5 Обновить `android/README.md` разделом о новых операциях API и проверить, что приведённые команды/примеры соответствуют коду
|
||||||
|
|
||||||
|
## 2. Модель атрибутов и резолвинг источника
|
||||||
|
|
||||||
|
- [x] 2.1 Ввести модель «атрибуты альбома» и резолвер источник→модель (серверный / локальный связанный / локальный без связи) отдельным классом домена; проверить unit-тестами все три случая
|
||||||
|
- [x] 2.2 Передавать сопоставление device-медиа→серверное фото (`serverPhotoByMediaId`) в `DeviceViewerScreen`; проверить unit-тестом, что элемент без сопоставления получает локальный источник, а со сопоставлением — серверный
|
||||||
|
|
||||||
|
## 3. Панель «Атрибуты и действия» на экранах альбомов
|
||||||
|
|
||||||
|
- [x] 3.1 Добавить на `AlbumScreen` end-drawer, открываемый свайпом справа налево, с общим контентом панели; проверить androidTest-жестом открытия и закрытия
|
||||||
|
- [x] 3.2 Добавить такой же end-drawer на `DeviceAlbumScreen`, не меняя левый drawer настроек синхронизации; проверить androidTest, что оба drawer'а открываются независимо
|
||||||
|
- [x] 3.3 Реализовать разделы «Обложка», «Информация», «Навигация» с гейтингом по роли (владелец/админ); проверить androidTest: у наблюдателя нет управления обложкой и навигации
|
||||||
|
- [x] 3.4 Реализовать действия «Переименовать» и «Удалить» с диалогом подтверждения и постановкой фото в корзину; проверить androidTest обоих сценариев
|
||||||
|
- [x] 3.5 Добавить экран настроек альбома (правка названия/описания, список участников, управление share-ссылками) и переход к нему из «Навигации»; проверить androidTest сохранения названия и создания/отзыва ссылки
|
||||||
|
|
||||||
|
## 4. Лист «Атрибуты и действия» в просмотре
|
||||||
|
|
||||||
|
- [x] 4.1 Добавить `ModalBottomSheet` в `ViewerScreen` и `DeviceViewerScreen` с открытием по свайпу вверх и условием «не открывать при зуме, панораме и активном видео»; проверить androidTest: при зуме/панораме лист не открывается
|
||||||
|
- [x] 4.2 Реализовать раздел «Информация» (автор, файл, тип, разрешение, EXIF, снято, координаты, загружено) и раздел «Метки» с добавлением/снятием и поиском; проверить androidTest появления метки и обновления без перезагрузки
|
||||||
|
- [x] 4.3 Реализовать действия «Скачать», «Переместить», «Копировать», «Установить как обложку», «Удалить» (по правам) и переименование по тапу на имя; проверить androidTest каждого действия, включая подтверждение удаления
|
||||||
|
|
||||||
|
## 5. Настройки списка атрибутов
|
||||||
|
|
||||||
|
- [x] 5.1 Перенести каталог EXIF-атрибутов и базовый набор (эквивалент web `exif.ts`) в Android; проверить unit-тестом, что базовый набор и подписи совпадают с web
|
||||||
|
- [x] 5.2 Реализовать экран настроек атрибутов (галочки видимости, поля подписей, «отметить все»/«снять все») и сохранение/сброс через `preferences`; проверить androidTest сохранения и сброса настроек
|
||||||
|
|
||||||
|
## 6. Интеграция и проверка
|
||||||
|
|
||||||
|
- [x] 6.1 Прогнать сквозные сценарии: серверный альбом (панель+настройки), device-альбом связанный и без связи, просмотр серверный и device (лист, метки, действия); проверить androidTest-сценариями
|
||||||
|
- [x] 6.2 Собрать и прогнать статический анализ: `./gradlew :app:assembleDebug :app:lint :app:testDebugUnitTest` без ошибок
|
||||||
|
|
||||||
|
## 7. Локальные атрибуты и действия (все альбомы)
|
||||||
|
|
||||||
|
- [x] 7.1 Добавить app-only метаданные локального альбома (Room `device_album_meta`: отображаемое имя и обложка) и применять их в панели и списке альбомов; проверить unit- и Robolectric-тестами
|
||||||
|
- [x] 7.2 Показывать раздел «Навигация» у всех альбомов; строки «Участники»/«Ссылки» активны только при связи с серверным альбомом, иначе неактивны; проверить тестом
|
||||||
|
- [x] 7.3 Реализовать «Переименовать» (отображаемое имя в приложении) и локальную обложку (выбор из медиа альбома/сброс) для локального альбома; проверить тестами
|
||||||
|
- [x] 7.4 Реализовать «Удалить» локального альбома: подтверждение + удаление медиа с телефона через системное согласие (`MediaStore.createDeleteRequest`); проверить тестами
|
||||||
|
|
||||||
|
## 8. Полная проверка синхронизации
|
||||||
|
|
||||||
|
- [x] 8.1 `SyncWorker` реконсилирует все известные галереи (связанные + с локальным индексом), восстанавливает частичные связи через `recover` и перевыставляет незавершённые загрузки; проверить unit-тестами (`reconcileAll`, `indexedAlbumIds`)
|
||||||
|
- [x] 8.2 Запускать стартовую проверку после загрузки настроек (base URL) и вызывать `reconcile` при смене режима на серверный (не создавая связь с пустым серверным альбомом); проверить сборкой и тестами
|
||||||
|
- [x] 8.3 Разгрузка в режиме переноса выполняется автоматически после загрузки и повторно на следующем проходе (без баннера и отдельных запросов согласия); проверить unit-тестом повтора `AWAITING_FREE`
|
||||||
|
- [x] 8.4 Проверять доступ «Все файлы» при запуске и предлагать включить (диалог + системный экран); проверить Robolectric-тестом
|
||||||
|
- [x] 8.5 Проверка и исправление при открытии галереи: любой ассет, уже загруженный на сервер, но всё ещё с оригиналом на телефоне, разгружается в режиме переноса; проверить unit-тестами
|
||||||
|
- [x] 8.6 Возврат оригиналов при перенос→копии/выключено: восстановление не обрывается на единичных ошибках + довосстановление при запуске и открытии альбома; проверить тестами (`restoreTargets`, матрица переходов)
|
||||||
|
- [x] 8.7 Единая политика и проверка «режим × состояние» (`GallerySyncPolicy`/`SyncCheck`) с восстановлением индекса по метке; применяется при запуске и открытии альбома; проверить тестами на все сочетания
|
||||||
|
- [x] 8.8 Устранить гонки и дубли: сериализация `reconcile`, пропуск повторной загрузки при наличии задания в очереди, метка происхождения хранит режим (не состояние); проверить unit-тестами и инструментальными E2E на эмуляторе
|
||||||
|
|
||||||
|
## 9. Подпись карточки альбома
|
||||||
|
|
||||||
|
- [x] 9.1 Переделать подпись карточки: первая строка — имя и «→ имя серверного» только при различии имён; вторая строка — значки расположения (карта памяти / карта памяти + облако / облако) и число элементов; проверить Robolectric-тестами
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Архивация change после прохождения ревью проекта.
|
||||||
|
- Проверка результата архивации.
|
||||||
@@ -356,3 +356,266 @@ SHALL выполняться свайпом.
|
|||||||
|
|
||||||
- **WHEN** оригинал загружен или недоступен
|
- **WHEN** оригинал загружен или недоступен
|
||||||
- **THEN** индикатор загрузки скрыт
|
- **THEN** индикатор загрузки скрыт
|
||||||
|
|
||||||
|
### Requirement: Проверка доступа «Все файлы» при запуске
|
||||||
|
Приложение SHALL при запуске, когда оно уже настроено и авторизовано, проверять наличие доступа
|
||||||
|
«Все файлы» и при его отсутствии SHALL предлагать его включить, открывая системный экран настроек.
|
||||||
|
|
||||||
|
#### Scenario: Доступ не выдан
|
||||||
|
- **WHEN** приложение запущено и доступ «Все файлы» не выдан
|
||||||
|
- **THEN** показывается диалог с предложением включить доступ
|
||||||
|
|
||||||
|
#### Scenario: Включение доступа
|
||||||
|
- **WHEN** пользователь подтверждает
|
||||||
|
- **THEN** открывается системный экран выдачи доступа «Все файлы»
|
||||||
|
|
||||||
|
#### Scenario: Доступ уже выдан
|
||||||
|
- **WHEN** доступ уже выдан
|
||||||
|
- **THEN** диалог не показывается
|
||||||
|
|
||||||
|
### Requirement: Возврат оригиналов при выходе из переноса
|
||||||
|
При переключении альбома из режима переноса в «копии» или «выключено» приложение SHALL
|
||||||
|
возвращать оригиналы с сервера (когда пользователь выбирает возврат), а незавершённый возврат
|
||||||
|
SHALL довосстанавливаться при следующей проверке (при запуске и при открытии альбома).
|
||||||
|
|
||||||
|
#### Scenario: Перенос → копии
|
||||||
|
- **WHEN** пользователь переключает альбом из переноса в копии и подтверждает возврат оригиналов
|
||||||
|
- **THEN** оригиналы скачиваются с сервера и заменяют превью на устройстве
|
||||||
|
|
||||||
|
#### Scenario: Довосстановление
|
||||||
|
- **WHEN** альбом не в режиме переноса, но содержит превью с серверным фото
|
||||||
|
- **THEN** при запуске или открытии альбома оригиналы довосстанавливаются
|
||||||
|
|
||||||
|
#### Scenario: Перенос → выключено
|
||||||
|
- **WHEN** пользователь переключает перенос в выключено
|
||||||
|
- **THEN** предлагается вернуть оригиналы или оставить превью
|
||||||
|
|
||||||
|
### Requirement: Единая проверка соответствия режиму
|
||||||
|
При запуске и при открытии альбома приложение SHALL приводить локальное содержимое в
|
||||||
|
соответствие режиму для всех сочетаний состояния ассета: в «переносе» разгружать уже загруженные
|
||||||
|
оригиналы, а в «копиях»/«выключено» восстанавливать превью; отсутствующий локальный индекс SHALL
|
||||||
|
восстанавливаться по метке происхождения.
|
||||||
|
|
||||||
|
#### Scenario: Копии с разгруженными файлами
|
||||||
|
- **WHEN** альбом в режиме копий, а часть файлов разгружена (превью)
|
||||||
|
- **THEN** проверка восстанавливает оригиналы
|
||||||
|
|
||||||
|
#### Scenario: Перенос с неразгруженными файлами
|
||||||
|
- **WHEN** альбом в режиме переноса, файл есть на сервере, но оригинал ещё на телефоне
|
||||||
|
- **THEN** проверка разгружает оригинал
|
||||||
|
|
||||||
|
#### Scenario: Потерянный индекс
|
||||||
|
- **WHEN** у разгруженного медиа нет записи в локальном индексе
|
||||||
|
- **THEN** связь и происхождение восстанавливаются по метке в файле
|
||||||
|
|
||||||
|
#### Scenario: Выключенный режим
|
||||||
|
- **WHEN** альбом без синхронизации содержит превью
|
||||||
|
- **THEN** оригиналы восстанавливаются, а сервер не затрагивается
|
||||||
|
|
||||||
|
### Requirement: Отсутствие дублей и гонок при синхронизации
|
||||||
|
Проверка синхронизации SHALL выполняться последовательно (без одновременных проходов по одной
|
||||||
|
галерее) и SHALL NOT ставить повторную загрузку, пока по тому же элементу уже есть задание в
|
||||||
|
очереди. Метка происхождения в разгруженном файле SHALL хранить режим синхронизации, а не
|
||||||
|
состояние элемента, чтобы восстановление не меняло режим галереи.
|
||||||
|
|
||||||
|
#### Scenario: Повторная проверка во время загрузки
|
||||||
|
- **WHEN** проверка запускается снова, пока загрузка элемента ещё стоит в очереди
|
||||||
|
- **THEN** повторная загрузка не ставится
|
||||||
|
|
||||||
|
#### Scenario: Метка происхождения хранит режим
|
||||||
|
- **WHEN** разгруженный файл восстанавливается после переустановки
|
||||||
|
- **THEN** режим галереи берётся из метки как режим (перенос/копии), а не как состояние элемента
|
||||||
|
|
||||||
|
### Requirement: Подпись карточки альбома
|
||||||
|
Карточка альбома SHALL показывать в первой строке имя альбома, а для связанной пары — со
|
||||||
|
стрелкой имя связанного серверного альбома, и только когда имена различаются. Вторая строка
|
||||||
|
SHALL показывать значками расположение содержимого и число элементов: значок карты памяти для
|
||||||
|
локального без синхронизации, значки карты памяти и облака для режима копий, значок облака для
|
||||||
|
режима переноса; для серверного альбома — значок облака.
|
||||||
|
|
||||||
|
#### Scenario: Локальный без синхронизации
|
||||||
|
- **WHEN** показан локальный альбом без синхронизации
|
||||||
|
- **THEN** вторая строка показывает значок карты памяти и число элементов
|
||||||
|
|
||||||
|
#### Scenario: Режим копий
|
||||||
|
- **WHEN** локальный альбом синхронизируется в режиме копий
|
||||||
|
- **THEN** вторая строка показывает значки карты памяти и облака и число элементов
|
||||||
|
|
||||||
|
#### Scenario: Режим переноса
|
||||||
|
- **WHEN** локальный альбом синхронизируется в режиме переноса
|
||||||
|
- **THEN** вторая строка показывает значок облака и число элементов
|
||||||
|
|
||||||
|
#### Scenario: Разные имена связанной пары
|
||||||
|
- **WHEN** имя локального альбома не совпадает с именем связанного серверного
|
||||||
|
- **THEN** первая строка показывает «имя → имя серверного»
|
||||||
|
|
||||||
|
#### Scenario: Одинаковые имена
|
||||||
|
- **WHEN** имена совпадают
|
||||||
|
- **THEN** стрелка и имя серверного не показываются
|
||||||
|
|
||||||
|
### Requirement: Панель атрибутов и действий альбома
|
||||||
|
Приложение SHALL открывать панель «Атрибуты и действия» по свайпу справа налево на экране
|
||||||
|
любого альбома — серверного и локального. Панель SHALL повторять структуру web: разделы
|
||||||
|
«Обложка», «Информация», «Навигация» и раздел «Действия».
|
||||||
|
|
||||||
|
#### Scenario: Открытие свайпом
|
||||||
|
- **WHEN** пользователь свайпает справа налево в открытом альбоме
|
||||||
|
- **THEN** открывается панель «Атрибуты и действия»
|
||||||
|
|
||||||
|
#### Scenario: Структура панели
|
||||||
|
- **WHEN** панель открыта
|
||||||
|
- **THEN** показаны разделы «Обложка», «Информация», «Навигация» и «Действия»
|
||||||
|
|
||||||
|
#### Scenario: Закрытие панели
|
||||||
|
- **WHEN** пользователь закрывает панель (свайп в обратную сторону или касание вне панели)
|
||||||
|
- **THEN** панель скрывается, сетка медиа снова доступна
|
||||||
|
|
||||||
|
### Requirement: Источник атрибутов локального альбома
|
||||||
|
Для локального альбома приложение SHALL показывать ту же панель «Атрибуты и действия», что и
|
||||||
|
для серверного, а метаданные SHALL брать локально (имя, число элементов, локальная обложка,
|
||||||
|
режим синхронизации, связанный серверный альбом). Действия локального альбома SHALL
|
||||||
|
применяться локально.
|
||||||
|
|
||||||
|
#### Scenario: Локальные метаданные
|
||||||
|
- **WHEN** пользователь открывает панель локального альбома
|
||||||
|
- **THEN** раздел «Информация» показывает локальные имя, число элементов и обложку
|
||||||
|
|
||||||
|
#### Scenario: Неактивная навигация без связи
|
||||||
|
- **WHEN** локальный альбом не связан с серверным
|
||||||
|
- **THEN** строки «Участники» и «Ссылки для просмотра» показаны, но неактивны
|
||||||
|
|
||||||
|
#### Scenario: Активная навигация при связи
|
||||||
|
- **WHEN** локальный альбом связан с серверным
|
||||||
|
- **THEN** строки «Участники» и «Ссылки для просмотра» активны и относятся к связанному серверному альбому
|
||||||
|
|
||||||
|
### Requirement: Переименование и обложка локального альбома
|
||||||
|
Для локального альбома действие «Переименовать» SHALL менять отображаемое имя в приложении, не
|
||||||
|
изменяя медиа или bucket на устройстве, а «Выбрать обложку»/«Сбросить» SHALL назначать или
|
||||||
|
снимать обложку из медиа этого альбома. Имя и обложка SHALL храниться локально.
|
||||||
|
|
||||||
|
#### Scenario: Переименование
|
||||||
|
- **WHEN** пользователь вводит новое имя локального альбома
|
||||||
|
- **THEN** панель, заголовок и карточка в списке альбомов показывают новое имя
|
||||||
|
|
||||||
|
#### Scenario: Назначение и сброс обложки
|
||||||
|
- **WHEN** пользователь выбирает медиа обложкой или сбрасывает её
|
||||||
|
- **THEN** панель и карточка альбома отражают выбор
|
||||||
|
|
||||||
|
### Requirement: Удаление локального альбома с телефона
|
||||||
|
Действие «Удалить» локального альбома SHALL после подтверждения удалять все фото и видео
|
||||||
|
этого альбома с устройства, запрашивая при необходимости системное согласие.
|
||||||
|
|
||||||
|
#### Scenario: Подтверждение
|
||||||
|
- **WHEN** пользователь выбирает «Удалить» локального альбома
|
||||||
|
- **THEN** открывается диалог с предупреждением, что медиа будут удалены с телефона
|
||||||
|
|
||||||
|
#### Scenario: Удаление
|
||||||
|
- **WHEN** пользователь подтверждает удаление
|
||||||
|
- **THEN** медиа удаляются с устройства, а альбом исчезает из приложения
|
||||||
|
|
||||||
|
### Requirement: Атрибуты альбома
|
||||||
|
Панель SHALL показывать обложку с выбором и сбросом, информацию (название, описание, число
|
||||||
|
фото и участников) и переходы к участникам, настройкам и ссылкам для просмотра. Управление
|
||||||
|
обложкой и раздел «Навигация» SHALL быть доступны только владельцу и администратору.
|
||||||
|
|
||||||
|
#### Scenario: Управление обложкой
|
||||||
|
- **WHEN** владелец или администратор выбирает или сбрасывает обложку
|
||||||
|
- **THEN** изменение сохраняется на сервере и отражается в списке и панели
|
||||||
|
|
||||||
|
#### Scenario: Наблюдатель
|
||||||
|
- **WHEN** наблюдатель открывает панель
|
||||||
|
- **THEN** управление обложкой не показывается, а строки «Настройки» и «Ссылки для просмотра» неактивны
|
||||||
|
|
||||||
|
#### Scenario: Переход к настройкам
|
||||||
|
- **WHEN** владелец или администратор открывает настройки из раздела «Навигация»
|
||||||
|
- **THEN** доступны правка названия и описания и управление ссылками для просмотра
|
||||||
|
|
||||||
|
### Requirement: Действия с альбомом
|
||||||
|
Панель SHALL содержать действия «Переименовать» и «Удалить». Удаление SHALL подтверждаться
|
||||||
|
диалогом, а удалённый альбом SHALL помещать свои фото в корзину.
|
||||||
|
|
||||||
|
#### Scenario: Переименование
|
||||||
|
- **WHEN** владелец или администратор выбирает «Переименовать» и вводит новое название
|
||||||
|
- **THEN** название сохраняется на сервере и отражается в списке и заголовке
|
||||||
|
|
||||||
|
#### Scenario: Удаление с подтверждением
|
||||||
|
- **WHEN** владелец выбирает «Удалить» и подтверждает в диалоге
|
||||||
|
- **THEN** альбом удаляется, а его фото попадают в корзину
|
||||||
|
|
||||||
|
### Requirement: Лист атрибутов и действий фото
|
||||||
|
Полноэкранный просмотр SHALL открывать лист «Атрибуты и действия» по свайпу вверх как в
|
||||||
|
серверном, так и в device-просмотре. Лист SHALL повторять структуру web: разделы
|
||||||
|
«Информация», «Метки» и «Действия». При зуме изображения, панораме или активном управлении
|
||||||
|
видео свайп вверх SHALL NOT открывать лист.
|
||||||
|
|
||||||
|
#### Scenario: Открытие листа
|
||||||
|
- **WHEN** пользователь свайпает вверх во вписанном изображении
|
||||||
|
- **THEN** открывается лист «Атрибуты и действия» для текущего элемента
|
||||||
|
|
||||||
|
#### Scenario: Зум или панорама
|
||||||
|
- **WHEN** изображение увеличено или открыта панорама, и пользователь свайпает вверх
|
||||||
|
- **THEN** лист не открывается, жест обрабатывается изображением
|
||||||
|
|
||||||
|
#### Scenario: Видео
|
||||||
|
- **WHEN** открыт видеоэлемент, и пользователь взаимодействует с плеером
|
||||||
|
- **THEN** свайп вверх не мешает управлению воспроизведением
|
||||||
|
|
||||||
|
### Requirement: Атрибуты фото в листе
|
||||||
|
Раздел «Информация» SHALL показывать автора, размер файла, тип, разрешение, EXIF-атрибуты,
|
||||||
|
дату съёмки, координаты и дату загрузки. Раздел «Метки» SHALL позволять добавлять и снимать
|
||||||
|
метки с поиском по меткам пользователя и созданием новых.
|
||||||
|
|
||||||
|
#### Scenario: Информация
|
||||||
|
- **WHEN** лист открыт
|
||||||
|
- **THEN** раздел «Информация» показывает метаданные и EXIF текущего фото
|
||||||
|
|
||||||
|
#### Scenario: Метки
|
||||||
|
- **WHEN** пользователь добавляет или снимает метку в разделе «Метки»
|
||||||
|
- **THEN** изменение сразу отражается в просмотре без перезагрузки
|
||||||
|
|
||||||
|
### Requirement: Настройки списка атрибутов
|
||||||
|
Раздел «Информация» SHALL давать доступ к настройкам со списком EXIF-атрибутов, где
|
||||||
|
пользователь отмечает отображаемые атрибуты и задаёт их подписи, с действиями «отметить все»
|
||||||
|
и «снять все». Настройки SHALL применяться ко всем фото и сохраняться в профиле пользователя.
|
||||||
|
|
||||||
|
#### Scenario: Открытие настроек
|
||||||
|
- **WHEN** пользователь открывает настройки атрибутов
|
||||||
|
- **THEN** показан список EXIF-атрибутов с галочками видимости и полями подписей
|
||||||
|
|
||||||
|
#### Scenario: Сохранение настроек
|
||||||
|
- **WHEN** пользователь отмечает атрибуты, задаёт подписи и сохраняет
|
||||||
|
- **THEN** настройки сохраняются в профиле и применяются в разделе «Информация»
|
||||||
|
|
||||||
|
#### Scenario: Сброс настроек
|
||||||
|
- **WHEN** пользователь сбрасывает настройки
|
||||||
|
- **THEN** применяется базовый набор атрибутов с исходными подписями
|
||||||
|
|
||||||
|
### Requirement: Действия с фото в листе
|
||||||
|
Раздел «Действия» SHALL содержать «Скачать», «Переместить», «Копировать», «Установить как
|
||||||
|
обложку альбома» (по правам) и «Удалить»; из сетки также «Открыть (просмотр)». Удаление SHALL
|
||||||
|
подтверждаться диалогом. Переименование SHALL выполняться по тапу на имя файла.
|
||||||
|
|
||||||
|
#### Scenario: Состав действий
|
||||||
|
- **WHEN** лист открыт
|
||||||
|
- **THEN** показаны действия «Скачать», «Переместить», «Копировать», «Установить как обложку» и «Удалить» по правам
|
||||||
|
|
||||||
|
#### Scenario: Удаление фото
|
||||||
|
- **WHEN** пользователь выбирает «Удалить» и подтверждает
|
||||||
|
- **THEN** фото уходит в корзину, а просмотр переходит к соседнему элементу
|
||||||
|
|
||||||
|
#### Scenario: Переименование фото
|
||||||
|
- **WHEN** автор, владелец или администратор меняет имя файла на непустое
|
||||||
|
- **THEN** новое имя сохраняется и отражается в просмотре и сетке
|
||||||
|
|
||||||
|
### Requirement: Источник атрибутов device-медиа
|
||||||
|
Для device-медиа приложение SHALL показывать атрибуты и действия сопоставленного серверного
|
||||||
|
фото, когда сопоставление есть, и локальные атрибуты и действия (имя, размер, тип,
|
||||||
|
разрешение, дата, путь) при его отсутствии.
|
||||||
|
|
||||||
|
#### Scenario: Сопоставлено серверному фото
|
||||||
|
- **WHEN** device-медиа сопоставлено серверному фото
|
||||||
|
- **THEN** лист показывает серверные атрибуты и серверные действия этого фото
|
||||||
|
|
||||||
|
#### Scenario: Не сопоставлено
|
||||||
|
- **WHEN** сопоставления с серверным фото нет
|
||||||
|
- **THEN** лист показывает локальные атрибуты и локальные действия элемента
|
||||||
|
|||||||
Reference in New Issue
Block a user