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:
Trey t
2025-11-09 20:42:18 -06:00
parent 99228d03b5
commit 131637e6f3
13 changed files with 719 additions and 81 deletions

View File

@@ -82,6 +82,8 @@ kotlin {
implementation(libs.ktor.client.logging)
implementation(compose.materialIconsExtended)
implementation("org.jetbrains.kotlinx:kotlinx-datetime:<latest-version>")
implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3)
}
commonTest.dependencies {
implementation(libs.kotlin.test)

View File

@@ -11,12 +11,21 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
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.TokenStorage
import com.mycrib.storage.TaskCacheManager
import com.mycrib.storage.TaskCacheStorage
class MainActivity : ComponentActivity() {
class MainActivity : ComponentActivity(), SingletonImageLoader.Factory {
private var deepLinkResetToken by mutableStateOf<String?>(null)
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

View File

@@ -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()
}

View File

@@ -18,6 +18,11 @@ object ApiClient {
*/
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
*/
@@ -25,5 +30,6 @@ object ApiClient {
println("🌐 API Client initialized")
println("📍 Environment: ${ApiConfig.getEnvironmentName()}")
println("🔗 Base URL: ${getBaseUrl()}")
println("📁 Media URL: ${getMediaBaseUrl()}")
}
}

View File

@@ -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
*/

View File

@@ -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
)
}
}
}
}
}
}
}
}
}
}

View File

@@ -5,7 +5,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TaskFrequency
import com.mycrib.shared.models.TaskStatus
import com.mycrib.shared.models.TaskCompletion
import org.jetbrains.compose.ui.tooling.preview.Preview
@Composable
@@ -178,72 +179,7 @@ fun TaskCard(
task.completions.forEach { completion ->
Spacer(modifier = Modifier.height(12.dp))
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
)
}
}
}
CompletionCard(completion = completion)
}
}
@@ -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
@Composable
fun TaskCardPreview() {

View File

@@ -10,6 +10,7 @@ androidx-espresso = "3.7.0"
androidx-lifecycle = "2.9.5"
androidx-navigation = "2.9.1"
androidx-testExt = "1.3.0"
coil = "3.0.4"
composeHotReload = "1.0.0-rc02"
composeMultiplatform = "1.9.1"
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" }
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" }
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]
androidApplication = { id = "com.android.application", version.ref = "agp" }

View File

@@ -41,7 +41,9 @@
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; };
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; };
AD6CD907CA1045CBBC845D91 /* CompletionCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionCardView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -114,6 +116,15 @@
name = Frameworks;
sourceTree = "<group>";
};
1C078A1B2EC1820B00392B46 /* Recovered References */ = {
isa = PBXGroup;
children = (
AD6CD907CA1045CBBC845D91 /* CompletionCardView.swift */,
4B07E04F794A4C1CAA8CCD5D /* PhotoViewerSheet.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
86BC7E88090398B44B7DB0E4 = {
isa = PBXGroup;
children = (
@@ -123,6 +134,7 @@
1C0789432EBC218B00392B46 /* MyCrib */,
1C07893E2EBC218B00392B46 /* Frameworks */,
FA6022B7B844191C54E57EB4 /* Products */,
1C078A1B2EC1820B00392B46 /* Recovered References */,
);
sourceTree = "<group>";
};

View 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
}
}

View 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()
}
}
}
}
}
}
}
}

View File

@@ -53,12 +53,18 @@ struct TaskCard: View {
if task.completions.count > 0 {
Divider()
HStack {
Image(systemName: "checkmark.circle")
.foregroundColor(.green)
Text("Completed \(task.completions.count) time\(task.completions.count == 1 ? "" : "s")")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Completions (\(task.completions.count))")
.font(.caption)
.fontWeight(.semibold)
}
ForEach(task.completions, id: \.id) { completion in
CompletionCardView(completion: completion)
}
}
}

View File

@@ -330,13 +330,19 @@ struct DynamicTaskCard: View {
if task.completions.count > 0 {
Divider()
HStack {
Image(systemName: "checkmark.circle")
.foregroundColor(.green)
Text("Completed \(task.completions.count) time\(task.completions.count == 1 ? "" : "s")")
.font(.caption)
.foregroundColor(.secondary)
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Completions (\(task.completions.count))")
.font(.caption)
.fontWeight(.semibold)
}
ForEach(task.completions, id: \.id) { completion in
CompletionCardView(completion: completion)
}
}
}