Skip to content

Commit

Permalink
Merge pull request #112 from voynow/100-mobile-notifications
Browse files Browse the repository at this point in the history
100 mobile notifications
  • Loading branch information
voynow authored Oct 31, 2024
2 parents b7aa153 + 8eb10e2 commit 7855ea2
Show file tree
Hide file tree
Showing 17 changed files with 191 additions and 118 deletions.
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:voynow99@gmail.com" 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

0 comments on commit 7855ea2

Please sign in to comment.