Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/vectors.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
- name: FlipcashCore vectors
working-directory: FlipcashCoreVectors
run: |
xcodebuild test -scheme FlipcashCoreVectors-Package \
xcodebuild test -scheme FlipcashCoreVectors \
-destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \
-derivedDataPath "$GITHUB_WORKSPACE/.derived/core" \
-quiet
Expand Down
27 changes: 2 additions & 25 deletions Flipcash/Core/Screens/Main/Tips/ChatListChips.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ import SwiftUI
import FlipcashCore
import FlipcashUI

/// The Chats tab's chip filters. The selection lives in the screen's `@State` and is never
/// persisted: every cold start opens on `.all`.
enum ChatListFilter: CaseIterable, Hashable {
case all
case unread
case groups
/// What each chip reads on screen.
extension ChatListFilter {

var title: String {
switch self {
Expand All @@ -22,25 +18,6 @@ enum ChatListFilter: CaseIterable, Hashable {
}
}

func ids<ID>(in projection: ChatListProjection<ID>) -> [ID] {
switch self {
case .all: projection.main
case .unread: projection.unreadChip
case .groups: projection.groupsChip
}
}

/// The number a chip shows: unread chats inside its filter, or nothing when zero. `All` shows
/// none because the tab badge already shows that number.
func badge<ID>(in projection: ChatListProjection<ID>) -> Int? {
let count: Int = switch self {
case .all: 0
case .unread: projection.unreadChipCount
case .groups: projection.groupsChipCount
}
return count > 0 ? count : nil
}

/// The one-line empty state for a filter with no chats. `.all` only reaches this when every
/// chat is archived.
var emptyMessage: String {
Expand Down
30 changes: 9 additions & 21 deletions Flipcash/Core/Screens/Main/Tips/ChatReadActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,34 +61,22 @@ enum ChatReadSource: Equatable {
@Observable
final class ChatSelection {

private(set) var isActive = false
private(set) var selected: Set<ConversationID> = []
/// The rules, shared with ``ChatsModel`` so the Puppet host checks the same ones.
private var mode = ChatListSelectionMode<ConversationID>()

var isActive: Bool { mode.isActive }
var selected: Set<ConversationID> { mode.selected }

/// Enters select mode with nothing selected.
func begin() {
selected = []
isActive = true
}
func begin() { mode.begin() }

/// Leaves select mode.
func end() {
isActive = false
selected = []
}
func end() { mode.end() }

func toggle(_ id: ConversationID) {
if selected.contains(id) {
selected.remove(id)
} else {
selected.insert(id)
}
}
func toggle(_ id: ConversationID) { mode.toggle(id) }

/// Drops picks that are no longer in the list, so an archived or left chat isn't acted on.
func retain(_ ids: Set<ConversationID>) {
let kept = selected.intersection(ids)
if kept != selected { selected = kept }
}
func retain(_ ids: Set<ConversationID>) { mode.retain(ids) }
}

/// What the select-mode read capsule does.
Expand Down
45 changes: 28 additions & 17 deletions Flipcash/Core/Screens/Main/Tips/TipConversationsScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ struct TipConversationsScreen: View {
var showsSearchButton = true

@State private var muteTarget: MuteTarget?
@State private var filter: ChatListFilter = .all
/// The filter, chip reveal and where a tap goes. Shared with the Puppet host, which replays the
/// Chats behavior files against it.
@State private var model = ChatsModel<ConversationID>()
@Environment(ToastController.self) private var toasts
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.floatingTabBar) private var floatingTabBar
/// Set by a pull down that starts at the top of the list; cleared when the screen goes away.
@State private var chipsRevealed = false
@State private var isTabBarOnSide = false
/// The chip row's height: while hidden, the list rests scrolled down by exactly this much.
@State private var chipsHeight: CGFloat = 0
Expand All @@ -48,16 +48,29 @@ struct TipConversationsScreen: View {
conversationController.chatListConversations.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first }
)
return filter.ids(in: projection).compactMap { byID[$0] }
return model.visibleIDs(in: projection).compactMap { byID[$0] }
}

/// A selected filter keeps the chips in view: they explain the list.
private var showsChips: Bool { chipsRevealed || filter != .all }
private var showsChips: Bool { model.showsChips }

private var filter: Binding<ChatListFilter> {
Binding(get: { model.filter }, set: { model.select($0) })
}

/// Pushes the screen ``ChatsModel`` sends the user to.
private func open(_ destination: ChatsDestination<ConversationID>) {
switch destination {
case .chat(let id): router.push(.tipConversation(id))
case .archived: router.push(.archivedChats)
case .newChat: router.push(.newChat)
}
}

var body: some View {
let projection = self.projection
let conversations = rows(projection)
let nothingAtAll = projection.main.isEmpty && !projection.archivedRowVisible
let nothingAtAll = ChatsModel.hasNoChatsAtAll(in: projection)

Background(color: .backgroundMain) {
if nothingAtAll {
Expand All @@ -73,7 +86,7 @@ struct TipConversationsScreen: View {
// finger. The chips use a custom button style, which keeps each chip's tap
// its own inside the row.
FadingChips(fade: chipFade, isShown: showsChips) {
ChatListChips(selection: $filter, projection: projection)
ChatListChips(selection: filter, projection: projection)
}
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: {
chipsHeight = $0
Expand All @@ -83,16 +96,16 @@ struct TipConversationsScreen: View {
chipsHeight: chipsHeight,
isRevealed: showsChips,
fade: chipFade,
reveal: { chipsRevealed = true }
reveal: { model.revealChips() }
)
}
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
// Only under All: the other filters narrow the list, and archived chats aren't in it.
if projection.archivedRowVisible, filter == .all {
if model.showsArchivedRow(in: projection) {
ArchivedRow(count: projection.archivedRowCount) {
router.push(.archivedChats)
open(model.openArchived())
}
.listRowSeparator(.hidden, edges: .top)
.listRowBackground(Color.clear)
Expand All @@ -105,7 +118,7 @@ struct TipConversationsScreen: View {
if isSelecting {
selection.toggle(conversation.id)
} else {
router.push(.tipConversation(conversation.id))
open(model.openChat(conversation.id))
}
}
// Separators divide rows from each other; the first
Expand Down Expand Up @@ -165,7 +178,7 @@ struct TipConversationsScreen: View {
// Over the List rather than a row in it, so it centres in the whole
// screen and the List keeps the pull that reveals the chips.
if conversations.isEmpty {
Text(filter.emptyMessage)
Text(model.filter.emptyMessage)
.font(.appTextMedium)
.foregroundStyle(Color.textSecondary)
.allowsHitTesting(false)
Expand Down Expand Up @@ -312,7 +325,7 @@ struct TipConversationsScreen: View {
}
}
ToolbarItem(placement: .topBarTrailing) {
NewChatButton()
NewChatButton { open(model.openNewChat()) }
}
}

Expand Down Expand Up @@ -401,12 +414,10 @@ private struct MuteTarget: Identifiable {
/// under it.
private struct NewChatButton: View {

@Environment(AppRouter.self) private var router
let action: () -> Void

var body: some View {
Button {
router.push(.newChat)
} label: {
Button(action: action) {
// Bar items get their glass from the system on iOS 26, so this
// carries no button style of its own.
Image.system(.plus)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// ChatListFilter.swift
// FlipcashCore
//

import Foundation

/// The Chats tab's chip filters. Never persisted: every cold start opens on `.all`. The raw value is
/// the name the shared behavior files use.
public enum ChatListFilter: String, CaseIterable, Hashable, Sendable {
case all = "All"
case unread = "Unread"
case groups = "Groups"

/// The chats this filter shows, in the projection's order.
public func ids<ID>(in projection: ChatListProjection<ID>) -> [ID] {
switch self {
case .all: projection.main
case .unread: projection.unreadChip
case .groups: projection.groupsChip
}
}

/// The number a chip shows: unread chats inside its filter, or nothing when zero. `All` shows
/// none because the tab badge already shows that number.
public func badge<ID>(in projection: ChatListProjection<ID>) -> Int? {
let count: Int = switch self {
case .all: 0
case .unread: projection.unreadChipCount
case .groups: projection.groupsChipCount
}
return count > 0 ? count : nil
}
}
Loading
Loading