diff --git a/ProductivityApp.xcodeproj/project.pbxproj b/ProductivityApp.xcodeproj/project.pbxproj index 3ab29f5..b5d24b2 100644 --- a/ProductivityApp.xcodeproj/project.pbxproj +++ b/ProductivityApp.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 36A3171C2F5FB97C00070F06 /* LRStreakKit in Frameworks */ = {isa = PBXBuildFile; productRef = 36A3171B2F5FB97C00070F06 /* LRStreakKit */; }; 84F314692F286D2000D83064 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 84F314682F286D2000D83064 /* GoogleSignIn */; }; 84F3146B2F286D2000D83064 /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 84F3146A2F286D2000D83064 /* GoogleSignInSwift */; }; 84FEE9EB2F3AA45600DBB49F /* AuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F314602F286C6800D83064 /* AuthenticationManager.swift */; }; @@ -21,7 +22,6 @@ 84FEEA122F3AE63100DBB49F /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEEA112F3AE63100DBB49F /* DashboardView.swift */; }; 84FEEA142F3AE63300DBB49F /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEEA132F3AE63300DBB49F /* ShareView.swift */; }; 84FEEA262F460D3900DBB49F /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEEA252F460D3900DBB49F /* SettingsView.swift */; }; - 84FEEA322F460E8D00DBB49F /* DashboardView 2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEEA312F460E8D00DBB49F /* DashboardView 2.swift */; }; 84FEEA3C2F46110900DBB49F /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEEA3B2F46110900DBB49F /* RootView.swift */; }; 84FEEA3E2F46114200DBB49F /* EmailSignInView 2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEEA3D2F46114200DBB49F /* EmailSignInView 2.swift */; }; 84FEEA402F46114500DBB49F /* EmailSignUpView 2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FEEA3F2F46114500DBB49F /* EmailSignUpView 2.swift */; }; @@ -42,7 +42,6 @@ 84FEEA112F3AE63100DBB49F /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 84FEEA132F3AE63300DBB49F /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = ""; }; 84FEEA252F460D3900DBB49F /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; - 84FEEA312F460E8D00DBB49F /* DashboardView 2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DashboardView 2.swift"; sourceTree = ""; }; 84FEEA3B2F46110900DBB49F /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 84FEEA3D2F46114200DBB49F /* EmailSignInView 2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmailSignInView 2.swift"; sourceTree = ""; }; 84FEEA3F2F46114500DBB49F /* EmailSignUpView 2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EmailSignUpView 2.swift"; sourceTree = ""; }; @@ -77,6 +76,7 @@ files = ( 84F314692F286D2000D83064 /* GoogleSignIn in Frameworks */, 84F3146B2F286D2000D83064 /* GoogleSignInSwift in Frameworks */, + 36A3171C2F5FB97C00070F06 /* LRStreakKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -131,7 +131,6 @@ 84FEEA112F3AE63100DBB49F /* DashboardView.swift */, 84FEEA132F3AE63300DBB49F /* ShareView.swift */, 84FEEA252F460D3900DBB49F /* SettingsView.swift */, - 84FEEA312F460E8D00DBB49F /* DashboardView 2.swift */, 84FEEA3B2F46110900DBB49F /* RootView.swift */, 84FEEA3D2F46114200DBB49F /* EmailSignInView 2.swift */, 84FEEA3F2F46114500DBB49F /* EmailSignUpView 2.swift */, @@ -162,6 +161,7 @@ packageProductDependencies = ( 84F314682F286D2000D83064 /* GoogleSignIn */, 84F3146A2F286D2000D83064 /* GoogleSignInSwift */, + 36A3171B2F5FB97C00070F06 /* LRStreakKit */, ); productName = ProductivityApp; productReference = 364323802F21D77600D6F8A5 /* ProductivityApp.app */; @@ -193,6 +193,7 @@ minimizedProjectReferenceProxies = 1; packageReferences = ( 84F314672F286D2000D83064 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, + 36A3171A2F5FB97C00070F06 /* XCRemoteSwiftPackageReference "LRStreakKit" */, ); preferredProjectObjectVersion = 77; productRefGroup = 364323812F21D77600D6F8A5 /* Products */; @@ -223,7 +224,6 @@ 84FEEA122F3AE63100DBB49F /* DashboardView.swift in Sources */, 84FEE9EC2F3AA46C00DBB49F /* User.swift in Sources */, 84FEE9EB2F3AA45600DBB49F /* AuthenticationManager.swift in Sources */, - 84FEEA322F460E8D00DBB49F /* DashboardView 2.swift in Sources */, 84FEE9EE2F3AA47D00DBB49F /* EmailSignInView.swift in Sources */, 84FEE9F02F3AA48600DBB49F /* ProfileView.swift in Sources */, 84FEEA4A2F46134400DBB49F /* ShareManager.swift in Sources */, @@ -449,6 +449,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 36A3171A2F5FB97C00070F06 /* XCRemoteSwiftPackageReference "LRStreakKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/lukerobertsapps/LRStreakKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.1.0; + }; + }; 84F314672F286D2000D83064 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; @@ -460,6 +468,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 36A3171B2F5FB97C00070F06 /* LRStreakKit */ = { + isa = XCSwiftPackageProductDependency; + package = 36A3171A2F5FB97C00070F06 /* XCRemoteSwiftPackageReference "LRStreakKit" */; + productName = LRStreakKit; + }; 84F314682F286D2000D83064 /* GoogleSignIn */ = { isa = XCSwiftPackageProductDependency; package = 84F314672F286D2000D83064 /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; diff --git a/ProductivityApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ProductivityApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 160c1d4..577f451 100644 --- a/ProductivityApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ProductivityApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "68aa00e3cba36db2e0a76f54dbc84d1f079d8aa9a19bfa9fce02d6286bd620b7", + "originHash" : "7288fb69bd6a9f80d0cbed7076bcc4a77e077186bc8d808f0ad394ebdbadad8d", "pins" : [ { "identity" : "app-check", @@ -55,6 +55,15 @@ "version" : "5.0.0" } }, + { + "identity" : "lrstreakkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/lukerobertsapps/LRStreakKit", + "state" : { + "revision" : "e7a4e176932f1de1d0932a1ed2489862543cb401", + "version" : "1.1.0" + } + }, { "identity" : "promises", "kind" : "remoteSourceControl", diff --git a/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/alexanderbuki.xcuserdatad/UserInterfaceState.xcuserstate b/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/alexanderbuki.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..0aec51f Binary files /dev/null and b/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/alexanderbuki.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/avakaplin.xcuserdatad/UserInterfaceState.xcuserstate b/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/avakaplin.xcuserdatad/UserInterfaceState.xcuserstate index 965cb8f..f2f5b64 100644 Binary files a/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/avakaplin.xcuserdatad/UserInterfaceState.xcuserstate and b/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/avakaplin.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/penglin.zhong.xcuserdatad/UserInterfaceState.xcuserstate b/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/penglin.zhong.xcuserdatad/UserInterfaceState.xcuserstate index 8683e14..cbd0ffe 100644 Binary files a/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/penglin.zhong.xcuserdatad/UserInterfaceState.xcuserstate and b/ProductivityApp.xcodeproj/project.xcworkspace/xcuserdata/penglin.zhong.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/ProductivityApp.xcodeproj/xcuserdata/alexanderbuki.xcuserdatad/xcschemes/xcschememanagement.plist b/ProductivityApp.xcodeproj/xcuserdata/alexanderbuki.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..1299e74 --- /dev/null +++ b/ProductivityApp.xcodeproj/xcuserdata/alexanderbuki.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + ProductivityApp.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/ProductivityApp/ContentView.swift b/ProductivityApp/ContentView.swift index 08daa10..0874356 100644 --- a/ProductivityApp/ContentView.swift +++ b/ProductivityApp/ContentView.swift @@ -1,10 +1,3 @@ -// -// ContentView.swift -// ProductivityApp -// -// Created by Ava Kaplin on 1/21/26. -// - import SwiftUI struct ContentView: View { @@ -13,14 +6,13 @@ struct ContentView: View { var body: some View { Group { - // 2. 判斷目前是否有使用者登入 + // 判斷目前是否有使用者登入 if authManager.currentUser != nil { - // 已登入:前往主畫面 ProfileView() } else { - // 未登入:前往登入畫面 AuthenticationView() } } } } + diff --git a/ProductivityApp/HomePage/DeadlineView.swift b/ProductivityApp/HomePage/DeadlineView.swift new file mode 100644 index 0000000..6d335d8 --- /dev/null +++ b/ProductivityApp/HomePage/DeadlineView.swift @@ -0,0 +1,227 @@ +// +// DeadlineView.swift +// ProductivityApp +// +// + +import SwiftUI + +private struct DeadlineItem: Identifiable { + let id = UUID() + let title: String + let dueDate: Date? + let color: Color +} + +struct DeadlineView: View { + @State private var isExpanded = false + @State private var newTitle = "" + @State private var newDue = "" + @State private var deadlines: [DeadlineItem] = [ + DeadlineItem(title: "Math Homework", dueDate: Calendar.current.date(byAdding: .day, value: 1, to: Date()), color: .orange), + DeadlineItem(title: "History Essay", dueDate: Self.nextWeekday(6), color: .pink), // Friday + DeadlineItem(title: "Chemistry Quiz", dueDate: Self.nextWeekday(2), color: .mint) // Monday + ] + + private var sortedDeadlines: [DeadlineItem] { + deadlines.sorted { lhs, rhs in + switch (lhs.dueDate, rhs.dueDate) { + case let (.some(l), .some(r)): + return l < r + case (.some, .none): + return true + case (.none, .some): + return false + case (.none, .none): + return lhs.title.localizedCaseInsensitiveCompare(rhs.title) == .orderedAscending + } + } + } + + private var visibleDeadlines: [DeadlineItem] { + isExpanded ? sortedDeadlines : Array(sortedDeadlines.prefix(2)) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Upcoming Deadlines") + .font(.title3.weight(.semibold)) + + Spacer() + + Button { + withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) { + isExpanded.toggle() + } + } label: { + Image(systemName: isExpanded ? "xmark" : "plus") + .font(.system(size: 16, weight: .semibold)) + .frame(width: 36, height: 36) + .background(Color(.systemTeal)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + } + + ForEach(visibleDeadlines) { item in + HStack(spacing: 12) { + Circle() + .fill(item.color) + .frame(width: 10, height: 10) + + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.system(size: 16, weight: .semibold)) + Text(Self.dueText(for: item.dueDate)) + .font(.footnote) + .foregroundStyle(Color(.secondaryLabel)) + } + + Spacer() + + Button { + deadlines.removeAll { $0.id == item.id } + } label: { + Image(systemName: "trash") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Color(.secondaryLabel)) + } + .buttonStyle(.plain) + } + .padding(.vertical, 11) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(red: 0.97, green: 0.95, blue: 0.90)) + ) + .shadow(color: .clear, radius: 0) + } + + if isExpanded { + VStack(spacing: 10) { + TextField("Task name", text: $newTitle) + .textFieldStyle(.roundedBorder) + + TextField("Due date", text: $newDue) + .textFieldStyle(.roundedBorder) + + Button { + let title = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) + let due = newDue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { return } + deadlines.insert( + DeadlineItem( + title: title, + dueDate: Self.parseDueDate(from: due), + color: .blue + ) + , at: 0 + ) + newTitle = "" + newDue = "" + } label: { + Text("Add Deadline") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 11) + .background(Color(.systemTeal)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .padding(18) + .background( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(Color(red: 1.00, green: 0.99, blue: 0.96)) + ) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(Color(.separator).opacity(0.5), lineWidth: 1) + ) + } + + private static func dueText(for dueDate: Date?) -> String { + guard let dueDate else { return "Due Soon" } + let cal = Calendar.current + if cal.isDateInToday(dueDate) { return "Due Today" } + if cal.isDateInTomorrow(dueDate) { return "Due Tomorrow" } + + let startToday = cal.startOfDay(for: Date()) + let startDue = cal.startOfDay(for: dueDate) + if let days = cal.dateComponents([.day], from: startToday, to: startDue).day, + days >= 2, days <= 6 { + let weekday = DateFormatter() + weekday.dateFormat = "EEEE" + return "Due \(weekday.string(from: dueDate))" + } + + let formatter = DateFormatter() + formatter.dateStyle = .medium + return "Due \(formatter.string(from: dueDate))" + } + + private static func parseDueDate(from input: String) -> Date? { + let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + + let lower = trimmed.lowercased() + let cal = Calendar.current + let now = Date() + + if lower == "today" { return now } + if lower == "tomorrow" { return cal.date(byAdding: .day, value: 1, to: now) } + + let weekdayMap: [String: Int] = [ + "sunday": 1, "monday": 2, "tuesday": 3, "wednesday": 4, + "thursday": 5, "friday": 6, "saturday": 7 + ] + if let weekday = weekdayMap[lower] { + return nextWeekday(weekday) + } + + let formats = ["M/d/yyyy", "M/d/yy", "M/d", "MM/dd/yyyy", "MM/dd/yy", "MM/dd"] + for format in formats { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = format + if let parsed = formatter.date(from: trimmed) { + if format == "M/d" || format == "MM/dd" { + let comps = cal.dateComponents([.month, .day], from: parsed) + var candidate = cal.date(from: DateComponents( + year: cal.component(.year, from: now), + month: comps.month, + day: comps.day + )) + if let candidateDate = candidate, candidateDate < cal.startOfDay(for: now) { + candidate = cal.date(byAdding: .year, value: 1, to: candidateDate) + } + return candidate + } + return parsed + } + } + + return nil + } + + private static func nextWeekday(_ weekday: Int) -> Date? { + let cal = Calendar.current + let today = Date() + return cal.nextDate( + after: today, + matching: DateComponents(weekday: weekday), + matchingPolicy: .nextTimePreservingSmallerComponents + ) + } +} + + +#Preview { + DeadlineView() +} diff --git a/ProductivityApp/HomePage/HomeView.swift b/ProductivityApp/HomePage/HomeView.swift new file mode 100644 index 0000000..f520e67 --- /dev/null +++ b/ProductivityApp/HomePage/HomeView.swift @@ -0,0 +1,84 @@ +// +// HomeView.swift +// ProductivityApp +// +// + +import SwiftUI + +struct HomeView: View { + @StateObject private var store = TaskStore() + + var body: some View { + ZStack { + Color(red: 0.98, green: 0.96, blue: 0.92).ignoresSafeArea() + + ScrollView { + VStack(spacing: 18) { + HomeHeaderView() + .padding(.horizontal) + + StreakView(store: store) + .padding(.horizontal) + + TaskCardView(store: store) + .padding(.horizontal) + + DeadlineView() + .padding(.horizontal) + } + .padding(.vertical, 16) + } + } + } +} + +private struct HomeHeaderView: View { + var body: some View { + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.white.opacity(0.24)) + .frame(width: 44, height: 44) + Image(systemName: "teddybear.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white) + } + + VStack(alignment: .leading, spacing: 2) { + Text("APP NAME HERE") + .font(.title3.weight(.black)) + .foregroundStyle(.white) + Text("possible subtext") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white.opacity(0.9)) + } + + Spacer() + } + .padding(.horizontal, 18) + .padding(.vertical, 15) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill( + LinearGradient( + colors: [ + Color(red: 0.73, green: 0.49, blue: 0.33), + Color(red: 0.58, green: 0.34, blue: 0.24) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(Color.white.opacity(0.18), lineWidth: 1) + ) + } +} + +#Preview { + HomeView() +} + diff --git a/ProductivityApp/HomePage/StreakView.swift b/ProductivityApp/HomePage/StreakView.swift new file mode 100644 index 0000000..ff4c0c9 --- /dev/null +++ b/ProductivityApp/HomePage/StreakView.swift @@ -0,0 +1,112 @@ +// +// StreakView.swift +// ProductivityApp +// +// + +import SwiftUI + +struct StreakView: View { + @ObservedObject var store: TaskStore + private let streakText = Color(red: 0.38, green: 0.20, blue: 0.08) + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + Text("Current Streak") + .font(.subheadline.weight(.semibold)) + .foregroundColor(streakText) + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("\(store.currentStreak)") + .font(.system(size: 40, weight: .semibold)) + Text("days") + .font(.headline) + } + .foregroundColor(streakText) + } + + Spacer() + + ZStack { + Circle() + .fill(Color.white.opacity(0.5)) + .frame(width: 54, height: 54) + Image(systemName: "flame.fill") + .font(.system(size: 24)) + .foregroundColor(streakText) + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Today's Progress") + .fontWeight(.medium) + Spacer() + Text("\(store.completedCount)/\(store.totalCount) tasks") + .fontWeight(.bold) + } + .foregroundColor(streakText) + + // Custom Progress Bar + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule() + .fill(streakText.opacity(0.2)) + Capsule() + .fill(streakText) + .frame(width: geo.size.width * progressFraction) + } + } + .frame(height: 8) + + //Text("Complete 2 more tasks to maintain your streak") + Text(statusMessage) + .font(.footnote) + .foregroundColor(streakText) + } + + Divider() + .background(streakText.opacity(0.25)) + + HStack { + Image(systemName: "chart.line.uptrend.xyaxis") + //Text("Longest streak: **12 days**") + Text("Longest streak: **\(store.longestStreak) days**") + } + .font(.footnote.weight(.semibold)) + .foregroundColor(streakText) + } + .padding(16) + .background( + LinearGradient( + gradient: Gradient(colors: [Color.orange.opacity(0.6), Color.yellow]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .cornerRadius(24) + } + private var progressFraction: CGFloat { + guard store.totalCount > 0 else { return 0 } + return CGFloat(store.completedCount) / CGFloat(store.totalCount) + } + + private var statusMessage: String { + if store.streakCountedToday { + return "You completed a task today. Your streak counts for today." + } + if store.currentStreak > 0 { + return "Complete one task today to keep your \(store.currentStreak)-day streak alive." + } + return "Complete a task today to start a new daily streak." + } +} + + + +#Preview { + //StreakView() + StreakView(store: TaskStore()) +} diff --git a/ProductivityApp/HomePage/TaskCardView.swift b/ProductivityApp/HomePage/TaskCardView.swift new file mode 100644 index 0000000..086dad6 --- /dev/null +++ b/ProductivityApp/HomePage/TaskCardView.swift @@ -0,0 +1,210 @@ +// +// TaskCardView.swift +// ProductivityApp +// +// + +import SwiftUI + +struct TaskCardView: View { + @ObservedObject var store: TaskStore + + @State private var newTitle: String = "" + @State private var selectedPriority: TaskPriority = .medium + + var body: some View { + VStack(spacing: 14) { + header + addRow + priorityPicker + taskList + } + + + .padding(20) + // makes card start small and grow with tasks + .frame(minHeight: 200) // starting size + .frame(maxHeight: 500) // prevents taking whole screen + + .background( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .fill(Color(red: 1.00, green: 0.99, blue: 0.96)) + ) + .overlay( + RoundedRectangle(cornerRadius: 28, style: .continuous) + .stroke(Color(.separator).opacity(0.6), lineWidth: 1) + ) + + } + + private var header: some View { + HStack { + Text("Today's Tasks") + .font(.title3.weight(.semibold)) + + Spacer() + + HStack(spacing: 10) { + Text("\(store.completedCount)/\(store.totalCount)") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.white.opacity(0.95)) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Capsule().fill(Color(.systemTeal))) + } + } + + private var addRow: some View { + HStack(spacing: 12) { + TextField("Add a new task...", text: $newTitle) + .padding(.vertical, 12) + .padding(.horizontal, 14) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(red: 0.95, green: 0.92, blue: 0.86)) + ) + + Button { + store.addTask(title: newTitle, priority: selectedPriority) + newTitle = "" + } label: { + Image(systemName: "plus") + .font(.system(size: 18, weight: .semibold)) + .frame(width: 46, height: 46) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(Color(.systemTeal)) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + } + } + + private var priorityPicker: some View { + HStack(spacing: 10) { + ForEach(TaskPriority.allCases, id: \.self) { p in + Button { + selectedPriority = p + } label: { + Text(p.label) + .font(.footnote.weight(.semibold)) + .foregroundStyle(selectedPriority == p ? Color(.systemBackground) : Color(.label)) + .padding(.vertical, 7) + .padding(.horizontal, 14) + .background( + Capsule().fill(selectedPriority == p ? priorityPickColor(p) : Color(.secondarySystemGroupedBackground)) + ) + } + .buttonStyle(.plain) + } + Spacer() + } + } + + private var taskList: some View { + ScrollView { + VStack(spacing: 10) { + + // Uncompleted + ForEach(store.activeTasks) { task in + TaskRow( + task: task, + onToggle: { store.toggle(task) }, + onDelete: { store.delete(task) } + ) + } + + if !store.completedTasks.isEmpty { + Divider().padding(.vertical, 6) + + Text("Completed") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color(.secondaryLabel)) + .frame(maxWidth: .infinity, alignment: .leading) + + ForEach(store.completedTasks) { task in + TaskRow( + task: task, + onToggle: { store.toggle(task) }, + onDelete: { store.delete(task) } + ) + } + } + } + .padding(.top, 4) + } + .frame(maxHeight: 340) + } + + private func priorityPickColor(_ p: TaskPriority) -> Color { + switch p { + case .low: return Color(.systemGreen) + case .medium: return Color(.systemYellow) + case .high: return Color(.systemRed) + } + } +} + +// one task row card +private struct TaskRow: View { + let task: TaskItem + let onToggle: () -> Void + let onDelete: () -> Void + + var body: some View { + HStack(spacing: 14) { + Button(action: onToggle) { + Image(systemName: task.isCompleted ? "checkmark.square.fill" : "square") + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(task.isCompleted ? Color(.systemTeal) : Color(.tertiaryLabel)) + } + .buttonStyle(.plain) + + Text(task.title) + .font(.system(size: 18, weight: .semibold)) + .strikethrough(task.isCompleted, color: Color(.secondaryLabel)) + .opacity(task.isCompleted ? 0.45 : 1.0) + + Spacer() + + Text(task.priority.rawValue) + .font(.footnote.weight(.bold)) + .foregroundStyle(.white) + .padding(.vertical, 6) + .padding(.horizontal, 11) + .background(priorityBadgeColor) + .clipShape(Capsule()) + + // Trash on every task (completed + uncompleted) + Button(action: onDelete) { + Image(systemName: "trash") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(Color(.secondaryLabel)) + } + .buttonStyle(.plain) + } + .padding(.vertical, 12) + .padding(.horizontal, 12) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(Color(red: 0.97, green: 0.95, blue: 0.90)) + ) + .shadow(color: .clear, radius: 0) + } + + private var priorityBadgeColor: Color { + switch task.priority { + case .low: return Color(.systemGreen) + case .medium: return Color(.systemYellow) + case .high: return Color(.systemRed) + } + } +} + + + diff --git a/ProductivityApp/HomePage/TaskItem.swift b/ProductivityApp/HomePage/TaskItem.swift new file mode 100644 index 0000000..087603e --- /dev/null +++ b/ProductivityApp/HomePage/TaskItem.swift @@ -0,0 +1,29 @@ +// +// TaskItem.swift +// ProductivityApp +// +// + +import Foundation + +enum TaskPriority: String, CaseIterable, Codable { + case low + case medium + case high + + var label: String { rawValue.capitalized } +} + +struct TaskItem: Identifiable, Codable, Equatable { + let id: UUID + var title: String + var priority: TaskPriority + var isCompleted: Bool + + init(id: UUID = UUID(), title: String, priority: TaskPriority, isCompleted: Bool = false) { + self.id = id + self.title = title + self.priority = priority + self.isCompleted = isCompleted + } +} diff --git a/ProductivityApp/HomePage/TaskStore.swift b/ProductivityApp/HomePage/TaskStore.swift new file mode 100644 index 0000000..e921cf6 --- /dev/null +++ b/ProductivityApp/HomePage/TaskStore.swift @@ -0,0 +1,182 @@ +// +// TaskStore.swift +// ProductivityApp +// +// + +import Foundation +import Combine +import SwiftUI + +@MainActor +final class TaskStore: ObservableObject { + @Published private(set) var tasks: [TaskItem] = [] + + @AppStorage("tasks_last_active_day") private var lastActiveDayISO: String = "" + @AppStorage("tasks_last_completion_day") private var lastCompletionDayISO: String = "" + @AppStorage("tasks_current_streak") private var storedCurrentStreak: Int = 0 + @AppStorage("tasks_longest_streak") private var storedLongestStreak: Int = 0 + + private let fileURL: URL = { + let dir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + return dir.appendingPathComponent("tasks_today.json") + }() + + init() { + enforceDailyResetIfNeeded() + load() + startMidnightWatcher() + } + + // MARK: - Derived lists + var completedCount: Int { tasks.filter { $0.isCompleted }.count } + var totalCount: Int { tasks.count } + var completedToday: Bool { completedCount > 0 } + var streakCountedToday: Bool { streakStatus == .completedToday } + var currentStreak: Int { + switch streakStatus { + case .completedToday, .completedYesterday: + return storedCurrentStreak + case .inactive: + return 0 + } + } + var longestStreak: Int { storedLongestStreak } + + var activeTasks: [TaskItem] { + tasks.filter { !$0.isCompleted } + } + + var completedTasks: [TaskItem] { + tasks.filter { $0.isCompleted } + } + + // adding tasks + func addTask(title: String, priority: TaskPriority) { + let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + tasks.append(TaskItem(title: trimmed, priority: priority)) + save() + } + + func toggle(_ task: TaskItem) { + guard let idx = tasks.firstIndex(where: { $0.id == task.id }) else { return } + tasks[idx].isCompleted.toggle() + recordTodayCompletionIfNeeded() + save() + } + + func delete(_ task: TaskItem) { + tasks.removeAll { $0.id == task.id } + save() + } + + // MARK: - Daily reset + private func enforceDailyResetIfNeeded() { + let todayISO = Self.dayStampISO(Date()) + if lastActiveDayISO.isEmpty { + lastActiveDayISO = todayISO + return + } + if lastActiveDayISO != todayISO { + tasks = [] // tasks disappear on new day + save() + lastActiveDayISO = todayISO + } + } + + private static func dayStampISO(_ date: Date) -> String { + let cal = Calendar.current + let comps = cal.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", comps.year ?? 0, comps.month ?? 0, comps.day ?? 0) + } + + private func startMidnightWatcher() { + let cal = Calendar.current + let now = Date() + + guard let nextMidnight = cal.nextDate( + after: now, + matching: DateComponents(hour: 0, minute: 0, second: 1), + matchingPolicy: .nextTime + ) else { return } + + let interval = nextMidnight.timeIntervalSince(now) + Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in + Task { @MainActor in + self?.enforceDailyResetIfNeeded() + self?.startMidnightWatcher() + } + } + } + + + private func load() { + do { + let data = try Data(contentsOf: fileURL) + tasks = try JSONDecoder().decode([TaskItem].self, from: data) + } catch { + // no saved file yet (first run) + } + } + + private func save() { + do { + let data = try JSONEncoder().encode(tasks) + try data.write(to: fileURL, options: [.atomic]) + } catch { + // ignore for now + } + } + + private func recordTodayCompletionIfNeeded() { + guard completedToday else { return } + + let todayISO = Self.dayStampISO(Date()) + guard lastCompletionDayISO != todayISO else { return } + + if let lastDate = Self.date(fromISO: lastCompletionDayISO), + Calendar.current.isDate(lastDate, inSameDayAs: Self.yesterday()) { + storedCurrentStreak += 1 + } else { + storedCurrentStreak = 1 + } + + storedLongestStreak = max(storedLongestStreak, storedCurrentStreak) + lastCompletionDayISO = todayISO + objectWillChange.send() + } + + private var streakStatus: StreakStatus { + guard let lastDate = Self.date(fromISO: lastCompletionDayISO) else { + return .inactive + } + let calendar = Calendar.current + if calendar.isDateInToday(lastDate) { + return .completedToday + } + if calendar.isDateInYesterday(lastDate) { + return .completedYesterday + } + return .inactive + } + + private enum StreakStatus { + case completedToday + case completedYesterday + case inactive + } + + private static func yesterday() -> Date { + Calendar.current.date(byAdding: .day, value: -1, to: Date()) ?? Date() + } + + private static func date(fromISO value: String) -> Date? { + guard !value.isEmpty else { return nil } + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd" + return formatter.date(from: value) + } +} + diff --git a/ProductivityApp/ProductivityAppApp.swift b/ProductivityApp/ProductivityAppApp.swift index 2ed3468..1136e3b 100644 --- a/ProductivityApp/ProductivityAppApp.swift +++ b/ProductivityApp/ProductivityAppApp.swift @@ -2,20 +2,20 @@ // ProductivityAppApp.swift // ProductivityApp // -// Created by Ava Kaplin on 1/21/26. // + import SwiftUI import GoogleSignIn @main struct ProductivityAppApp: App { - @StateObject private var authManager = AuthManager() + @StateObject private var authManager = AuthenticationManager() @StateObject private var shareManager = ShareManager() var body: some Scene { WindowGroup { - MainTabView() + RootView() .environmentObject(authManager) .environmentObject(shareManager) } diff --git a/ProductivityApp/Views/ViewA.swift b/ProductivityApp/Views/ViewA.swift index 3078c93..2a5d03f 100644 --- a/ProductivityApp/Views/ViewA.swift +++ b/ProductivityApp/Views/ViewA.swift @@ -9,7 +9,24 @@ import SwiftUI struct ViewA: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + TabView { + HomeView() + .tabItem() { + Image(systemName: "house") + Text("Home") + } + ViewB() + .tabItem() { + Image(systemName: "person.2.fill") + Text("Friends") + } + ViewC() + .tabItem() { + Image(systemName: "slider.horizontal.3") + Text("Settings") + } + } + } } diff --git a/ProductivityApp/Views/ViewB.swift b/ProductivityApp/Views/ViewB.swift index cf6f36f..1cd0322 100644 --- a/ProductivityApp/Views/ViewB.swift +++ b/ProductivityApp/Views/ViewB.swift @@ -9,7 +9,7 @@ import SwiftUI struct ViewB: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } } diff --git a/ProductivityApp/Views/ViewC.swift b/ProductivityApp/Views/ViewC.swift index e6492c1..6a6c570 100644 --- a/ProductivityApp/Views/ViewC.swift +++ b/ProductivityApp/Views/ViewC.swift @@ -9,7 +9,7 @@ import SwiftUI struct ViewC: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } } diff --git a/Service/AuthenticationManager.swift b/Service/AuthenticationManager.swift index cd2a9e1..60e3702 100644 --- a/Service/AuthenticationManager.swift +++ b/Service/AuthenticationManager.swift @@ -19,6 +19,10 @@ class AuthenticationManager: ObservableObject { @Published var isLoading: Bool = false @Published var errorMessage: String? + /// If true, the app will always require a fresh login on launch and will not auto-restore a previous session. + /// Set to false to restore the previous signed-in user from storage on launch. + var alwaysRequireLogin: Bool = true + // For Apple Sign In private var currentNonce: String? @@ -29,7 +33,12 @@ class AuthenticationManager: ObservableObject { private let appleEmailsKey = "appleEmails" init() { - loadStoredUser() + if alwaysRequireLogin { + // Ensure no user is restored on launch + signOut() + } else { + loadStoredUser() + } } // MARK: - Persistence diff --git a/views/AuthenticationView.swift b/views/AuthenticationView.swift index 2e9d8b5..0a8d3dd 100644 --- a/views/AuthenticationView.swift +++ b/views/AuthenticationView.swift @@ -6,9 +6,10 @@ // import SwiftUI +import AuthenticationServices struct AuthenticationView: View { - @EnvironmentObject var authManager: AuthManager + @EnvironmentObject var authManager: AuthenticationManager @State private var showEmailSignIn = false @State private var showEmailSignUp = false @@ -38,31 +39,20 @@ struct AuthenticationView: View { // Sign-in Options VStack(spacing: 16) { - // Apple Sign In (Stub) - Button { - // Stub Apple sign-in: mark authenticated - authManager.isAuthenticated = true - authManager.currentUser = CurrentUser(id: UUID().uuidString, displayName: "Apple User", email: nil, photoURL: nil, authProvider: .apple) - } label: { - HStack(spacing: 12) { - Image(systemName: "apple.logo") - .resizable() - .scaledToFit() - .frame(width: 20, height: 20) - Text("Sign in with Apple") - .fontWeight(.medium) - } - .frame(maxWidth: .infinity) - .frame(height: 50) - .background(Color.black) - .foregroundStyle(.white) - .cornerRadius(8) + // Apple Sign In (Using SignInWithAppleButton) + SignInWithAppleButton(.signIn) { request in + authManager.handleAppleSignInRequest(request) + } onCompletion: { result in + authManager.handleAppleSignInCompletion(result) } + .signInWithAppleButtonStyle(.black) + .frame(maxWidth: .infinity) + .frame(height: 50) + .cornerRadius(8) // Google Sign In (Stub) Button { - authManager.isAuthenticated = true - authManager.currentUser = CurrentUser(id: UUID().uuidString, displayName: "Google User", email: nil, photoURL: nil, authProvider: .google) + Task { await authManager.signInWithGoogle() } } label: { HStack(spacing: 12) { Image(systemName: "g.circle.fill") @@ -151,5 +141,5 @@ struct AuthenticationView: View { #Preview { AuthenticationView() - .environmentObject(AuthManager()) + .environmentObject(AuthenticationManager()) } diff --git a/views/DashboardView 2.swift b/views/DashboardView 2.swift deleted file mode 100644 index 6d63073..0000000 --- a/views/DashboardView 2.swift +++ /dev/null @@ -1,124 +0,0 @@ -import SwiftUI - -struct DashboardScreen: View { - @EnvironmentObject var authManager: AuthManager - - var body: some View { - ScrollView { - VStack(spacing: 20) { - // Check-in Header - VStack(spacing: 8) { - Text("Streak: \(authManager.currentStreak) days") - .font(.headline) - ProgressView(value: min(Double(authManager.currentStreak), 30), total: 30) - Text("\(authManager.currentStreak) / 30") - .font(.caption) - .foregroundColor(.secondary) - Button("Check in") { - authManager.checkInIfNeeded() - } - .buttonStyle(.borderedProminent) - } - .padding() - - // Greeting Section - VStack(alignment: .leading) { - Text("Welcome back!") - .font(.largeTitle) - .bold() - Text("Here's what's happening today.") - .font(.subheadline) - .foregroundColor(.secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - - // Stats Section - VStack(alignment: .leading, spacing: 16) { - Text("Your Stats") - .font(.title2) - .bold() - // Example stat placeholders - HStack { - VStack { - Text("Tasks Completed") - .font(.caption) - .foregroundColor(.secondary) - Text("12") - .font(.title3) - .bold() - } - Spacer() - VStack { - Text("Hours Logged") - .font(.caption) - .foregroundColor(.secondary) - Text("5.5") - .font(.title3) - .bold() - } - } - } - .padding() - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(10) - .padding(.horizontal) - - // Recent Activity Section - VStack(alignment: .leading, spacing: 16) { - Text("Recent Activity") - .font(.title2) - .bold() - // Placeholder for recent activity list - VStack(alignment: .leading, spacing: 8) { - Text("• Finished 'SwiftUI Tutorial'") - Text("• Completed daily check-in") - Text("• Added new task: 'Buy groceries'") - } - .font(.body) - } - .padding() - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(10) - .padding(.horizontal) - - // Quick Actions Section - VStack(alignment: .leading, spacing: 16) { - Text("Quick Actions") - .font(.title2) - .bold() - HStack { - Button(action: { - // Action 1 - }) { - Label("New Task", systemImage: "plus.circle") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - - Button(action: { - // Action 2 - }) { - Label("View Stats", systemImage: "chart.bar") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - } - } - .padding() - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(10) - .padding(.horizontal) - } - .padding(.vertical) - } - .onAppear { - authManager.checkInIfNeeded() - } - } -} - -#Preview { - DashboardScreen() - .environmentObject(AuthManager()) -} diff --git a/views/MainTabView.swift b/views/MainTabView.swift index 42dfcda..3e48c01 100644 --- a/views/MainTabView.swift +++ b/views/MainTabView.swift @@ -14,7 +14,7 @@ struct MainTabView: View { var body: some View { TabView(selection: $selectedTab) { NavigationStack { - DashboardScreen() + HomeView() .navigationTitle("Dashboard") } .tabItem { Label("Dashboard", systemImage: "house") } diff --git a/views/ProfileView.swift b/views/ProfileView.swift index a73f55b..590c3b2 100644 --- a/views/ProfileView.swift +++ b/views/ProfileView.swift @@ -9,7 +9,7 @@ import SwiftUI import PhotosUI struct ProfileView: View { - @EnvironmentObject var authManager: AuthManager + @EnvironmentObject var authManager: AuthenticationManager @State private var isPresentingPhotoPicker = false @State private var selectedUIImage: UIImage? @State private var isUpdatingPhoto = false @@ -165,7 +165,7 @@ struct ProfileView: View { DetailRow( icon: "shield.fill", title: "Auth Provider", - value: providerName(for: authManager.currentUser?.authProvider ?? AppAuthProvider.email) + value: providerName(for: authManager.currentUser?.authProvider ?? .email) ) } } @@ -212,7 +212,6 @@ struct ProfileView: View { ToolbarItem(placement: .topBarTrailing) { NavigationLink("Edit") { EditProfileView() - .environmentObject(authManager) } } } @@ -228,7 +227,7 @@ struct ProfileView: View { } @ViewBuilder - private func providerIcon(for provider: AppAuthProvider) -> some View { + private func providerIcon(for provider: AuthProvider) -> some View { switch provider { case .apple: Image(systemName: "apple.logo") @@ -239,7 +238,7 @@ struct ProfileView: View { } } - private func providerName(for provider: AppAuthProvider) -> String { + private func providerName(for provider: AuthProvider) -> String { switch provider { case .apple: return "Apple" @@ -278,6 +277,6 @@ struct DetailRow: View { #Preview { ProfileView() - .environmentObject(AuthManager()) + .environmentObject(AuthenticationManager()) } diff --git a/views/RootView.swift b/views/RootView.swift index 2483b89..7f515ae 100644 --- a/views/RootView.swift +++ b/views/RootView.swift @@ -1,7 +1,7 @@ import SwiftUI struct RootView: View { - @EnvironmentObject var authManager: AuthManager + @EnvironmentObject var authManager: AuthenticationManager var body: some View { Group { @@ -16,5 +16,5 @@ struct RootView: View { #Preview { RootView() - .environmentObject(AuthManager()) + .environmentObject(AuthenticationManager()) } diff --git a/views/ShareView.swift b/views/ShareView.swift index 8d583bd..f4ab52b 100644 --- a/views/ShareView.swift +++ b/views/ShareView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ShareView: View { - @EnvironmentObject var authManager: AuthManager + @EnvironmentObject var authManager: AuthenticationManager @EnvironmentObject var shareManager: ShareManager @State private var friendEmail: String = "" @@ -166,7 +166,6 @@ struct ShareView: View { #Preview { NavigationStack { ShareView() - .environmentObject(AuthManager(isAuthenticated: true, currentUser: CurrentUser(id: "1", displayName: "Me", email: "me@example.com", photoURL: nil, authProvider: .email))) - .environmentObject(ShareManager()) + .environmentObject(AuthenticationManager()) } }