Release 1.4.0 (build 31): retención, quick update, streak, what's new
- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes) - 1B: Badge de racha mensual en Dashboard (streak counter) - 1C: Empty states mejorados en Goals y Journal con CTAs claros - 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla - 2B: Notificación batch update redirige al Quick Update sheet - 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update - 3A: App Store version check banner en Settings - 3C: What's New sheet en primer launch de versión nueva - Localización completa en 7 idiomas para todas las nuevas strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,49 +7,171 @@ struct JournalView: View {
|
||||
@State private var scrubberLabel = ""
|
||||
@State private var scrubberOffset: CGFloat = 0
|
||||
@State private var currentVisibleMonth: Date?
|
||||
@State private var selectedDate: Date?
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
AppBackground()
|
||||
if horizontalSizeClass == .regular {
|
||||
iPadJournalLayout
|
||||
} else {
|
||||
iPhoneJournalLayout
|
||||
}
|
||||
}
|
||||
|
||||
List {
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
Text(searchText.isEmpty ? "No monthly notes yet." : "No matching notes.")
|
||||
// MARK: - iPad Layout (list + inline detail)
|
||||
|
||||
private var iPadJournalLayout: some View {
|
||||
HStack(spacing: 0) {
|
||||
// Left: month list
|
||||
NavigationStack {
|
||||
journalListContent(isPad: true)
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear { viewModel.refresh() }
|
||||
}
|
||||
.frame(width: 320)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right: monthly check-in detail
|
||||
NavigationStack {
|
||||
if let date = selectedDate {
|
||||
MonthlyCheckInView(referenceDate: date)
|
||||
} else {
|
||||
noMonthSelectedView
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - iPhone Layout (push navigation)
|
||||
|
||||
private var iPhoneJournalLayout: some View {
|
||||
NavigationStack {
|
||||
journalListContent(isPad: false)
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear { viewModel.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shared List Content
|
||||
|
||||
private func journalListContent(isPad: Bool) -> some View {
|
||||
ScrollViewReader { proxy in
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
List {
|
||||
Section("Monthly Check-ins") {
|
||||
if filteredMonthlyNotes.isEmpty {
|
||||
if searchText.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "book.closed")
|
||||
.font(.system(size: 36))
|
||||
.foregroundColor(.appSecondary)
|
||||
Text(String(localized: "journal_empty_title"))
|
||||
.font(.headline)
|
||||
Text(String(localized: "journal_empty_body"))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding(.vertical, 16)
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("No matching notes.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
ForEach(filteredMonthlyNotes) { entry in
|
||||
}
|
||||
} else {
|
||||
ForEach(filteredMonthlyNotes) { entry in
|
||||
if isPad {
|
||||
Button {
|
||||
selectedDate = entry.date
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowBackground(
|
||||
selectedDate.map { $0.isSameMonth(as: entry.date) } == true
|
||||
? Color.appPrimary.opacity(0.08)
|
||||
: Color.clear
|
||||
)
|
||||
.id(entry.date)
|
||||
.onAppear { currentVisibleMonth = entry.date }
|
||||
} else {
|
||||
NavigationLink {
|
||||
MonthlyCheckInView(referenceDate: entry.date)
|
||||
} label: {
|
||||
monthlyNoteRow(entry)
|
||||
}
|
||||
.id(entry.date)
|
||||
.onAppear {
|
||||
currentVisibleMonth = entry.date
|
||||
}
|
||||
.onAppear { currentVisibleMonth = entry.date }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
monthScrubber(proxy: proxy)
|
||||
}
|
||||
.navigationTitle("Journal")
|
||||
.searchable(text: $searchText, prompt: "Search monthly notes")
|
||||
.onAppear {
|
||||
viewModel.refresh()
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
}
|
||||
.overlay(alignment: .trailing) {
|
||||
monthScrubber(proxy: proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var noMonthSelectedView: some View {
|
||||
ZStack {
|
||||
AppBackground()
|
||||
|
||||
VStack(spacing: 32) {
|
||||
// Icon badge con gradiente
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(LinearGradient(
|
||||
colors: [Color.appSecondary, Color.appSecondary.lighter()],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
))
|
||||
.frame(width: 120, height: 120)
|
||||
.shadow(color: Color.appSecondary.opacity(0.25), radius: 28, y: 10)
|
||||
|
||||
Circle()
|
||||
.fill(Color.white.opacity(0.12))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "book.closed.fill")
|
||||
.font(.system(size: 50, weight: .light))
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
VStack(spacing: 10) {
|
||||
Text("Select a Month")
|
||||
.font(.title2.weight(.semibold))
|
||||
|
||||
Text("Choose a month from the list on the\nleft to view your check-in notes.")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
Label("Select from the list", systemImage: "arrow.left")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundColor(.appSecondary)
|
||||
.padding(.horizontal, 22)
|
||||
.padding(.vertical, 11)
|
||||
.background(Color.appSecondary.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
.overlay(Capsule().stroke(Color.appSecondary.opacity(0.2), lineWidth: 1))
|
||||
}
|
||||
.padding(48)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var filteredMonthlyNotes: [MonthlyNoteItem] {
|
||||
let trimmedQuery = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let hasQuery = !trimmedQuery.isEmpty
|
||||
|
||||
Reference in New Issue
Block a user