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