Add photo viewer for task completions on iOS and Android
- Add PhotoViewerDialog (Android) and PhotoViewerSheet (iOS) for viewing completion photos - Add CompletionCardView (iOS) to display completion details with photo button - Update AllTasksView (iOS) to show full completion details instead of just count - Update TaskCard (Android) to use CompletionCard component - Add Coil 3.0.4 image loading library for Android - Configure ImageLoader in MainActivity with network, memory, and disk caching - Add getMediaBaseUrl() to ApiClient for loading media files without /api path - Fix photo viewer background color to match app theme 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -82,6 +82,8 @@ kotlin {
|
|||||||
implementation(libs.ktor.client.logging)
|
implementation(libs.ktor.client.logging)
|
||||||
implementation(compose.materialIconsExtended)
|
implementation(compose.materialIconsExtended)
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-datetime:<latest-version>")
|
implementation("org.jetbrains.kotlinx:kotlinx-datetime:<latest-version>")
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.ktor3)
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|||||||
@@ -11,12 +11,21 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.PlatformContext
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||||
|
import coil3.disk.DiskCache
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.util.DebugLogger
|
||||||
|
import okio.FileSystem
|
||||||
import com.mycrib.storage.TokenManager
|
import com.mycrib.storage.TokenManager
|
||||||
import com.mycrib.storage.TokenStorage
|
import com.mycrib.storage.TokenStorage
|
||||||
import com.mycrib.storage.TaskCacheManager
|
import com.mycrib.storage.TaskCacheManager
|
||||||
import com.mycrib.storage.TaskCacheStorage
|
import com.mycrib.storage.TaskCacheStorage
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
|
||||||
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
private var deepLinkResetToken by mutableStateOf<String?>(null)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -58,6 +67,27 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||||
|
return ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
add(KtorNetworkFetcherFactory())
|
||||||
|
}
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder()
|
||||||
|
.maxSizePercent(context, 0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "image_cache")
|
||||||
|
.maxSizeBytes(512L * 1024 * 1024) // 512MB
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.crossfade(true)
|
||||||
|
.logger(DebugLogger())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.mycrib.android.platform
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.PlatformContext
|
||||||
|
import coil3.disk.DiskCache
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import coil3.network.ktor3.KtorNetworkFetcherFactory
|
||||||
|
import coil3.request.crossfade
|
||||||
|
import coil3.util.DebugLogger
|
||||||
|
import okio.FileSystem
|
||||||
|
|
||||||
|
fun getAsyncImageLoader(context: PlatformContext): ImageLoader {
|
||||||
|
return ImageLoader.Builder(context)
|
||||||
|
.components {
|
||||||
|
add(KtorNetworkFetcherFactory())
|
||||||
|
}
|
||||||
|
.memoryCache {
|
||||||
|
MemoryCache.Builder()
|
||||||
|
.maxSizePercent(context, 0.25)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "image_cache")
|
||||||
|
.maxSizeBytes(512L * 1024 * 1024) // 512MB
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.crossfade(true)
|
||||||
|
.logger(DebugLogger())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
@@ -18,6 +18,11 @@ object ApiClient {
|
|||||||
*/
|
*/
|
||||||
fun getBaseUrl(): String = ApiConfig.getBaseUrl()
|
fun getBaseUrl(): String = ApiConfig.getBaseUrl()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the media base URL (without /api suffix) for serving media files
|
||||||
|
*/
|
||||||
|
fun getMediaBaseUrl(): String = ApiConfig.getMediaBaseUrl()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Print current environment configuration
|
* Print current environment configuration
|
||||||
*/
|
*/
|
||||||
@@ -25,5 +30,6 @@ object ApiClient {
|
|||||||
println("🌐 API Client initialized")
|
println("🌐 API Client initialized")
|
||||||
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
|
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
|
||||||
println("🔗 Base URL: ${getBaseUrl()}")
|
println("🔗 Base URL: ${getBaseUrl()}")
|
||||||
|
println("📁 Media URL: ${getMediaBaseUrl()}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ object ApiConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the media base URL (without /api suffix) for serving media files
|
||||||
|
*/
|
||||||
|
fun getMediaBaseUrl(): String {
|
||||||
|
return when (CURRENT_ENV) {
|
||||||
|
Environment.LOCAL -> "http://${getLocalhostAddress()}:8000"
|
||||||
|
Environment.DEV -> "https://mycrib.treytartt.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get environment name for logging
|
* Get environment name for logging
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package com.mycrib.android.ui.components.task
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.BrokenImage
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
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.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import coil3.compose.AsyncImagePainter
|
||||||
|
import coil3.compose.SubcomposeAsyncImage
|
||||||
|
import coil3.compose.SubcomposeAsyncImageContent
|
||||||
|
import com.mycrib.shared.models.TaskCompletionImage
|
||||||
|
import com.mycrib.shared.network.ApiClient
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PhotoViewerDialog(
|
||||||
|
images: List<TaskCompletionImage>,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
var selectedImage by remember { mutableStateOf<TaskCompletionImage?>(null) }
|
||||||
|
val baseUrl = ApiClient.getMediaBaseUrl()
|
||||||
|
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
if (selectedImage != null) {
|
||||||
|
selectedImage = null
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
properties = DialogProperties(
|
||||||
|
dismissOnBackPress = true,
|
||||||
|
dismissOnClickOutside = true,
|
||||||
|
usePlatformDefaultWidth = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(0.95f)
|
||||||
|
.fillMaxHeight(0.9f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (selectedImage != null) "Photo" else "Completion Photos",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
IconButton(onClick = {
|
||||||
|
if (selectedImage != null) {
|
||||||
|
selectedImage = null
|
||||||
|
} else {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Close,
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
// Content
|
||||||
|
if (selectedImage != null) {
|
||||||
|
// Single image view
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = baseUrl + selectedImage!!.image,
|
||||||
|
contentDescription = selectedImage!!.caption ?: "Task completion photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.weight(1f),
|
||||||
|
contentScale = ContentScale.Fit
|
||||||
|
) {
|
||||||
|
val state = painter.state
|
||||||
|
when (state) {
|
||||||
|
is AsyncImagePainter.State.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AsyncImagePainter.State.Error -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.BrokenImage,
|
||||||
|
contentDescription = "Error loading image",
|
||||||
|
modifier = Modifier.size(64.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"Failed to load image",
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
selectedImage!!.image,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> SubcomposeAsyncImageContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedImage!!.caption?.let { caption ->
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = caption,
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Grid view
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(images) { image ->
|
||||||
|
Card(
|
||||||
|
onClick = { selectedImage = image },
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
SubcomposeAsyncImage(
|
||||||
|
model = baseUrl + image.image,
|
||||||
|
contentDescription = image.caption ?: "Task completion photo",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(1f),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
) {
|
||||||
|
val state = painter.state
|
||||||
|
when (state) {
|
||||||
|
is AsyncImagePainter.State.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is AsyncImagePainter.State.Error -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MaterialTheme.colorScheme.errorContainer),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.BrokenImage,
|
||||||
|
contentDescription = "Error",
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> SubcomposeAsyncImageContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
image.caption?.let { caption ->
|
||||||
|
Text(
|
||||||
|
text = caption,
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.*
|
import androidx.compose.material.icons.filled.*
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -15,6 +15,7 @@ import com.mycrib.shared.models.TaskCategory
|
|||||||
import com.mycrib.shared.models.TaskPriority
|
import com.mycrib.shared.models.TaskPriority
|
||||||
import com.mycrib.shared.models.TaskFrequency
|
import com.mycrib.shared.models.TaskFrequency
|
||||||
import com.mycrib.shared.models.TaskStatus
|
import com.mycrib.shared.models.TaskStatus
|
||||||
|
import com.mycrib.shared.models.TaskCompletion
|
||||||
import org.jetbrains.compose.ui.tooling.preview.Preview
|
import org.jetbrains.compose.ui.tooling.preview.Preview
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -178,72 +179,7 @@ fun TaskCard(
|
|||||||
|
|
||||||
task.completions.forEach { completion ->
|
task.completions.forEach { completion ->
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Card(
|
CompletionCard(completion = completion)
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(12.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = completion.completionDate.split("T")[0],
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
completion.rating?.let { rating ->
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "$rating★",
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onTertiaryContainer
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
completion.completedByName?.let {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "By: $it",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
completion.actualCost?.let {
|
|
||||||
Text(
|
|
||||||
text = "Cost: $$it",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.tertiary,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
completion.notes?.let {
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,6 +271,121 @@ fun TaskCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CompletionCard(completion: TaskCompletion) {
|
||||||
|
var showPhotoDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val hasImages = !completion.images.isNullOrEmpty()
|
||||||
|
println("CompletionCard: hasImages = $hasImages, images count = ${completion.images?.size ?: 0}")
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
),
|
||||||
|
shape = RoundedCornerShape(12.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = completion.completionDate.split("T")[0],
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
completion.rating?.let { rating ->
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$rating★",
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onTertiaryContainer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion.completedByName?.let {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "By: $it",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
completion.actualCost?.let {
|
||||||
|
Text(
|
||||||
|
text = "Cost: $$it",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.tertiary,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
completion.notes?.let {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show button to view photos if images exist
|
||||||
|
if (hasImages) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
println("View Photos button clicked!")
|
||||||
|
showPhotoDialog = true
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.PhotoLibrary,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "View Photos (${completion.images?.size ?: 0})",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo viewer dialog
|
||||||
|
if (showPhotoDialog && hasImages) {
|
||||||
|
println("Showing PhotoViewerDialog with ${completion.images?.size} images")
|
||||||
|
PhotoViewerDialog(
|
||||||
|
images = completion.images!!,
|
||||||
|
onDismiss = {
|
||||||
|
println("PhotoViewerDialog dismissed")
|
||||||
|
showPhotoDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
fun TaskCardPreview() {
|
fun TaskCardPreview() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ androidx-espresso = "3.7.0"
|
|||||||
androidx-lifecycle = "2.9.5"
|
androidx-lifecycle = "2.9.5"
|
||||||
androidx-navigation = "2.9.1"
|
androidx-navigation = "2.9.1"
|
||||||
androidx-testExt = "1.3.0"
|
androidx-testExt = "1.3.0"
|
||||||
|
coil = "3.0.4"
|
||||||
composeHotReload = "1.0.0-rc02"
|
composeHotReload = "1.0.0-rc02"
|
||||||
composeMultiplatform = "1.9.1"
|
composeMultiplatform = "1.9.1"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
@@ -44,6 +45,8 @@ ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
|||||||
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
ktor-client-js = { module = "io.ktor:ktor-client-js", version.ref = "ktor" }
|
||||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
|
||||||
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
|
coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
|||||||
@@ -41,7 +41,9 @@
|
|||||||
1C07893F2EBC218B00392B46 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
1C07893F2EBC218B00392B46 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
1C0789412EBC218B00392B46 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
1C0789412EBC218B00392B46 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
1C0789612EBC2F5400392B46 /* MyCribExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MyCribExtension.entitlements; sourceTree = "<group>"; };
|
1C0789612EBC2F5400392B46 /* MyCribExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = MyCribExtension.entitlements; sourceTree = "<group>"; };
|
||||||
|
4B07E04F794A4C1CAA8CCD5D /* PhotoViewerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewerSheet.swift; sourceTree = "<group>"; };
|
||||||
96A3DDC05E14B3F83E56282F /* MyCrib.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MyCrib.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
96A3DDC05E14B3F83E56282F /* MyCrib.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MyCrib.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AD6CD907CA1045CBBC845D91 /* CompletionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionCardView.swift; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
@@ -114,6 +116,15 @@
|
|||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
1C078A1B2EC1820B00392B46 /* Recovered References */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AD6CD907CA1045CBBC845D91 /* CompletionCardView.swift */,
|
||||||
|
4B07E04F794A4C1CAA8CCD5D /* PhotoViewerSheet.swift */,
|
||||||
|
);
|
||||||
|
name = "Recovered References";
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
86BC7E88090398B44B7DB0E4 = {
|
86BC7E88090398B44B7DB0E4 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -123,6 +134,7 @@
|
|||||||
1C0789432EBC218B00392B46 /* MyCrib */,
|
1C0789432EBC218B00392B46 /* MyCrib */,
|
||||||
1C07893E2EBC218B00392B46 /* Frameworks */,
|
1C07893E2EBC218B00392B46 /* Frameworks */,
|
||||||
FA6022B7B844191C54E57EB4 /* Products */,
|
FA6022B7B844191C54E57EB4 /* Products */,
|
||||||
|
1C078A1B2EC1820B00392B46 /* Recovered References */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
|||||||
100
iosApp/iosApp/Subviews/Task/CompletionCardView.swift
Normal file
100
iosApp/iosApp/Subviews/Task/CompletionCardView.swift
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposeApp
|
||||||
|
|
||||||
|
struct CompletionCardView: View {
|
||||||
|
let completion: TaskCompletion
|
||||||
|
@State private var showPhotoSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text(formatDate(completion.completionDate))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let rating = completion.rating {
|
||||||
|
HStack(spacing: 2) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(rating)")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
}
|
||||||
|
.foregroundColor(.orange)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.orange.opacity(0.1))
|
||||||
|
.cornerRadius(6)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let completedBy = completion.completedByName {
|
||||||
|
Text("By: \(completedBy)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let cost = completion.actualCost {
|
||||||
|
Text("Cost: $\(cost)")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.green)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let notes = completion.notes {
|
||||||
|
Text(notes)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show button to view photos if images exist
|
||||||
|
if let images = completion.images, !images.isEmpty {
|
||||||
|
Button(action: {
|
||||||
|
showPhotoSheet = true
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "photo.on.rectangle")
|
||||||
|
.font(.caption)
|
||||||
|
Text("View Photos (\(images.count))")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.foregroundColor(.blue)
|
||||||
|
.cornerRadius(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.sheet(isPresented: $showPhotoSheet) {
|
||||||
|
if let images = completion.images {
|
||||||
|
PhotoViewerSheet(images: images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatDate(_ dateString: String) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
|
||||||
|
if let date = formatter.date(from: dateString) {
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
formatter.timeStyle = .none
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
// Try without time
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
if let date = formatter.date(from: dateString) {
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
return dateString
|
||||||
|
}
|
||||||
|
}
|
||||||
146
iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift
Normal file
146
iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import ComposeApp
|
||||||
|
|
||||||
|
struct PhotoViewerSheet: View {
|
||||||
|
let images: [TaskCompletionImage]
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
@State private var selectedImage: TaskCompletionImage?
|
||||||
|
|
||||||
|
private let baseUrl = ApiClient.shared.getMediaBaseUrl()
|
||||||
|
|
||||||
|
private func fullImageUrl(_ imagePath: String) -> URL? {
|
||||||
|
return URL(string: baseUrl + imagePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
Group {
|
||||||
|
if let selectedImage = selectedImage {
|
||||||
|
// Single image view
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
AsyncImage(url: fullImageUrl(selectedImage.image)) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
.frame(height: 300)
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
case .failure:
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 60))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
Text("Failed to load image")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(height: 300)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let caption = selectedImage.caption {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Caption")
|
||||||
|
.font(.headline)
|
||||||
|
Text(caption)
|
||||||
|
.font(.body)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding()
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.cornerRadius(12)
|
||||||
|
.padding(.horizontal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical)
|
||||||
|
}
|
||||||
|
.navigationTitle("Photo")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button(action: {
|
||||||
|
self.selectedImage = nil
|
||||||
|
}) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "chevron.left")
|
||||||
|
Text("Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Grid view
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: [
|
||||||
|
GridItem(.flexible(), spacing: 12),
|
||||||
|
GridItem(.flexible(), spacing: 12)
|
||||||
|
], spacing: 12) {
|
||||||
|
ForEach(images, id: \.id) { image in
|
||||||
|
Button(action: {
|
||||||
|
selectedImage = image
|
||||||
|
}) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
AsyncImage(url: fullImageUrl(image.image)) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .empty:
|
||||||
|
ProgressView()
|
||||||
|
.frame(height: 150)
|
||||||
|
case .success(let img):
|
||||||
|
img
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
.frame(height: 150)
|
||||||
|
.clipped()
|
||||||
|
case .failure:
|
||||||
|
VStack {
|
||||||
|
Image(systemName: "photo")
|
||||||
|
.font(.system(size: 40))
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
Text("Failed to load")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
.frame(height: 150)
|
||||||
|
@unknown default:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cornerRadius(8)
|
||||||
|
|
||||||
|
if let caption = image.caption {
|
||||||
|
Text(caption)
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.navigationTitle("Completion Photos")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Done") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,12 +53,18 @@ struct TaskCard: View {
|
|||||||
if task.completions.count > 0 {
|
if task.completions.count > 0 {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Image(systemName: "checkmark.circle")
|
HStack {
|
||||||
.foregroundColor(.green)
|
Image(systemName: "checkmark.circle.fill")
|
||||||
Text("Completed \(task.completions.count) time\(task.completions.count == 1 ? "" : "s")")
|
.foregroundColor(.green)
|
||||||
.font(.caption)
|
Text("Completions (\(task.completions.count))")
|
||||||
.foregroundColor(.secondary)
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(task.completions, id: \.id) { completion in
|
||||||
|
CompletionCardView(completion: completion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -331,12 +331,18 @@ struct DynamicTaskCard: View {
|
|||||||
if task.completions.count > 0 {
|
if task.completions.count > 0 {
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
HStack {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Image(systemName: "checkmark.circle")
|
HStack {
|
||||||
.foregroundColor(.green)
|
Image(systemName: "checkmark.circle.fill")
|
||||||
Text("Completed \(task.completions.count) time\(task.completions.count == 1 ? "" : "s")")
|
.foregroundColor(.green)
|
||||||
.font(.caption)
|
Text("Completions (\(task.completions.count))")
|
||||||
.foregroundColor(.secondary)
|
.font(.caption)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
}
|
||||||
|
|
||||||
|
ForEach(task.completions, id: \.id) { completion in
|
||||||
|
CompletionCardView(completion: completion)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user