From c4c80f0372b69bae05a88f30a3bcc5e20525cc50 Mon Sep 17 00:00:00 2001 From: voynow Date: Wed, 30 Oct 2024 18:45:25 -0400 Subject: [PATCH 1/6] simplifying webapp --- web/src/app/components/Navbar.tsx | 13 ++---- web/src/app/dashboard/page.tsx | 5 +- web/src/app/page.tsx | 18 +++---- web/src/app/signup/page.tsx | 78 ------------------------------- 4 files changed, 15 insertions(+), 99 deletions(-) delete mode 100644 web/src/app/signup/page.tsx diff --git a/web/src/app/components/Navbar.tsx b/web/src/app/components/Navbar.tsx index 3e8c9d7..553d68d 100644 --- a/web/src/app/components/Navbar.tsx +++ b/web/src/app/components/Navbar.tsx @@ -1,13 +1,8 @@ import Link from 'next/link'; - +import { useRouter } from 'next/navigation'; export default function Navbar(): JSX.Element { - const handleSignIn = (): void => { - const isDevelopment = process.env.NODE_ENV === 'development'; - const redirectUri = `https://www.trackflow.xyz/verify${isDevelopment ? '?env=dev' : ''}`; - const stravaAuthUrl = `https://www.strava.com/oauth/authorize?client_id=95101&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&approval_prompt=auto&scope=read_all,profile:read_all,activity:read_all`; - window.location.href = stravaAuthUrl; - }; + const router = useRouter(); return ( diff --git a/web/src/app/dashboard/page.tsx b/web/src/app/dashboard/page.tsx index 0e30f40..3a8b766 100644 --- a/web/src/app/dashboard/page.tsx +++ b/web/src/app/dashboard/page.tsx @@ -29,9 +29,8 @@ const DashboardPage = () => { In the meantime... Contact @jamievoynow diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 18c1d35..e54c62c 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,10 +1,10 @@ 'use client'; import { motion } from 'framer-motion'; +import Image from 'next/image'; import { useRouter } from 'next/navigation'; import Footer from './components/Footer'; import Navbar from './components/Navbar'; -import Image from 'next/image'; export default function Home(): JSX.Element { const router = useRouter(); @@ -17,18 +17,18 @@ export default function Home(): JSX.Element { ]; const testimonials: Array<{ name: string; quote: string; image: string }> = [ - { - name: "Danny Lio", + { + name: "Danny Lio", quote: "The training plan I found online was good, but it was missing the level of personalization that TrackFlow provides.", image: "/danny-lio.png" }, - { - name: "Jared Palek", + { + name: "Jared Palek", quote: "I used to pay $50.00 per month to work with my coach, but TrackFlow is just as good and a whole lot cheaper!", image: "/jared-palek.png" }, - { - name: "Rachel Decker", + { + name: "Rachel Decker", quote: "I love that my TrackFlow training plan is always up to date. It's like having a coach by my side for every activity!", image: "/rachel-decker.png" }, @@ -112,7 +112,7 @@ export default function Home(): JSX.Element { @@ -173,7 +173,7 @@ export default function Home(): JSX.Element {

Ready to Transform Your Training?

router.push('/signup')} + onClick={() => router.push('/dashboard')} whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }} > diff --git a/web/src/app/signup/page.tsx b/web/src/app/signup/page.tsx deleted file mode 100644 index b0c0ad0..0000000 --- a/web/src/app/signup/page.tsx +++ /dev/null @@ -1,78 +0,0 @@ -'use client'; - -import Image from 'next/image'; -import Link from 'next/link'; -import { useState } from 'react'; -import { motion } from 'framer-motion'; -import Navbar from '../components/Navbar'; - -/** - * SignUpPage component for user registration - * @returns JSX.Element - */ -export default function SignUpPage(): JSX.Element { - const [email, setEmail] = useState(''); - - const handleSignUp = (event: React.FormEvent): void => { - event.preventDefault(); - localStorage.setItem('email', email); - const isDevelopment = process.env.NODE_ENV === 'development'; - const redirectUri = `https://www.trackflow.xyz/verify${isDevelopment ? '?env=dev' : ''}`; - const stravaAuthUrl = `https://www.strava.com/oauth/authorize?client_id=95101&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&approval_prompt=auto&scope=read_all,profile:read_all,activity:read_all`; - window.location.href = stravaAuthUrl; - }; - - const fadeInUp = { - initial: { opacity: 0, y: 20 }, - animate: { opacity: 1, y: 0 }, - transition: { duration: 0.6 } - }; - - return ( -
- -
- -
- -

- TrackFlow -

- -

- Elevate Your Running Game -

-
-
- setEmail(e.target.value)} - placeholder="Enter your email" - required - className="appearance-none rounded-lg relative block w-full px-3 py-2 border border-gray-600 bg-gray-700 placeholder-gray-400 text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" - /> -
- - Sign Up with Strava - - Strava Logo - - -
-
-
-
-
- ); -} From 0e04827e9303edb353674c0cea3a4f2a1de07a40 Mon Sep 17 00:00:00 2001 From: voynow Date: Wed, 30 Oct 2024 19:29:21 -0400 Subject: [PATCH 2/6] mobile user permission for push notifications --- mobile/mobile.xcodeproj/project.pbxproj | 7 +++-- mobile/mobile/APIManager.swift | 26 ++++++++++++++--- mobile/mobile/AppDelegate.swift | 38 +++++++++++++++++++++++-- mobile/mobile/AppState.swift | 23 +++++++++++++++ mobile/mobile/DashboardView.swift | 7 ++++- mobile/mobile/Info.plist | 4 +++ mobile/mobile/Models.swift | 5 ++++ mobile/mobile/NotificationManager.swift | 34 ++++++++++++++++++++++ mobile/mobile/mobile.entitlements | 8 ++++++ mobile/mobile/mobileApp.swift | 1 + 10 files changed, 144 insertions(+), 9 deletions(-) create mode 100644 mobile/mobile/NotificationManager.swift create mode 100644 mobile/mobile/mobile.entitlements diff --git a/mobile/mobile.xcodeproj/project.pbxproj b/mobile/mobile.xcodeproj/project.pbxproj index 97722bf..2a88915 100644 --- a/mobile/mobile.xcodeproj/project.pbxproj +++ b/mobile/mobile.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ LoadingView.swift, mobileApp.swift, Models.swift, + NotificationManager.swift, OnboardingView.swift, PreferencesView.swift, "Preview Content/Preview Assets.xcassets", @@ -264,6 +265,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = mobile/mobile.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"mobile/Preview Content\""; @@ -280,7 +282,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -294,6 +296,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = mobile/mobile.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"mobile/Preview Content\""; @@ -310,7 +313,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2; + MARKETING_VERSION = 1.3; PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/mobile/mobile/APIManager.swift b/mobile/mobile/APIManager.swift index 0ec8881..b5a7681 100644 --- a/mobile/mobile/APIManager.swift +++ b/mobile/mobile/APIManager.swift @@ -220,9 +220,27 @@ class APIManager { } } } -} -struct GenericResponse: Codable { - let success: Bool - let message: String? + func updateDeviceToken(token: String, deviceToken: String, completion: @escaping (Result) -> Void) { + let body: [String: Any] = [ + "jwt_token": token, + "method": "update_device_token", + "device_token": deviceToken + ] + + performRequest(body: body, responseType: GenericResponse.self) { result in + switch result { + case .success(let response): + if response.success { + completion(.success(())) + } else { + let errorMessage = response.message ?? "Failed to update device token" + completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) + } + case .failure(let error): + completion(.failure(error)) + } + } + } } + diff --git a/mobile/mobile/AppDelegate.swift b/mobile/mobile/AppDelegate.swift index 5b5f798..761f6e6 100644 --- a/mobile/mobile/AppDelegate.swift +++ b/mobile/mobile/AppDelegate.swift @@ -1,7 +1,8 @@ import UIKit import os +import UserNotifications -class AppDelegate: UIResponder, UIApplicationDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { private let logger = Logger( subsystem: Bundle.main.bundleIdentifier ?? "com.trackflow", category: "AppDelegate") @@ -9,9 +10,42 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - logger.info( "Application did finish launching with options: \(String(describing: launchOptions))") return true } + + func application( + _ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data + ) { + let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined() + NotificationManager.shared.updateDeviceToken(tokenString) + } + + func application( + _ application: UIApplication, + didFailToRegisterForRemoteNotificationsWithError error: Error + ) { + logger.error("Failed to register for remote notifications: \(error.localizedDescription)") + } + + private func registerForPushNotifications() { + UNUserNotificationCenter.current().delegate = self + + UNUserNotificationCenter.current().requestAuthorization( + options: [.alert, .sound, .badge] + ) { [weak self] granted, error in + guard let self = self else { return } + + if granted { + self.logger.info("Notification permission granted") + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } else if let error = error { + self.logger.error("Error requesting notification permission: \(error.localizedDescription)") + } + } + } } diff --git a/mobile/mobile/AppState.swift b/mobile/mobile/AppState.swift index 9408beb..1c600c1 100644 --- a/mobile/mobile/AppState.swift +++ b/mobile/mobile/AppState.swift @@ -1,6 +1,29 @@ import SwiftUI +import UserNotifications class AppState: ObservableObject { @Published var status: AppStateStatus = .loggedOut @Published var jwtToken: String? = nil + @Published var notificationStatus: UNAuthorizationStatus = .notDetermined + + func checkNotificationStatus() { + UNUserNotificationCenter.current().getNotificationSettings { settings in + DispatchQueue.main.async { + self.notificationStatus = settings.authorizationStatus + } + } + } + + func requestNotificationPermission() { + UNUserNotificationCenter.current().delegate = UIApplication.shared.delegate as? UNUserNotificationCenterDelegate + + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in + DispatchQueue.main.async { + self.checkNotificationStatus() + if granted { + UIApplication.shared.registerForRemoteNotifications() + } + } + } + } } diff --git a/mobile/mobile/DashboardView.swift b/mobile/mobile/DashboardView.swift index da440e8..67c48fa 100644 --- a/mobile/mobile/DashboardView.swift +++ b/mobile/mobile/DashboardView.swift @@ -50,7 +50,12 @@ struct DashboardView: View { } } } - .onAppear(perform: fetchData) + .onAppear { + fetchData() + if appState.notificationStatus == .notDetermined { + appState.requestNotificationPermission() + } + } .alert(isPresented: $showErrorAlert) { Alert( title: Text("Error"), diff --git a/mobile/mobile/Info.plist b/mobile/mobile/Info.plist index 4cf9079..e866ae2 100644 --- a/mobile/mobile/Info.plist +++ b/mobile/mobile/Info.plist @@ -21,5 +21,9 @@ strava + UIBackgroundModes + + remote-notification + diff --git a/mobile/mobile/Models.swift b/mobile/mobile/Models.swift index 0fa7929..e0696f9 100644 --- a/mobile/mobile/Models.swift +++ b/mobile/mobile/Models.swift @@ -180,3 +180,8 @@ enum AppStateStatus { case loggedIn case newUser } + +struct GenericResponse: Codable { + let success: Bool + let message: String? +} \ No newline at end of file diff --git a/mobile/mobile/NotificationManager.swift b/mobile/mobile/NotificationManager.swift new file mode 100644 index 0000000..f9fa851 --- /dev/null +++ b/mobile/mobile/NotificationManager.swift @@ -0,0 +1,34 @@ +import Foundation +import os + +class NotificationManager { + static let shared = NotificationManager() + private var deviceToken: String? + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "com.trackflow", category: "NotificationManager") + + private init() {} + + func updateDeviceToken(_ token: String) { + deviceToken = token + logger.info("Received new device token") + sendTokenToServer() + } + + private func sendTokenToServer() { + guard let token = deviceToken, + let jwtToken = UserDefaults.standard.string(forKey: "jwt_token") + else { + logger.error("Missing device token or JWT token") + return + } + + APIManager.shared.updateDeviceToken(token: jwtToken, deviceToken: token) { result in + switch result { + case .success: + self.logger.info("Successfully registered device token with server") + case .failure(let error): + self.logger.error("Failed to register device token: \(error.localizedDescription)") + } + } + } +} diff --git a/mobile/mobile/mobile.entitlements b/mobile/mobile/mobile.entitlements new file mode 100644 index 0000000..903def2 --- /dev/null +++ b/mobile/mobile/mobile.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/mobile/mobile/mobileApp.swift b/mobile/mobile/mobileApp.swift index ad6fe3a..b814d50 100644 --- a/mobile/mobile/mobileApp.swift +++ b/mobile/mobile/mobileApp.swift @@ -2,6 +2,7 @@ import SwiftUI @main struct mobileApp: App { + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var appState = AppState() var body: some Scene { From 23453b05e7f19279d4817deb607eb1796e38d00f Mon Sep 17 00:00:00 2001 From: voynow Date: Wed, 30 Oct 2024 19:45:38 -0400 Subject: [PATCH 3/6] backend route accepting user device token --- src/frontend_router.py | 13 +++++++++++++ src/supabase_client.py | 12 ++++++++++++ src/types/user_auth_row.py | 1 + 3 files changed, 26 insertions(+) diff --git a/src/frontend_router.py b/src/frontend_router.py index 00adba9..205314a 100644 --- a/src/frontend_router.py +++ b/src/frontend_router.py @@ -11,6 +11,7 @@ get_user, get_user_auth, update_preferences, + update_user_device_token, ) from src.types.update_pipeline import ExeType from src.update_pipeline import training_week_update_executor @@ -85,12 +86,24 @@ def start_onboarding(athlete_id: str, payload: dict) -> dict: return {"success": True} +def update_device_token_handler(athlete_id: str, payload: dict) -> dict: + """Handle update_device_token request.""" + if not payload or "device_token" not in payload: + return {"success": False, "error": "Missing device_token in payload"} + try: + update_user_device_token(athlete_id=athlete_id, device_token=payload["device_token"]) + return {"success": True} + except Exception as e: + return {"success": False, "error": f"Failed to update device token: {str(e)}"} + + METHOD_HANDLERS: Dict[str, Callable[[str, Optional[dict]], dict]] = { "get_training_week": get_training_week_handler, "get_profile": get_profile_handler, "update_preferences": update_preferences_handler, "get_weekly_summaries": get_weekly_summaries_handler, "start_onboarding": start_onboarding, + "update_device_token": update_device_token_handler, } diff --git a/src/supabase_client.py b/src/supabase_client.py index d5e8eef..e4b4959 100644 --- a/src/supabase_client.py +++ b/src/supabase_client.py @@ -257,3 +257,15 @@ def user_exists(athlete_id: int) -> bool: table = client.table("user") response = table.select("*").eq("athlete_id", athlete_id).execute() return bool(response.data) + + +def update_user_device_token(athlete_id: str, device_token: str) -> None: + """ + Update the device token for a user in the database. + + :param athlete_id: The athlete's ID + :param device_token: The device token for push notifications + """ + client.table("user_auth").update({"device_token": device_token}).eq( + "athlete_id", athlete_id + ).execute() diff --git a/src/types/user_auth_row.py b/src/types/user_auth_row.py index 31f3d68..6d103a9 100644 --- a/src/types/user_auth_row.py +++ b/src/types/user_auth_row.py @@ -10,3 +10,4 @@ class UserAuthRow(BaseModel): refresh_token: str expires_at: datetime.datetime jwt_token: str + device_token: Optional[str] = None From 34e6c7a6db696dede86876b3be75d07d5cd0c1e7 Mon Sep 17 00:00:00 2001 From: voynow Date: Wed, 30 Oct 2024 19:52:50 -0400 Subject: [PATCH 4/6] debugging update device token route --- src/frontend_router.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/frontend_router.py b/src/frontend_router.py index 205314a..273e237 100644 --- a/src/frontend_router.py +++ b/src/frontend_router.py @@ -1,4 +1,3 @@ -import time from typing import Callable, Dict, Optional import jwt @@ -19,10 +18,7 @@ def get_training_week_handler(athlete_id: str, payload: dict) -> dict: """Handle get_training_week request.""" - start = time.time() training_week = get_training_week(athlete_id) - end = time.time() - print(f"get_training_week took {end - start} seconds") return { "success": True, "training_week": training_week.json(), @@ -31,11 +27,8 @@ def get_training_week_handler(athlete_id: str, payload: dict) -> dict: def get_profile_handler(athlete_id: str, payload: dict) -> dict: """Handle get_profile request.""" - start = time.time() user = get_user(athlete_id) athlete = auth_manager.get_strava_client(athlete_id).get_athlete() - end = time.time() - print(f"get_profile took {end - start} seconds") return { "success": True, "profile": { @@ -65,13 +58,10 @@ def get_weekly_summaries_handler(athlete_id: str, payload: dict) -> dict: :param payload: unused payload :return: List of WeekSummary objects as JSON """ - start = time.time() user = get_user(athlete_id) strava_client = get_strava_client(user.athlete_id) activities_df = get_activities_df(strava_client) weekly_summaries = get_weekly_summaries(activities_df) - end = time.time() - print(f"get_weekly_summaries took {end - start} seconds") return { "success": True, "weekly_summaries": [summary.json() for summary in weekly_summaries], @@ -88,10 +78,13 @@ def start_onboarding(athlete_id: str, payload: dict) -> dict: def update_device_token_handler(athlete_id: str, payload: dict) -> dict: """Handle update_device_token request.""" + print(payload) if not payload or "device_token" not in payload: return {"success": False, "error": "Missing device_token in payload"} try: - update_user_device_token(athlete_id=athlete_id, device_token=payload["device_token"]) + update_user_device_token( + athlete_id=athlete_id, device_token=payload["device_token"] + ) return {"success": True} except Exception as e: return {"success": False, "error": f"Failed to update device token: {str(e)}"} From ec2e224ebffe20252d3c0d110abc024f2ea3c8dd Mon Sep 17 00:00:00 2001 From: voynow Date: Wed, 30 Oct 2024 20:07:33 -0400 Subject: [PATCH 5/6] fix for updateDeviceToken payload structure --- mobile/mobile/APIManager.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/mobile/mobile/APIManager.swift b/mobile/mobile/APIManager.swift index b5a7681..02a4b2e 100644 --- a/mobile/mobile/APIManager.swift +++ b/mobile/mobile/APIManager.swift @@ -221,13 +221,15 @@ class APIManager { } } - func updateDeviceToken(token: String, deviceToken: String, completion: @escaping (Result) -> Void) { + func updateDeviceToken( + token: String, deviceToken: String, completion: @escaping (Result) -> Void + ) { let body: [String: Any] = [ "jwt_token": token, + "payload": ["device_token": deviceToken], "method": "update_device_token", - "device_token": deviceToken ] - + performRequest(body: body, responseType: GenericResponse.self) { result in switch result { case .success(let response): @@ -235,7 +237,9 @@ class APIManager { completion(.success(())) } else { let errorMessage = response.message ?? "Failed to update device token" - completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) + completion( + .failure( + NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) } case .failure(let error): completion(.failure(error)) @@ -243,4 +247,3 @@ class APIManager { } } } - From 8eb10e25004e1fde72d28210676a70203ff4ce11 Mon Sep 17 00:00:00 2001 From: voynow Date: Wed, 30 Oct 2024 20:08:16 -0400 Subject: [PATCH 6/6] incrementing mobile version --- mobile/mobile.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/mobile.xcodeproj/project.pbxproj b/mobile/mobile.xcodeproj/project.pbxproj index 2a88915..4a821b0 100644 --- a/mobile/mobile.xcodeproj/project.pbxproj +++ b/mobile/mobile.xcodeproj/project.pbxproj @@ -282,7 +282,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -313,7 +313,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3; + MARKETING_VERSION = 1.4; PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES;