Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,124 changes: 1,608 additions & 1,516 deletions Rewind/Localizable.xcstrings

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions Rewind/Model/AppGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ final class AppGraph {
urlOpener: urlOpener,
settings: settings.asVariable(),
appState: Variable { appModelRef?.state },
annotationStore: AnnotationStore()
annotationStore: AnnotationStore(),
sorting: settings.asVariable().map(\.sorting)
)
mapModelRef = mapModel
mapStore = mapModel.viewStore.bimap(
Expand Down Expand Up @@ -105,7 +106,8 @@ final class AppGraph {
performMapAction: { mapModelRef?(.external($0)) },
favoritesModel: favoritesModel,
onboardingViewModel: onboardingViewModel,
currentRegionImages: Variable { mapModelRef?.state.currentRegionImages ?? [] }
currentRegionImages: Variable { mapModelRef?.state.currentRegionImages ?? [] },
sorting: settings.asProperty().bimap(get: \.sorting, modify: { $0.sorting = $1 })
)
appModelRef = appModel
appStore = appModel.viewStore
Expand All @@ -119,5 +121,8 @@ final class AppGraph {
locationModel.$state.currentAndNewValues.addObserver {
mapModelRef?(.external(.newLocationState($0)))
}.dispose(in: disposePool)
settings.asObservableVariable().map(\.sorting).onChange { _ in
mapModelRef?(.internal(.updatePreviews))
}.dispose(in: disposePool)
}
}
12 changes: 8 additions & 4 deletions Rewind/Model/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ func makeAppModel(
performMapAction: @escaping (MapAction.External) -> Void,
favoritesModel: FavoritesModel,
onboardingViewModel: OnboardingViewModel?,
currentRegionImages: Variable<[Model.Image]>
currentRegionImages: Variable<[Model.Image]>,
sorting: Property<ImageSorting>
) -> AppModel {
AppModel(
initial: .makeInitial(
Expand All @@ -108,7 +109,8 @@ func makeAppModel(
matchedTransitionSourceName: source,
images: favoritesModel.state,
listUpdates: favoritesModel.$state.newValues,
imageDetailsFactory: imageDetailsFactory
imageDetailsFactory: imageDetailsFactory,
sorting: nil
).viewStore
)
case let .presentCurrentRegionImages(source):
Expand All @@ -118,7 +120,8 @@ func makeAppModel(
matchedTransitionSourceName: source,
images: currentRegionImages.value,
listUpdates: .empty,
imageDetailsFactory: imageDetailsFactory
imageDetailsFactory: imageDetailsFactory,
sorting: sorting
).viewStore
)
case let .present(images, source, title):
Expand All @@ -128,7 +131,8 @@ func makeAppModel(
matchedTransitionSourceName: source,
images: images,
listUpdates: .empty,
imageDetailsFactory: imageDetailsFactory
imageDetailsFactory: imageDetailsFactory,
sorting: sorting
).viewStore
)
case .dismiss:
Expand Down
6 changes: 6 additions & 0 deletions Rewind/Model/Data Types/ImageDate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,9 @@ struct ImageDate: Equatable, Codable, Hashable {
return "\(year) - \(year2)"
}
}

extension ImageDate: Comparable {
static func <(lhs: ImageDate, rhs: ImageDate) -> Bool {
(lhs.year, lhs.year2) < (rhs.year, rhs.year2)
}
}
13 changes: 11 additions & 2 deletions Rewind/Model/ImageListModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,27 +17,31 @@ struct ImageListState {
var matchedTransitionSourceName: String
var images: [Model.Image]
var imageDetails: Identified<ImageDetailsModel.Store>?
var sorting: ImageSorting?
}

enum ImageListAction {
case presentImage(Model.Image)
case dismissImage
case updateImages([Model.Image])
case setSorting(ImageSorting)
}

func makeImageListModel(
title: LocalizedStringKey,
matchedTransitionSourceName: String,
images: [Model.Image],
listUpdates: Signal<[Model.Image]>,
imageDetailsFactory: @escaping ImageDetailsFactory
imageDetailsFactory: @escaping ImageDetailsFactory,
sorting: Property<ImageSorting>?
) -> ImageListModel {
ImageListModel(
initial: ImageListState(
title: title,
matchedTransitionSourceName: matchedTransitionSourceName,
images: images,
imageDetails: nil
imageDetails: nil,
sorting: sorting?.value
),
reduce: { state, action, _ in
switch action {
Expand All @@ -49,6 +53,11 @@ func makeImageListModel(
state.imageDetails = nil
case let .updateImages(images):
state.images = images
case let .setSorting(newSorting):
guard state.sorting != newSorting else { return }
state.sorting = newSorting
sorting?.value = newSorting
state.images = state.images.sorted(by: newSorting)
Comment on lines +59 to +60
Copy link

Copilot AI Jan 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Picker's selection binding creates a new ImageSorting value on each change, but the guard statement on line 57 checks if the sorting hasn't changed before proceeding. However, since enums are value types and the comparison should work correctly, this is acceptable. Still, consider whether the setter should be called before or after the guard check to ensure proper state synchronization.

Suggested change
sorting?.value = newSorting
state.images = state.images.sorted(by: newSorting)
state.images = state.images.sorted(by: newSorting)
sorting?.value = newSorting

Copilot uses AI. Check for mistakes.
}
}
).adding(
Expand Down
24 changes: 24 additions & 0 deletions Rewind/Model/ImageSorting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// ImageSorting.swift
// Rewind
//
// Created by Aleksei Sherstnev on 10. 1. 2026..
//

import Foundation

enum ImageSorting: CaseIterable, Codable {
case dateAscending
case dateDescending
case shuffle
}

extension [Model.Image] {
func sorted(by sorting: ImageSorting) -> [Model.Image] {
switch sorting {
case .dateAscending: sorted { $0.date < $1.date }
case .dateDescending: sorted { $0.date > $1.date }
case .shuffle: shuffled()
}
}
}
5 changes: 3 additions & 2 deletions Rewind/Model/MapModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ func makeMapModel(
urlOpener: @escaping UrlOpener,
settings: Variable<SettingsState>,
appState: Variable<AppState?>,
annotationStore: AnnotationStore
annotationStore: AnnotationStore,
sorting: Variable<ImageSorting>
) -> MapModel {
MapModel(
initial: .makeInitial(locationState: locationModel.state),
Expand Down Expand Up @@ -257,7 +258,7 @@ func makeMapModel(
return []
}
}
state.currentRegionImages = Array(Set(modelValues))
state.currentRegionImages = Array(Set(modelValues)).sorted(by: sorting.value)
state.previews = makePreviews(images: state.currentRegionImages, limit: 10)
case .unfoldMapControlsBack:
performAppAction(.mapControls(.setMinimization(.normal)))
Expand Down
30 changes: 29 additions & 1 deletion Rewind/Model/SettingsViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,33 @@ typealias SettingsViewModel = Reducer<SettingsState, SettingsViewAction.UI>
struct SettingsState: Codable, Equatable {
var showYearColorInClusters: Bool
var openClusterPreviews: Bool

var sorting: ImageSorting

init(
showYearColorInClusters: Bool,
openClusterPreviews: Bool,
sorting: ImageSorting
) {
self.showYearColorInClusters = showYearColorInClusters
self.openClusterPreviews = openClusterPreviews
self.sorting = sorting
}

enum CodingKeys: String, CodingKey {
case showYearColorInClusters
case openClusterPreviews
case sorting
}

init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.showYearColorInClusters = try container.decode(Bool.self, forKey: .showYearColorInClusters)
self.openClusterPreviews = try container.decode(Bool.self, forKey: .openClusterPreviews)

self.sorting = try container
.decodeIfPresent(ImageSorting.self, forKey: .sorting) ?? .dateAscending
}
}

enum SettingsViewAction {
Expand Down Expand Up @@ -90,6 +117,7 @@ func makeSettingsViewModel(
extension SettingsState {
static let `default` = SettingsState(
showYearColorInClusters: true,
openClusterPreviews: false
openClusterPreviews: false,
sorting: .dateAscending
)
}
69 changes: 61 additions & 8 deletions Rewind/View/ImageList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,7 @@ struct ImageList: View {
NavigationStack {
content
.navigationTitle(viewStore.title)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
ToolbarBackButton()
}
}
.toolbar { toolbar }
.fullScreenCover(
item: viewStore.binding(\.imageDetails, send: { _ in .dismissImage }),
content: { identified in
Expand Down Expand Up @@ -67,6 +63,33 @@ struct ImageList: View {
.matchedTransitionSource(id: image.cid, in: namespace)
}
}

@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) {
ToolbarBackButton()
}
if let sorting = viewStore.sorting {
ToolbarItem(placement: .topBarTrailing) {
Menu(content: {
Picker(
"Sorting",
selection: Binding(
get: { sorting },
set: { viewStore(.setSorting($0)) }
),
content: {
ForEach(ImageSorting.allCases) {
Label($0.title, systemImage: $0.iconName)
}
}
)
}, label: {
Image(systemName: "arrow.up.arrow.down")
})
}
}
}
}

struct ToolbarBackButton: View {
Expand All @@ -85,6 +108,26 @@ struct ToolbarBackButton: View {
}
}

extension ImageSorting: Identifiable {
var id: ImageSorting { self }

fileprivate var title: LocalizedStringKey {
switch self {
case .dateAscending: "Date Ascending"
case .dateDescending: "Date Descending"
case .shuffle: "Shuffle"
}
}

fileprivate var iconName: String {
switch self {
case .dateAscending: "arrow.up"
case .dateDescending: "arrow.down"
case .shuffle: "shuffle"
}
}
}

#if DEBUG
private let imageDetailsFactoryMock: ImageDetailsFactory = { _, source in
makeImageDetailsModel(
Expand All @@ -106,10 +149,19 @@ private let imageDetailsFactoryMock: ImageDetailsFactory = { _, source in
title: "Images",
matchedTransitionSourceName: "",
images: (0..<10).map { idx in
modified(.mock) { $0.cid = idx }
modified(.mock) {
let year = Int.random(in: 1826...1995)
let year2 = year + Int.random(in: 0...5)
$0.date = ImageDate(
year: year,
year2: year2
)
$0.cid = idx
}
},
listUpdates: .empty,
imageDetailsFactory: imageDetailsFactoryMock
imageDetailsFactory: imageDetailsFactoryMock,
sorting: .constant(.dateAscending)
).viewStore

ImageList(viewStore: store)
Expand All @@ -122,7 +174,8 @@ private let imageDetailsFactoryMock: ImageDetailsFactory = { _, source in
matchedTransitionSourceName: "",
images: [],
listUpdates: .empty,
imageDetailsFactory: imageDetailsFactoryMock
imageDetailsFactory: imageDetailsFactoryMock,
sorting: .constant(.dateAscending)
).viewStore

ImageList(viewStore: store)
Expand Down
Loading