diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 9903339..74df536 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -82,6 +82,8 @@ kotlin { implementation(libs.ktor.client.logging) implementation(compose.materialIconsExtended) implementation("org.jetbrains.kotlinx:kotlinx-datetime:") + implementation(libs.coil.compose) + implementation(libs.coil.network.ktor3) } commonTest.dependencies { implementation(libs.kotlin.test) diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt index 628159c..02a7978 100644 --- a/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/MainActivity.kt @@ -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(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 diff --git a/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/ImageLoader.android.kt b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/ImageLoader.android.kt new file mode 100644 index 0000000..529ff7d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/example/mycrib/platform/ImageLoader.android.kt @@ -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() +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt index abda88e..29c6c69 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiClient.kt @@ -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()}") } } diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt index 48723bb..cfc83c4 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/network/ApiConfig.kt @@ -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 */ diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/PhotoViewerDialog.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/PhotoViewerDialog.kt new file mode 100644 index 0000000..1c5c1d1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/PhotoViewerDialog.kt @@ -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, + onDismiss: () -> Unit +) { + var selectedImage by remember { mutableStateOf(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 + ) + } + } + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt index 22f3aaf..91fd476 100644 --- a/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt +++ b/composeApp/src/commonMain/kotlin/com/example/mycrib/ui/components/task/TaskCard.kt @@ -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() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 824e881..b812f58 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 3728c39..90c91c0 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -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 = ""; }; + 4B07E04F794A4C1CAA8CCD5D /* PhotoViewerSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoViewerSheet.swift; sourceTree = ""; }; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -114,6 +116,15 @@ name = Frameworks; sourceTree = ""; }; + 1C078A1B2EC1820B00392B46 /* Recovered References */ = { + isa = PBXGroup; + children = ( + AD6CD907CA1045CBBC845D91 /* CompletionCardView.swift */, + 4B07E04F794A4C1CAA8CCD5D /* PhotoViewerSheet.swift */, + ); + name = "Recovered References"; + sourceTree = ""; + }; 86BC7E88090398B44B7DB0E4 = { isa = PBXGroup; children = ( @@ -123,6 +134,7 @@ 1C0789432EBC218B00392B46 /* MyCrib */, 1C07893E2EBC218B00392B46 /* Frameworks */, FA6022B7B844191C54E57EB4 /* Products */, + 1C078A1B2EC1820B00392B46 /* Recovered References */, ); sourceTree = ""; }; diff --git a/iosApp/iosApp/Subviews/Task/CompletionCardView.swift b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift new file mode 100644 index 0000000..9d75a3b --- /dev/null +++ b/iosApp/iosApp/Subviews/Task/CompletionCardView.swift @@ -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 + } +} diff --git a/iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift b/iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift new file mode 100644 index 0000000..3025640 --- /dev/null +++ b/iosApp/iosApp/Subviews/Task/PhotoViewerSheet.swift @@ -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() + } + } + } + } + } + } + } +} diff --git a/iosApp/iosApp/Subviews/Task/TaskCard.swift b/iosApp/iosApp/Subviews/Task/TaskCard.swift index 7ecb1c0..9e4948b 100644 --- a/iosApp/iosApp/Subviews/Task/TaskCard.swift +++ b/iosApp/iosApp/Subviews/Task/TaskCard.swift @@ -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) + } } } diff --git a/iosApp/iosApp/Task/AllTasksView.swift b/iosApp/iosApp/Task/AllTasksView.swift index 1d350af..b382b85 100644 --- a/iosApp/iosApp/Task/AllTasksView.swift +++ b/iosApp/iosApp/Task/AllTasksView.swift @@ -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) + } } }