This commit is contained in:
2025-03-01 22:38:56 +11:00
parent 0e7bf260c2
commit 8955223e0a
8 changed files with 265 additions and 131 deletions
+6 -2
View File
@@ -5,7 +5,7 @@ A beautiful iOS app that combines your photos with stylish clock displays for a
## Features
- Select multiple photos from your photo library
- Choose from different clock styles (Digital, Analog, Minimal)
- Choose from different clock styles (Digital, Flip, Minimal)
- Select transition animations between photos (Fade, Slide, Zoom)
- Fullscreen slideshow mode with automatic photo transitions every 10 seconds
- Tap to exit fullscreen mode
@@ -27,7 +27,7 @@ A beautiful iOS app that combines your photos with stylish clock displays for a
1. Launch the app
2. Tap "Select Photos" to choose images from your photo library
3. Select a clock style (Digital, Analog, or Minimal)
3. Select a clock style (Digital, Flip, 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
@@ -35,3 +35,7 @@ A beautiful iOS app that combines your photos with stylish clock displays for a
## 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.
## Credits
* https://github.com/elpassion/FlipClock-SwiftUI
+62 -55
View File
@@ -24,8 +24,8 @@ struct ClockPreviewView: View {
switch clockStyle {
case .digital:
digitalPreview
case .analog:
analogPreview
case .flip:
flipPreview
case .minimal:
minimalPreview
}
@@ -37,62 +37,69 @@ struct ClockPreviewView: View {
}
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)
}
Text(minimalTimeFormatter.string(from: currentTime))
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundColor(.primary)
.padding(8)
.background(Color.black.opacity(0.1))
.cornerRadius(8)
}
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
private var flipPreview: some View {
HStack(spacing: 8) {
// Hours
VStack {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
.frame(width: 30, height: 30)
Text("\(String(format: "%02d", calendar.component(.hour, from: currentTime)))")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
}
Rectangle()
.fill(Color.black)
.frame(width: 2, height: hour % 3 == 0 ? 6 : 3)
.offset(y: -30)
.rotationEffect(.degrees(Double(hour) * 30))
.fill(Color.gray.opacity(0.5))
.frame(height: 1)
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
.frame(width: 30, height: 30)
Text("\(String(format: "%02d", calendar.component(.hour, from: currentTime)))")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
}
}
// 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)
// Minutes
VStack {
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
.frame(width: 30, height: 30)
Text("\(String(format: "%02d", calendar.component(.minute, from: currentTime)))")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
}
Rectangle()
.fill(Color.gray.opacity(0.5))
.frame(height: 1)
ZStack {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black)
.frame(width: 30, height: 30)
Text("\(String(format: "%02d", calendar.component(.minute, from: currentTime)))")
.font(.system(size: 22, weight: .bold))
.foregroundColor(.white)
}
}
}
}
@@ -106,7 +113,7 @@ struct ClockPreviewView: View {
private var timeFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
formatter.dateFormat = "HH:mm"
return formatter
}
@@ -147,7 +154,7 @@ struct ClockPreviewView: View {
#Preview {
VStack(spacing: 20) {
ClockPreviewView(clockStyle: .digital)
ClockPreviewView(clockStyle: .analog)
ClockPreviewView(clockStyle: .flip)
ClockPreviewView(clockStyle: .minimal)
}
.padding()
+3 -3
View File
@@ -9,13 +9,13 @@ import Foundation
enum ClockStyle: String, CaseIterable {
case digital
case analog
case flip
case minimal
var displayName: String {
switch self {
case .digital: return "Digital"
case .analog: return "Analog"
case .flip: return "Flip"
case .minimal: return "Minimal"
}
}
@@ -23,7 +23,7 @@ enum ClockStyle: String, CaseIterable {
var icon: String {
switch self {
case .digital: return "clock"
case .analog: return "clock.circle"
case .flip: return "clock.circle"
case .minimal: return "clock.badge"
}
}
+35
View File
@@ -0,0 +1,35 @@
import Foundation
import Combine
class FlipClockViewModel: ObservableObject {
init() {
setupTimer()
}
private(set) lazy var flipViewModels = { (0...5).map { _ in FlipViewModel() } }()
// MARK: - Private
private func setupTimer() {
Timer.publish(every: 1, on: .main, in: .default)
.autoconnect()
.map { [timeFormatter] in timeFormatter.string(from: $0) }
.removeDuplicates()
.sink(receiveValue: { [weak self] in self?.setTimeInViewModels(time: $0) })
.store(in: &cancellables)
}
private func setTimeInViewModels(time: String) {
zip(time, flipViewModels).forEach { number, viewModel in
viewModel.text = "\(number)"
}
}
private var cancellables = Set<AnyCancellable>()
private var timeFormatter: DateFormatter {
let formatter = DateFormatter()
formatter.dateFormat = "HHmmss"
return formatter
}
}
+33
View File
@@ -0,0 +1,33 @@
import SwiftUI
struct FlipView: View {
init(viewModel: FlipViewModel) {
self.viewModel = viewModel
}
@ObservedObject var viewModel: FlipViewModel
var body: some View {
VStack(spacing: 0) {
ZStack {
SingleFlipView(text: viewModel.newValue ?? "", type: .top)
SingleFlipView(text: viewModel.oldValue ?? "", type: .top)
.rotation3DEffect(.init(degrees: self.viewModel.animateTop ? -90 : .zero),
axis: (1, 0, 0),
anchor: .bottom,
perspective: 0.5)
}
Color.gray.opacity(0.5)
.frame(height: 1)
ZStack {
SingleFlipView(text: viewModel.oldValue ?? "", type: .bottom)
SingleFlipView(text: viewModel.newValue ?? "", type: .bottom)
.rotation3DEffect(.init(degrees: self.viewModel.animateBottom ? .zero : 90),
axis: (1, 0, 0),
anchor: .top,
perspective: 0.5)
}
}
.fixedSize()
}
}
+30
View File
@@ -0,0 +1,30 @@
import Combine
import SwiftUI
class FlipViewModel: ObservableObject, Identifiable {
var text: String? {
didSet { updateTexts(old: oldValue, new: text) }
}
@Published var newValue: String?
@Published var oldValue: String?
@Published var animateTop: Bool = false
@Published var animateBottom: Bool = false
func updateTexts(old: String?, new: String?) {
guard old != new else { return }
oldValue = old
animateTop = false
animateBottom = false
withAnimation(Animation.easeIn(duration: 0.2)) { [weak self] in
self?.newValue = new
self?.animateTop = true
}
withAnimation(Animation.easeOut(duration: 0.2).delay(0.2)) { [weak self] in
self?.animateBottom = true
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import SwiftUI
struct SingleFlipView: View {
init(text: String, type: FlipType) {
self.text = text
self.type = type
}
var body: some View {
Text(text)
.font(.system(size: 40))
.fontWeight(.heavy)
.foregroundColor(.white)
.fixedSize()
.padding(type.padding, -20)
.frame(width: 15, height: 20, alignment: type.alignment)
.padding(type.paddingEdges, 10)
.clipped()
.background(Color.black)
.cornerRadius(4)
.padding(type.padding, -4.5)
.clipped()
}
enum FlipType {
case top
case bottom
var padding: Edge.Set {
switch self {
case .top:
return .bottom
case .bottom:
return .top
}
}
var paddingEdges: Edge.Set {
switch self {
case .top:
return [.top, .leading, .trailing]
case .bottom:
return [.bottom, .leading, .trailing]
}
}
var alignment: Alignment {
switch self {
case .top:
return .bottom
case .bottom:
return .top
}
}
}
// MARK: - Private
private let text: String
private let type: FlipType
}
+36 -71
View File
@@ -82,8 +82,8 @@ struct SlideshowView: View {
switch clockStyle {
case .digital:
DigitalClockView(currentTime: currentTime)
case .analog:
AnalogClockView(currentTime: currentTime)
case .flip:
FlipClockView(currentTime: currentTime)
case .minimal:
MinimalClockView(currentTime: currentTime)
}
@@ -180,81 +180,46 @@ struct DigitalClockView: View {
}
}
struct AnalogClockView: View {
struct FlipClockView: 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
}
@StateObject private var viewModel = FlipClockViewModel()
var body: some View {
ZStack {
// Clock face
Circle()
.fill(Color.black.opacity(0.5))
.frame(width: 150, height: 150)
.overlay(
Circle()
.stroke(Color.white, lineWidth: 3)
)
GeometryReader { geometry in
let isPortrait = geometry.size.width < geometry.size.height
let scale: CGFloat = isPortrait ? 1.0 : 0.7
// Hour markers
ForEach(0..<12) { hour in
Rectangle()
.fill(Color.white)
.frame(width: 3, height: hour % 3 == 0 ? 12 : 6)
.offset(y: -65)
.rotationEffect(.degrees(Double(hour) * 30))
VStack {
HStack(spacing: 15) {
// Hours
HStack(spacing: 5) {
FlipView(viewModel: viewModel.flipViewModels[0])
FlipView(viewModel: viewModel.flipViewModels[1])
}
// Minutes
HStack(spacing: 5) {
FlipView(viewModel: viewModel.flipViewModels[2])
FlipView(viewModel: viewModel.flipViewModels[3])
}
// Seconds
HStack(spacing: 5) {
FlipView(viewModel: viewModel.flipViewModels[4])
FlipView(viewModel: viewModel.flipViewModels[5])
}
}
.scaleEffect(scale)
// AM/PM indicator
Text(Calendar.current.component(.hour, from: currentTime) >= 12 ? "PM" : "AM")
.font(.system(size: 24 * scale, weight: .bold, design: .rounded))
.foregroundColor(.white)
.padding(.top, 10 * scale)
}
// Hour hand
Rectangle()
.fill(Color.white)
.frame(width: 3, height: 45)
.cornerRadius(1.5)
.offset(y: -22.5)
.rotationEffect(.degrees(Double(hour) * 360))
// Minute hand
Rectangle()
.fill(Color.white)
.frame(width: 2, height: 60)
.cornerRadius(1)
.offset(y: -30)
.rotationEffect(.degrees(Double(minute) * 360))
// Second hand
Rectangle()
.fill(Color.red)
.frame(width: 1, height: 70)
.cornerRadius(0.5)
.offset(y: -35)
.rotationEffect(.degrees(Double(second) * 360))
// Center cap
Circle()
.fill(Color.white)
.frame(width: 8, height: 8)
.shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 2)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: 160, height: 160)
.shadow(color: .black.opacity(0.5), radius: 5, x: 0, y: 2)
}
}