diff --git a/README.md b/README.md index 19e7a2cb1..a6061681e 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,11 @@ This project has a pair of native mobile applications backed by the Sessionize d The apps need a Firebase account set up to run. You'll need to get the `google-services.json` and put it in `android/google-services.json` for Android, and the `GoogleService-Info.plist` and put that in `ios/Droidcon/Droidcon/GoogleService-Info.plist` for iOS. +Additionally for Firebase Authentication you'll need to pass in your client ID into the project. + +For Android you'll need to add a `clientId` property to your `local.properties`. +For iOS you'll need to pass the clientId into your [URL Types](https://firebase.google.com/docs/auth/ios/google-signin#implement_google_sign-in). + ## Compose UI for both! We're running a very early version of Compose UI for iOS as the iOS interface. It mostly shares the screen code with the Android app. While Native Compose UI is obviously experimental, it works surprisingly well. diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 1d7011afa..5e06de253 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -21,12 +21,15 @@ android { compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { - applicationId = "co.touchlab.droidcon.london" + applicationId = "co.touchlab.droidconauthtest" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 20201 versionName = "2.2.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + val clientId = properties.getProperty("clientId", "") + buildConfigField("String", "CLIENT_ID", clientId) } packaging { resources.excludes.add("META-INF/*.kotlin_module") @@ -34,6 +37,7 @@ android { if (releaseEnabled) { signingConfigs { create("release") { + keyAlias = "key0" keyPassword = releasePassword storeFile = file("./release.jks") @@ -65,6 +69,7 @@ android { buildFeatures { compose = true + buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() @@ -89,6 +94,9 @@ dependencies { implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) + implementation(libs.firebase.auth) + implementation(libs.playservices.auth) + implementation(libs.hyperdrive.multiplatformx.api) implementation(libs.bundles.androidx.compose) diff --git a/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt b/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt index 68fd1ad6f..612e63fcd 100644 --- a/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt +++ b/android/src/main/java/co/touchlab/droidcon/android/MainActivity.kt @@ -4,6 +4,9 @@ import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -21,9 +24,12 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import co.touchlab.droidcon.R +import co.touchlab.droidcon.android.service.impl.AndroidGoogleSignInService import co.touchlab.droidcon.application.service.NotificationSchedulingService import co.touchlab.droidcon.application.service.NotificationService import co.touchlab.droidcon.domain.service.AnalyticsService +import co.touchlab.droidcon.domain.service.AuthenticationService +import co.touchlab.droidcon.domain.service.GoogleSignInService import co.touchlab.droidcon.domain.service.SyncService import co.touchlab.droidcon.service.AndroidNotificationService import co.touchlab.droidcon.ui.theme.Colors @@ -32,6 +38,12 @@ import co.touchlab.droidcon.util.AppChecker import co.touchlab.droidcon.util.NavigationController import co.touchlab.droidcon.viewmodel.ApplicationViewModel import co.touchlab.kermit.Logger +import com.google.android.gms.auth.api.identity.Identity +import com.google.android.gms.common.api.ApiException +import com.google.firebase.auth.FirebaseAuth +import com.google.firebase.auth.GoogleAuthProvider +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import org.brightify.hyperdrive.multiplatformx.LifecycleGraph @@ -45,16 +57,60 @@ class MainActivity : ComponentActivity(), KoinComponent { private val analyticsService: AnalyticsService by inject() private val applicationViewModel: ApplicationViewModel by inject() - private val root = LifecycleGraph.Root(this) + private val authenticationService: AuthenticationService by inject() + private val googleSignInService: GoogleSignInService by inject() + + private lateinit var auth: FirebaseAuth + + private val firebaseAuthListener = FirebaseAuth.AuthStateListener { firebaseAuth -> + firebaseAuth.currentUser?.let { user -> + authenticationService.setCredentials( + id = user.uid, + name = user.displayName, + email = user.email, + pictureUrl = user.photoUrl?.toString(), + ) + } ?: run { authenticationService.clearCredentials() } + } + + private val firebaseIntentResultLauncher: ActivityResultLauncher = + registerForActivityResult(ActivityResultContracts.StartIntentSenderForResult()) { result -> + val logger = Logger.withTag("Authentication") + try { + val oneTapClient = Identity.getSignInClient(baseContext) + val credential = oneTapClient.getSignInCredentialFromIntent(result.data) + val firebaseCredential = + GoogleAuthProvider.getCredential(credential.googleIdToken, null) + auth.signInWithCredential(firebaseCredential) + .addOnCompleteListener(this) { task -> + if (task.isSuccessful) { + logger.d { "signInWithCredential:success" } + auth.currentUser?.let { user -> + authenticationService.setCredentials( + id = user.uid, + name = user.displayName, + email = user.email, + pictureUrl = user.photoUrl?.toString(), + ) + } + } else { + logger.e(task.exception) { "signInWithCredential:failure" } + } + } + } catch (e: ApiException) { + logger.e(e) { "NO ID Token" } + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + auth = Firebase.auth + auth.addAuthStateListener(firebaseAuthListener) installSplashScreen() - AppChecker.checkTimeZoneHash() + (googleSignInService as AndroidGoogleSignInService).setActivity(this, firebaseIntentResultLauncher) analyticsService.logEvent(AnalyticsService.EVENT_STARTED) applicationViewModel.lifecycle.removeFromParent() @@ -70,7 +126,6 @@ class MainActivity : ComponentActivity(), KoinComponent { setContent { MainView(viewModel = applicationViewModel) - val showSplashScreen by applicationViewModel.showSplashScreen.collectAsState() Crossfade(targetState = showSplashScreen) { shouldShowSplashScreen -> if (shouldShowSplashScreen) { @@ -111,8 +166,12 @@ class MainActivity : ComponentActivity(), KoinComponent { } private fun handleNotificationDeeplink(intent: Intent) { - val type = intent.getStringExtra(AndroidNotificationService.NOTIFICATION_TYPE_EXTRA_KEY) ?: return - val sessionId = intent.getStringExtra(AndroidNotificationService.NOTIFICATION_SESSION_ID_EXTRA_KEY) ?: return + val type = + intent.getStringExtra(AndroidNotificationService.NOTIFICATION_TYPE_EXTRA_KEY) + ?: return + val sessionId = + intent.getStringExtra(AndroidNotificationService.NOTIFICATION_SESSION_ID_EXTRA_KEY) + ?: return applicationViewModel.notificationReceived( sessionId, when (type) { @@ -133,6 +192,7 @@ class MainActivity : ComponentActivity(), KoinComponent { override fun onDestroy() { super.onDestroy() + auth.removeAuthStateListener(firebaseAuthListener) // Workaround for a crash we could not reproduce: https://console.firebase.google.com/project/droidcon-148cc/crashlytics/app/android:co.touchlab.droidcon.london/issues/8c559569e69164d7109bd6b1be99ade5 if (root.hasChild(applicationViewModel.lifecycle)) { root.removeChild(applicationViewModel.lifecycle) diff --git a/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt b/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt index b96e87dcd..be32929bd 100644 --- a/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt +++ b/android/src/main/java/co/touchlab/droidcon/android/MainApp.kt @@ -5,10 +5,12 @@ import android.app.Application import android.content.Context import android.content.SharedPreferences import co.touchlab.droidcon.android.service.impl.AndroidAnalyticsService +import co.touchlab.droidcon.android.service.impl.AndroidGoogleSignInService import co.touchlab.droidcon.android.service.impl.DefaultParseUrlViewService import co.touchlab.droidcon.android.util.NotificationLocalizedStringFactory import co.touchlab.droidcon.application.service.NotificationSchedulingService import co.touchlab.droidcon.domain.service.AnalyticsService +import co.touchlab.droidcon.domain.service.GoogleSignInService import co.touchlab.droidcon.domain.service.impl.ResourceReader import co.touchlab.droidcon.initKoin import co.touchlab.droidcon.service.ParseUrlViewService @@ -31,7 +33,10 @@ class MainApp : Application() { single { this@MainApp } single> { MainActivity::class.java } single { - get().getSharedPreferences("DROIDCON_SETTINGS_2023", Context.MODE_PRIVATE) + get().getSharedPreferences( + "DROIDCON_SETTINGS_2023", + Context.MODE_PRIVATE + ) } single { SharedPreferencesSettings(delegate = get()) } @@ -50,6 +55,10 @@ class MainApp : Application() { single { AndroidAnalyticsService(firebaseAnalytics = Firebase.analytics) } + + single { + AndroidGoogleSignInService() + } } + uiModule ) } diff --git a/android/src/main/java/co/touchlab/droidcon/android/service/impl/AndroidGoogleSignInService.kt b/android/src/main/java/co/touchlab/droidcon/android/service/impl/AndroidGoogleSignInService.kt new file mode 100644 index 000000000..57c215c30 --- /dev/null +++ b/android/src/main/java/co/touchlab/droidcon/android/service/impl/AndroidGoogleSignInService.kt @@ -0,0 +1,71 @@ +package co.touchlab.droidcon.android.service.impl + +import android.app.Activity +import android.content.IntentSender +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.IntentSenderRequest +import co.touchlab.droidcon.BuildConfig +import co.touchlab.droidcon.domain.service.GoogleSignInService +import co.touchlab.kermit.Logger +import com.google.android.gms.auth.api.identity.BeginSignInRequest +import com.google.android.gms.auth.api.identity.Identity +import com.google.firebase.auth.ktx.auth +import com.google.firebase.ktx.Firebase +import org.brightify.hyperdrive.utils.WeakReference +import org.koin.core.component.KoinComponent + +class AndroidGoogleSignInService : GoogleSignInService, KoinComponent { + + private val logger = Logger.withTag("AuthenticationService") + private val clientId = BuildConfig.CLIENT_ID + + private lateinit var weakActivity: WeakReference + private lateinit var weakLauncher: WeakReference> + + fun setActivity( + activity: Activity, + launcher: ActivityResultLauncher, + ) { + weakActivity = WeakReference(activity) + weakLauncher = WeakReference(launcher) + } + + override fun performGoogleLogin(): Boolean { + weakActivity.get()?.let { activity -> + logger.i { "Performing Google Login" } + val oneTapClient = Identity.getSignInClient(activity) + val signInRequest = BeginSignInRequest.builder() + .setGoogleIdTokenRequestOptions( + BeginSignInRequest.GoogleIdTokenRequestOptions.builder() + .setSupported(true) + .setServerClientId(clientId) + .setFilterByAuthorizedAccounts(false) + .build() + ) + .build() + logger.v { "Beginning Sign In" } + oneTapClient.beginSignIn(signInRequest) + .addOnSuccessListener(activity) { result -> + logger.v { "Success! Starting Intent Sender" } + try { + val request = IntentSenderRequest + .Builder(result.pendingIntent.intentSender) + .build() + weakLauncher.get()?.launch(request) + } catch (e: IntentSender.SendIntentException) { + logger.e(e) { "Couldn't Start Intent" } + } + } + .addOnFailureListener(activity) { e -> + logger.e(e) { "Failed to Sign in" } + } + return true + } + return false + } + + override fun performGoogleLogout(): Boolean { + Firebase.auth.signOut() + return true + } +} diff --git a/android/src/main/res/drawable-nodpi/continue_with_google_rd.xml b/android/src/main/res/drawable-nodpi/continue_with_google_rd.xml new file mode 100644 index 000000000..6a2f91e63 --- /dev/null +++ b/android/src/main/res/drawable-nodpi/continue_with_google_rd.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ddd380e14..01cc48e89 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -57,6 +57,9 @@ accompanist-navigationAnimation = { module = "com.google.accompanist:accompanist firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } firebase-analytics = { module = "com.google.firebase:firebase-analytics-ktx", version = "_" } firebase-crashlytics = { module = "com.google.firebase:firebase-crashlytics-ktx", version = "_" } +firebase-auth = { module = "com.google.firebase:firebase-auth", version = "_" } +playservices-auth = { module = "com.google.android.gms:play-services-auth", version = "21.0.0" } + hyperdrive-multiplatformx-api = { module = "org.brightify.hyperdrive:multiplatformx-api", version.ref = "hyperdrive" } android-desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugaring" } uuid = { module = "com.benasher44:uuid", version.ref = "uuid" } diff --git a/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj b/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj index 84cb4b44d..d3c241351 100644 --- a/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj +++ b/ios/Droidcon/Droidcon.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -49,6 +49,7 @@ A35DC2E328AB6C6F00C7B298 /* ComposeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35DC2E228AB6C6F00C7B298 /* ComposeController.swift */; }; A35DEF2228AA265C0072605A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = A35DEF2128AA265C0072605A /* Settings.bundle */; }; A35DEF2428AA26C80072605A /* SettingsBundleHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = A35DEF2328AA26C80072605A /* SettingsBundleHelper.swift */; }; + F127D3592BB46A0A00E08281 /* IOSGoogleSignInService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F127D3582BB46A0A00E08281 /* IOSGoogleSignInService.swift */; }; F1465F0123AA94BF0055F7C3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */; }; F1465F0A23AA94BF0055F7C3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F1465F0923AA94BF0055F7C3 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -97,6 +98,7 @@ A35DEF2328AA26C80072605A /* SettingsBundleHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBundleHelper.swift; sourceTree = ""; }; DD90C0C0A4D331CEBDBE69E8 /* Pods-Droidcon.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Droidcon.release.xcconfig"; path = "Target Support Files/Pods-Droidcon/Pods-Droidcon.release.xcconfig"; sourceTree = ""; }; EBA278C64D82609BA00FE2A5 /* Pods_Droidcon.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Droidcon.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + F127D3582BB46A0A00E08281 /* IOSGoogleSignInService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSGoogleSignInService.swift; sourceTree = ""; }; F1465EFD23AA94BF0055F7C3 /* Droidcon.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Droidcon.app; sourceTree = BUILT_PRODUCTS_DIR; }; F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; F1465F0923AA94BF0055F7C3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -254,6 +256,7 @@ F1465F0023AA94BF0055F7C3 /* AppDelegate.swift */, 46B5284C249C5CF400A7725D /* Koin.swift */, 8404D80D26C64B9E00AE200F /* IOSAnalyticsService.swift */, + F127D3582BB46A0A00E08281 /* IOSGoogleSignInService.swift */, F1465F0923AA94BF0055F7C3 /* Assets.xcassets */, F1465F0B23AA94BF0055F7C3 /* LaunchScreen.storyboard */, F1465F0E23AA94BF0055F7C3 /* Info.plist */, @@ -447,6 +450,7 @@ 684FAA7726B2A4EA00673AFF /* SettingsView.swift in Sources */, 689DD2FB26B40F1800A9B009 /* LazyView.swift in Sources */, 689DD30526B438CA00A9B009 /* Avatar.swift in Sources */, + F127D3592BB46A0A00E08281 /* IOSGoogleSignInService.swift in Sources */, 681C95A126C555D90011330B /* VisualEffectView.swift in Sources */, 1833221026B0CF5600D79482 /* DroidconApp.swift in Sources */, 684FAA7426B2A4D400673AFF /* ScheduleView.swift in Sources */, @@ -645,7 +649,7 @@ "\"DroidconKit\"", "-lsqlite3", ); - PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.droidcon.ios.london; + PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.droidconauthtest; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; @@ -682,7 +686,7 @@ "\"DroidconKit\"", "-lsqlite3", ); - PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.droidcon.ios.london; + PRODUCT_BUNDLE_IDENTIFIER = co.touchlab.droidconauthtest; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; diff --git a/ios/Droidcon/Droidcon/AppDelegate.swift b/ios/Droidcon/Droidcon/AppDelegate.swift index fcffdb5b6..7e3740e52 100644 --- a/ios/Droidcon/Droidcon/AppDelegate.swift +++ b/ios/Droidcon/Droidcon/AppDelegate.swift @@ -1,4 +1,6 @@ import UIKit +import FirebaseAuth +import GoogleSignIn import Firebase import DroidconKit @@ -7,7 +9,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { lazy var log = koin.get(objCClass: Logger.self, parameter: "AppDelegate") as! Logger lazy var analytics = koin.get(objCProtocol: AnalyticsService.self, qualifier: nil) as! AnalyticsService lazy var appChecker = koin.get(objCClass: AppChecker.self) as! AppChecker + lazy var authenticationService = koin.get(objCProtocol: AuthenticationService.self, qualifier: nil) as! AuthenticationService + var firebaseAuthListener:AuthStateDidChangeListenerHandle? + + func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -21,7 +27,33 @@ class AppDelegate: NSObject, UIApplicationDelegate { analytics.logEvent(name: AnalyticsServiceCompanion().EVENT_STARTED, params: [:]) + firebaseAuthListener = Auth.auth().addStateDidChangeListener() { auth, user in + if let user { + self.authenticationService.setCredentials( + id: user.uid, + name: user.displayName, + email: user.email, + pictureUrl: user.photoURL?.absoluteString + ) + } else { + self.authenticationService.clearCredentials() + } + } + log.v(message: { "App Started" }) return true } + + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + return GIDSignIn.sharedInstance.handle(url) + } + + func applicationWillTerminate(_ application: UIApplication) { + if let firebaseAuthListener { + Auth.auth().removeStateDidChangeListener(firebaseAuthListener) + } + } + } diff --git a/ios/Droidcon/Droidcon/Assets.xcassets/continue_with_google_rd.imageset/Contents.json b/ios/Droidcon/Droidcon/Assets.xcassets/continue_with_google_rd.imageset/Contents.json new file mode 100644 index 000000000..35bc0fb51 --- /dev/null +++ b/ios/Droidcon/Droidcon/Assets.xcassets/continue_with_google_rd.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "android_neutral_rd_ctn.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Droidcon/Droidcon/Assets.xcassets/continue_with_google_rd.imageset/android_neutral_rd_ctn.svg b/ios/Droidcon/Droidcon/Assets.xcassets/continue_with_google_rd.imageset/android_neutral_rd_ctn.svg new file mode 100644 index 000000000..158d32283 --- /dev/null +++ b/ios/Droidcon/Droidcon/Assets.xcassets/continue_with_google_rd.imageset/android_neutral_rd_ctn.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ios/Droidcon/Droidcon/IOSGoogleSignInService.swift b/ios/Droidcon/Droidcon/IOSGoogleSignInService.swift new file mode 100644 index 000000000..674321fb9 --- /dev/null +++ b/ios/Droidcon/Droidcon/IOSGoogleSignInService.swift @@ -0,0 +1,71 @@ +// +// FirebaseService.swift +// Droidcon +// +// Created by Kevin Schildhorn on 3/27/24. +// Copyright © 2024 Touchlab. All rights reserved. +// + +import Foundation +import FirebaseCore +import GoogleSignIn +import FirebaseAuth +import DroidconKit + +class IOSGoogleSignInService : GoogleSignInService { + + let logger = Logger.companion.withTag(tag: "IOSGoogleSignInService") + + func performGoogleLogin() -> Bool { + logger.i(message: { "Performing Google Login" }) + guard let clientID = FirebaseApp.app()?.options.clientID else { + logger.e(message: { "No ClientId" }) + return false + } + + let config = GIDConfiguration(clientID: clientID) + GIDSignIn.sharedInstance.configuration = config + guard let presentingViewController = UIApplication.shared.windows.first?.rootViewController + else { + logger.e(message: { "No Presenting Controller" }) + return false + } + + logger.v(message: { "Sign In with Shared Google Instance" }) + GIDSignIn.sharedInstance.signIn(withPresenting: presentingViewController) { result, error in + guard error == nil else { + self.logger.e(message: { error?.localizedDescription ?? "" }) + return + } + + guard let user = result?.user, + let idToken = user.idToken?.tokenString + else { + self.logger.e(message: { "No User Found" }) + return + } + + self.logger.v(message: { "Get Credentials from Auth Provider" }) + let credential = GoogleAuthProvider.credential(withIDToken: idToken, accessToken: user.accessToken.tokenString) + Auth.auth().signIn(with: credential) { result, error in + if let error { + self.logger.e(message: { error.localizedDescription }) + } else { + self.logger.v(message: { "Got results from Auth!" }) + } + } + } + return true + } + + func performGoogleLogout() -> Bool { + do { + self.logger.v(message: { "Performing Logout" }) + try Auth.auth().signOut() + return true + } catch let signOutError as NSError { + self.logger.e(message: { "Error occured signing out: \(signOutError.localizedDescription)" }) + return false + } + } +} diff --git a/ios/Droidcon/Droidcon/Koin.swift b/ios/Droidcon/Droidcon/Koin.swift index 66359b9a8..faad2632e 100644 --- a/ios/Droidcon/Droidcon/Koin.swift +++ b/ios/Droidcon/Droidcon/Koin.swift @@ -4,7 +4,11 @@ import DroidconKit func startKoin() { let userDefaults = UserDefaults(suiteName: "DROIDCON2023_SETTINGS")! - let koinApplication = DependencyInjectionKt.doInitKoinIos(userDefaults: userDefaults, analyticsService: IOSAnalyticsService()) + let koinApplication = DependencyInjectionKt.doInitKoinIos( + userDefaults: userDefaults, + analyticsService: IOSAnalyticsService(), + googleSignInService: IOSGoogleSignInService() + ) _koin = koinApplication.koin } diff --git a/ios/Droidcon/Droidcon/Settings/SettingsView.swift b/ios/Droidcon/Droidcon/Settings/SettingsView.swift index c3eb06f82..d790c1b83 100644 --- a/ios/Droidcon/Droidcon/Settings/SettingsView.swift +++ b/ios/Droidcon/Droidcon/Settings/SettingsView.swift @@ -1,10 +1,14 @@ import SwiftUI +import FirebaseAuth import DroidconKit struct SettingsView: View { @ObservedObject private(set) var viewModel: SettingsViewModel - + + @State var errorMessage: String = "" + @State var showingAlert: Bool = false + var body: some View { NavigationView { ZStack { @@ -15,9 +19,9 @@ struct SettingsView: View { } .padding(.vertical, 8) .padding(.horizontal) - + Divider().padding(.horizontal) - + Toggle(isOn: $viewModel.isRemindersEnabled) { Label("Settings.Reminders", systemImage: "calendar") } @@ -25,21 +29,82 @@ struct SettingsView: View { .padding(.horizontal) Divider().padding(.horizontal) - + Toggle(isOn: $viewModel.useCompose) { Label("Settings.Compose", systemImage: "doc.text.image") } .padding(.vertical, 8) .padding(.horizontal) - + Divider().padding(.horizontal) - + HStack{ + Image(systemName: "person.fill") + .padding(EdgeInsets(top: 20, leading: 16, bottom: 20, trailing: 0)) + VStack(alignment: .leading) { + Text("Settings.Account") + if viewModel.email != nil { + Text(viewModel.email ?? "") + .font(.caption) + .foregroundColor(.gray) + } + } + Spacer() + if viewModel.isAuthenticated { + Button(action: { + if viewModel.signOut() { + errorMessage = "" + showingAlert = false + } else { + errorMessage = "Failed to Sign Out" + showingAlert = true + } + }) { + Text("Settings.SignOut") + .frame(height: 10) + .padding() + .foregroundColor(.white) + .background( + RoundedRectangle(cornerRadius: 25) + .fill(Color("NavBar_Background")) + ) + } + .padding(.vertical, 8) + .padding(.horizontal) + .buttonStyle(PlainButtonStyle()) + } else { + Button(action: { + if viewModel.signIn() { + errorMessage = "" + showingAlert = false + } else { + errorMessage = "Failed To Sign In" + showingAlert = true + } + }, label: { + Image("continue_with_google_rd") + }) + .padding(.vertical, 8) + .padding(.horizontal) + } + } + + Divider().padding(.horizontal) + AboutView(viewModel: viewModel.about) } } } .navigationTitle("Settings.Title") .navigationBarTitleDisplayMode(.inline) + .alert(isPresented: $showingAlert, content: { + Alert( + title: Text("Error Occurred"), + message: Text(errorMessage), + dismissButton: .default(Text("Got it!")){ + showingAlert = false + } + ) + }) } .navigationViewStyle(StackNavigationViewStyle()) } diff --git a/ios/Droidcon/Droidcon/en.lproj/Localizable.strings b/ios/Droidcon/Droidcon/en.lproj/Localizable.strings index 6790e7969..c35b128e7 100644 --- a/ios/Droidcon/Droidcon/en.lproj/Localizable.strings +++ b/ios/Droidcon/Droidcon/en.lproj/Localizable.strings @@ -37,6 +37,8 @@ "Settings.Reminders" = "Enable reminders"; "Settings.Compose" = "Use compose for iOS"; "Settings.About" = "About"; +"Settings.SignOut" = "Sign Out"; +"Settings.Account" = "Account"; "About.Title" = "About"; diff --git a/ios/Droidcon/Podfile b/ios/Droidcon/Podfile index 08d085866..ec36eeb22 100644 --- a/ios/Droidcon/Podfile +++ b/ios/Droidcon/Podfile @@ -8,4 +8,6 @@ target 'Droidcon' do pod 'Kingfisher', '~> 7.8.1' pod 'Firebase/Analytics' pod 'Firebase/Crashlytics' + pod 'GoogleSignIn' + pod 'FirebaseAuth' end diff --git a/ios/Droidcon/Podfile.lock b/ios/Droidcon/Podfile.lock index 7f8b0e275..738efee74 100644 --- a/ios/Droidcon/Podfile.lock +++ b/ios/Droidcon/Podfile.lock @@ -1,4 +1,10 @@ PODS: + - AppAuth (1.7.3): + - AppAuth/Core (= 1.7.3) + - AppAuth/ExternalUserAgent (= 1.7.3) + - AppAuth/Core (1.7.3) + - AppAuth/ExternalUserAgent (1.7.3): + - AppAuth/Core - Firebase/Analytics (10.15.0): - Firebase/Core - Firebase/Core (10.15.0): @@ -27,6 +33,14 @@ PODS: - GoogleUtilities/Network (~> 7.11) - "GoogleUtilities/NSData+zlib (~> 7.11)" - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseAppCheckInterop (10.23.0) + - FirebaseAuth (10.23.0): + - FirebaseAppCheckInterop (~> 10.17) + - FirebaseCore (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.8) + - GoogleUtilities/Environment (~> 7.8) + - GTMSessionFetcher/Core (< 4.0, >= 2.1) + - RecaptchaInterop (~> 100.0) - FirebaseCore (10.15.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.8) @@ -80,6 +94,10 @@ PODS: - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) + - GoogleSignIn (7.1.0): + - AppAuth (< 2.0, >= 1.7.3) + - GTMAppAuth (< 5.0, >= 4.1.1) + - GTMSessionFetcher/Core (~> 3.3) - GoogleUtilities/AppDelegateSwizzler (7.11.5): - GoogleUtilities/Environment - GoogleUtilities/Logger @@ -99,6 +117,10 @@ PODS: - GoogleUtilities/Logger - GoogleUtilities/UserDefaults (7.11.5): - GoogleUtilities/Logger + - GTMAppAuth (4.1.1): + - AppAuth/Core (~> 1.7) + - GTMSessionFetcher/Core (< 4.0, >= 3.3) + - GTMSessionFetcher/Core (3.3.2) - Kingfisher (7.8.1) - nanopb (2.30909.0): - nanopb/decode (= 2.30909.0) @@ -108,16 +130,22 @@ PODS: - PromisesObjC (2.3.1) - PromisesSwift (2.3.1): - PromisesObjC (= 2.3.1) + - RecaptchaInterop (100.0.0) DEPENDENCIES: - Firebase/Analytics - Firebase/Crashlytics + - FirebaseAuth + - GoogleSignIn - Kingfisher (~> 7.8.1) SPEC REPOS: trunk: + - AppAuth - Firebase - FirebaseAnalytics + - FirebaseAppCheckInterop + - FirebaseAuth - FirebaseCore - FirebaseCoreExtension - FirebaseCoreInternal @@ -126,15 +154,22 @@ SPEC REPOS: - FirebaseSessions - GoogleAppMeasurement - GoogleDataTransport + - GoogleSignIn - GoogleUtilities + - GTMAppAuth + - GTMSessionFetcher - Kingfisher - nanopb - PromisesObjC - PromisesSwift + - RecaptchaInterop SPEC CHECKSUMS: + AppAuth: a13994980c1ec792f7e2e665acd4d4aa6be43240 Firebase: 66043bd4579e5b73811f96829c694c7af8d67435 FirebaseAnalytics: 47cef43728f81a839cf1306576bdd77ffa2eac7e + FirebaseAppCheckInterop: a1955ce8c30f38f87e7d091630e871e91154d65d + FirebaseAuth: 22eb85d3853141de7062bfabc131aa7d6335cade FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e FirebaseCoreExtension: c08d14c7b22e07994e876d837e6f58642f340087 FirebaseCoreInternal: b444828ea7cfd594fca83046b95db98a2be4f290 @@ -143,12 +178,16 @@ SPEC CHECKSUMS: FirebaseSessions: e5f4caa188dc8bc6142abc974355be75b042215e GoogleAppMeasurement: 722db6550d1e6d552b08398b69a975ac61039338 GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 + GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 + GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de + GTMSessionFetcher: 0e876eea9782ec6462e91ab872711c357322c94f Kingfisher: 63f677311d36a3473f6b978584f8a3845d023dc5 nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265 + RecaptchaInterop: 7d1a4a01a6b2cb1610a47ef3f85f0c411434cb21 -PODFILE CHECKSUM: 4654f3ccb4ae86c4f9440935f0df48a0ebdaaeb8 +PODFILE CHECKSUM: 2e35e57413390790899d1dead666725334a49b5b -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt index b72072461..b0973895d 100644 --- a/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt +++ b/ios/src/iosMain/kotlin/co/touchlab/droidcon/ios/DependencyInjection.kt @@ -2,6 +2,7 @@ package co.touchlab.droidcon.ios import co.touchlab.droidcon.application.service.NotificationSchedulingService import co.touchlab.droidcon.domain.service.AnalyticsService +import co.touchlab.droidcon.domain.service.GoogleSignInService import co.touchlab.droidcon.domain.service.impl.ResourceReader import co.touchlab.droidcon.initKoin import co.touchlab.droidcon.ios.service.DefaultParseUrlViewService @@ -24,7 +25,8 @@ import platform.Foundation.NSUserDefaults @OptIn(ExperimentalSettingsApi::class) fun initKoinIos( userDefaults: NSUserDefaults, - analyticsService: AnalyticsService + analyticsService: AnalyticsService, + googleSignInService: GoogleSignInService, ): KoinApplication = initKoin( module { single { BundleProvider(bundle = NSBundle.mainBundle) } @@ -38,6 +40,7 @@ fun initKoinIos( } single { analyticsService } + single { googleSignInService } single { DefaultParseUrlViewService() } } + uiModule diff --git a/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt b/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt index 43609b076..5a73691d1 100644 --- a/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt +++ b/shared-ui/src/androidMain/kotlin/co/touchlab/droidcon/ui/util/MainView.kt @@ -7,6 +7,11 @@ import co.touchlab.droidcon.ui.MainComposeView import co.touchlab.droidcon.viewmodel.ApplicationViewModel @Composable -fun MainView(viewModel: ApplicationViewModel) { - MainComposeView(viewModel = viewModel, modifier = Modifier.systemBarsPadding()) +fun MainView( + viewModel: ApplicationViewModel, +) { + MainComposeView( + viewModel = viewModel, + modifier = Modifier.systemBarsPadding(), + ) } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt index 53cf5696f..bd42efc7c 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/BottomNavigationView.kt @@ -24,7 +24,10 @@ import co.touchlab.droidcon.ui.util.observeAsState import co.touchlab.droidcon.viewmodel.ApplicationViewModel @Composable -internal fun BottomNavigationView(viewModel: ApplicationViewModel, modifier: Modifier = Modifier) { +internal fun BottomNavigationView( + viewModel: ApplicationViewModel, + modifier: Modifier = Modifier, +) { val selectedTab by viewModel.observeSelectedTab.observeAsState() Scaffold( @@ -60,7 +63,9 @@ internal fun BottomNavigationView(viewModel: ApplicationViewModel, modifier: Mod ApplicationViewModel.Tab.Schedule -> SessionListView(viewModel.schedule) ApplicationViewModel.Tab.MyAgenda -> SessionListView(viewModel.agenda) ApplicationViewModel.Tab.Sponsors -> SponsorsView(viewModel.sponsors) - ApplicationViewModel.Tab.Settings -> SettingsView(viewModel.settings) + ApplicationViewModel.Tab.Settings -> SettingsView( + viewModel.settings, + ) } } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt index b6247827c..bacb5344d 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/MainComposeView.kt @@ -10,7 +10,10 @@ import coil3.compose.setSingletonImageLoaderFactory @OptIn(ExperimentalCoilApi::class) @Composable -internal fun MainComposeView(viewModel: ApplicationViewModel, modifier: Modifier = Modifier) { +internal fun MainComposeView( + viewModel: ApplicationViewModel, + modifier: Modifier = Modifier, +) { setSingletonImageLoaderFactory { context -> dcImageLoader(context, true) } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt index 5e644fbbf..36991d7e3 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/UiModule.kt @@ -84,15 +84,39 @@ val uiModule = module { single { SpeakerDetailViewModel.Factory(parseUrlViewService = get()) } - single { SponsorListViewModel.Factory(sponsorGateway = get(), sponsorGroupFactory = get(), sponsorDetailFactory = get()) } + single { + SponsorListViewModel.Factory( + sponsorGateway = get(), + sponsorGroupFactory = get(), + sponsorDetailFactory = get() + ) + } single { SponsorGroupViewModel.Factory(sponsorGroupItemFactory = get()) } single { SponsorGroupItemViewModel.Factory() } - single { SponsorDetailViewModel.Factory(sponsorGateway = get(), speakerListItemFactory = get(), speakerDetailFactory = get()) } + single { + SponsorDetailViewModel.Factory( + sponsorGateway = get(), + speakerListItemFactory = get(), + speakerDetailFactory = get() + ) + } - single { SettingsViewModel.Factory(settingsGateway = get(), aboutFactory = get()) } + single { + SettingsViewModel.Factory( + settingsGateway = get(), + authenticationService = get(), + googleSignInService = get(), + aboutFactory = get() + ) + } single { AboutViewModel.Factory(aboutRepository = get(), parseUrlViewService = get()) } - single { FeedbackDialogViewModel.Factory(sessionGateway = get(), get(parameters = { parametersOf("FeedbackDialogViewModel") })) } + single { + FeedbackDialogViewModel.Factory( + sessionGateway = get(), + get(parameters = { parametersOf("FeedbackDialogViewModel") }) + ) + } single { SessionDetailScrollStateStorage() } } diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt index b95b70f24..cc57b2a98 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/ui/settings/SettingsView.kt @@ -1,8 +1,11 @@ package co.touchlab.droidcon.ui.settings +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -11,12 +14,16 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MailOutline import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Divider import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState @@ -24,17 +31,27 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import co.touchlab.droidcon.ui.theme.Dimensions +import co.touchlab.droidcon.ui.theme.Typography +import co.touchlab.droidcon.ui.util.LocalImage import co.touchlab.droidcon.ui.util.observeAsState import co.touchlab.droidcon.viewmodel.settings.SettingsViewModel import org.brightify.hyperdrive.multiplatformx.property.MutableObservableProperty @OptIn(ExperimentalMaterial3Api::class) @Composable -internal fun SettingsView(viewModel: SettingsViewModel) { +internal fun SettingsView( + viewModel: SettingsViewModel, +) { + val isAuthenticated by viewModel.observeIsAuthenticated.observeAsState() + val email by viewModel.observeEmail.observeAsState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()) Scaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -68,6 +85,30 @@ internal fun SettingsView(viewModel: SettingsViewModel) { Divider() + SettingRow( + text = "Account", + subtext = email, + image = Icons.Default.Person, + ) { + if (isAuthenticated) { + Button( + onClick = { viewModel.signOut() }, + ) { + Text("Sign Out") + } + } else { + TextButton( + onClick = { viewModel.signIn() }, + contentPadding = PaddingValues(), + ) { + LocalImage(imageResourceName = "continue_with_google_rd") + } + } + + } + + Divider() + PlatformSpecificSettingsView(viewModel = viewModel) AboutView(viewModel.about) @@ -76,27 +117,58 @@ internal fun SettingsView(viewModel: SettingsViewModel) { } @Composable -internal fun IconTextSwitchRow(text: String, image: ImageVector, checked: MutableObservableProperty) { +internal fun IconTextSwitchRow( + text: String, + image: ImageVector, + checked: MutableObservableProperty +) { val isChecked by checked.observeAsState() + SettingRow( + text = text, + image = image, + modifier = Modifier.clickable { checked.value = !checked.value }, + ) { + Switch( + modifier = Modifier.padding(start = Dimensions.Padding.default), + checked = isChecked, + onCheckedChange = { checked.value = it }, + ) + } +} + +@Composable +private fun SettingRow( + text: String, + image: ImageVector, + subtext: String? = null, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() - .clickable { checked.value = !checked.value }, + .padding(vertical = Dimensions.Padding.half, horizontal = 24.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( - modifier = Modifier.padding(Dimensions.Padding.default), + modifier = Modifier.padding(end = Dimensions.Padding.default), imageVector = image, contentDescription = text, ) - Text( + Column( modifier = Modifier.weight(1f), - text = text, - ) - Switch( - modifier = Modifier.padding(vertical = Dimensions.Padding.half, horizontal = 24.dp), - checked = isChecked, - onCheckedChange = { checked.value = it }, - ) + ) { + Text( + text = text, + ) + subtext?.let { + Text( + text = it, + color = Color.Gray, + style = Typography.typography.labelMedium, + ) + } + } + content() } -} +} \ No newline at end of file diff --git a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsViewModel.kt b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsViewModel.kt index 0507acaa2..d0d4c8421 100644 --- a/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsViewModel.kt +++ b/shared-ui/src/commonMain/kotlin/co.touchlab.droidcon/viewmodel/settings/SettingsViewModel.kt @@ -1,10 +1,14 @@ package co.touchlab.droidcon.viewmodel.settings import co.touchlab.droidcon.application.gateway.SettingsGateway +import co.touchlab.droidcon.domain.service.AuthenticationService +import co.touchlab.droidcon.domain.service.GoogleSignInService import org.brightify.hyperdrive.multiplatformx.BaseViewModel class SettingsViewModel( settingsGateway: SettingsGateway, + private val authenticationService: AuthenticationService, + private val googleSignInService: GoogleSignInService, private val aboutFactory: AboutViewModel.Factory, ) : BaseViewModel() { @@ -46,11 +50,31 @@ class SettingsViewModel( ) val observeUseCompose by observe(::useCompose) + var isAuthenticated: Boolean by binding( + authenticationService.isAuthenticated, + mapping = { it }, + set = { } + ) + val observeIsAuthenticated by observe(::isAuthenticated) + + + var email: String? by binding( + authenticationService.email, + mapping = { it }, + set = { } + ) + val observeEmail by observe(::email) + + fun signIn() = googleSignInService.performGoogleLogin() + fun signOut() = googleSignInService.performGoogleLogout() + class Factory( private val settingsGateway: SettingsGateway, + private val authenticationService: AuthenticationService, + private val googleSignInService: GoogleSignInService, private val aboutFactory: AboutViewModel.Factory, ) { - fun create() = SettingsViewModel(settingsGateway, aboutFactory) + fun create() = SettingsViewModel(settingsGateway, authenticationService, googleSignInService, aboutFactory) } } diff --git a/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt b/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt index ea984965b..d017d9618 100644 --- a/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt +++ b/shared-ui/src/iosMain/kotlin/co/touchlab/droidcon/ui/ComposeRootController.kt @@ -3,6 +3,9 @@ package co.touchlab.droidcon.ui import androidx.compose.ui.window.ComposeUIViewController import co.touchlab.droidcon.viewmodel.ApplicationViewModel -fun getRootController(viewModel: ApplicationViewModel) = ComposeUIViewController { - MainComposeView(viewModel) -} +fun getRootController( + viewModel: ApplicationViewModel, +) = + ComposeUIViewController { + MainComposeView(viewModel) + } diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt index eda6e3b09..a00d046b2 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/Koin.kt @@ -27,6 +27,7 @@ import co.touchlab.droidcon.domain.repository.impl.SqlDelightSessionRepository import co.touchlab.droidcon.domain.repository.impl.SqlDelightSponsorGroupRepository import co.touchlab.droidcon.domain.repository.impl.SqlDelightSponsorRepository import co.touchlab.droidcon.domain.repository.impl.adapter.InstantSqlDelightAdapter +import co.touchlab.droidcon.domain.service.AuthenticationService import co.touchlab.droidcon.domain.service.DateTimeService import co.touchlab.droidcon.domain.service.FeedbackService import co.touchlab.droidcon.domain.service.ScheduleService @@ -34,6 +35,7 @@ import co.touchlab.droidcon.domain.service.ServerApi import co.touchlab.droidcon.domain.service.SyncService import co.touchlab.droidcon.domain.service.UserIdProvider import co.touchlab.droidcon.domain.service.impl.DefaultApiDataSource +import co.touchlab.droidcon.domain.service.impl.DefaultAuthenticationService import co.touchlab.droidcon.domain.service.impl.DefaultDateTimeService import co.touchlab.droidcon.domain.service.impl.DefaultFeedbackService import co.touchlab.droidcon.domain.service.impl.DefaultScheduleService @@ -235,6 +237,7 @@ private val coreModule = module { clock = get(), ) } + single { DefaultAuthenticationService() } } internal inline fun Scope.getWith(vararg params: Any?): T { diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/UserContext.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/UserContext.kt new file mode 100644 index 000000000..61c813990 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/UserContext.kt @@ -0,0 +1,13 @@ +package co.touchlab.droidcon + +data class UserContext( + val isAuthenticated: Boolean, + val userData: UserData?, +) + +data class UserData( + val id: String, + val name: String?, + val email: String?, + val pictureUrl: String?, +) diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/AuthenticationService.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/AuthenticationService.kt new file mode 100644 index 000000000..d64521109 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/AuthenticationService.kt @@ -0,0 +1,16 @@ +package co.touchlab.droidcon.domain.service + +import kotlinx.coroutines.flow.StateFlow + +interface AuthenticationService { + val isAuthenticated: StateFlow + val email: StateFlow + + fun setCredentials( + id: String, + name: String?, + email: String?, + pictureUrl: String?, + ) + fun clearCredentials() +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/GoogleSignInService.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/GoogleSignInService.kt new file mode 100644 index 000000000..8f08b2613 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/GoogleSignInService.kt @@ -0,0 +1,6 @@ +package co.touchlab.droidcon.domain.service + +interface GoogleSignInService { + fun performGoogleLogin(): Boolean + fun performGoogleLogout(): Boolean +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/UserIdProvider.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/UserIdProvider.kt index 25f2b5d48..3ec21b236 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/UserIdProvider.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/UserIdProvider.kt @@ -1,5 +1,9 @@ package co.touchlab.droidcon.domain.service +import co.touchlab.droidcon.UserContext + interface UserIdProvider { suspend fun getId(): String + + fun saveUserContext(userContext: UserContext) } diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultAuthenticationService.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultAuthenticationService.kt new file mode 100644 index 000000000..ef6d61b42 --- /dev/null +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultAuthenticationService.kt @@ -0,0 +1,54 @@ +package co.touchlab.droidcon.domain.service.impl + +import co.touchlab.droidcon.UserContext +import co.touchlab.droidcon.UserData +import co.touchlab.droidcon.domain.service.AuthenticationService +import co.touchlab.droidcon.domain.service.UserIdProvider +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject + +class DefaultAuthenticationService : AuthenticationService, KoinComponent { + + private val userIdProvider: UserIdProvider by inject() + + private val _isAuthenticated = MutableStateFlow(false) + override val isAuthenticated: StateFlow = _isAuthenticated + + private val _email = MutableStateFlow(null) + override val email: StateFlow = _email + + override fun setCredentials( + id: String, + name: String?, + email: String?, + pictureUrl: String?, + ) { + _isAuthenticated.update { true } + _email.update { email } + userIdProvider.saveUserContext( + UserContext( + isAuthenticated = true, + userData = UserData( + id = id, + name = name, + email = email, + pictureUrl = pictureUrl, + ) + ) + ) + } + + override fun clearCredentials() { + _isAuthenticated.update { false } + _email.update { null } + userIdProvider.saveUserContext( + UserContext( + isAuthenticated = false, + userData = null + ) + ) + } +} diff --git a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultUserIdProvider.kt b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultUserIdProvider.kt index 53959911e..e699a25eb 100644 --- a/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultUserIdProvider.kt +++ b/shared/src/commonMain/kotlin/co/touchlab/droidcon/domain/service/impl/DefaultUserIdProvider.kt @@ -1,5 +1,6 @@ package co.touchlab.droidcon.domain.service.impl +import co.touchlab.droidcon.UserContext import co.touchlab.droidcon.domain.service.UserIdProvider import com.benasher44.uuid.uuid4 import com.russhwolf.settings.ExperimentalSettingsApi @@ -23,4 +24,8 @@ class DefaultUserIdProvider( } return id } + + override fun saveUserContext(userContext: UserContext) { + // TODO + } }