diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b2543f --- /dev/null +++ b/.gitignore @@ -0,0 +1,117 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# macOS specific files +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c58702 --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# Image Flow + +A beautiful iOS app that combines your photos with stylish clock displays for a personalized fullscreen slideshow experience. + +## Features + +- Select multiple photos from your photo library +- Choose from different clock styles (Digital, Analog, Minimal) +- Select transition animations between photos (Fade, Slide, Zoom) +- Fullscreen slideshow mode with automatic photo transitions every 10 seconds +- Tap to exit fullscreen mode +- Device stays awake during slideshow + +## Requirements + +- iOS 16.0+ +- Xcode 15.0+ +- Swift 5.9+ + +## Installation + +1. Clone the repository +2. Open the project in Xcode +3. Build and run on your iOS device or simulator + +## Usage + +1. Launch the app +2. Tap "Select Photos" to choose images from your photo library +3. Select a clock style (Digital, Analog, or Minimal) +4. Choose a transition animation (Fade, Slide, or Zoom) +5. Tap "Start Slideshow" to enter fullscreen mode +6. Tap anywhere on the screen to exit fullscreen mode + +## Privacy + +The app requires access to your photo library to display selected images in the slideshow. Photos are only accessed within the app and are not shared or stored externally. diff --git a/image-flow/ClockPreviewView.swift b/image-flow/ClockPreviewView.swift new file mode 100644 index 0000000..17e951c --- /dev/null +++ b/image-flow/ClockPreviewView.swift @@ -0,0 +1,153 @@ +// +// ClockPreviewView.swift +// image-flow +// +// Created by Junv on 1/3/2025. +// + +import SwiftUI + +struct ClockPreviewView: View { + let clockStyle: ClockStyle + @State private var currentTime = Date() + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color.blue.opacity(0.2)) + .frame(height: 100) + + Group { + switch clockStyle { + case .digital: + digitalPreview + case .analog: + analogPreview + case .minimal: + minimalPreview + } + } + } + .onReceive(timer) { _ in + currentTime = Date() + } + } + + private var digitalPreview: some View { + VStack(spacing: 2) { + Text(timeFormatter.string(from: currentTime)) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(.primary) + + Text(dateFormatter.string(from: currentTime)) + .font(.system(size: 12, weight: .medium, design: .rounded)) + .foregroundColor(.secondary) + } + } + + private var analogPreview: some View { + ZStack { + // Clock face + Circle() + .fill(Color.white) + .frame(width: 70, height: 70) + .shadow(radius: 2) + + // Hour markers + ForEach(0..<12) { hour in + Rectangle() + .fill(Color.black) + .frame(width: 2, height: hour % 3 == 0 ? 6 : 3) + .offset(y: -30) + .rotationEffect(.degrees(Double(hour) * 30)) + } + + // Hour hand + Rectangle() + .fill(Color.black) + .frame(width: 2, height: 20) + .cornerRadius(1) + .offset(y: -10) + .rotationEffect(.degrees(Double(hour) * 360)) + + // Minute hand + Rectangle() + .fill(Color.black) + .frame(width: 1.5, height: 28) + .cornerRadius(0.75) + .offset(y: -14) + .rotationEffect(.degrees(Double(minute) * 360)) + + // Second hand + Rectangle() + .fill(Color.red) + .frame(width: 1, height: 30) + .cornerRadius(0.5) + .offset(y: -15) + .rotationEffect(.degrees(Double(second) * 360)) + + // Center cap + Circle() + .fill(Color.black) + .frame(width: 4, height: 4) + } + } + + private var minimalPreview: some View { + Text(minimalTimeFormatter.string(from: currentTime)) + .font(.system(size: 36, weight: .thin, design: .rounded)) + .foregroundColor(.primary) + } + + // MARK: - Helper Properties + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + } + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMM d" + return formatter + } + + private var minimalTimeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + } + + private var calendar: Calendar { + return Calendar.current + } + + private var hour: CGFloat { + let hour = CGFloat(calendar.component(.hour, from: currentTime) % 12) + let minute = CGFloat(calendar.component(.minute, from: currentTime)) + return (hour + minute / 60) / 12 + } + + private var minute: CGFloat { + let minute = CGFloat(calendar.component(.minute, from: currentTime)) + let second = CGFloat(calendar.component(.second, from: currentTime)) + return (minute + second / 60) / 60 + } + + private var second: CGFloat { + let second = CGFloat(calendar.component(.second, from: currentTime)) + return second / 60 + } +} + +#Preview { + VStack(spacing: 20) { + ClockPreviewView(clockStyle: .digital) + ClockPreviewView(clockStyle: .analog) + ClockPreviewView(clockStyle: .minimal) + } + .padding() +} diff --git a/image-flow/ContentView.swift b/image-flow/ContentView.swift index 6302a3e..5d949d6 100644 --- a/image-flow/ContentView.swift +++ b/image-flow/ContentView.swift @@ -6,16 +6,212 @@ // import SwiftUI +import PhotosUI struct ContentView: View { + @StateObject private var photoManager = PhotoPickerManager() + @State private var clockStyle: ClockStyle = .digital + @State private var isFullscreen = false + @State private var currentImageIndex = 0 + @State private var transitionAnimation: TransitionAnimation = .fade + @State private var showingPermissionAlert = false + + let timer = Timer.publish(every: 10, on: .main, in: .common).autoconnect() + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + if isFullscreen { + SlideshowView( + images: photoManager.selectedImages, + clockStyle: clockStyle, + currentIndex: $currentImageIndex, + animation: transitionAnimation + ) + .statusBar(hidden: true) + .ignoresSafeArea() + .onTapGesture { + isFullscreen = false + } + .onReceive(timer) { _ in + withAnimation { + currentImageIndex = (currentImageIndex + 1) % max(photoManager.selectedImages.count, 1) + } + } + } else { + NavigationStack { + List { + Section("Select Photos") { + PhotosPicker( + selection: $photoManager.selectedItems, + matching: .images, + photoLibrary: .shared() + ) { + HStack { + Image(systemName: "photo.on.rectangle.angled") + Text("Select Photos") + } + } + .onChange(of: photoManager.selectedItems) { _ in + Task { + await photoManager.loadImages() + } + } + + if photoManager.isLoading { + HStack { + Spacer() + ProgressView() + .padding() + Spacer() + } + } else if !photoManager.selectedImages.isEmpty { + ScrollView(.horizontal) { + LazyHStack { + ForEach(0.. String { + switch transitionAnimation { + case .fade: + return "arrow.left.and.right.square" + case .slide: + return "arrow.right.square" + case .zoom: + return "arrow.up.left.and.arrow.down.right" + } + } +} + +enum ClockStyle: String, CaseIterable { + case digital + case analog + case minimal + + var displayName: String { + switch self { + case .digital: return "Digital" + case .analog: return "Analog" + case .minimal: return "Minimal" + } + } +} + +enum TransitionAnimation: String, CaseIterable { + case fade + case slide + case zoom + + var displayName: String { + switch self { + case .fade: return "Fade" + case .slide: return "Slide" + case .zoom: return "Zoom" } - .padding() } } diff --git a/image-flow/Info.plist b/image-flow/Info.plist new file mode 100644 index 0000000..0de2616 --- /dev/null +++ b/image-flow/Info.plist @@ -0,0 +1,17 @@ + + + + + NSPhotoLibraryUsageDescription + This app needs access to your photo library to display selected images in the slideshow. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + + UILaunchStoryboardName + LaunchScreen + + diff --git a/image-flow/LaunchScreen.storyboard b/image-flow/LaunchScreen.storyboard new file mode 100644 index 0000000..4905cbc --- /dev/null +++ b/image-flow/LaunchScreen.storyboard @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/image-flow/PhotoPickerManager.swift b/image-flow/PhotoPickerManager.swift new file mode 100644 index 0000000..c9658fa --- /dev/null +++ b/image-flow/PhotoPickerManager.swift @@ -0,0 +1,84 @@ +// +// PhotoPickerManager.swift +// image-flow +// +// Created by Junv on 1/3/2025. +// + +import SwiftUI +import PhotosUI + +class PhotoPickerManager: ObservableObject { + @Published var selectedItems: [PhotosPickerItem] = [] + @Published var selectedImages: [UIImage] = [] + @Published var isLoading = false + + func loadImages() async { + DispatchQueue.main.async { + self.isLoading = true + } + + var newImages: [UIImage] = [] + + for item in selectedItems { + if let data = try? await item.loadTransferable(type: Data.self), + let uiImage = UIImage(data: data) { + newImages.append(uiImage) + } + } + + DispatchQueue.main.async { + self.selectedImages = newImages + self.isLoading = false + } + } + + func clearSelection() { + selectedItems = [] + selectedImages = [] + } +} + +// Extension to check and request photo library permission +extension PhotoPickerManager { + enum PhotoLibraryPermissionStatus { + case authorized + case denied + case restricted + case notDetermined + } + + func checkPhotoLibraryPermission() -> PhotoLibraryPermissionStatus { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + + switch status { + case .authorized, .limited: + return .authorized + case .denied: + return .denied + case .restricted: + return .restricted + case .notDetermined: + return .notDetermined + @unknown default: + return .notDetermined + } + } + + func requestPhotoLibraryPermission() async -> PhotoLibraryPermissionStatus { + let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + + switch status { + case .authorized, .limited: + return .authorized + case .denied: + return .denied + case .restricted: + return .restricted + case .notDetermined: + return .notDetermined + @unknown default: + return .notDetermined + } + } +} diff --git a/image-flow/SlideshowView.swift b/image-flow/SlideshowView.swift new file mode 100644 index 0000000..9ba24e6 --- /dev/null +++ b/image-flow/SlideshowView.swift @@ -0,0 +1,235 @@ +// +// SlideshowView.swift +// image-flow +// +// Created by Junv on 1/3/2025. +// + +import SwiftUI + +struct SlideshowView: View { + let images: [UIImage] + let clockStyle: ClockStyle + @Binding var currentIndex: Int + let animation: TransitionAnimation + + @State private var currentTime = Date() + @State private var previousIndex: Int? + @State private var isAnimating = false + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var body: some View { + ZStack { + // Background image + if !images.isEmpty { + GeometryReader { geometry in + ZStack { + // Current image + Image(uiImage: images[currentIndex]) + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + .transition(transitionEffect()) + + // Overlay gradient for better clock visibility + LinearGradient( + gradient: Gradient(colors: [Color.black.opacity(0.4), Color.clear]), + startPoint: .top, + endPoint: .center + ) + } + } + .ignoresSafeArea() + .animation(.easeInOut(duration: 1.0), value: currentIndex) + } + + // Clock overlay + VStack { + clockView + .padding(.top, 50) + + Spacer() + + // Image counter + if images.count > 1 { + Text("\(currentIndex + 1) / \(images.count)") + .font(.caption) + .padding(8) + .background(Color.black.opacity(0.6)) + .foregroundColor(.white) + .cornerRadius(8) + .padding(.bottom, 20) + } + } + } + .onReceive(timer) { _ in + currentTime = Date() + } + } + + private var clockView: some View { + Group { + switch clockStyle { + case .digital: + DigitalClockView(currentTime: currentTime) + case .analog: + AnalogClockView(currentTime: currentTime) + case .minimal: + MinimalClockView(currentTime: currentTime) + } + } + } + + private func transitionEffect() -> AnyTransition { + switch animation { + case .fade: + return AnyTransition.opacity + case .slide: + return AnyTransition.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading) + ) + case .zoom: + return AnyTransition.scale(scale: 0.8).combined(with: .opacity) + } + } +} + +struct DigitalClockView: View { + let currentTime: Date + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss" + return formatter + } + + private var dateFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, MMMM d, yyyy" + return formatter + } + + var body: some View { + VStack(spacing: 4) { + Text(timeFormatter.string(from: currentTime)) + .font(.system(size: 60, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) + + Text(dateFormatter.string(from: currentTime)) + .font(.system(size: 20, weight: .medium, design: .rounded)) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) + } + .padding() + .background(Color.black.opacity(0.3)) + .cornerRadius(16) + } +} + +struct AnalogClockView: View { + let currentTime: Date + + private var calendar: Calendar { + return Calendar.current + } + + private var hour: CGFloat { + let hour = CGFloat(calendar.component(.hour, from: currentTime) % 12) + let minute = CGFloat(calendar.component(.minute, from: currentTime)) + return (hour + minute / 60) / 12 + } + + private var minute: CGFloat { + let minute = CGFloat(calendar.component(.minute, from: currentTime)) + let second = CGFloat(calendar.component(.second, from: currentTime)) + return (minute + second / 60) / 60 + } + + private var second: CGFloat { + let second = CGFloat(calendar.component(.second, from: currentTime)) + return second / 60 + } + + var body: some View { + ZStack { + // Clock face + Circle() + .fill(Color.black.opacity(0.5)) + .frame(width: 200, height: 200) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 4) + ) + + // Hour markers + ForEach(0..<12) { hour in + Rectangle() + .fill(Color.white) + .frame(width: 4, height: hour % 3 == 0 ? 15 : 8) + .offset(y: -85) + .rotationEffect(.degrees(Double(hour) * 30)) + } + + // Hour hand + Rectangle() + .fill(Color.white) + .frame(width: 4, height: 60) + .cornerRadius(2) + .offset(y: -30) + .rotationEffect(.degrees(Double(hour) * 360)) + + // Minute hand + Rectangle() + .fill(Color.white) + .frame(width: 3, height: 80) + .cornerRadius(1.5) + .offset(y: -40) + .rotationEffect(.degrees(Double(minute) * 360)) + + // Second hand + Rectangle() + .fill(Color.red) + .frame(width: 1, height: 90) + .cornerRadius(0.5) + .offset(y: -45) + .rotationEffect(.degrees(Double(second) * 360)) + + // Center cap + Circle() + .fill(Color.white) + .frame(width: 12, height: 12) + } + .frame(width: 220, height: 220) + .shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 2) + } +} + +struct MinimalClockView: View { + let currentTime: Date + + private var timeFormatter: DateFormatter { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + } + + var body: some View { + Text(timeFormatter.string(from: currentTime)) + .font(.system(size: 80, weight: .thin, design: .rounded)) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.5), radius: 2, x: 0, y: 1) + } +} + +#Preview { + SlideshowView( + images: [UIImage(systemName: "photo")!], + clockStyle: .digital, + currentIndex: .constant(0), + animation: .fade + ) +} diff --git a/image-flow/image_flowApp.swift b/image-flow/image_flowApp.swift index b0dc66e..b7308c7 100644 --- a/image-flow/image_flowApp.swift +++ b/image-flow/image_flowApp.swift @@ -6,12 +6,23 @@ // import SwiftUI +import UIKit @main struct image_flowApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + var body: some Scene { WindowGroup { ContentView() } } } + +class AppDelegate: NSObject, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool { + // Prevent screen from dimming during slideshow + UIApplication.shared.isIdleTimerDisabled = true + return true + } +}