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
56 changes: 56 additions & 0 deletions Hax/Domain/Models/ReadItems.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// ReadItems.swift
// Hax
//
// Created by Luis Fariña on 22/2/25.
//

import Foundation

@MainActor
@Observable
final class ReadItems {

// MARK: Properties

static let shared = ReadItems()
private let userDefaults: UserDefaults
private let limit: Int
private let key = UserDefaults.Key.readItems
private var itemIdentifiers: [Int]
private var itemIdentifierSet: Set<Int> = []

// MARK: Initialization

init(userDefaults: UserDefaults = .standard, limit: Int = 100) {
self.userDefaults = userDefaults
self.limit = limit
itemIdentifiers = userDefaults
.array(forKey: key) as? [Int] ?? []
itemIdentifierSet = Set(itemIdentifiers)
}

// MARK: Methods

func add(_ itemIdentifier: Int) {
if contains(itemIdentifier),
let index = itemIdentifiers.firstIndex(of: itemIdentifier) {
itemIdentifiers.remove(at: index)
itemIdentifiers.append(itemIdentifier)
} else {
itemIdentifiers.append(itemIdentifier)

if itemIdentifiers.count > limit {
itemIdentifiers = itemIdentifiers.suffix(limit)
}

itemIdentifierSet.insert(itemIdentifier)
}

userDefaults.set(itemIdentifiers, forKey: key)
}

func contains(_ itemIdentifier: Int) -> Bool {
itemIdentifierSet.contains(itemIdentifier)
}
}
1 change: 1 addition & 0 deletions Hax/Extensions/UserDefaultsExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ extension UserDefaults {
enum Key {
static let defaultFeed = "defaultFeed"
static let numberOfLaunches = "numberOfLaunches"
static let readItems = "readItems"
static let reviewHasBeenRequested = "reviewHasBeenRequested"
static let url = "url"
}
Expand Down
1 change: 1 addition & 0 deletions Hax/HaxApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ struct HaxApp: App {
var body: some Scene {
WindowGroup {
MainView(model: mainViewModel)
.environment(ReadItems.shared)
.onContinueUserActivity(
Constant.readItemUserActivity
) { userActivity in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ protocol ItemRowViewModelProtocol {
/// The action to be carried out when tapping a link in the body of the item.
var onLinkTap: OnLinkTap? { get }

/// Whether the item has been read before.
var isRead: Bool { get }

/// Whether the index of the item should be displayed.
var shouldDisplayIndex: Bool { get }

Expand All @@ -83,6 +86,7 @@ struct ItemRowViewModel: ItemRowViewModelProtocol {
let onUserTap: OnUserTap?
let onNumberOfCommentsTap: OnNumberOfCommentsTap?
let onLinkTap: OnLinkTap?
let isRead: Bool

var shouldDisplayIndex: Bool {
view == .feed
Expand Down Expand Up @@ -120,6 +124,7 @@ struct ItemRowViewModel: ItemRowViewModelProtocol {
onUserTap: OnUserTap? = nil,
onNumberOfCommentsTap: OnNumberOfCommentsTap? = nil,
onLinkTap: OnLinkTap? = nil,
isRead: Bool = false,
commentIsHighlighted: Bool = false
) {
self.view = view
Expand All @@ -128,6 +133,7 @@ struct ItemRowViewModel: ItemRowViewModelProtocol {
self.onUserTap = onUserTap
self.onNumberOfCommentsTap = onNumberOfCommentsTap
self.onLinkTap = onLinkTap
self.isRead = isRead
self.commentIsHighlighted = commentIsHighlighted
}
}
6 changes: 5 additions & 1 deletion Hax/Presentation/Components/Views/ItemList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct ItemList: View {
@Binding var selectedItem: Item?
@Binding var translationPopoverIsPresented: Bool
@Binding var textToBeTranslated: String
@Environment(ReadItems.self) private var readItems

// MARK: Body

Expand All @@ -33,6 +34,8 @@ struct ItemList: View {
} else {
selectedItem = item
}

readItems.add(item.id)
} label: {
ItemRowView(
model: ItemRowViewModel(
Expand All @@ -41,7 +44,8 @@ struct ItemList: View {
item: item,
onNumberOfCommentsTap: {
selectedItem = item
}
},
isRead: readItems.contains(item.id)
)
)
}
Expand Down
4 changes: 4 additions & 0 deletions Hax/Presentation/Components/Views/ItemRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ struct ItemRowView<Model: ItemRowViewModelProtocol>: View {
Text(title)
.font(titleFont)
.fontWeight(titleFontWeight)
.foregroundStyle(
model.isRead ? .secondary : .primary
)
}
if model.shouldDisplayBody,
let body = model.item.markdownBody {
Expand Down Expand Up @@ -88,6 +91,7 @@ struct ItemRowView<Model: ItemRowViewModelProtocol>: View {
}
}
}
.opacity(model.isRead ? 0.7 : 1)
.padding(.vertical, 5)
}
}
Expand Down
2 changes: 2 additions & 0 deletions Hax/Presentation/Screens/Views/ItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ struct ItemView<Model: ItemViewModelProtocol>: View {
Task {
await model.onViewAppear()
}

ReadItems.shared.add(model.item.id)
}
.refreshable {
await model.onRefreshRequest()
Expand Down
8 changes: 7 additions & 1 deletion HaxTests/Mocks/UserDefaultsMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ final class UserDefaultsMock: UserDefaults {

// MARK: Properties

private var dictionary: [String: Any] = [:]
private(set) var setCallCount = Int.zero
private(set) var stringCallCount = Int.zero
private var dictionary: [String: Any] = [:]

// MARK: Methods

override func set(_ value: Any?, forKey defaultName: String) {
setCallCount += 1
dictionary[defaultName] = value
}

Expand All @@ -25,4 +27,8 @@ final class UserDefaultsMock: UserDefaults {

return dictionary[defaultName] as? String
}

override func array(forKey defaultName: String) -> [Any]? {
dictionary[defaultName] as? [Any]
}
}
88 changes: 88 additions & 0 deletions HaxTests/Tests/Domain/Models/ReadItemsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
//
// ReadItemsTests.swift
// HaxTests
//
// Created by Luis Fariña on 27/2/25.
//

import Testing
@testable import Hax

@MainActor
struct ReadItemsTests {

// MARK: Properties

private let sut: ReadItems
private let userDefaultsMock: UserDefaultsMock

// MARK: Initialization

init() {
userDefaultsMock = UserDefaultsMock()
userDefaultsMock.set([1, 2], forKey: "readItems")
sut = ReadItems(userDefaults: userDefaultsMock, limit: 3)
}

// MARK: Tests

@Test func add_givenItemIsNotReadAndLimitIsNotExceeded() {
// When
sut.add(3)

// Then
for itemIdentifier in [1, 2, 3] {
#expect(sut.contains(itemIdentifier))
}
#expect(userDefaultsMock.setCallCount == 2)
}

@Test func add_givenItemIsNotReadAndLimitIsExceeded() {
// Given
sut.add(3)

// When
sut.add(4)

// Then
for itemIdentifier in [2, 3, 4] {
#expect(sut.contains(itemIdentifier))
}
#expect(userDefaultsMock.setCallCount == 3)
}

@Test func add_givenItemIsRead() {
// Given
sut.add(3)

// When
sut.add(1)

// Then
for itemIdentifier in [2, 3, 1] {
#expect(sut.contains(itemIdentifier))
}
#expect(userDefaultsMock.setCallCount == 3)
}

@Test func contains_givenItemIsNotRead() {
// When
let contains = sut.contains(3)

// Then
#expect(!contains)
#expect(userDefaultsMock.setCallCount == 1)
}

@Test func contains_givenItemIsRead() {
// Given
sut.add(3)

// When
let contains = sut.contains(3)

// Then
#expect(contains)
#expect(userDefaultsMock.setCallCount == 2)
}
}