feat(android): add native Android client

Kotlin + Jetpack Compose client for a self-hosted instance: manual
server setup and health check, auth with Keystore-backed tokens, album
and photo browsing with SSE live updates, upload queue in WorkManager
with HEIC-to-JPEG conversion, and Room/Coil offline cache. Add an
android Makefile target and revise project.md §17.
This commit is contained in:
iu
2026-10-12 12:14:54 +03:00
parent e4975aafb5
commit aa2f7552bc
63 changed files with 4575 additions and 2 deletions
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!--
Cleartext HTTP is gated in the application layer (see HttpGateway): the
platform permits it, but the client refuses plain HTTP until the user
explicitly enables the insecure LAN mode.
-->
<application
android:name=".GaloApplication"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.Galo"
tools:targetApi="35">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.Galo">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge" />
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />
</application>
</manifest>
@@ -0,0 +1,37 @@
package com.galo.android
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import com.galo.android.di.AppContainer
class GaloApplication : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this).also { it.start() }
createNotificationChannels()
}
private fun createNotificationChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(
UPLOAD_CHANNEL_ID,
getString(R.string.upload_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.upload_channel_description)
}
manager.createNotificationChannel(channel)
}
companion object {
const val UPLOAD_CHANNEL_ID = "galo-uploads"
}
}
@@ -0,0 +1,90 @@
package com.galo.android.di
import android.content.Context
import com.galo.android.data.auth.SecureTokenStore
import com.galo.android.data.auth.TokenStore
import com.galo.android.data.db.GaloDatabase
import com.galo.android.data.network.AlbumSseClient
import com.galo.android.data.network.ApiClient
import com.galo.android.data.network.GaloApi
import com.galo.android.data.network.HealthChecker
import com.galo.android.data.prefs.SettingsRepository
import com.galo.android.data.prefs.SettingsState
import com.galo.android.data.repo.AlbumRepository
import com.galo.android.data.repo.ApiAlbumRemoteSource
import com.galo.android.data.repo.ApiPhotoRemoteSource
import com.galo.android.data.repo.PhotoRepository
import com.galo.android.data.repo.RoomAlbumLocalSource
import com.galo.android.data.repo.RoomPhotoLocalSource
import com.galo.android.data.upload.UploadRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/**
* Manual dependency container. Created once by [com.galo.android.GaloApplication]
* and shared through an application-scoped ViewModel factory.
*/
class AppContainer(context: Context) {
private val appContext = context.applicationContext
val settingsRepository = SettingsRepository(appContext)
val tokenStore: TokenStore = SecureTokenStore(appContext)
@Volatile
private var settingsState: SettingsState? = null
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
val apiClient = ApiClient(
tokenStore = tokenStore,
baseUrlProvider = { settingsState?.baseUrl },
insecureAllowedProvider = { settingsState?.insecureAllowed == true },
)
val api = GaloApi(apiClient)
val healthChecker = HealthChecker(apiClient.client)
val albumSseClient = AlbumSseClient(api, apiClient.sseClient)
private val database: GaloDatabase = GaloDatabase.create(appContext)
val albumRepository = AlbumRepository(
remote = ApiAlbumRemoteSource(api),
local = RoomAlbumLocalSource(database.albumDao()),
resolver = apiClient.objectUrls,
)
val photoRepository = PhotoRepository(
remote = ApiPhotoRemoteSource(api),
local = RoomPhotoLocalSource(database.photoDao()),
resolver = apiClient.objectUrls,
)
val uploadRepository = UploadRepository(appContext, database.uploadDao())
fun start() {
scope.launch {
settingsRepository.flow.collect { settingsState = it }
}
scope.launch {
runCatching { uploadRepository.requeuePending() }
}
}
fun currentSettings(): SettingsState? = settingsState
fun baseUrl(): String? = settingsState?.baseUrl
fun insecureAllowed(): Boolean = settingsState?.insecureAllowed == true
/** Clears the local session immediately and best-effort revokes it server-side. */
fun logout() {
val refreshToken = tokenStore.refreshToken()
tokenStore.clear()
if (refreshToken.isNullOrBlank()) return
scope.launch {
runCatching { api.logout(refreshToken) }
}
}
}
@@ -0,0 +1,29 @@
package com.galo.android.domain
import com.galo.android.domain.model.InsecureConnectionBlockedException
/**
* Application-level cleartext gate.
*
* Android's manifest permits cleartext so the user can enable LAN mode at
* runtime, but the client itself refuses `http://` URLs unless insecure mode is
* explicitly switched on.
*/
object ConnectionPolicy {
fun isInsecure(url: String): Boolean =
url.trim().lowercase().startsWith("http://")
fun isSecure(url: String): Boolean =
url.trim().lowercase().startsWith("https://")
/**
* @throws InsecureConnectionBlockedException when [url] is plain HTTP and
* [insecureAllowed] is false.
*/
fun check(url: String, insecureAllowed: Boolean) {
if (isInsecure(url) && !insecureAllowed) {
throw InsecureConnectionBlockedException()
}
}
}
@@ -0,0 +1,17 @@
package com.galo.android.domain.model
/** Normalised server error from the `{error:{code,message}}` envelope. */
data class ApiError(
val code: String,
val message: String,
val status: Int,
)
class ApiException(val error: ApiError) : Exception(error.message) {
constructor(status: Int, code: String, message: String) :
this(ApiError(code, message, status))
}
/** Raised when the user points at plain HTTP without enabling insecure mode. */
class InsecureConnectionBlockedException :
IllegalStateException("plain HTTP is disabled; enable insecure LAN mode")
@@ -0,0 +1,79 @@
package com.galo.android.domain.model
/**
* User-selected connection to a self-hosted galo instance.
*
* [insecureAllowed] mirrors the explicit "insecure LAN connection" toggle: plain
* HTTP stays blocked until the user opts in.
*/
data class ServerConfig(
val baseUrl: String,
val insecureAllowed: Boolean,
)
data class Session(
val accessToken: String,
val refreshToken: String,
)
data class Album(
val id: String,
val title: String,
val description: String,
val ownerId: String,
val coverPhotoId: String?,
val coverThumbUrl: String?,
val coverMediumUrl: String?,
val photosCount: Int,
val membersCount: Int,
val updatedAt: String?,
)
enum class MediaKind {
IMAGE,
VIDEO,
PANORAMA,
OTHER,
;
companion object {
const val PANO_TAG = "pano360"
fun from(raw: String?): MediaKind = when (raw?.lowercase()) {
"image", "photo" -> IMAGE
"video" -> VIDEO
"panorama", "pano" -> PANORAMA
else -> OTHER
}
fun isPanoTag(name: String): Boolean =
name.trim().equals(PANO_TAG, ignoreCase = true)
}
}
data class Photo(
val id: String,
val albumId: String,
val originalFilename: String,
val kind: MediaKind,
val mimeType: String?,
val width: Int,
val height: Int,
val durationMs: Long,
val size: Long,
val takenAt: String?,
val caption: String,
val isPanorama: Boolean,
val thumbUrl: String?,
val mediumUrl: String?,
val previewUrl: String?,
val playbackUrl: String?,
val originalUrl: String?,
val downloadUrl: String?,
val createdAt: String?,
)
data class Page<T>(
val items: List<T>,
val nextCursor: String?,
)
@@ -0,0 +1,28 @@
package com.galo.android.domain.model
enum class UploadStatus {
QUEUED,
RUNNING,
SUCCESS,
ERROR,
;
companion object {
fun from(raw: String): UploadStatus =
entries.firstOrNull { it.name == raw } ?: QUEUED
}
}
data class UploadJob(
val id: String,
val albumId: String,
val filename: String,
val mimeType: String?,
val size: Long,
val status: UploadStatus,
val progress: Int,
val error: String?,
val attempts: Int,
val wifiOnly: Boolean,
val createdAt: Long,
)
@@ -0,0 +1,46 @@
package com.galo.android.domain.viewer
/**
* Camera state for the 360 panorama viewer, mirroring the web `PanoViewer`
* math: horizontal drag rotates yaw, vertical drag changes pitch (clamped to
* ±89°), and the mapping constant is `fov / viewportHeight`. Zoom scales the
* field of view within [MIN_FOV, MAX_FOV].
*/
class PanoramaCamera(
yaw: Float = 0f,
pitch: Float = 0f,
fov: Float = DEFAULT_FOV,
) {
var yaw: Float = yaw
private set
var pitch: Float = pitch
private set
var fov: Float = fov.coerceIn(MIN_FOV, MAX_FOV)
private set
fun drag(dx: Float, dy: Float, scale: Float) {
yaw += dx * scale
pitch = (pitch + dy * scale).coerceIn(-PITCH_LIMIT, PITCH_LIMIT)
}
/** [factor] > 1 widens the view (zooms out). */
fun zoom(factor: Float) {
fov = (fov * factor).coerceIn(MIN_FOV, MAX_FOV)
}
fun reset() {
yaw = 0f
pitch = 0f
fov = DEFAULT_FOV
}
companion object {
val DEFAULT_FOV = (75 * Math.PI / 180).toFloat()
val MIN_FOV = (30 * Math.PI / 180).toFloat()
val MAX_FOV = (100 * Math.PI / 180).toFloat()
val PITCH_LIMIT = (89 * Math.PI / 180).toFloat()
}
}
@@ -0,0 +1,132 @@
package com.galo.android.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.galo.android.GaloApplication
import com.galo.android.data.prefs.SettingsState
import com.galo.android.ui.album.AlbumScreen
import com.galo.android.ui.albums.AlbumsScreen
import com.galo.android.ui.auth.AuthScreen
import com.galo.android.ui.setup.SetupScreen
import com.galo.android.ui.upload.UploadScreen
import com.galo.android.ui.viewer.ViewerScreen
object Routes {
const val SETUP = "setup"
const val AUTH = "auth"
const val ALBUMS = "albums"
const val UPLOADS = "uploads"
const val ALBUM = "album/{albumId}"
const val VIEWER = "album/{albumId}/viewer?index={index}"
fun album(albumId: String) = "album/$albumId"
fun viewer(albumId: String, index: Int) = "album/$albumId/viewer?index=$index"
}
@Composable
fun GaloNavHost(app: GaloApplication) {
val container = app.container
val factory = GaloViewModelFactory(app)
val settings: SettingsState? by container.settingsRepository.flow
.collectAsStateWithLifecycle(initialValue = container.currentSettings())
val current = settings
if (current == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return
}
val navController = rememberNavController()
val start = when {
current.baseUrl.isBlank() -> Routes.SETUP
container.tokenStore.accessToken().isNullOrBlank() -> Routes.AUTH
else -> Routes.ALBUMS
}
NavHost(navController = navController, startDestination = start) {
composable(Routes.SETUP) {
SetupScreen(factory = factory) {
val next = if (container.tokenStore.accessToken().isNullOrBlank()) {
Routes.AUTH
} else {
Routes.ALBUMS
}
navController.navigate(next) {
popUpTo(Routes.SETUP) { inclusive = true }
}
}
}
composable(Routes.AUTH) {
AuthScreen(factory = factory) {
navController.navigate(Routes.ALBUMS) {
popUpTo(Routes.AUTH) { inclusive = true }
}
}
}
composable(Routes.ALBUMS) {
AlbumsScreen(
factory = factory,
onOpenAlbum = { id -> navController.navigate(Routes.album(id)) },
onOpenUploads = { navController.navigate(Routes.UPLOADS) },
onLoggedOut = {
navController.navigate(Routes.AUTH) {
popUpTo(Routes.ALBUMS) { inclusive = true }
}
},
)
}
composable(Routes.UPLOADS) {
UploadScreen(
factory = factory,
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.ALBUM,
arguments = listOf(navArgument("albumId") { type = NavType.StringType }),
) { entry ->
val albumId = entry.arguments?.getString("albumId").orEmpty()
AlbumScreen(
app = app,
albumId = albumId,
onOpenViewer = { index ->
navController.navigate(Routes.viewer(albumId, index))
},
onBack = { navController.popBackStack() },
)
}
composable(
route = Routes.VIEWER,
arguments = listOf(
navArgument("albumId") { type = NavType.StringType },
navArgument("index") {
type = NavType.IntType
defaultValue = 0
},
),
) { entry ->
val albumId = entry.arguments?.getString("albumId").orEmpty()
val index = entry.arguments?.getInt("index") ?: 0
ViewerScreen(
app = app,
albumId = albumId,
initialIndex = index,
onBack = { navController.popBackStack() },
)
}
}
}
@@ -0,0 +1,26 @@
package com.galo.android.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import com.galo.android.GaloApplication
import com.galo.android.ui.albums.AlbumsViewModel
import com.galo.android.ui.auth.AuthViewModel
import com.galo.android.ui.setup.SetupViewModel
import com.galo.android.ui.upload.UploadViewModel
/** Resolves ViewModels against the application's [com.galo.android.di.AppContainer]. */
class GaloViewModelFactory(private val app: GaloApplication) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
val container = app.container
val viewModel: ViewModel = when {
modelClass.isAssignableFrom(SetupViewModel::class.java) -> SetupViewModel(container)
modelClass.isAssignableFrom(AuthViewModel::class.java) -> AuthViewModel(container)
modelClass.isAssignableFrom(AlbumsViewModel::class.java) -> AlbumsViewModel(container)
modelClass.isAssignableFrom(UploadViewModel::class.java) -> UploadViewModel(container)
else -> throw IllegalArgumentException("Unknown ViewModel: ${modelClass.name}")
}
return viewModel as T
}
}
@@ -0,0 +1,30 @@
package com.galo.android.ui
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.galo.android.GaloApplication
import com.galo.android.ui.theme.GaloTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val app = application as GaloApplication
setContent {
GaloTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background,
) {
GaloNavHost(app = app)
}
}
}
}
}
@@ -0,0 +1,154 @@
package com.galo.android.ui.album
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.common.GaloAsyncImage
const val TAG_ALBUM_GRID = "album_grid"
const val TAG_ALBUM_OFFLINE = "album_offline"
const val TAG_PHOTO_PREFIX = "photo_"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumScreen(
app: GaloApplication,
albumId: String,
onOpenViewer: (Int) -> Unit,
onBack: () -> Unit,
) {
val factory = viewModelFactory {
initializer { AlbumViewModel(app.container, albumId) }
}
val viewModel: AlbumViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
topBar = {
TopAppBar(
title = { Text(state.album?.title ?: "Альбом") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
actions = {
IconButton(onClick = viewModel::refresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Обновить")
}
},
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (state.offline) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUM_OFFLINE),
) {
Text(
text = state.error ?: "Офлайн-режим",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
when {
state.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
state.photos.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("В альбоме пока нет медиа")
}
else -> LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(2.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUM_GRID),
) {
itemsIndexed(state.photos, key = { _, p -> p.id }) { index, photo ->
PhotoCell(photo = photo, onClick = { onOpenViewer(index) })
}
if (!state.endReached) {
item(span = { GridItemSpan(maxLineSpan) }) {
LaunchedEffect(state.photos.size) { viewModel.loadNextPage() }
Box(
Modifier.fillMaxWidth().padding(16.dp),
contentAlignment = Alignment.Center,
) {
if (state.loadingMore) CircularProgressIndicator(Modifier.size(24.dp))
}
}
}
}
}
}
}
}
@Composable
private fun PhotoCell(photo: Photo, onClick: () -> Unit) {
Box(
modifier = Modifier
.aspectRatio(1f)
.testTag(TAG_PHOTO_PREFIX + photo.id)
.clickable(onClick = onClick),
) {
GaloAsyncImage(
url = photo.thumbUrl ?: photo.mediumUrl ?: photo.previewUrl,
contentDescription = photo.originalFilename,
modifier = Modifier.fillMaxSize(),
)
if (photo.kind == MediaKind.VIDEO) {
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Видео",
tint = Color.White,
modifier = Modifier.align(Alignment.Center),
)
}
}
}
@@ -0,0 +1,126 @@
package com.galo.android.ui.album
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.network.AlbumEvent
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.Photo
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.time.Instant
class AlbumViewModel(
private val container: AppContainer,
private val albumId: String,
) : ViewModel() {
data class UiState(
val album: Album? = null,
val photos: List<Photo> = emptyList(),
val loading: Boolean = true,
val loadingMore: Boolean = false,
val offline: Boolean = false,
val endReached: Boolean = false,
val error: String? = null,
)
private data class Status(
val loaded: Boolean = false,
val loadingMore: Boolean = false,
val offline: Boolean = false,
val endReached: Boolean = false,
val error: String? = null,
val nextCursor: String? = null,
)
private val status = MutableStateFlow(Status())
/** Watermark for `?since=` backfill; advanced as events are applied. */
private var lastSync: String = Instant.now().toString()
val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbum(albumId),
container.photoRepository.observePhotos(albumId),
status,
) { album, photos, s ->
UiState(
album = album,
photos = photos,
loading = !s.loaded,
loadingMore = s.loadingMore,
offline = s.offline,
endReached = s.endReached,
error = s.error,
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState())
init {
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
loadNextPage()
observeEvents()
}
/** Live updates while the screen is alive; reconnect backfills missed changes. */
private fun observeEvents() {
viewModelScope.launch {
container.albumSseClient.events(albumId).collect { event ->
when (event) {
is AlbumEvent.PhotoUpserted -> {
container.photoRepository.upsert(event.photo)
lastSync = Instant.now().toString()
}
is AlbumEvent.PhotoDeleted -> {
container.photoRepository.remove(event.photoId)
lastSync = Instant.now().toString()
}
is AlbumEvent.SocialChanged -> {
container.photoRepository.refreshPhoto(event.photoId)
}
AlbumEvent.AlbumChanged -> {
container.albumRepository.loadAlbum(albumId)
lastSync = Instant.now().toString()
}
AlbumEvent.Reconnected -> {
container.photoRepository.loadSince(albumId, lastSync)
lastSync = Instant.now().toString()
}
}
}
}
}
fun loadNextPage() {
val current = status.value
if (current.loadingMore || (current.loaded && current.endReached)) return
status.update { it.copy(loadingMore = it.loaded) }
viewModelScope.launch {
val result = container.photoRepository.loadNextPage(albumId, current.nextCursor)
status.update {
it.copy(
loaded = true,
loadingMore = false,
offline = !result.online,
endReached = result.online && result.nextCursor == null,
nextCursor = if (result.online) result.nextCursor else it.nextCursor,
error = if (result.online) null else "Нет сети — показаны кэшированные данные",
)
}
}
}
fun refresh() {
status.update { it.copy(loaded = false, endReached = false, nextCursor = null) }
viewModelScope.launch { container.albumRepository.loadAlbum(albumId) }
loadNextPage()
}
}
@@ -0,0 +1,168 @@
package com.galo.android.ui.albums
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.galo.android.domain.model.Album
import com.galo.android.ui.GaloViewModelFactory
import com.galo.android.ui.common.GaloAsyncImage
const val TAG_ALBUMS_GRID = "albums_grid"
const val TAG_ALBUMS_OFFLINE = "albums_offline"
const val TAG_ALBUM_CARD_PREFIX = "album_card_"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumsScreen(
factory: GaloViewModelFactory,
onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit,
onLoggedOut: () -> Unit,
) {
val viewModel: AlbumsViewModel = viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
AlbumsContent(
state = state,
onOpenAlbum = onOpenAlbum,
onOpenUploads = onOpenUploads,
onRefresh = viewModel::refresh,
onLogout = { viewModel.logout(onLoggedOut) },
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AlbumsContent(
state: AlbumsViewModel.UiState,
onOpenAlbum: (String) -> Unit,
onOpenUploads: () -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Альбомы") },
actions = {
IconButton(onClick = onOpenUploads) {
Icon(Icons.Filled.CloudUpload, contentDescription = "Загрузки")
}
IconButton(onClick = onRefresh) {
Icon(Icons.Filled.Refresh, contentDescription = "Обновить")
}
IconButton(onClick = onLogout) {
Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = "Выйти")
}
},
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
if (state.offline) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
modifier = Modifier.fillMaxWidth().testTag(TAG_ALBUMS_OFFLINE),
) {
Text(
text = state.error ?: "Офлайн-режим",
color = MaterialTheme.colorScheme.onErrorContainer,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
}
when {
state.loading && state.albums.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
state.albums.isEmpty() -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Пока нет альбомов")
}
}
else -> {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
contentPadding = PaddingValues(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize().testTag(TAG_ALBUMS_GRID),
) {
items(state.albums, key = { it.id }) { album ->
AlbumCard(album = album, onClick = { onOpenAlbum(album.id) })
}
}
}
}
}
}
}
@Composable
private fun AlbumCard(album: Album, onClick: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag(TAG_ALBUM_CARD_PREFIX + album.id)
.clickable(onClick = onClick),
) {
GaloAsyncImage(
url = album.coverThumbUrl ?: album.coverMediumUrl,
contentDescription = album.title,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(MaterialTheme.shapes.medium),
)
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = album.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${album.photosCount} фото · ${album.membersCount} участн.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,61 @@
package com.galo.android.ui.albums
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Album
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AlbumsViewModel(private val container: AppContainer) : ViewModel() {
data class UiState(
val albums: List<Album> = emptyList(),
val loading: Boolean = false,
val offline: Boolean = false,
val error: String? = null,
)
private data class Status(
val loading: Boolean = false,
val offline: Boolean = false,
val error: String? = null,
)
private val status = MutableStateFlow(Status())
val state: StateFlow<UiState> = combine(
container.albumRepository.observeAlbums(),
status,
) { albums, s ->
UiState(albums = albums, loading = s.loading, offline = s.offline, error = s.error)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), UiState(loading = true))
init {
refresh()
}
fun refresh() {
viewModelScope.launch {
status.update { it.copy(loading = true, error = null) }
val online = container.albumRepository.refresh()
status.update {
it.copy(
loading = false,
offline = !online,
error = if (online) null else "Нет сети — показаны кэшированные данные",
)
}
}
}
fun logout(onDone: () -> Unit) {
container.logout()
onDone()
}
}
@@ -0,0 +1,116 @@
package com.galo.android.ui.auth
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.text.KeyboardOptions
import androidx.compose.material3.Button
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory
const val TAG_AUTH_EMAIL = "auth_email"
const val TAG_AUTH_PASSWORD = "auth_password"
const val TAG_AUTH_NAME = "auth_name"
const val TAG_AUTH_SUBMIT = "auth_submit"
@Composable
fun AuthScreen(
factory: GaloViewModelFactory,
onAuthenticated: () -> Unit,
) {
val viewModel: AuthViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.authenticated.collect { onAuthenticated() }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
if (state.mode == AuthViewModel.Mode.LOGIN) "Вход" else "Регистрация",
style = MaterialTheme.typography.headlineSmall,
)
if (state.mode == AuthViewModel.Mode.REGISTER) {
OutlinedTextField(
value = state.name,
onValueChange = viewModel::onNameChange,
label = { Text("Имя") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_NAME),
)
}
OutlinedTextField(
value = state.email,
onValueChange = viewModel::onEmailChange,
label = { Text("Email") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_EMAIL),
)
OutlinedTextField(
value = state.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("Пароль") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_PASSWORD),
)
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Button(
onClick = viewModel::submit,
enabled = !state.loading,
modifier = Modifier.fillMaxWidth().testTag(TAG_AUTH_SUBMIT),
) {
Text(
when {
state.loading -> "Подождите…"
state.mode == AuthViewModel.Mode.LOGIN -> "Войти"
else -> "Зарегистрироваться"
},
)
}
Row {
TextButton(
onClick = {
viewModel.setMode(
if (state.mode == AuthViewModel.Mode.LOGIN) {
AuthViewModel.Mode.REGISTER
} else {
AuthViewModel.Mode.LOGIN
},
)
},
) {
Text(
if (state.mode == AuthViewModel.Mode.LOGIN) {
"Создать аккаунт"
} else {
"У меня уже есть аккаунт"
},
)
}
}
}
}
@@ -0,0 +1,74 @@
package com.galo.android.ui.auth
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.network.ErrorMapper
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.ApiException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AuthViewModel(private val container: AppContainer) : ViewModel() {
enum class Mode { LOGIN, REGISTER }
data class UiState(
val mode: Mode = Mode.LOGIN,
val email: String = "",
val password: String = "",
val name: String = "",
val loading: Boolean = false,
val error: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
private val _authenticated = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val authenticated = _authenticated.asSharedFlow()
fun onEmailChange(value: String) = _state.update { it.copy(email = value, error = null) }
fun onPasswordChange(value: String) = _state.update { it.copy(password = value, error = null) }
fun onNameChange(value: String) = _state.update { it.copy(name = value, error = null) }
fun setMode(mode: Mode) = _state.update { it.copy(mode = mode, error = null) }
fun submit() {
val current = _state.value
val email = current.email.trim()
when {
email.isEmpty() || current.password.isEmpty() -> {
_state.update { it.copy(error = "Заполните email и пароль") }
return
}
current.mode == Mode.REGISTER && current.name.trim().isEmpty() -> {
_state.update { it.copy(error = "Укажите имя") }
return
}
}
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
val tokens = when (current.mode) {
Mode.LOGIN -> container.api.login(email, current.password)
Mode.REGISTER -> container.api.register(email, current.password, current.name.trim())
}
container.tokenStore.save(tokens.accessToken, tokens.refreshToken)
_state.update { it.copy(loading = false) }
_authenticated.emit(Unit)
} catch (e: ApiException) {
_state.update { it.copy(loading = false, error = ErrorMapper.userMessage(e.error)) }
} catch (e: Exception) {
_state.update { it.copy(loading = false, error = e.message ?: "Ошибка входа") }
}
}
}
}
@@ -0,0 +1,54 @@
package com.galo.android.ui.common
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.galo.android.data.image.ObjectCacheKey
/**
* Coil image that caches by object path (stable across signature rotation) and
* shows a neutral placeholder while loading or when the URL is absent.
*/
@Composable
fun GaloAsyncImage(
url: String?,
contentDescription: String?,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Crop,
) {
if (url.isNullOrBlank()) {
Box(
modifier = modifier.background(MaterialTheme.colorScheme.surfaceVariant),
)
return
}
val context = LocalContext.current
val request = remember(url) {
val key = ObjectCacheKey.keyFor(url)
ImageRequest.Builder(context)
.data(url)
.memoryCacheKey(key)
.diskCacheKey(key)
.crossfade(true)
.build()
}
AsyncImage(
model = request,
contentDescription = contentDescription,
contentScale = contentScale,
modifier = modifier,
)
}
@Composable
fun Placeholder(modifier: Modifier = Modifier) {
Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant))
}
@@ -0,0 +1,99 @@
package com.galo.android.ui.setup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
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.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.galo.android.ui.GaloViewModelFactory
const val TAG_SETUP_URL = "setup_url"
const val TAG_SETUP_INSECURE = "setup_insecure"
const val TAG_SETUP_CONNECT = "setup_connect"
@Composable
fun SetupScreen(
factory: GaloViewModelFactory,
onConnected: () -> Unit,
) {
val viewModel: SetupViewModel = androidx.lifecycle.viewmodel.compose.viewModel(factory = factory)
val state by viewModel.state.collectAsStateWithLifecycle()
androidx.compose.runtime.LaunchedEffect(Unit) {
viewModel.connected.collect { onConnected() }
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Подключение к galo", style = MaterialTheme.typography.headlineSmall)
Text(
"Введите адрес вашего self-hosted инстанса",
style = MaterialTheme.typography.bodyMedium,
)
OutlinedTextField(
value = state.baseUrl,
onValueChange = viewModel::onUrlChange,
label = { Text("Базовый URL") },
placeholder = { Text("https://photos.example.com") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier
.fillMaxWidth()
.testTag(TAG_SETUP_URL),
)
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(
checked = state.insecureAllowed,
onCheckedChange = viewModel::onInsecureChange,
modifier = Modifier.testTag(TAG_SETUP_INSECURE),
)
Spacer(Modifier.width(12.dp))
Text("Небезопасное соединение (только LAN)")
}
if (state.insecureAllowed) {
Text(
"HTTP-соединение не шифруется. Включайте только в доверенной локальной сети.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
state.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
}
Button(
onClick = viewModel::connect,
enabled = !state.loading,
modifier = Modifier
.fillMaxWidth()
.testTag(TAG_SETUP_CONNECT),
) {
if (state.loading) {
CircularProgressIndicator(modifier = Modifier.height(20.dp).width(20.dp))
} else {
Text("Подключить")
}
}
}
}
@@ -0,0 +1,92 @@
package com.galo.android.ui.setup
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.prefs.SettingsState
import com.galo.android.di.AppContainer
import com.galo.android.domain.ConnectionPolicy
import com.galo.android.domain.model.InsecureConnectionBlockedException
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class SetupViewModel(private val container: AppContainer) : ViewModel() {
data class UiState(
val baseUrl: String = "",
val insecureAllowed: Boolean = false,
val loading: Boolean = false,
val error: String? = null,
)
private val _state = MutableStateFlow(UiState())
val state = _state.asStateFlow()
private val _connected = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val connected = _connected.asSharedFlow()
init {
container.currentSettings()?.let { applySettings(it) }
viewModelScope.launch {
container.settingsRepository.flow.collect { settings ->
if (_state.value.baseUrl.isEmpty() && settings.baseUrl.isNotEmpty()) {
applySettings(settings)
}
}
}
}
private fun applySettings(settings: SettingsState) {
_state.update {
it.copy(baseUrl = settings.baseUrl, insecureAllowed = settings.insecureAllowed)
}
}
fun onUrlChange(value: String) = _state.update { it.copy(baseUrl = value, error = null) }
fun onInsecureChange(value: Boolean) = _state.update { it.copy(insecureAllowed = value) }
fun connect() {
val url = _state.value.baseUrl.trim()
when {
url.isEmpty() -> {
_state.update { it.copy(error = "Укажите адрес сервера") }
return
}
!ConnectionPolicy.isSecure(url) && !ConnectionPolicy.isInsecure(url) -> {
_state.update { it.copy(error = "Адрес должен начинаться с http:// или https://") }
return
}
}
try {
ConnectionPolicy.check(url, _state.value.insecureAllowed)
} catch (_: InsecureConnectionBlockedException) {
_state.update { it.copy(error = "HTTP запрещён. Включите режим небезопасного соединения для LAN.") }
return
}
val insecure = _state.value.insecureAllowed
_state.update { it.copy(loading = true, error = null) }
viewModelScope.launch {
try {
container.healthChecker.check(url)
container.settingsRepository.setInsecureAllowed(insecure)
container.settingsRepository.setBaseUrl(url)
_state.update { it.copy(loading = false) }
_connected.emit(Unit)
} catch (e: Exception) {
_state.update {
it.copy(
loading = false,
error = "Не удалось подключиться: ${e.message ?: "сервер недоступен"}",
)
}
}
}
}
}
@@ -0,0 +1,32 @@
package com.galo.android.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val LightColors = lightColorScheme()
private val DarkColors = darkColorScheme()
@Composable
fun GaloTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
dynamicColor: Boolean = true,
content: @Composable () -> Unit,
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColors
else -> LightColors
}
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -0,0 +1,239 @@
package com.galo.android.ui.upload
import android.Manifest
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
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.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.material.icons.filled.Add
import androidx.compose.material.icons.filled.DeleteSweep
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
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 com.galo.android.domain.model.Album
import com.galo.android.domain.model.UploadJob
import com.galo.android.domain.model.UploadStatus
import com.galo.android.ui.GaloViewModelFactory
const val TAG_UPLOAD_LIST = "upload_list"
const val TAG_UPLOAD_WIFI = "upload_wifi"
const val TAG_UPLOAD_ADD = "upload_add"
@Composable
fun UploadScreen(
factory: GaloViewModelFactory,
onBack: () -> Unit,
) {
val viewModel: UploadViewModel = viewModel(factory = factory)
val jobs by viewModel.jobs.collectAsStateWithLifecycle()
val albums by viewModel.albums.collectAsStateWithLifecycle()
val settings by viewModel.settings.collectAsStateWithLifecycle()
val message by viewModel.message.collectAsStateWithLifecycle()
var pendingUris by remember { mutableStateOf<List<android.net.Uri>>(emptyList()) }
var showAlbumPicker by remember { mutableStateOf(false) }
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { }
val picker = rememberLauncherForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(20),
) { uris ->
if (uris.isNotEmpty()) {
pendingUris = uris
showAlbumPicker = true
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
if (showAlbumPicker) {
AlbumChooserDialog(
albums = albums,
onDismiss = { showAlbumPicker = false },
onSelect = { albumId ->
viewModel.enqueue(albumId, pendingUris)
pendingUris = emptyList()
showAlbumPicker = false
},
)
}
UploadContent(
jobs = jobs,
wifiOnly = settings?.wifiOnlyUploads == true,
message = message,
onToggleWifi = viewModel::setWifiOnly,
onClearCompleted = viewModel::clearCompleted,
onAdd = {
picker.launch(
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo),
)
},
onBack = onBack,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UploadContent(
jobs: List<UploadJob>,
wifiOnly: Boolean,
message: String?,
onToggleWifi: (Boolean) -> Unit,
onClearCompleted: () -> Unit,
onAdd: () -> Unit,
onBack: () -> Unit,
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Загрузки") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
actions = {
IconButton(onClick = onClearCompleted) {
Icon(Icons.Filled.DeleteSweep, contentDescription = "Убрать завершённые")
}
},
)
},
) { padding ->
Column(modifier = Modifier.fillMaxSize().padding(padding)) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Switch(
checked = wifiOnly,
onCheckedChange = onToggleWifi,
modifier = Modifier.testTag(TAG_UPLOAD_WIFI),
)
Text("Только Wi-Fi", modifier = Modifier.padding(start = 12.dp))
}
message?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
Button(
onClick = onAdd,
modifier = Modifier.fillMaxWidth().padding(16.dp).testTag(TAG_UPLOAD_ADD),
) {
Icon(Icons.Filled.Add, contentDescription = null)
Text("Добавить фото и видео", modifier = Modifier.padding(start = 8.dp))
}
if (jobs.isEmpty()) {
Text(
"Очередь пуста",
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium,
)
} else {
LazyColumn(modifier = Modifier.fillMaxSize().testTag(TAG_UPLOAD_LIST)) {
items(jobs, key = { it.id }) { job -> UploadRow(job) }
}
}
}
}
}
@Composable
private fun UploadRow(job: UploadJob) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(job.filename, style = MaterialTheme.typography.bodyMedium)
Text(statusLabel(job.status), style = MaterialTheme.typography.labelMedium)
}
if (job.status == UploadStatus.RUNNING) {
LinearProgressIndicator(
progress = { job.progress / 100f },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
job.error?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
}
}
}
private fun statusLabel(status: UploadStatus): String = when (status) {
UploadStatus.QUEUED -> "В очереди"
UploadStatus.RUNNING -> "Загрузка"
UploadStatus.SUCCESS -> "Готово"
UploadStatus.ERROR -> "Ошибка"
}
@Composable
private fun AlbumChooserDialog(
albums: List<Album>,
onDismiss: () -> Unit,
onSelect: (String) -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Выберите альбом") },
text = {
if (albums.isEmpty()) {
Text("Нет доступных альбомов")
} else {
LazyColumn {
items(albums, key = { it.id }) { album ->
Text(
text = album.title,
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect(album.id) }
.padding(vertical = 12.dp),
)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Отмена") }
},
)
}
@@ -0,0 +1,56 @@
package com.galo.android.ui.upload
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.data.prefs.SettingsState
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Album
import com.galo.android.domain.model.UploadJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class UploadViewModel(private val container: AppContainer) : ViewModel() {
val jobs: StateFlow<List<UploadJob>> = container.uploadRepository.observeJobs()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val albums: StateFlow<List<Album>> = container.albumRepository.observeAlbums()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
val settings: StateFlow<SettingsState?> = container.settingsRepository.flow
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), container.currentSettings())
private val _message = MutableStateFlow<String?>(null)
val message: StateFlow<String?> = _message.asStateFlow()
init {
viewModelScope.launch { container.albumRepository.refresh() }
viewModelScope.launch { container.uploadRepository.requeuePending() }
}
fun enqueue(albumId: String, uris: List<Uri>) {
if (uris.isEmpty()) return
viewModelScope.launch {
val wifiOnly = settings.value?.wifiOnlyUploads == true
val queued = container.uploadRepository.enqueue(albumId, uris, wifiOnly)
_message.value = "В очередь добавлено файлов: $queued"
}
}
fun setWifiOnly(enabled: Boolean) {
viewModelScope.launch { container.settingsRepository.setWifiOnlyUploads(enabled) }
}
fun clearCompleted() {
viewModelScope.launch { container.uploadRepository.clearCompleted() }
}
fun consumeMessage() {
_message.value = null
}
}
@@ -0,0 +1,256 @@
package com.galo.android.ui.viewer
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.opengl.GLES20
import android.opengl.GLSurfaceView
import android.opengl.GLUtils
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.ScaleGestureDetector
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
import java.util.concurrent.atomic.AtomicBoolean
import javax.microedition.khronos.egl.EGLConfig
import javax.microedition.khronos.opengles.GL10
/**
* Equirectangular 360 panorama viewer mirrored from the web `PanoViewer`
* (`frontend/src/lib/components/PanoViewer.svelte`): same spherical projection,
* the same drag-to-look mapping (yaw/pitch scaled by `fov / viewportHeight`),
* pitch clamped to ±89°, and pinch/wheel zoom mapped to the field of view.
*/
class PanoramaView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
) : GLSurfaceView(context, attrs) {
private val renderer = PanoRenderer()
private val scaleDetector = ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
renderer.zoom(if (detector.scaleFactor > 1f) 1f / detector.scaleFactor else 1f)
return true
}
},
)
private var lastX = 0f
private var lastY = 0f
init {
setEGLContextClientVersion(2)
setRenderer(renderer)
renderMode = RENDERMODE_WHEN_DIRTY
}
fun load(url: String) {
renderer.loadImage(url)
}
@androidx.annotation.VisibleForTesting
internal fun currentYawForTest(): Float = renderer.camera.yaw
@androidx.annotation.VisibleForTesting
internal fun currentPitchForTest(): Float = renderer.camera.pitch
override fun onTouchEvent(event: MotionEvent): Boolean {
scaleDetector.onTouchEvent(event)
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
}
MotionEvent.ACTION_MOVE -> {
if (!scaleDetector.isInProgress) {
val k = renderer.camera.fov / height.coerceAtLeast(1)
renderer.drag(event.x - lastX, event.y - lastY, k)
lastX = event.x
lastY = event.y
}
}
}
return true
}
private inner class PanoRenderer : Renderer {
private var program = 0
private var texture = 0
val camera = com.galo.android.domain.viewer.PanoramaCamera()
private val disposed = AtomicBoolean(false)
private var pendingUrl: String? = null
private var vertexBuffer: FloatBuffer = ByteBuffer
.allocateDirect(VERTS.size * 4)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.apply {
put(VERTS)
position(0)
}
fun drag(dx: Float, dy: Float, k: Float) {
camera.drag(dx, dy, k)
requestRender()
}
fun zoom(factor: Float) {
camera.zoom(factor)
requestRender()
}
fun loadImage(url: String) {
pendingUrl = url
queueEvent { downloadAndUpload(url) }
}
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
program = buildProgram()
val locs = IntArray(5)
GLES20.glGetUniformLocation(program, "uRes").let { locs[0] = it }
GLES20.glGetUniformLocation(program, "uFov").let { locs[1] = it }
GLES20.glGetUniformLocation(program, "uYaw").let { locs[2] = it }
GLES20.glGetUniformLocation(program, "uPitch").let { locs[3] = it }
GLES20.glGetUniformLocation(program, "uTex").let { locs[4] = it }
pRes = locs[0]; pFov = locs[1]; pYaw = locs[2]; pPitch = locs[3]; pTex = locs[4]
texture = createTexture()
val aPos = GLES20.glGetAttribLocation(program, "aPos")
GLES20.glEnableVertexAttribArray(aPos)
GLES20.glVertexAttribPointer(aPos, 2, GLES20.GL_FLOAT, false, 0, vertexBuffer)
pendingUrl?.let { downloadAndUpload(it) }
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES20.glViewport(0, 0, width, height)
}
override fun onDrawFrame(gl: GL10?) {
if (program == 0) return
GLES20.glUseProgram(program)
GLES20.glUniform2f(pRes, width.toFloat().coerceAtLeast(1f), height.toFloat().coerceAtLeast(1f))
GLES20.glUniform1f(pFov, camera.fov)
GLES20.glUniform1f(pYaw, camera.yaw)
GLES20.glUniform1f(pPitch, camera.pitch)
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture)
GLES20.glUniform1i(pTex, 0)
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, 3)
}
private var pRes = -1
private var pFov = -1
private var pYaw = -1
private var pPitch = -1
private var pTex = -1
private fun downloadAndUpload(url: String) {
if (disposed.get()) return
val bitmap = runCatching {
java.net.URL(url).openConnection().run {
connectTimeout = 15_000
readTimeout = 30_000
getInputStream().use { BitmapFactory.decodeStream(it) }
}
}.getOrNull() ?: return
if (disposed.get()) {
bitmap.recycle()
return
}
upload(bitmap)
}
private fun upload(bitmap: Bitmap) {
val maxSize = 4096
val scaled = if (maxOf(bitmap.width, bitmap.height) > maxSize) {
val scale = maxSize.toFloat() / maxOf(bitmap.width, bitmap.height)
Bitmap.createScaledBitmap(
bitmap,
(bitmap.width * scale).toInt().coerceAtLeast(1),
(bitmap.height * scale).toInt().coerceAtLeast(1),
true,
).also { if (it !== bitmap) bitmap.recycle() }
} else {
bitmap
}
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, texture)
GLUtils.texImage2D(GLES20.GL_TEXTURE_2D, 0, scaled, 0)
scaled.recycle()
requestRender()
}
private fun createTexture(): Int {
val handles = IntArray(1)
GLES20.glGenTextures(1, handles, 0)
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, handles[0])
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
return handles[0]
}
private fun buildProgram(): Int {
val vs = compile(GLES20.GL_VERTEX_SHADER, VERT)
val fs = compile(GLES20.GL_FRAGMENT_SHADER, FRAG)
val p = GLES20.glCreateProgram()
GLES20.glAttachShader(p, vs)
GLES20.glAttachShader(p, fs)
GLES20.glLinkProgram(p)
GLES20.glDeleteShader(vs)
GLES20.glDeleteShader(fs)
return p
}
private fun compile(type: Int, source: String): Int {
val shader = GLES20.glCreateShader(type)
GLES20.glShaderSource(shader, source)
GLES20.glCompileShader(shader)
return shader
}
}
companion object {
private val VERTS = floatArrayOf(-1f, -1f, 3f, -1f, -1f, 3f)
// Kept in sync with PanoViewer.svelte (VERT/FRAG).
private val VERT = """
attribute vec2 aPos;
varying vec2 vUv;
void main() {
vUv = aPos * 0.5 + 0.5;
gl_Position = vec4(aPos, 0.0, 1.0);
}
""".trimIndent()
private val FRAG = """
precision mediump float;
varying vec2 vUv;
uniform sampler2D uTex;
uniform vec2 uRes;
uniform float uFov;
uniform float uYaw;
uniform float uPitch;
const float PI = 3.141592653589793;
void main() {
vec2 ndc = vUv * 2.0 - 1.0;
float aspect = uRes.x / uRes.y;
float tanHalf = tan(uFov * 0.5);
vec3 dir = normalize(vec3(ndc.x * tanHalf * aspect, ndc.y * tanHalf, -1.0));
float cp = cos(uPitch), sp = sin(uPitch);
vec3 d1 = vec3(dir.x, dir.y * cp - dir.z * sp, dir.y * sp + dir.z * cp);
float cy = cos(uYaw), sy = sin(uYaw);
vec3 d2 = vec3(d1.x * cy + d1.z * sy, d1.y, -d1.x * sy + d1.z * cy);
float lon = atan(d2.x, -d2.z);
float lat = asin(clamp(d2.y, -1.0, 1.0));
vec2 uv = vec2(lon / (2.0 * PI) + 0.5, 0.5 - lat / PI);
gl_FragColor = texture2D(uTex, uv);
}
""".trimIndent()
}
}
@@ -0,0 +1,111 @@
package com.galo.android.ui.viewer
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.galo.android.ui.common.GaloAsyncImage
/**
* Media3 playback with a poster shown until playback starts. The built-in
* controller provides seek/scrubbing; the player is paused when the screen
* leaves the foreground.
*/
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun VideoPlayer(
url: String,
posterUrl: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var showPoster by remember(url) { mutableStateOf(true) }
val player = remember(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
}
}
DisposableEffect(url) {
val listener = object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) showPoster = false
}
}
player.addListener(listener)
onDispose {
player.removeListener(listener)
player.release()
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_PAUSE) player.pause()
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
Box(modifier.background(Color.Black)) {
AndroidView(
factory = {
PlayerView(it).apply {
this.player = player
useController = true
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
}
},
modifier = Modifier.fillMaxSize(),
)
if (showPoster) {
Box(Modifier.fillMaxSize()) {
GaloAsyncImage(
url = posterUrl,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
)
Icon(
imageVector = Icons.Filled.PlayArrow,
contentDescription = "Воспроизвести",
tint = Color.White,
modifier = Modifier
.align(Alignment.Center)
.clickable {
showPoster = false
player.play()
},
)
}
}
}
LaunchedEffect(url) { player.playWhenReady = false }
}
@@ -0,0 +1,164 @@
package com.galo.android.ui.viewer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.galo.android.GaloApplication
import com.galo.android.domain.model.MediaKind
import com.galo.android.domain.model.Photo
import com.galo.android.ui.common.GaloAsyncImage
const val TAG_VIEWER_PAGER = "viewer_pager"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewerScreen(
app: GaloApplication,
albumId: String,
initialIndex: Int,
onBack: () -> Unit,
) {
val factory = viewModelFactory {
initializer { ViewerViewModel(app.container, albumId) }
}
val viewModel: ViewerViewModel = viewModel(factory = factory)
val photos by viewModel.photos.collectAsStateWithLifecycle()
ViewerContent(photos = photos, initialIndex = initialIndex, onBack = onBack)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ViewerContent(
photos: List<Photo>,
initialIndex: Int,
onBack: () -> Unit,
) {
if (photos.isEmpty()) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Просмотр", style = MaterialTheme.typography.titleMedium) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
)
},
) { padding ->
Surface(color = Color.Black, modifier = Modifier.fillMaxSize().padding(padding)) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = Color.White)
}
}
}
return
}
val pagerState = rememberPagerState(
initialPage = initialIndex.coerceIn(0, photos.lastIndex),
) { photos.size }
val current = photos.getOrNull(pagerState.currentPage)
// A 360 panorama wraps horizontally forever, so on panorama pages the pager
// must not steal horizontal drags: they rotate the view instead.
val pagerScrollEnabled = current?.isPanorama != true
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
current?.originalFilename ?: "Просмотр",
style = MaterialTheme.typography.titleMedium,
)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Назад")
}
},
)
},
) { padding ->
Surface(
color = Color.Black,
modifier = Modifier.fillMaxSize().padding(padding),
) {
HorizontalPager(
state = pagerState,
userScrollEnabled = pagerScrollEnabled,
modifier = Modifier.fillMaxSize().testTag(TAG_VIEWER_PAGER),
key = { index -> photos.getOrNull(index)?.id ?: index },
) { page ->
val photo = photos.getOrNull(page) ?: return@HorizontalPager
MediaPage(photo)
}
}
}
}
@Composable
private fun MediaPage(photo: Photo) {
when {
photo.kind == MediaKind.VIDEO -> {
val playback = photo.playbackUrl ?: photo.originalUrl ?: photo.downloadUrl
if (playback != null) {
VideoPlayer(
url = playback,
posterUrl = photo.mediumUrl ?: photo.thumbUrl,
modifier = Modifier.fillMaxSize(),
)
}
}
photo.isPanorama -> {
val source = photo.originalUrl ?: photo.previewUrl ?: photo.mediumUrl
if (source != null) {
AndroidPanorama(source)
}
}
else -> {
GaloAsyncImage(
url = photo.previewUrl ?: photo.mediumUrl ?: photo.originalUrl ?: photo.thumbUrl,
contentDescription = photo.originalFilename,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize(),
)
}
}
}
@Composable
private fun AndroidPanorama(url: String) {
androidx.compose.ui.viewinterop.AndroidView(
factory = { context -> PanoramaView(context) },
update = { view -> view.load(url) },
modifier = Modifier.fillMaxSize(),
)
}
@@ -0,0 +1,28 @@
package com.galo.android.ui.viewer
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.galo.android.di.AppContainer
import com.galo.android.domain.model.Photo
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
class ViewerViewModel(
private val container: AppContainer,
private val albumId: String,
) : ViewModel() {
val photos: StateFlow<List<Photo>> = container.photoRepository.observePhotos(albumId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
init {
// Cold start (e.g. restored route): make sure the cache is populated.
viewModelScope.launch {
if (photos.value.isEmpty()) {
container.photoRepository.loadNextPage(albumId, cursor = null)
}
}
}
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">galo</string>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="upload_channel_name">Загрузка в galo</string>
<string name="upload_channel_description">Прогресс фоновой загрузки фотографий и видео</string>
</resources>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Galo" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@android:color/white</item>
</style>
</resources>