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:
@@ -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>
|
||||
Reference in New Issue
Block a user