Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

100 mobile notifications #112

Merged
merged 6 commits into from
Oct 31, 2024
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
7 changes: 5 additions & 2 deletions mobile/mobile.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
LoadingView.swift,
mobileApp.swift,
Models.swift,
NotificationManager.swift,
OnboardingView.swift,
PreferencesView.swift,
"Preview Content/Preview Assets.xcassets",
Expand Down Expand Up @@ -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\"";
Expand All @@ -280,7 +282,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
Expand All @@ -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\"";
Expand All @@ -310,7 +313,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.2;
MARKETING_VERSION = 1.4;
PRODUCT_BUNDLE_IDENTIFIER = voynow.mobile;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
Expand Down
29 changes: 25 additions & 4 deletions mobile/mobile/APIManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,9 +220,30 @@ class APIManager {
}
}
}
}

struct GenericResponse: Codable {
let success: Bool
let message: String?
func updateDeviceToken(
token: String, deviceToken: String, completion: @escaping (Result<Void, Error>) -> Void
) {
let body: [String: Any] = [
"jwt_token": token,
"payload": ["device_token": deviceToken],
"method": "update_device_token",
]

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))
}
}
}
}
38 changes: 36 additions & 2 deletions mobile/mobile/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,51 @@
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")

func application(
_ 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)")
}
}
}
}
23 changes: 23 additions & 0 deletions mobile/mobile/AppState.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
}
}
7 changes: 6 additions & 1 deletion mobile/mobile/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions mobile/mobile/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@
<array>
<string>strava</string>
</array>
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
</dict>
</plist>
5 changes: 5 additions & 0 deletions mobile/mobile/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,8 @@ enum AppStateStatus {
case loggedIn
case newUser
}

struct GenericResponse: Codable {
let success: Bool
let message: String?
}
34 changes: 34 additions & 0 deletions mobile/mobile/NotificationManager.swift
Original file line number Diff line number Diff line change
@@ -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)")
}
}
}
}
8 changes: 8 additions & 0 deletions mobile/mobile/mobile.entitlements
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>development</string>
</dict>
</plist>
1 change: 1 addition & 0 deletions mobile/mobile/mobileApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import SwiftUI

@main
struct mobileApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var appState = AppState()

var body: some Scene {
Expand Down
26 changes: 16 additions & 10 deletions src/frontend_router.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import time
from typing import Callable, Dict, Optional

import jwt
Expand All @@ -11,17 +10,15 @@
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


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(),
Expand All @@ -30,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": {
Expand Down Expand Up @@ -64,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],
Expand All @@ -85,12 +76,27 @@ 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."""
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"]
)
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,
}


Expand Down
12 changes: 12 additions & 0 deletions src/supabase_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
1 change: 1 addition & 0 deletions src/types/user_auth_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ class UserAuthRow(BaseModel):
refresh_token: str
expires_at: datetime.datetime
jwt_token: str
device_token: Optional[str] = None
13 changes: 4 additions & 9 deletions web/src/app/components/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<nav className="fixed top-0 w-full bg-gray-900 text-gray-100 z-10">
Expand All @@ -17,12 +12,12 @@ export default function Navbar(): JSX.Element {
</Link>
<button
className="px-4 py-2 text-gray-200 bg-gray-900 font-semibold rounded-3xl flex space-x-2 outline outline-2 outline-gray-200 hover:scale-105 hover:shadow-lg transition duration-300 ease-in-out"
onClick={handleSignIn}
onClick={() => router.push('/dashboard')}
>
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span>Sign in</span>
<span>Dashboard</span>
</button>
</div>
</nav>
Expand Down
5 changes: 2 additions & 3 deletions web/src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,8 @@ const DashboardPage = () => {
In the meantime...
</h3>
<ul className="list-disc list-inside mb-8 text-left sm:text-lg md:text-xl">
<li className="mb-2">Our mobile app is the preferred interface</li>
<li className="mb-2">Web dashboard will be ready in about a week</li>
<li>Feel free to reach out with any questions</li>
<li className="mb-2">Download our mobile app TrackFlowAI from <a href="https://apps.apple.com/us/app/trackflowai/id6737172627" target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 underline">the App Store</a>.</li>
<li>Have questions? Reach out to us.</li>
</ul>
<Link href="mailto:[email protected]" className="px-8 py-4 text-xl text-gray-200 bg-blue-600 font-bold rounded-full hover:bg-blue-700 hover:scale-105 transition duration-300 ease-in-out shadow-lg hover:shadow-blue-500/50 inline-block">
Contact @jamievoynow
Expand Down
Loading
Loading