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

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