package com.syncflow.ui.auth import android.accounts.AccountManager import android.app.Activity import android.view.WindowManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.syncflow.R import com.syncflow.domain.model.ProviderType // All providers in display order private val ALL_PROVIDERS = listOf( ProviderType.NEXTCLOUD, ProviderType.OWNCLOUD, ProviderType.SFTPGO, ProviderType.WEBDAV, ProviderType.SFTP, ProviderType.GOOGLE_DRIVE, ProviderType.DROPBOX, ProviderType.ONEDRIVE, ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountSetupScreen( onDone: () -> Unit, vm: AccountSetupViewModel = hiltViewModel(), ) { val state by vm.state.collectAsState() val context = LocalContext.current LaunchedEffect(state.done) { if (state.done) onDone() } val googleAccountLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == Activity.RESULT_OK) { val name = result.data?.getStringExtra(AccountManager.KEY_ACCOUNT_NAME) if (name != null) vm.onGoogleAccountChosen(name) } } Scaffold( topBar = { TopAppBar( title = { Text( if (state.providerType == null) "Choose a service" else state.providerType!!.friendlyName(), fontWeight = FontWeight.SemiBold, ) }, navigationIcon = { IconButton(onClick = { if (state.providerType != null) vm.update { copy(providerType = null, testResult = null, error = null) } else onDone() }) { Icon( if (state.providerType != null) Icons.Default.ArrowBack else Icons.Default.Close, contentDescription = null, ) } }, actions = { if (state.providerType != null) { TextButton( onClick = vm::save, enabled = !state.isSaving && state.testResult is TestResult.Success, ) { if (state.isSaving) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) else Text("Save") } } }, ) }, ) { padding -> if (state.providerType == null) { ProviderPickerContent( modifier = Modifier.padding(padding), onPick = { vm.update { copy(providerType = it) } }, ) } else { CredentialContent( state = state, modifier = Modifier.padding(padding), vm = vm, onDropboxConnect = { launchDropboxOAuth(context, vm.credentialStore, context.getString(R.string.dropbox_app_key)) }, onGoogleSignIn = { val intent = AccountManager.newChooseAccountIntent( null, null, arrayOf("com.google"), null, null, null, null, ) googleAccountLauncher.launch(intent) }, onOneDriveSignIn = { launchOneDriveOAuth(context, vm.credentialStore, context.getString(R.string.onedrive_client_id)) }, ) } } } // ─── Step 1: Provider picker grid ──────────────────────────────────────────── @Composable private fun ProviderPickerContent(modifier: Modifier = Modifier, onPick: (ProviderType) -> Unit) { Column(modifier = modifier.fillMaxSize()) { Text( "Select the service you want to sync with", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), ) LazyVerticalGrid( columns = GridCells.Fixed(2), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxSize(), ) { items(ALL_PROVIDERS) { provider -> ProviderCard(provider = provider, onClick = { onPick(provider) }) } } } } @Composable private fun ProviderCard(provider: ProviderType, onClick: () -> Unit) { ElevatedCard( modifier = Modifier .fillMaxWidth() .aspectRatio(1.4f) .clip(MaterialTheme.shapes.large) .clickable(onClick = onClick), elevation = CardDefaults.elevatedCardElevation(defaultElevation = 3.dp), ) { Column( modifier = Modifier.fillMaxSize().padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Image( painter = painterResource(provider.iconRes()), contentDescription = null, modifier = Modifier.size(48.dp), ) Spacer(Modifier.height(10.dp)) Text( provider.friendlyName(), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Medium, ) provider.subtitle()?.let { sub -> Text( sub, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } } // ─── Step 2: Credential form ────────────────────────────────────────────────── @Composable private fun CredentialContent( state: AccountSetupState, modifier: Modifier, vm: AccountSetupViewModel, onDropboxConnect: () -> Unit, onGoogleSignIn: () -> Unit, onOneDriveSignIn: () -> Unit, ) { val provider = state.providerType ?: return // Prevent screenshots and screen recording while credentials are visible val activity = LocalContext.current as? Activity DisposableEffect(Unit) { activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_SECURE) onDispose { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } } Column( modifier = modifier .padding(horizontal = 20.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(14.dp), ) { Spacer(Modifier.height(4.dp)) // Account display name OutlinedTextField( value = state.displayName, onValueChange = { vm.update { copy(displayName = it) } }, label = { Text("Account name (optional)") }, placeholder = { Text(provider.friendlyName()) }, leadingIcon = { Icon(Icons.Default.Badge, null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), ) HorizontalDivider() // Provider-specific fields when { provider == ProviderType.GOOGLE_DRIVE -> OAuthSection( providerName = "Google Drive", email = state.oauthEmail, isConnected = state.oauthToken.isNotBlank(), onConnect = onGoogleSignIn, connectLabel = "Choose Google Account", description = "Picks any Google account already added to this device.", ) provider == ProviderType.DROPBOX -> OAuthSection( providerName = "Dropbox", email = state.oauthEmail, isConnected = state.oauthToken.isNotBlank(), onConnect = onDropboxConnect, connectLabel = "Authorize with Dropbox", description = "Opens Dropbox in your browser. Works with any Dropbox account.", ) provider == ProviderType.ONEDRIVE -> OAuthSection( providerName = "OneDrive", email = state.oauthEmail, isConnected = state.oauthToken.isNotBlank(), onConnect = onOneDriveSignIn, connectLabel = "Sign in with Microsoft", description = "Personal, work, or school Microsoft account.", ) provider == ProviderType.SFTP -> SftpFields(state, vm) else -> ServerFields(provider, state, vm) } // Test Connection (required before Save) HorizontalDivider() Button( onClick = vm::testConnection, enabled = !state.isTestingConnection && credentialsFilled(state), modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors( containerColor = when (state.testResult) { is TestResult.Success -> MaterialTheme.colorScheme.primary is TestResult.Failure -> MaterialTheme.colorScheme.error null -> MaterialTheme.colorScheme.secondary }, ), ) { when { state.isTestingConnection -> { CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp, color = Color.White) Spacer(Modifier.width(10.dp)) Text("Testing…") } state.testResult is TestResult.Success -> { Icon(Icons.Default.CheckCircle, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text("Connected — tap Save ↑") } state.testResult is TestResult.Failure -> { Icon(Icons.Default.Refresh, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text("Retry Test Connection") } else -> { Icon(Icons.Default.NetworkCheck, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text("Test Connection") } } } when (val r = state.testResult) { is TestResult.Failure -> Text(r.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) is TestResult.Success -> Text("Connection successful!", color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.bodySmall) null -> if (credentialsFilled(state)) Text("Test required before saving.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } state.error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } Spacer(Modifier.height(32.dp)) } } // ─── Credential field composables ──────────────────────────────────────────── @Composable private fun OAuthSection( providerName: String, email: String, isConnected: Boolean, onConnect: () -> Unit, connectLabel: String, description: String, ) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (!isConnected) { Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } else { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { Icon(Icons.Default.CheckCircle, null, Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary) Column { Text("Authorized", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) if (email.isNotBlank()) Text(email, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } } OutlinedButton(onClick = onConnect, modifier = Modifier.fillMaxWidth()) { Icon(if (isConnected) Icons.Default.SwitchAccount else Icons.Default.Login, null, Modifier.size(18.dp)) Spacer(Modifier.width(8.dp)) Text(if (isConnected) "Switch account" else connectLabel) } } } @Composable private fun ServerFields(provider: ProviderType, state: AccountSetupState, vm: AccountSetupViewModel) { val urlHint = when (provider) { ProviderType.NEXTCLOUD -> "https://cloud.example.com" ProviderType.OWNCLOUD -> "https://owncloud.example.com" ProviderType.SFTPGO -> "https://sftpgo.example.com" else -> "https://dav.example.com" } val urlNote = when (provider) { ProviderType.NEXTCLOUD -> "Just the base URL — WebDAV path is added automatically." ProviderType.OWNCLOUD -> "Just the base URL — WebDAV path is added automatically." ProviderType.SFTPGO -> "Base URL only — SFTPGo WebDAV path added automatically." else -> null } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = state.serverUrl, onValueChange = { vm.update { copy(serverUrl = it) } }, label = { Text("Server URL") }, placeholder = { Text(urlHint) }, leadingIcon = { Icon(Icons.Default.Language, null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), ) urlNote?.let { Text(it, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } if (state.httpWarning) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.fillMaxWidth(), ) { Icon(Icons.Default.Warning, null, Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error) Text( "HTTP sends credentials in plaintext. Use HTTPS for security.", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.error, ) } } OutlinedTextField( value = state.username, onValueChange = { vm.update { copy(username = it) } }, label = { Text("Username") }, leadingIcon = { Icon(Icons.Default.Person, null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), ) PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password") } } @Composable private fun SftpFields(state: AccountSetupState, vm: AccountSetupViewModel) { var useKey by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = state.serverUrl, onValueChange = { vm.update { copy(serverUrl = it) } }, label = { Text("Hostname or IP") }, placeholder = { Text("sftp.example.com") }, leadingIcon = { Icon(Icons.Default.Dns, null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next), ) OutlinedTextField( value = state.port, onValueChange = { vm.update { copy(port = it) } }, label = { Text("Port") }, placeholder = { Text("22") }, leadingIcon = { Icon(Icons.Default.SettingsEthernet, null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next), ) OutlinedTextField( value = state.username, onValueChange = { vm.update { copy(username = it) } }, label = { Text("Username") }, leadingIcon = { Icon(Icons.Default.Person, null) }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), ) Row(verticalAlignment = Alignment.CenterVertically) { Switch(checked = useKey, onCheckedChange = { useKey = it }) Spacer(Modifier.width(10.dp)) Text("Use SSH private key", style = MaterialTheme.typography.bodySmall) } if (useKey) { OutlinedTextField( value = state.privateKey, onValueChange = { vm.update { copy(privateKey = it) } }, label = { Text("Private key (PEM)") }, placeholder = { Text("-----BEGIN RSA PRIVATE KEY-----\n…") }, modifier = Modifier.fillMaxWidth().height(120.dp), ) } else { PasswordField(value = state.password, onValueChange = { vm.update { copy(password = it) } }, label = "Password") } } } @Composable private fun PasswordField(value: String, onValueChange: (String) -> Unit, label: String) { var visible by remember { mutableStateOf(false) } OutlinedTextField( value = value, onValueChange = onValueChange, label = { Text(label) }, leadingIcon = { Icon(Icons.Default.Lock, null) }, trailingIcon = { IconButton(onClick = { visible = !visible }) { Icon(if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, null) } }, visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done), ) } // ─── Helpers ────────────────────────────────────────────────────────────────── private fun credentialsFilled(state: AccountSetupState): Boolean { val p = state.providerType ?: return false return when { p.isOAuth() -> state.oauthToken.isNotBlank() p == ProviderType.SFTP -> state.serverUrl.isNotBlank() && state.username.isNotBlank() else -> state.serverUrl.isNotBlank() && state.username.isNotBlank() } } private fun ProviderType.iconRes() = when (this) { ProviderType.NEXTCLOUD -> R.drawable.ic_provider_nextcloud ProviderType.OWNCLOUD -> R.drawable.ic_provider_owncloud ProviderType.SFTPGO -> R.drawable.ic_provider_sftpgo ProviderType.WEBDAV -> R.drawable.ic_provider_webdav ProviderType.SFTP -> R.drawable.ic_provider_sftp ProviderType.GOOGLE_DRIVE -> R.drawable.ic_provider_googledrive ProviderType.DROPBOX -> R.drawable.ic_provider_dropbox ProviderType.ONEDRIVE -> R.drawable.ic_provider_onedrive } private fun ProviderType.subtitle() = when (this) { ProviderType.NEXTCLOUD -> "Self-hosted" ProviderType.OWNCLOUD -> "Self-hosted" ProviderType.SFTPGO -> "Self-hosted" ProviderType.WEBDAV -> "Any WebDAV server" ProviderType.SFTP -> "SSH file transfer" ProviderType.GOOGLE_DRIVE -> "Google account" ProviderType.DROPBOX -> "Dropbox account" ProviderType.ONEDRIVE -> "Microsoft account" }