diff --git a/android/app/build.gradle b/android/app/build.gradle index 3ef258a2..010393fa 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,3 +1,11 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlinx-serialization' + id 'dev.flutter.flutter-gradle-plugin' + id 'com.google.android.gms.oss-licenses-plugin' +} + def localProperties = new Properties() def localPropertiesFile = rootProject.file('local.properties') if (localPropertiesFile.exists()) { @@ -6,31 +14,16 @@ if (localPropertiesFile.exists()) { } } -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new FileNotFoundException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - def flutterVersionCode = localProperties.getProperty('flutter.versionCode') if (flutterVersionCode == null) { - //noinspection GroovyUnusedAssignment flutterVersionCode = '1' } def flutterVersionName = localProperties.getProperty('flutter.versionName') if (flutterVersionName == null) { - //noinspection GroovyUnusedAssignment flutterVersionName = '1.0' } -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply plugin: 'kotlinx-serialization' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" -apply plugin: 'com.google.android.gms.oss-licenses-plugin' - -import com.android.build.OutputFile - android { namespace 'com.yubico.authenticator' @@ -58,7 +51,6 @@ android { versionName flutterVersionName } - buildTypes { release { minifyEnabled true @@ -78,7 +70,7 @@ android { applicationVariants.all { variant -> variant.outputs.each { output -> def abiCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, x86: 3, x86_64: 4] - def abiCode = abiCodes.get(output.getFilter(OutputFile.ABI)) + def abiCode = abiCodes.get(output.getFilter(com.android.build.OutputFile.ABI)) output.versionCodeOverride = variant.versionCode * 10 + (abiCode != null ? abiCode : 0) } } @@ -99,7 +91,7 @@ dependencies { api "com.yubico.yubikit:fido:$project.yubiKitVersion" api "com.yubico.yubikit:support:$project.yubiKitVersion" - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.2' + implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3' // Lifecycle implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' diff --git a/android/app/collect_licenses.gradle b/android/app/collect_licenses.gradle index 36425d51..165e6e80 100644 --- a/android/app/collect_licenses.gradle +++ b/android/app/collect_licenses.gradle @@ -68,7 +68,7 @@ def collectLicenses(File rootDir, File ossPluginResDir, File outDir) { println "Created ${outFile.absolutePath}" // copy license assets to flutter resources - def licensesDir = new File(rootDir, "licenses/"); + def licensesDir = new File(rootDir, "licenses/") copy { from(licensesDir.absolutePath) { include "**/*txt" diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/ChannelHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/ChannelHelper.kt index 35272a21..c33b5ee3 100755 --- a/android/app/src/main/kotlin/com/yubico/authenticator/ChannelHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/ChannelHelper.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -32,6 +32,16 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +interface JsonSerializable { + fun toJson() : String +} + +sealed interface ViewModelData { + data object Empty : ViewModelData + data object Loading : ViewModelData + data class Value(val data: T) : ViewModelData +} + /** * Observes a LiveData value, sending each change to Flutter via an EventChannel. */ @@ -61,6 +71,48 @@ inline fun LiveData.streamTo(lifecycleOwner: LifecycleOwner, mess } } +/** + * Observes a ViewModelData LiveData value, sending each change to Flutter via an EventChannel. + */ +@JvmName("streamViewModelData") +inline fun LiveData.streamTo(lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, channelName: String): Closeable { + val channel = EventChannel(messenger, channelName) + var sink: EventChannel.EventSink? = null + + val get: (ViewModelData) -> String = { + when (it) { + is ViewModelData.Empty -> NULL + is ViewModelData.Loading -> LOADING + is ViewModelData.Value<*> -> it.data.toJson() + } + } + + channel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + sink = events + events.success( + value?.let { + get(it) + } ?: NULL + ) + } + + override fun onCancel(arguments: Any?) { + sink = null + } + }) + + val observer = Observer { + sink?.success(get(it)) + } + observe(lifecycleOwner, observer) + + return Closeable { + removeObserver(observer) + channel.setStreamHandler(null) + } +} + typealias MethodHandler = suspend (method: String, args: Map) -> String /** diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt b/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt index a3187259..982e91ac 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022,2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,6 +20,8 @@ import kotlinx.serialization.json.Json const val NULL = "null" +const val LOADING = "\"loading\"" + val jsonSerializer = Json { // creates properties for default values encodeDefaults = true diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index ae340232..528a8195 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -285,6 +285,10 @@ class MainActivity : FlutterFragmentActivity() { switchContext(preferredContext) } + if (contextManager == null) { + switchContext(DeviceManager.getPreferredContext(supportedApps)) + } + contextManager?.let { try { it.processYubiKey(device) @@ -336,27 +340,40 @@ class MainActivity : FlutterFragmentActivity() { } private fun switchContext(appContext: OperationContext) { - contextManager?.dispose() - contextManager = when (appContext) { - OperationContext.Oath -> OathManager( - this, - messenger, - deviceManager, - oathViewModel, - dialogManager, - appPreferences - ) + // TODO: refactor this when more OperationContext are handled + // only recreate the contextManager object if it cannot be reused + if (appContext == OperationContext.Home || + (appContext == OperationContext.Oath && contextManager is OathManager) || + (appContext == OperationContext.FidoPasskeys && contextManager is FidoManager) + ) { + // no need to dispose this context + } else { + contextManager?.dispose() + contextManager = null + } - OperationContext.FidoFingerprints, - OperationContext.FidoPasskeys -> FidoManager( - messenger, - deviceManager, - fidoViewModel, - viewModel, - dialogManager - ) + if (contextManager == null) { + contextManager = when (appContext) { + OperationContext.Oath -> OathManager( + this, + messenger, + deviceManager, + oathViewModel, + dialogManager, + appPreferences + ) - else -> null + OperationContext.FidoFingerprints, + OperationContext.FidoPasskeys -> FidoManager( + messenger, + deviceManager, + fidoViewModel, + viewModel, + dialogManager + ) + + else -> null + } } } @@ -481,6 +498,11 @@ class MainActivity : FlutterFragmentActivity() { startActivity(Intent(ACTION_NFC_SETTINGS)) result.success(true) } + + "isArc" -> { + val regex = ".+_cheets|cheets_.+".toRegex() + result.success(Build.DEVICE?.matches(regex) ?: false) + } else -> logger.warn("Unknown app method: {}", methodCall.method) } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt index 09083bd4..ee8cef32 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt @@ -23,15 +23,16 @@ import com.yubico.authenticator.device.Info import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice enum class OperationContext(val value: Int) { - Oath(0), - FidoU2f(1), - FidoFingerprints(2), - FidoPasskeys(3), - YubiOtp(4), - Piv(5), - OpenPgp(6), - HsmAuth(7), - Management(8), + Home(0), + Oath(1), + FidoU2f(2), + FidoFingerprints(3), + FidoPasskeys(4), + YubiOtp(5), + Piv(6), + OpenPgp(7), + HsmAuth(8), + Management(9), Invalid(-1); companion object { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt index a0e7c802..841af3ea 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/device/DeviceManager.kt @@ -45,7 +45,7 @@ class DeviceManager( const val NFC_DATA_CLEANUP_DELAY = 30L * 1000 // 30s private val logger = LoggerFactory.getLogger(DeviceManager::class.java) - fun getSupportedContexts(device: YubiKeyDevice) : ArraySet { + fun getSupportedContexts(device: YubiKeyDevice) : ArraySet = try { val operationContexts = ArraySet() @@ -61,7 +61,6 @@ class DeviceManager( try { Ctap2Session(it) operationContexts.add(OperationContext.FidoPasskeys) - operationContexts.add(OperationContext.FidoFingerprints) } catch (e: Throwable) { // ignored } @@ -80,7 +79,10 @@ class DeviceManager( } logger.debug("Device supports following contexts: {}", operationContexts) - return operationContexts + operationContexts + } catch(e: Exception) { + logger.debug("The device does not support any context. The following exception was caught: ", e) + ArraySet() } fun getPreferredContext(contexts: ArraySet) : OperationContext { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt index 641b8d75..ba83fa2e 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoConnectionHelper.kt @@ -43,6 +43,13 @@ class FidoConnectionHelper( } } + fun cancelPending() { + pendingAction?.let { action -> + action.invoke(Result.failure(CancellationException())) + pendingAction = null + } + } + suspend fun useSession( actionDescription: FidoActionDescription, action: (YubiKitFidoSession) -> T diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt index 8f9e418e..ad4a26b2 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoManager.kt @@ -16,6 +16,7 @@ package com.yubico.authenticator.fido +import android.nfc.TagLostException import com.yubico.authenticator.AppContextManager import com.yubico.authenticator.DialogManager import com.yubico.authenticator.MainViewModel @@ -28,6 +29,7 @@ import com.yubico.authenticator.device.UnknownDevice import com.yubico.authenticator.fido.data.FidoCredential import com.yubico.authenticator.fido.data.FidoFingerprint import com.yubico.authenticator.fido.data.Session +import com.yubico.authenticator.fido.data.SessionInfo import com.yubico.authenticator.fido.data.YubiKitFidoSession import com.yubico.authenticator.setHandler import com.yubico.authenticator.yubikit.getDeviceInfo @@ -135,14 +137,14 @@ class FidoManager( (args["pin"] as String).toCharArray() ) - "set_pin" -> setPin( + "setPin" -> setPin( (args["pin"] as String?)?.toCharArray(), - (args["new_pin"] as String).toCharArray(), + (args["newPin"] as String).toCharArray(), ) - "delete_credential" -> deleteCredential( - args["rp_id"] as String, - args["credential_id"] as String + "deleteCredential" -> deleteCredential( + args["rpId"] as String, + args["credentialId"] as String ) "delete_fingerprint" -> deleteFingerprint( @@ -163,20 +165,14 @@ class FidoManager( else -> throw NotImplementedError() } } - - if (!deviceManager.isUsbKeyConnected()) { - // for NFC connections require extra tap when switching context - if (fidoViewModel.sessionState.value == null) { - fidoViewModel.setSessionState(Session.uninitialized) - } - } - } override fun dispose() { super.dispose() deviceManager.removeDeviceListener(this) fidoChannel.setMethodCallHandler(null) + fidoViewModel.clearSessionState() + fidoViewModel.updateCredentials(emptyList()) coroutineScope.cancel() } @@ -210,7 +206,7 @@ class FidoManager( } // Clear any cached FIDO state - fidoViewModel.setSessionState(null) + fidoViewModel.clearSessionState() } } @@ -223,8 +219,8 @@ class FidoManager( YubiKitFidoSession(connection as SmartCardConnection) } - val previousSession = fidoViewModel.sessionState.value?.info - val currentSession = fidoSession.cachedInfo + val previousSession = fidoViewModel.currentSession()?.info + val currentSession = SessionInfo(fidoSession.cachedInfo) logger.debug( "Previous session: {}, current session: {}", previousSession, @@ -241,6 +237,7 @@ class FidoManager( // different key logger.debug("This is a different key than previous, invalidating the PIN token") pinStore.setPin(null) + connectionHelper.cancelPending() } fidoViewModel.setSessionState( @@ -279,6 +276,8 @@ class FidoManager( pin: CharArray ): String { + //fidoViewModel.setSessionLoadingState() + val pinPermissionsCM = getPinPermissionsCM(fidoSession) val pinPermissionsBE = getPinPermissionsBE(fidoSession) val permissions = pinPermissionsCM or pinPermissionsBE @@ -342,7 +341,12 @@ class FidoManager( catchPinErrors(clientPin) { unlockSession(fidoSession, clientPin, pin) } - + } catch (e: IOException) { + // something failed, keep the session locked + fidoViewModel.currentSession()?.let { + fidoViewModel.setSessionState(it.copy(info = it.info, unlocked = false)) + } + throw e } finally { Arrays.fill(pin, 0.toChar()) } @@ -416,13 +420,8 @@ class FidoManager( val clientPin = ClientPin(fidoSession, getPreferredPinUvAuthProtocol(fidoSession.cachedInfo)) - val token = - clientPin.getPinToken( - pinStore.getPin(), - getPinPermissionsCM(fidoSession), - null - ) - + val permissions = getPinPermissionsCM(fidoSession) + val token = clientPin.getPinToken(pinStore.getPin(), permissions, null) val credMan = CredentialManagement(fidoSession, clientPin.pinUvAuth, token) val credentialDescriptor = @@ -593,11 +592,11 @@ class FidoManager( override fun onDisconnected() { if (!resetHelper.inProgress) { - fidoViewModel.setSessionState(null) + fidoViewModel.clearSessionState() } } override fun onTimeout() { - fidoViewModel.setSessionState(null) + fidoViewModel.clearSessionState() } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoPinStore.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoPinStore.kt index 43e2a841..58170df8 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoPinStore.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoPinStore.kt @@ -28,6 +28,7 @@ class FidoPinStore { } fun setPin(newPin: CharArray?) { + pin?.fill(0.toChar()) pin = newPin?.clone() } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt index 165b4860..bffbbfbc 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoResetHelper.kt @@ -110,10 +110,6 @@ class FidoResetHelper( } finally { inProgress = false deviceManager.clearDeviceInfoOnDisconnect = true - if (!deviceManager.isUsbKeyConnected()) { - fidoViewModel.setSessionState(null) - fidoViewModel.updateCredentials(emptyList()) - } } return NULL } @@ -227,10 +223,6 @@ class FidoResetHelper( continuation.resume(Unit) } } catch (e: Throwable) { - when (e) { - is CancellationException -> logger.debug("FIDO reset over NFC was cancelled") - else -> logger.error("FIDO reset over NFC failed with exception: ", e) - } // on NFC, clean device info in this situation mainViewModel.setDeviceInfo(null) continuation.resumeWithException(e) diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt index 02bc1d57..52b7cf34 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/FidoViewModel.kt @@ -19,17 +19,28 @@ package com.yubico.authenticator.fido import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.yubico.authenticator.ViewModelData import com.yubico.authenticator.fido.data.FidoCredential import com.yubico.authenticator.fido.data.FidoFingerprint import com.yubico.authenticator.fido.data.Session import org.json.JSONObject class FidoViewModel : ViewModel() { - private val _sessionState = MutableLiveData(null) - val sessionState: LiveData = _sessionState + private val _sessionState = MutableLiveData() + val sessionState: LiveData = _sessionState - fun setSessionState(sessionState: Session?) { - _sessionState.postValue(sessionState) + fun currentSession() : Session? = (_sessionState.value as? ViewModelData.Value<*>)?.data as? Session? + + fun setSessionState(sessionState: Session) { + _sessionState.postValue(ViewModelData.Value(sessionState)) + } + + fun clearSessionState() { + _sessionState.postValue(ViewModelData.Empty) + } + + fun setSessionLoadingState() { + _sessionState.postValue(ViewModelData.Loading) } private val _credentials = MutableLiveData>() diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt b/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt index 37150c8d..73e343cf 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/fido/data/Session.kt @@ -16,6 +16,8 @@ package com.yubico.authenticator.fido.data +import com.yubico.authenticator.JsonSerializable +import com.yubico.authenticator.jsonSerializer import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData import kotlinx.serialization.* @@ -28,16 +30,21 @@ data class Options( val credentialMgmtPreview: Boolean, val bioEnroll: Boolean?, val alwaysUv: Boolean -) +) { + constructor(infoData: InfoData) : this( + infoData.getOptionsBoolean("clientPin") ?: false, + infoData.getOptionsBoolean("credMgmt") ?: false, + infoData.getOptionsBoolean("credentialMgmtPreview") ?: false, + infoData.getOptionsBoolean("bioEnroll"), + infoData.getOptionsBoolean("alwaysUv") ?: false, + ) -fun Map.getBoolean( - key: String, - default: Boolean = false -): Boolean = get(key) as? Boolean ?: default - -fun Map.getOptionalBoolean( - key: String -): Boolean? = get(key) as? Boolean + companion object { + private fun InfoData.getOptionsBoolean( + key: String + ): Boolean? = options[key] as? Boolean? + } +} @Serializable data class SessionInfo( @@ -49,13 +56,7 @@ data class SessionInfo( val forcePinChange: Boolean ) { constructor(infoData: InfoData) : this( - Options( - infoData.options.getBoolean("clientPin"), - infoData.options.getBoolean("credMgmt"), - infoData.options.getBoolean("credentialMgmtPreview"), - infoData.options.getOptionalBoolean("bioEnroll"), - infoData.options.getBoolean("alwaysUv") - ), + Options(infoData), infoData.aaguid, infoData.minPinLength, infoData.forcePinChange @@ -87,30 +88,13 @@ data class SessionInfo( data class Session( @SerialName("info") val info: SessionInfo, - val unlocked: Boolean, - val initialized: Boolean -) { - constructor(infoData: InfoData, unlocked: Boolean) : this( - SessionInfo(infoData), unlocked, true + val unlocked: Boolean +) : JsonSerializable { + constructor(infoData: InfoData, unlocked: Boolean) : this( + SessionInfo(infoData), unlocked ) - companion object { - val uninitialized = Session( - SessionInfo( - Options( - clientPin = false, - credMgmt = false, - credentialMgmtPreview = false, - bioEnroll = null, - alwaysUv = false - ), - aaguid = ByteArray(0), - minPinLength = 0, - forcePinChange = false - ), - unlocked = false, - initialized = false - ) + override fun toJson(): String { + return jsonSerializer.encodeToString(this) } - } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt index 45e92a9f..c32e053a 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -200,13 +200,6 @@ class OathManager( else -> throw NotImplementedError() } } - - if (!deviceManager.isUsbKeyConnected()) { - // for NFC connections require extra tap when switching context - if (oathViewModel.sessionState.value == null) { - oathViewModel.setSessionState(Session.uninitialized) - } - } } override fun dispose() { @@ -214,6 +207,8 @@ class OathManager( deviceManager.removeDeviceListener(this) oathViewModel.credentials.removeObserver(credentialObserver) oathChannel.setMethodCallHandler(null) + oathViewModel.clearSession() + oathViewModel.updateCredentials(mapOf()) coroutineScope.cancel() } @@ -221,7 +216,7 @@ class OathManager( try { device.withConnection { connection -> val session = getOathSession(connection) - val previousId = oathViewModel.sessionState.value?.deviceId + val previousId = oathViewModel.currentSession()?.deviceId if (session.deviceId == previousId && device is NfcYubiKeyDevice) { // Run any pending action pendingAction?.let { action -> @@ -323,7 +318,7 @@ class OathManager( } // Clear any cached OATH state - oathViewModel.setSessionState(null) + oathViewModel.clearSession() } } @@ -466,7 +461,7 @@ class OathManager( private fun forgetPassword(): String { keyManager.clearAll() logger.debug("Cleared all keys.") - oathViewModel.sessionState.value?.let { + oathViewModel.currentSession()?.let { oathViewModel.setSessionState( it.copy( isLocked = it.isAccessKeySet, @@ -750,10 +745,10 @@ class OathManager( override fun onDisconnected() { refreshJob?.cancel() - oathViewModel.setSessionState(null) + oathViewModel.clearSession() } override fun onTimeout() { - oathViewModel.setSessionState(null) + oathViewModel.clearSession() } } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt index dd13da43..cbef7238 100755 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2023 Yubico. + * Copyright (C) 2022-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,30 +19,39 @@ package com.yubico.authenticator.oath import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.yubico.authenticator.ViewModelData import com.yubico.authenticator.oath.data.Code import com.yubico.authenticator.oath.data.Credential import com.yubico.authenticator.oath.data.CredentialWithCode import com.yubico.authenticator.oath.data.Session class OathViewModel: ViewModel() { - private val _sessionState = MutableLiveData() - val sessionState: LiveData = _sessionState + + private val _sessionState = MutableLiveData() + val sessionState: LiveData = _sessionState + + fun currentSession() : Session? = (_sessionState.value as? ViewModelData.Value<*>)?.data as? Session? // Sets session and credentials after performing OATH reset // Note: we cannot use [setSessionState] because resetting OATH changes deviceId fun resetOathSession(sessionState: Session, credentials: Map) { - _sessionState.postValue(sessionState) + _sessionState.postValue(ViewModelData.Value(sessionState)) updateCredentials(credentials) } - fun setSessionState(sessionState: Session?) { - val oldDeviceId = _sessionState.value?.deviceId - _sessionState.postValue(sessionState) - if(oldDeviceId != sessionState?.deviceId) { + fun setSessionState(sessionState: Session) { + val oldDeviceId = currentSession()?.deviceId + _sessionState.postValue(ViewModelData.Value(sessionState)) + if(oldDeviceId != sessionState.deviceId) { _credentials.postValue(null) } } + fun clearSession() { + _sessionState.postValue(ViewModelData.Empty) + _credentials.postValue(null) + } + private val _credentials = MutableLiveData?>() val credentials: LiveData?> = _credentials @@ -59,7 +68,7 @@ class OathViewModel: ViewModel() { } fun addCredential(credential: Credential, code: Code?): CredentialWithCode { - require(credential.deviceId == _sessionState.value?.deviceId) { + require(credential.deviceId == currentSession()?.deviceId) { "Cannot add credential for different deviceId" } return CredentialWithCode(credential, code).also { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt index eaff2554..3a7bd0ab 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt @@ -16,10 +16,13 @@ package com.yubico.authenticator.oath.data +import com.yubico.authenticator.JsonSerializable import com.yubico.authenticator.device.Version +import com.yubico.authenticator.jsonSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString typealias YubiKitOathSession = com.yubico.yubikit.oath.OathSession @@ -34,9 +37,8 @@ data class Session( @SerialName("remembered") val isRemembered: Boolean, @SerialName("locked") - val isLocked: Boolean, - val initialized: Boolean -) { + val isLocked: Boolean +) : JsonSerializable { @SerialName("keystore") @Suppress("unused") val keystoreState: String = "unknown" @@ -51,18 +53,10 @@ data class Session( ), oathSession.isAccessKeySet, isRemembered, - oathSession.isLocked, - initialized = true + oathSession.isLocked ) - companion object { - val uninitialized = Session( - deviceId = "", - version = Version(0, 0, 0), - isAccessKeySet = false, - isRemembered = false, - isLocked = false, - initialized = false - ) + override fun toJson(): String { + return jsonSerializer.encodeToString(this) } } \ No newline at end of file diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt index 22b23889..98abb2a2 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt @@ -17,6 +17,7 @@ package com.yubico.authenticator.oath import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.yubico.authenticator.ViewModelData import com.yubico.authenticator.device.Version import com.yubico.authenticator.oath.OathTestHelper.code import com.yubico.authenticator.oath.OathTestHelper.emptyCredentials @@ -40,13 +41,12 @@ class ModelTest { private fun connectDevice(deviceId: String) { viewModel.setSessionState( Session( - deviceId, - Version(1, 2, 3), - isAccessKeySet = false, - isRemembered = false, - isLocked = false, - initialized = true - ) + deviceId, + Version(1, 2, 3), + isAccessKeySet = false, + isRemembered = false, + isLocked = false + ) ) } @@ -117,7 +117,7 @@ class ModelTest { viewModel.updateCredentials(m2) - assertEquals("device1", viewModel.sessionState.value?.deviceId) + assertEquals("device1", viewModel.currentSession()?.deviceId) assertEquals(3, viewModel.credentials.value!!.size) assertTrue(viewModel.credentials.value!!.find { it.credential == cred1 } != null) assertTrue(viewModel.credentials.value!!.find { it.credential == cred2 } != null) @@ -388,9 +388,9 @@ class ModelTest { val deviceId = "device" connectDevice(deviceId) viewModel.updateCredentials(mapOf(totp() to code())) - viewModel.setSessionState(null) + viewModel.clearSession() - assertNull(viewModel.sessionState.value) + assertEquals(ViewModelData.Empty, viewModel.sessionState.value) assertNull(viewModel.credentials.value) } } \ No newline at end of file diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt index 6fde4bcf..0c833721 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt @@ -40,8 +40,7 @@ class SerializationTest { Version(1, 2, 3), isAccessKeySet = false, isRemembered = false, - isLocked = false, - initialized = true + isLocked = false ) @Test diff --git a/android/build.gradle b/android/build.gradle index 72a8ac40..0660be88 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,18 +1,3 @@ -buildscript { - ext.kotlin_version = '1.9.22' - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:8.2.2' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version" - classpath 'com.google.android.gms:oss-licenses-plugin:0.10.6' - } -} - allprojects { repositories { google() @@ -27,7 +12,7 @@ allprojects { yubiKitVersion = "2.4.1-SNAPSHOT" junitVersion = "4.13.2" - mockitoVersion = "5.10.0" + mockitoVersion = "5.11.0" } } diff --git a/android/settings.gradle b/android/settings.gradle index 44e62bcf..21257d31 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,11 +1,35 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + resolutionStrategy { + eachPlugin { + // https://github.com/google/play-services-plugins/issues/223 + if (requested.id.id == "com.google.android.gms.oss-licenses-plugin") { + useModule("com.google.android.gms:oss-licenses-plugin:${requested.version}") + } + } + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.2.2" apply false + id "org.jetbrains.kotlin.android" version "1.9.22" apply false + id "org.jetbrains.kotlin.plugin.serialization" version "1.9.22" apply false + id "com.google.android.gms.oss-licenses-plugin" version "0.10.6" apply false +} + include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/integration_test/utils/oath_test_util.dart b/integration_test/utils/oath_test_util.dart index bf4c0014..42ef47aa 100644 --- a/integration_test/utils/oath_test_util.dart +++ b/integration_test/utils/oath_test_util.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:yubico_authenticator/app/views/keys.dart' as app_keys; import 'package:yubico_authenticator/app/views/keys.dart'; import 'package:yubico_authenticator/core/state.dart'; @@ -219,7 +220,7 @@ extension OathFunctions on WidgetTester { await openAccountDialog(a); /// click the delete IconButton in the account dialog - var deleteIconButton = find.byIcon(Icons.delete_outline).hitTestable(); + var deleteIconButton = find.byIcon(Symbols.delete).hitTestable(); expect(deleteIconButton, findsOneWidget); await tap(deleteIconButton); await longWait(); @@ -252,7 +253,7 @@ extension OathFunctions on WidgetTester { } await openAccountDialog(a); - var renameIconButton = find.byIcon(Icons.edit_outlined).hitTestable(); + var renameIconButton = find.byIcon(Symbols.edit).hitTestable(); /// only newer FW supports renaming if (renameIconButton.evaluate().isEmpty) { diff --git a/lib/about_page.dart b/lib/about_page.dart index 8796b484..97bb2aab 100755 --- a/lib/about_page.dart +++ b/lib/about_page.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'android/state.dart'; import 'app/app_url_launcher.dart'; @@ -157,7 +158,7 @@ class AboutPage extends ConsumerWidget { const SizedBox(height: 12.0), ActionChip( key: diagnosticsChip, - avatar: const Icon(Icons.bug_report_outlined), + avatar: const Icon(Symbols.bug_report), backgroundColor: Theme.of(context).colorScheme.surfaceVariant, label: Text(l10n.s_run_diagnostics), onPressed: () async { @@ -221,7 +222,7 @@ class LoggingPanel extends ConsumerWidget { children: [ ChoiceFilterChip( avatar: Icon( - Icons.insights, + Symbols.insights, color: Theme.of(context).colorScheme.primary, ), value: logLevel, @@ -238,7 +239,7 @@ class LoggingPanel extends ConsumerWidget { ), ActionChip( key: logChip, - avatar: const Icon(Icons.copy), + avatar: const Icon(Symbols.content_copy), backgroundColor: Theme.of(context).colorScheme.surfaceVariant, label: Text(l10n.s_copy_log), onPressed: () async { diff --git a/lib/android/app_methods.dart b/lib/android/app_methods.dart index 33ab4d26..f383cfb0 100644 --- a/lib/android/app_methods.dart +++ b/lib/android/app_methods.dart @@ -53,6 +53,10 @@ Future getAndroidSdkVersion() async { return await appMethodsChannel.invokeMethod('getAndroidSdkVersion'); } +Future getAndroidIsArc() async { + return await appMethodsChannel.invokeMethod('isArc'); +} + Future getPrimaryColor() async { final value = await appMethodsChannel.invokeMethod('getPrimaryColor'); return value != null ? Color(value) : defaultPrimaryColor; diff --git a/lib/android/fido/state.dart b/lib/android/fido/state.dart index dca7c1d4..4f5cd674 100644 --- a/lib/android/fido/state.dart +++ b/lib/android/fido/state.dart @@ -22,9 +22,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; import '../../app/logging.dart'; +import '../../app/message.dart'; import '../../app/models.dart'; +import '../../app/state.dart'; import '../../desktop/models.dart'; import '../../exception/cancellation_exception.dart'; +import '../../exception/no_data_exception.dart'; import '../../exception/platform_exception_decoder.dart'; import '../../fido/models.dart'; import '../../fido/state.dart'; @@ -45,6 +48,8 @@ class _FidoStateNotifier extends FidoStateNotifier { _sub = _events.receiveBroadcastStream().listen((event) { final json = jsonDecode(event); if (json == null) { + state = AsyncValue.error(const NoDataException(), StackTrace.current); + } else if (json == 'loading') { state = const AsyncValue.loading(); } else { final fidoState = FidoState.fromJson(json); @@ -64,30 +69,28 @@ class _FidoStateNotifier extends FidoStateNotifier { final controller = StreamController(); const resetEvents = EventChannel('android.fido.reset'); - final resetSub = + final subscription = resetEvents.receiveBroadcastStream().skip(1).listen((event) { - _log.debug('Received event: \'$event\''); if (event is String && event.isNotEmpty) { - controller.sink.add(InteractionEvent.values - .firstWhere((e) => '"${e.name}"' == event)); // TODO fix event form + controller.sink.add( + InteractionEvent.values.firstWhere((e) => '"${e.name}"' == event)); } }); controller.onCancel = () async { await _methods.invokeMethod('cancelReset'); if (!controller.isClosed) { - await resetSub.cancel(); + await subscription.cancel(); } }; controller.onListen = () async { try { await _methods.invokeMethod('reset'); - _log.debug('Finished reset'); await controller.sink.close(); ref.invalidateSelf(); } catch (e) { - _log.debug('Received error: \'$e\''); + _log.debug('Error during reset: \'$e\''); controller.sink.addError(e); } }; @@ -98,26 +101,28 @@ class _FidoStateNotifier extends FidoStateNotifier { @override Future setPin(String newPin, {String? oldPin}) async { try { - final setPinResponse = jsonDecode(await _methods.invokeMethod('set_pin', { - 'pin': oldPin, - 'new_pin': newPin, - })); - if (setPinResponse['success'] == true) { + final response = jsonDecode(await _methods.invokeMethod( + 'setPin', + { + 'pin': oldPin, + 'newPin': newPin, + }, + )); + if (response['success'] == true) { _log.debug('FIDO pin set/change successful'); return PinResult.success(); } _log.debug('FIDO pin set/change failed'); return PinResult.failed( - setPinResponse['pinRetries'], setPinResponse['authBlocked']); + response['pinRetries'], + response['authBlocked'], + ); } on PlatformException catch (pe) { var decodedException = pe.decode(); if (decodedException is CancellationException) { - _log.debug('User cancelled Set/Change FIDO PIN operation'); - } else { - _log.error('Set/Change FIDO PIN operation failed.', pe); + _log.debug('User cancelled set/change FIDO PIN operation'); } - throw decodedException; } } @@ -125,25 +130,32 @@ class _FidoStateNotifier extends FidoStateNotifier { @override Future unlock(String pin) async { try { - final unlockResponse = - jsonDecode(await _methods.invokeMethod('unlock', {'pin': pin})); + final response = jsonDecode(await _methods.invokeMethod( + 'unlock', + {'pin': pin}, + )); - if (unlockResponse['success'] == true) { + if (response['success'] == true) { _log.debug('FIDO applet unlocked'); return PinResult.success(); } _log.debug('FIDO applet unlock failed'); return PinResult.failed( - unlockResponse['pinRetries'], unlockResponse['authBlocked']); + response['pinRetries'], + response['authBlocked'], + ); } on PlatformException catch (pe) { var decodedException = pe.decode(); - if (decodedException is CancellationException) { - _log.debug('User cancelled unlock FIDO operation'); - } else { - _log.error('Unlock FIDO operation failed.', pe); + if (decodedException is! CancellationException) { + // non pin failure + // simulate cancellation but show an error + await ref.read(withContextProvider)((context) async => showMessage( + context, ref.watch(l10nProvider).p_operation_failed_try_again)); + throw CancellationException(); } + _log.debug('User cancelled unlock FIDO operation'); throw decodedException; } } @@ -332,28 +344,20 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier { @override Future deleteCredential(FidoCredential credential) async { try { - final deleteCredentialResponse = jsonDecode(await _methods.invokeMethod( - 'delete_credential', + await _methods.invokeMethod( + 'deleteCredential', { - 'rp_id': credential.rpId, - 'credential_id': credential.credentialId, + 'rpId': credential.rpId, + 'credentialId': credential.credentialId, }, - )); - - if (deleteCredentialResponse['success'] == true) { - _log.debug('FIDO delete credential succeeded'); - } else { - _log.debug('FIDO delete credential failed'); - } + ); } on PlatformException catch (pe) { var decodedException = pe.decode(); if (decodedException is CancellationException) { _log.debug('User cancelled delete credential FIDO operation'); } else { - _log.error('Delete credential FIDO operation failed.', pe); + throw decodedException; } - - throw decodedException; } } } diff --git a/lib/android/init.dart b/lib/android/init.dart index cbde5763..798af0a8 100644 --- a/lib/android/init.dart +++ b/lib/android/init.dart @@ -54,17 +54,10 @@ Future initialize() async { _initLicenses(); + final isArc = await getAndroidIsArc(); + return ProviderScope( overrides: [ - supportedAppsProvider.overrideWith( - (ref) { - return [ - Application.accounts, - Application.fingerprints, - Application.passkeys - ]; - }, - ), prefProvider.overrideWithValue(await SharedPreferences.getInstance()), logLevelProvider.overrideWith((ref) => AndroidLogger()), attachedDevicesProvider.overrideWith( @@ -76,15 +69,9 @@ Future initialize() async { oathStateProvider.overrideWithProvider(androidOathStateProvider.call), credentialListProvider .overrideWithProvider(androidCredentialListProvider.call), - currentAppProvider.overrideWith((ref) { - final notifier = - AndroidSubPageNotifier(ref, ref.watch(supportedAppsProvider)); - ref.listen>(currentDeviceDataProvider, - (_, data) { - notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); - }, fireImmediately: true); - return notifier; - }), + currentSectionProvider.overrideWith( + (ref) => androidCurrentSectionNotifier(ref), + ), managementStateProvider.overrideWithProvider(androidManagementState.call), currentDeviceProvider.overrideWith( () => AndroidCurrentDeviceNotifier(), @@ -98,6 +85,20 @@ Future initialize() async { ), androidSdkVersionProvider.overrideWithValue(await getAndroidSdkVersion()), androidNfcSupportProvider.overrideWithValue(await getHasNfc()), + supportedSectionsProvider.overrideWithValue([ + Section.home, + Section.accounts, + Section.passkeys, + Section.fingerprints + ]), + // this specifies the priority of sections to show when + // the connected YubiKey does not support current section + androidSectionPriority.overrideWithValue([ + Section.accounts, + Section.passkeys, + Section.fingerprints, + Section.home + ]), supportedThemesProvider.overrideWith( (ref) => ref.watch(androidSupportedThemesProvider), ), @@ -118,6 +119,7 @@ Future initialize() async { // Disable unimplemented feature ..setFeature(features.piv, false) ..setFeature(features.otp, false) + ..setFeature(features.fido, !isArc) ..setFeature(features.management, false); }); diff --git a/lib/android/oath/state.dart b/lib/android/oath/state.dart index 2418b765..14b4d1b5 100755 --- a/lib/android/oath/state.dart +++ b/lib/android/oath/state.dart @@ -22,6 +22,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/models.dart'; @@ -29,6 +30,7 @@ import '../../app/state.dart'; import '../../app/views/user_interaction.dart'; import '../../core/models.dart'; import '../../exception/cancellation_exception.dart'; +import '../../exception/no_data_exception.dart'; import '../../exception/platform_exception_decoder.dart'; import '../../oath/models.dart'; import '../../oath/state.dart'; @@ -50,6 +52,8 @@ class _AndroidOathStateNotifier extends OathStateNotifier { _sub = _events.receiveBroadcastStream().listen((event) { final json = jsonDecode(event); if (json == null) { + state = AsyncValue.error(const NoDataException(), StackTrace.current); + } else if (json == 'loading') { state = const AsyncValue.loading(); } else { final oathState = OathState.fromJson(json); @@ -224,7 +228,7 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier { final l10n = AppLocalizations.of(context)!; return promptUserInteraction( context, - icon: const Icon(Icons.touch_app), + icon: const Icon(Symbols.touch_app), title: l10n.s_touch_required, description: l10n.l_touch_button_now, ); diff --git a/lib/android/qr_scanner/qr_scanner_overlay_view.dart b/lib/android/qr_scanner/qr_scanner_overlay_view.dart index f7032a90..13ce3eec 100644 --- a/lib/android/qr_scanner/qr_scanner_overlay_view.dart +++ b/lib/android/qr_scanner/qr_scanner_overlay_view.dart @@ -17,6 +17,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'qr_scanner_scan_status.dart'; @@ -109,7 +110,7 @@ class _OverlayPainter extends CustomPainter { canvas.drawPath(path, paint); if (_status == ScanStatus.success) { - const icon = Icons.check_circle; + const icon = Symbols.check_circle; final iconSize = overlayRRect.width < 150 ? overlayRRect.width - 5.0 : 150.0; TextPainter iconPainter = TextPainter( diff --git a/lib/android/state.dart b/lib/android/state.dart index 178d3b95..9b5fa39e 100644 --- a/lib/android/state.dart +++ b/lib/android/state.dart @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022-2023 Yubico. + * Copyright (C) 2022-2024 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../app/logging.dart'; import '../app/models.dart'; import '../app/state.dart'; import '../core/state.dart'; @@ -26,6 +28,8 @@ import 'app_methods.dart'; import 'devices.dart'; import 'models.dart'; +final _log = Logger('android.state'); + const _contextChannel = MethodChannel('android.state.appContext'); final androidAllowScreenshotsProvider = @@ -73,6 +77,8 @@ class NfcStateNotifier extends StateNotifier { } } +final androidSectionPriority = Provider>((ref) => []); + final androidSdkVersionProvider = Provider((ref) => -1); final androidNfcSupportProvider = Provider((ref) => false); @@ -90,32 +96,57 @@ final androidSupportedThemesProvider = StateProvider>((ref) { } }); -class _AndroidAppContextHandler { - Future switchAppContext(Application subPage) async { - await _contextChannel.invokeMethod('setContext', {'index': subPage.index}); +class AndroidAppContextHandler { + Future switchAppContext(Section section) async { + await _contextChannel.invokeMethod('setContext', {'index': section.index}); } } final androidAppContextHandler = - Provider<_AndroidAppContextHandler>((ref) => _AndroidAppContextHandler()); + Provider((ref) => AndroidAppContextHandler()); -class AndroidSubPageNotifier extends CurrentAppNotifier { - final StateNotifierProviderRef _ref; +CurrentSectionNotifier androidCurrentSectionNotifier(Ref ref) { + final notifier = AndroidCurrentSectionNotifier( + ref.watch(androidSectionPriority), ref.watch(androidAppContextHandler)); + ref.listen>(currentDeviceDataProvider, (_, data) { + notifier._notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); + }, fireImmediately: true); + return notifier; +} - AndroidSubPageNotifier(this._ref, super.supportedApps) { - _ref.read(androidAppContextHandler).switchAppContext(state); - } +class AndroidCurrentSectionNotifier extends CurrentSectionNotifier { + final List
_supportedSectionsByPriority; + final AndroidAppContextHandler _appContextHandler; + + AndroidCurrentSectionNotifier( + this._supportedSectionsByPriority, + this._appContextHandler, + ) : super(Section.accounts); @override - void setCurrentApp(Application app) { - super.setCurrentApp(app); - _ref.read(androidAppContextHandler).switchAppContext(app); + void setCurrentSection(Section section) { + state = section; + _log.debug('Setting current section to $section'); + _appContextHandler.switchAppContext(state); } - @override - void notifyDeviceChanged(YubiKeyData? data) { - super.notifyDeviceChanged(data); - _ref.read(androidAppContextHandler).switchAppContext(state); + void _notifyDeviceChanged(YubiKeyData? data) { + if (data == null) { + _log.debug('Keeping current section because key was disconnected'); + return; + } + + final supportedSections = _supportedSectionsByPriority.where( + (e) => e.getAvailability(data) == Availability.enabled, + ); + + if (supportedSections.contains(state)) { + // the key supports current section + _log.debug('Keeping current section because new key support $state'); + return; + } + + setCurrentSection(supportedSections.firstOrNull ?? Section.home); } } diff --git a/lib/android/tap_request_dialog.dart b/lib/android/tap_request_dialog.dart index 3f81be3e..58905213 100755 --- a/lib/android/tap_request_dialog.dart +++ b/lib/android/tap_request_dialog.dart @@ -21,10 +21,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../app/state.dart'; import '../app/views/user_interaction.dart'; -import '../widgets/custom_icons.dart'; const _channel = MethodChannel('com.yubico.authenticator.channel.dialog'); @@ -149,16 +149,16 @@ class _DialogProvider { } Widget? _getIcon(int? icon) => switch (_DIcon.fromId(icon)) { - _DIcon.nfcIcon => nfcIcon, - _DIcon.successIcon => const Icon(Icons.check_circle), - _DIcon.failureIcon => const Icon(Icons.error), + _DIcon.nfcIcon => const Icon(Symbols.contactless), + _DIcon.successIcon => const Icon(Symbols.check_circle), + _DIcon.failureIcon => const Icon(Symbols.error), _ => null, }; String _getTitle(BuildContext context, int? titleId) { final l10n = AppLocalizations.of(context)!; return switch (_DTitle.fromId(titleId)) { - _DTitle.tapKey => l10n.s_nfc_dialog_tap_key, + _DTitle.tapKey => l10n.l_nfc_dialog_tap_key, _DTitle.operationSuccessful => l10n.s_nfc_dialog_operation_success, _DTitle.operationFailed => l10n.s_nfc_dialog_operation_failed, _ => '' diff --git a/lib/app/features.dart b/lib/app/features.dart index 9bf87976..c9f6eca3 100644 --- a/lib/app/features.dart +++ b/lib/app/features.dart @@ -16,10 +16,12 @@ import '../core/state.dart'; +final home = root.feature('home'); final oath = root.feature('oath'); final fido = root.feature('fido'); final piv = root.feature('piv'); final otp = root.feature('otp'); + final management = root.feature('management'); final fingerprints = fido.feature('fingerprints'); diff --git a/lib/app/key_customization/models.dart b/lib/app/key_customization/models.dart deleted file mode 100644 index 91410ba5..00000000 --- a/lib/app/key_customization/models.dart +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) 2024 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import 'dart:ui'; - -import 'package:freezed_annotation/freezed_annotation.dart'; - -part 'models.freezed.dart'; - -part 'models.g.dart'; - -@freezed -class KeyCustomization with _$KeyCustomization { - factory KeyCustomization({ - required int serial, - @JsonKey(includeIfNull: false) String? name, - @JsonKey(includeIfNull: false) @_ColorConverter() Color? color, - }) = _KeyCustomization; - - factory KeyCustomization.fromJson(Map json) => - _$KeyCustomizationFromJson(json); -} - -class _ColorConverter implements JsonConverter { - const _ColorConverter(); - - @override - Color? fromJson(int? json) => json != null ? Color(json) : null; - - @override - int? toJson(Color? object) => object?.value; -} diff --git a/lib/app/key_customization/models.freezed.dart b/lib/app/key_customization/models.freezed.dart deleted file mode 100644 index ab173b08..00000000 --- a/lib/app/key_customization/models.freezed.dart +++ /dev/null @@ -1,207 +0,0 @@ -// coverage:ignore-file -// GENERATED CODE - DO NOT MODIFY BY HAND -// ignore_for_file: type=lint -// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark - -part of 'models.dart'; - -// ************************************************************************** -// FreezedGenerator -// ************************************************************************** - -T _$identity(T value) => value; - -final _privateConstructorUsedError = UnsupportedError( - 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); - -KeyCustomization _$KeyCustomizationFromJson(Map json) { - return _KeyCustomization.fromJson(json); -} - -/// @nodoc -mixin _$KeyCustomization { - int get serial => throw _privateConstructorUsedError; - @JsonKey(includeIfNull: false) - String? get name => throw _privateConstructorUsedError; - @JsonKey(includeIfNull: false) - @_ColorConverter() - Color? get color => throw _privateConstructorUsedError; - - Map toJson() => throw _privateConstructorUsedError; - @JsonKey(ignore: true) - $KeyCustomizationCopyWith get copyWith => - throw _privateConstructorUsedError; -} - -/// @nodoc -abstract class $KeyCustomizationCopyWith<$Res> { - factory $KeyCustomizationCopyWith( - KeyCustomization value, $Res Function(KeyCustomization) then) = - _$KeyCustomizationCopyWithImpl<$Res, KeyCustomization>; - @useResult - $Res call( - {int serial, - @JsonKey(includeIfNull: false) String? name, - @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); -} - -/// @nodoc -class _$KeyCustomizationCopyWithImpl<$Res, $Val extends KeyCustomization> - implements $KeyCustomizationCopyWith<$Res> { - _$KeyCustomizationCopyWithImpl(this._value, this._then); - - // ignore: unused_field - final $Val _value; - // ignore: unused_field - final $Res Function($Val) _then; - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? serial = null, - Object? name = freezed, - Object? color = freezed, - }) { - return _then(_value.copyWith( - serial: null == serial - ? _value.serial - : serial // ignore: cast_nullable_to_non_nullable - as int, - name: freezed == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String?, - color: freezed == color - ? _value.color - : color // ignore: cast_nullable_to_non_nullable - as Color?, - ) as $Val); - } -} - -/// @nodoc -abstract class _$$KeyCustomizationImplCopyWith<$Res> - implements $KeyCustomizationCopyWith<$Res> { - factory _$$KeyCustomizationImplCopyWith(_$KeyCustomizationImpl value, - $Res Function(_$KeyCustomizationImpl) then) = - __$$KeyCustomizationImplCopyWithImpl<$Res>; - @override - @useResult - $Res call( - {int serial, - @JsonKey(includeIfNull: false) String? name, - @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); -} - -/// @nodoc -class __$$KeyCustomizationImplCopyWithImpl<$Res> - extends _$KeyCustomizationCopyWithImpl<$Res, _$KeyCustomizationImpl> - implements _$$KeyCustomizationImplCopyWith<$Res> { - __$$KeyCustomizationImplCopyWithImpl(_$KeyCustomizationImpl _value, - $Res Function(_$KeyCustomizationImpl) _then) - : super(_value, _then); - - @pragma('vm:prefer-inline') - @override - $Res call({ - Object? serial = null, - Object? name = freezed, - Object? color = freezed, - }) { - return _then(_$KeyCustomizationImpl( - serial: null == serial - ? _value.serial - : serial // ignore: cast_nullable_to_non_nullable - as int, - name: freezed == name - ? _value.name - : name // ignore: cast_nullable_to_non_nullable - as String?, - color: freezed == color - ? _value.color - : color // ignore: cast_nullable_to_non_nullable - as Color?, - )); - } -} - -/// @nodoc -@JsonSerializable() -class _$KeyCustomizationImpl implements _KeyCustomization { - _$KeyCustomizationImpl( - {required this.serial, - @JsonKey(includeIfNull: false) this.name, - @JsonKey(includeIfNull: false) @_ColorConverter() this.color}); - - factory _$KeyCustomizationImpl.fromJson(Map json) => - _$$KeyCustomizationImplFromJson(json); - - @override - final int serial; - @override - @JsonKey(includeIfNull: false) - final String? name; - @override - @JsonKey(includeIfNull: false) - @_ColorConverter() - final Color? color; - - @override - String toString() { - return 'KeyCustomization(serial: $serial, name: $name, color: $color)'; - } - - @override - bool operator ==(Object other) { - return identical(this, other) || - (other.runtimeType == runtimeType && - other is _$KeyCustomizationImpl && - (identical(other.serial, serial) || other.serial == serial) && - (identical(other.name, name) || other.name == name) && - (identical(other.color, color) || other.color == color)); - } - - @JsonKey(ignore: true) - @override - int get hashCode => Object.hash(runtimeType, serial, name, color); - - @JsonKey(ignore: true) - @override - @pragma('vm:prefer-inline') - _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => - __$$KeyCustomizationImplCopyWithImpl<_$KeyCustomizationImpl>( - this, _$identity); - - @override - Map toJson() { - return _$$KeyCustomizationImplToJson( - this, - ); - } -} - -abstract class _KeyCustomization implements KeyCustomization { - factory _KeyCustomization( - {required final int serial, - @JsonKey(includeIfNull: false) final String? name, - @JsonKey(includeIfNull: false) - @_ColorConverter() - final Color? color}) = _$KeyCustomizationImpl; - - factory _KeyCustomization.fromJson(Map json) = - _$KeyCustomizationImpl.fromJson; - - @override - int get serial; - @override - @JsonKey(includeIfNull: false) - String? get name; - @override - @JsonKey(includeIfNull: false) - @_ColorConverter() - Color? get color; - @override - @JsonKey(ignore: true) - _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => - throw _privateConstructorUsedError; -} diff --git a/lib/app/key_customization/state.dart b/lib/app/key_customization/state.dart deleted file mode 100644 index 972639a7..00000000 --- a/lib/app/key_customization/state.dart +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright (C) 2024 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import 'dart:convert'; -import 'dart:ui'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../core/state.dart'; -import '../logging.dart'; -import 'models.dart'; - -final keyCustomizationManagerProvider = - StateNotifierProvider>( - (ref) => KeyCustomizationNotifier(ref.watch(prefProvider))); - -final _log = Logger('key_customization_manager'); - -class KeyCustomizationNotifier - extends StateNotifier> { - static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS'; - final SharedPreferences _prefs; - - KeyCustomizationNotifier(this._prefs) - : super(_readCustomizations(_prefs.getString(_prefKeyCustomizations))); - - static Map _readCustomizations(String? pref) { - if (pref == null) { - return {}; - } - - try { - final retval = {}; - for (var element in json.decode(pref)) { - final keyCustomization = KeyCustomization.fromJson(element); - retval[keyCustomization.serial] = keyCustomization; - } - return retval; - } catch (e) { - _log.error('Failure reading customizations: $e'); - return {}; - } - } - - KeyCustomization? get(int serial) { - _log.debug('Getting key customization for $serial'); - return state[serial]; - } - - Future set({required int serial, String? name, Color? color}) async { - _log.debug('Setting key customization for $serial: $name, $color'); - if (name == null && color == null) { - // remove this customization - state = {...state..remove(serial)}; - } else { - state = { - ...state - ..[serial] = - KeyCustomization(serial: serial, name: name, color: color) - }; - } - await _prefs.setString( - _prefKeyCustomizations, json.encode(state.values.toList())); - } -} diff --git a/lib/app/key_customization/views/key_customization_dialog.dart b/lib/app/key_customization/views/key_customization_dialog.dart deleted file mode 100644 index ea1c1e56..00000000 --- a/lib/app/key_customization/views/key_customization_dialog.dart +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Copyright (C) 2024 Yubico. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/app_localizations.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../../android/state.dart'; -import '../../../core/state.dart'; -import '../../../management/models.dart'; -import '../../../theme.dart'; -import '../../../widgets/app_input_decoration.dart'; -import '../../../widgets/app_text_form_field.dart'; -import '../../../widgets/focus_utils.dart'; -import '../../../widgets/responsive_dialog.dart'; -import '../../models.dart'; -import '../../state.dart'; -import '../../views/device_avatar.dart'; -import '../../views/keys.dart'; -import '../models.dart'; -import '../state.dart'; - -class KeyCustomizationDialog extends ConsumerStatefulWidget { - final KeyCustomization initialCustomization; - final DeviceNode? node; - - const KeyCustomizationDialog( - {super.key, required this.node, required this.initialCustomization}); - - @override - ConsumerState createState() => - _KeyCustomizationDialogState(); -} - -class _KeyCustomizationDialogState - extends ConsumerState { - String? _customName; - Color? _customColor; - - @override - void initState() { - super.initState(); - _customName = widget.initialCustomization.name; - _customColor = widget.initialCustomization.color; - } - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final currentNode = widget.node; - final theme = Theme.of(context); - final primaryColor = ref.watch(defaultColorProvider); - - final Widget hero; - if (currentNode != null) { - hero = _CurrentDeviceAvatar(currentNode, _customColor ?? primaryColor); - } else { - hero = Column( - children: [ - _HeroAvatar( - color: _customColor ?? primaryColor, - child: DeviceAvatar( - radius: 64, - child: Icon(isAndroid ? Icons.no_cell : Icons.usb), - ), - ), - ListTile( - title: Center(child: Text(l10n.l_no_yk_present)), - subtitle: Center( - child: Text(isAndroid ? l10n.l_insert_or_tap_yk : l10n.s_usb)), - ), - ], - ); - } - - final didChange = widget.initialCustomization.name != _customName || - widget.initialCustomization.color != _customColor; - - return Theme( - data: AppTheme.getTheme(theme.brightness, _customColor ?? primaryColor), - child: ResponsiveDialog( - actions: [ - TextButton( - onPressed: didChange ? _submit : null, - child: Text(l10n.s_save), - ), - ], - child: Column( - children: [ - hero, - Padding( - padding: const EdgeInsets.fromLTRB(18, 18, 18, 0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - constraints: const BoxConstraints(maxWidth: 360), - child: AppTextFormField( - initialValue: _customName, - maxLength: 20, - decoration: AppInputDecoration( - border: const OutlineInputBorder(), - labelText: l10n.s_label, - helperText: - '', // Prevents dialog resizing when disabled - prefixIcon: const Icon(Icons.key), - ), - textInputAction: TextInputAction.done, - onChanged: (value) { - setState(() { - final trimmed = value.trim(); - _customName = trimmed.isEmpty ? null : trimmed; - }); - }, - onFieldSubmitted: (_) { - _submit(); - }, - ), - ), - Text(l10n.s_theme_color), - const SizedBox(height: 16), - Container( - constraints: const BoxConstraints(maxWidth: 360), - child: Wrap( - alignment: WrapAlignment.center, - runSpacing: 8, - spacing: 16, - children: [ - ...[ - Colors.teal, - Colors.cyan, - Colors.blueAccent, - Colors.deepPurple, - Colors.red, - Colors.orange, - Colors.yellow, - // add nice color to devices with dynamic color - if (isAndroid && - ref.read(androidSdkVersionProvider) >= 31) - Colors.lightGreen - ].map((e) => _ColorButton( - color: e, - isSelected: _customColor == e, - onPressed: () { - _updateColor(e); - }, - )), - - // remove color button - RawMaterialButton( - onPressed: () => _updateColor(null), - constraints: const BoxConstraints( - minWidth: 32.0, minHeight: 32.0), - fillColor: (isAndroid && - ref.read(androidSdkVersionProvider) >= 31) - ? theme.colorScheme.onSurface - : primaryColor, - shape: const CircleBorder(), - child: Icon( - Icons.cancel_rounded, - size: 16, - color: _customColor == null - ? theme.colorScheme.onSurface - : theme.colorScheme.surface.withOpacity(0.2), - ), - ), - ], - ), - ) - ], - ), - ), - ], - ), - ), - ); - } - - void _submit() async { - final manager = ref.read(keyCustomizationManagerProvider.notifier); - await manager.set( - serial: widget.initialCustomization.serial, - name: _customName, - color: _customColor); - - await ref.read(withContextProvider)((context) async { - FocusUtils.unfocus(context); - final nav = Navigator.of(context); - nav.pop(); - }); - } - - void _updateColor(Color? color) { - setState(() { - _customColor = color; - }); - } -} - -String _getDeviceInfoString(BuildContext context, DeviceInfo info) { - final l10n = AppLocalizations.of(context)!; - final serial = info.serial; - return [ - if (serial != null) l10n.s_sn_serial(serial), - if (info.version.isAtLeast(1)) - l10n.s_fw_version(info.version) - else - l10n.s_unknown_type, - ].join(' '); -} - -List _getDeviceStrings( - BuildContext context, WidgetRef ref, DeviceNode node) { - final data = ref.watch(currentDeviceDataProvider); - - final messages = node is UsbYubiKeyNode - ? node.info != null - ? [node.name, _getDeviceInfoString(context, node.info!)] - : [] - : data.hasValue - ? data.value?.node.path == node.path - ? [ - data.value!.name, - _getDeviceInfoString(context, data.value!.info) - ] - : [] - : []; - return messages; -} - -class _HeroAvatar extends StatelessWidget { - final Widget child; - final Color color; - - const _HeroAvatar({required this.color, required this.child}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - color.withOpacity(0.6), - color.withOpacity(0.25), - (DialogTheme.of(context).backgroundColor ?? - theme.dialogBackgroundColor) - .withOpacity(0), - ], - ), - ), - padding: const EdgeInsets.all(12), - child: Theme( - // Give the avatar a transparent background - data: theme.copyWith( - colorScheme: - theme.colorScheme.copyWith(surfaceVariant: Colors.transparent)), - child: child, - ), - ); - } -} - -class _CurrentDeviceAvatar extends ConsumerWidget { - final DeviceNode node; - final Color color; - - const _CurrentDeviceAvatar(this.node, this.color); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final hero = DeviceAvatar.deviceNode(node, radius: 64); - final messages = _getDeviceStrings(context, ref, node); - - return Column( - children: [ - _HeroAvatar(color: color, child: hero), - ListTile( - key: deviceInfoListTile, - title: Text(messages.removeAt(0), textAlign: TextAlign.center), - isThreeLine: messages.length > 1, - subtitle: Text(messages.join('\n'), textAlign: TextAlign.center), - ) - ], - ); - } -} - -class _ColorButton extends StatefulWidget { - final Color? color; - final bool isSelected; - final Function()? onPressed; - - const _ColorButton({ - required this.color, - required this.isSelected, - required this.onPressed, - }); - - @override - State<_ColorButton> createState() => _ColorButtonState(); -} - -class _ColorButtonState extends State<_ColorButton> { - @override - Widget build(BuildContext context) { - return RawMaterialButton( - onPressed: widget.onPressed, - constraints: const BoxConstraints(minWidth: 32.0, minHeight: 32.0), - fillColor: widget.color, - shape: const CircleBorder(), - child: Icon( - Icons.circle, - size: 16, - color: widget.isSelected ? Colors.white : Colors.transparent, - ), - ); - } -} diff --git a/lib/app/models.dart b/lib/app/models.dart index ab5b89b0..1f45ebab 100755 --- a/lib/app/models.dart +++ b/lib/app/models.dart @@ -25,45 +25,38 @@ import '../core/state.dart'; part 'models.freezed.dart'; +part 'models.g.dart'; + const _listEquality = ListEquality(); enum Availability { enabled, disabled, unsupported } -enum Application { +enum Section { + home(), accounts([Capability.oath]), - webauthn([Capability.u2f]), + securityKey([Capability.u2f]), fingerprints([Capability.fido2]), passkeys([Capability.fido2]), certificates([Capability.piv]), - slots([Capability.otp]), - management(); + slots([Capability.otp]); final List capabilities; - const Application([this.capabilities = const []]); + const Section([this.capabilities = const []]); String getDisplayName(AppLocalizations l10n) => switch (this) { - Application.accounts => l10n.s_accounts, - Application.webauthn => l10n.s_webauthn, - Application.fingerprints => l10n.s_fingerprints, - Application.passkeys => l10n.s_passkeys, - Application.certificates => l10n.s_certificates, - Application.slots => l10n.s_slots, - _ => name.substring(0, 1).toUpperCase() + name.substring(1), + Section.home => l10n.s_home, + Section.accounts => l10n.s_accounts, + Section.securityKey => l10n.s_security_key, + Section.fingerprints => l10n.s_fingerprints, + Section.passkeys => l10n.s_passkeys, + Section.certificates => l10n.s_certificates, + Section.slots => l10n.s_slots, }; Availability getAvailability(YubiKeyData data) { - if (this == Application.management) { - final version = data.info.version; - final available = (version.major > 4 || // YK5 and up - (version.major == 4 && version.minor >= 1) || // YK4.1 and up - version.major == 3); // NEO - // Management can't be disabled - return available ? Availability.enabled : Availability.unsupported; - } - // TODO: Require credman for passkeys? - if (this == Application.fingerprints) { + if (this == Section.fingerprints) { if (!const {FormFactor.usbABio, FormFactor.usbCBio} .contains(data.info.formFactor)) { return Availability.unsupported; @@ -75,8 +68,8 @@ enum Application { final int enabled = data.info.config.enabledCapabilities[data.node.transport] ?? 0; - // Don't show WebAuthn if we have FIDO2 - if (this == Application.webauthn && + // Don't show securityKey if we have FIDO2 + if (this == Section.securityKey && Capability.fido2.value & supported != 0) { return Availability.unsupported; } @@ -155,3 +148,25 @@ class WindowState with _$WindowState { @Default(false) bool hidden, }) = _WindowState; } + +@freezed +class KeyCustomization with _$KeyCustomization { + factory KeyCustomization({ + required int serial, + @JsonKey(includeIfNull: false) String? name, + @JsonKey(includeIfNull: false) @_ColorConverter() Color? color, + }) = _KeyCustomization; + + factory KeyCustomization.fromJson(Map json) => + _$KeyCustomizationFromJson(json); +} + +class _ColorConverter implements JsonConverter { + const _ColorConverter(); + + @override + Color? fromJson(int? json) => json != null ? Color(json) : null; + + @override + int? toJson(Color? object) => object?.value; +} diff --git a/lib/app/models.freezed.dart b/lib/app/models.freezed.dart index 4697880a..7b15fb31 100644 --- a/lib/app/models.freezed.dart +++ b/lib/app/models.freezed.dart @@ -1084,3 +1084,195 @@ abstract class _WindowState implements WindowState { _$$WindowStateImplCopyWith<_$WindowStateImpl> get copyWith => throw _privateConstructorUsedError; } + +KeyCustomization _$KeyCustomizationFromJson(Map json) { + return _KeyCustomization.fromJson(json); +} + +/// @nodoc +mixin _$KeyCustomization { + int get serial => throw _privateConstructorUsedError; + @JsonKey(includeIfNull: false) + String? get name => throw _privateConstructorUsedError; + @JsonKey(includeIfNull: false) + @_ColorConverter() + Color? get color => throw _privateConstructorUsedError; + + Map toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $KeyCustomizationCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $KeyCustomizationCopyWith<$Res> { + factory $KeyCustomizationCopyWith( + KeyCustomization value, $Res Function(KeyCustomization) then) = + _$KeyCustomizationCopyWithImpl<$Res, KeyCustomization>; + @useResult + $Res call( + {int serial, + @JsonKey(includeIfNull: false) String? name, + @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); +} + +/// @nodoc +class _$KeyCustomizationCopyWithImpl<$Res, $Val extends KeyCustomization> + implements $KeyCustomizationCopyWith<$Res> { + _$KeyCustomizationCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serial = null, + Object? name = freezed, + Object? color = freezed, + }) { + return _then(_value.copyWith( + serial: null == serial + ? _value.serial + : serial // ignore: cast_nullable_to_non_nullable + as int, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + color: freezed == color + ? _value.color + : color // ignore: cast_nullable_to_non_nullable + as Color?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$KeyCustomizationImplCopyWith<$Res> + implements $KeyCustomizationCopyWith<$Res> { + factory _$$KeyCustomizationImplCopyWith(_$KeyCustomizationImpl value, + $Res Function(_$KeyCustomizationImpl) then) = + __$$KeyCustomizationImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {int serial, + @JsonKey(includeIfNull: false) String? name, + @JsonKey(includeIfNull: false) @_ColorConverter() Color? color}); +} + +/// @nodoc +class __$$KeyCustomizationImplCopyWithImpl<$Res> + extends _$KeyCustomizationCopyWithImpl<$Res, _$KeyCustomizationImpl> + implements _$$KeyCustomizationImplCopyWith<$Res> { + __$$KeyCustomizationImplCopyWithImpl(_$KeyCustomizationImpl _value, + $Res Function(_$KeyCustomizationImpl) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? serial = null, + Object? name = freezed, + Object? color = freezed, + }) { + return _then(_$KeyCustomizationImpl( + serial: null == serial + ? _value.serial + : serial // ignore: cast_nullable_to_non_nullable + as int, + name: freezed == name + ? _value.name + : name // ignore: cast_nullable_to_non_nullable + as String?, + color: freezed == color + ? _value.color + : color // ignore: cast_nullable_to_non_nullable + as Color?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$KeyCustomizationImpl implements _KeyCustomization { + _$KeyCustomizationImpl( + {required this.serial, + @JsonKey(includeIfNull: false) this.name, + @JsonKey(includeIfNull: false) @_ColorConverter() this.color}); + + factory _$KeyCustomizationImpl.fromJson(Map json) => + _$$KeyCustomizationImplFromJson(json); + + @override + final int serial; + @override + @JsonKey(includeIfNull: false) + final String? name; + @override + @JsonKey(includeIfNull: false) + @_ColorConverter() + final Color? color; + + @override + String toString() { + return 'KeyCustomization(serial: $serial, name: $name, color: $color)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$KeyCustomizationImpl && + (identical(other.serial, serial) || other.serial == serial) && + (identical(other.name, name) || other.name == name) && + (identical(other.color, color) || other.color == color)); + } + + @JsonKey(ignore: true) + @override + int get hashCode => Object.hash(runtimeType, serial, name, color); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => + __$$KeyCustomizationImplCopyWithImpl<_$KeyCustomizationImpl>( + this, _$identity); + + @override + Map toJson() { + return _$$KeyCustomizationImplToJson( + this, + ); + } +} + +abstract class _KeyCustomization implements KeyCustomization { + factory _KeyCustomization( + {required final int serial, + @JsonKey(includeIfNull: false) final String? name, + @JsonKey(includeIfNull: false) + @_ColorConverter() + final Color? color}) = _$KeyCustomizationImpl; + + factory _KeyCustomization.fromJson(Map json) = + _$KeyCustomizationImpl.fromJson; + + @override + int get serial; + @override + @JsonKey(includeIfNull: false) + String? get name; + @override + @JsonKey(includeIfNull: false) + @_ColorConverter() + Color? get color; + @override + @JsonKey(ignore: true) + _$$KeyCustomizationImplCopyWith<_$KeyCustomizationImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/app/key_customization/models.g.dart b/lib/app/models.g.dart similarity index 100% rename from lib/app/key_customization/models.g.dart rename to lib/app/models.g.dart diff --git a/lib/app/state.dart b/lib/app/state.dart index 7eb306f9..f5ab3071 100755 --- a/lib/app/state.dart +++ b/lib/app/state.dart @@ -15,6 +15,7 @@ */ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:ui'; @@ -27,7 +28,6 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../core/state.dart'; import '../theme.dart'; import 'features.dart' as features; -import 'key_customization/state.dart'; import 'logging.dart'; import 'models.dart'; @@ -38,22 +38,24 @@ const officialLocales = [ Locale('en', ''), ]; -extension on Application { +extension on Section { Feature get _feature => switch (this) { - Application.accounts => features.oath, - Application.webauthn => features.fido, - Application.passkeys => features.fido, - Application.fingerprints => features.fingerprints, - Application.slots => features.otp, - Application.certificates => features.piv, - Application.management => features.management, + Section.home => features.home, + Section.accounts => features.oath, + Section.securityKey => features.fido, + Section.passkeys => features.fido, + Section.fingerprints => features.fingerprints, + Section.slots => features.otp, + Section.certificates => features.piv, }; } -final supportedAppsProvider = Provider>( +final supportedSectionsProvider = Provider>( (ref) { final hasFeature = ref.watch(featureProvider); - return Application.values.where((app) => hasFeature(app._feature)).toList(); + return Section.values + .where((section) => hasFeature(section._feature)) + .toList(); }, ); @@ -200,36 +202,14 @@ abstract class CurrentDeviceNotifier extends Notifier { setCurrentDevice(DeviceNode? device); } -final currentAppProvider = - StateNotifierProvider((ref) { - final notifier = CurrentAppNotifier(ref.watch(supportedAppsProvider)); - ref.listen>(currentDeviceDataProvider, (_, data) { - notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); - }, fireImmediately: true); - return notifier; -}); +final currentSectionProvider = + StateNotifierProvider( + (ref) => throw UnimplementedError()); -class CurrentAppNotifier extends StateNotifier { - final List _supportedApps; +abstract class CurrentSectionNotifier extends StateNotifier
{ + CurrentSectionNotifier(super.initial); - CurrentAppNotifier(this._supportedApps) : super(_supportedApps.first); - - void setCurrentApp(Application app) { - state = app; - } - - void notifyDeviceChanged(YubiKeyData? data) { - if (data == null || - state.getAvailability(data) != Availability.unsupported) { - // Keep current app - return; - } - - state = _supportedApps.firstWhere( - (app) => app.getAvailability(data) == Availability.enabled, - orElse: () => _supportedApps.first, - ); - } + setCurrentSection(Section section); } abstract class QrScanner { @@ -285,3 +265,55 @@ typedef WithContext = Future Function( final withContextProvider = Provider( (ref) => ref.watch(contextConsumer.notifier).withContext); + +final keyCustomizationManagerProvider = + StateNotifierProvider>( + (ref) => KeyCustomizationNotifier(ref.watch(prefProvider))); + +class KeyCustomizationNotifier + extends StateNotifier> { + static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS'; + final SharedPreferences _prefs; + + KeyCustomizationNotifier(this._prefs) + : super(_readCustomizations(_prefs.getString(_prefKeyCustomizations))); + + static Map _readCustomizations(String? pref) { + if (pref == null) { + return {}; + } + + try { + final retval = {}; + for (var element in json.decode(pref)) { + final keyCustomization = KeyCustomization.fromJson(element); + retval[keyCustomization.serial] = keyCustomization; + } + return retval; + } catch (e) { + _log.error('Failure reading customizations: $e'); + return {}; + } + } + + KeyCustomization? get(int serial) { + _log.debug('Getting key customization for $serial'); + return state[serial]; + } + + Future set({required int serial, String? name, Color? color}) async { + _log.debug('Setting key customization for $serial: $name, $color'); + if (name == null && color == null) { + // remove this customization + state = {...state..remove(serial)}; + } else { + state = { + ...state + ..[serial] = + KeyCustomization(serial: serial, name: name, color: color) + }; + } + await _prefs.setString( + _prefKeyCustomizations, json.encode(state.values.toList())); + } +} diff --git a/lib/app/views/app_failure_page.dart b/lib/app/views/app_failure_page.dart index 958cbdd1..e2ada04f 100755 --- a/lib/app/views/app_failure_page.dart +++ b/lib/app/views/app_failure_page.dart @@ -19,6 +19,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../core/state.dart'; import '../../desktop/models.dart'; @@ -37,8 +38,8 @@ class AppFailurePage extends ConsumerWidget { final l10n = AppLocalizations.of(context)!; final reason = cause; - Widget? graphic = - Icon(Icons.error, size: 96, color: Theme.of(context).colorScheme.error); + Widget? graphic = Icon(Symbols.error, + size: 96, color: Theme.of(context).colorScheme.error); String? header = l10n.l_error_occurred; String? message = reason.toString(); String? title; @@ -63,9 +64,9 @@ class AppFailurePage extends ConsumerWidget { case 'fido': if (Platform.isWindows && !ref.watch(rpcStateProvider.select((state) => state.isAdmin))) { - final currentApp = ref.read(currentAppProvider); - title = currentApp.getDisplayName(l10n); - capabilities = currentApp.capabilities; + final currentSection = ref.read(currentSectionProvider); + title = currentSection.getDisplayName(l10n); + capabilities = currentSection.capabilities; header = l10n.l_admin_privileges_required; message = l10n.p_webauthn_elevated_permissions_required; centered = false; diff --git a/lib/app/views/app_page.dart b/lib/app/views/app_page.dart index 4e3aa88f..605d6b02 100755 --- a/lib/app/views/app_page.dart +++ b/lib/app/views/app_page.dart @@ -18,6 +18,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../core/state.dart'; import '../../management/models.dart'; @@ -70,6 +71,7 @@ class AppPage extends StatelessWidget { Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; + if (width < 400 || (isAndroid && width < 600 && width < constraints.maxHeight)) { return _buildScaffold(context, true, false, false); @@ -159,28 +161,18 @@ class AppPage extends StatelessWidget { } Widget _buildTitle(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Wrap( - alignment: WrapAlignment.spaceBetween, - crossAxisAlignment: WrapCrossAlignment.center, - spacing: 2.0, + Text(title!, + style: Theme.of(context).textTheme.displaySmall!.copyWith( + color: Theme.of(context).colorScheme.primary.withOpacity(0.9))), + if (capabilities != null) + Wrap( + spacing: 4.0, runSpacing: 8.0, - children: [ - Text(title!, - style: Theme.of(context).textTheme.displaySmall!.copyWith( - color: Theme.of(context) - .colorScheme - .primary - .withOpacity(0.9))), - if (capabilities != null) - Wrap( - spacing: 4.0, - runSpacing: 8.0, - children: [...capabilities!.map((c) => _CapabilityBadge(c))], - ) - ]) + children: [...capabilities!.map((c) => CapabilityBadge(c))], + ) ], ); } @@ -188,10 +180,11 @@ class AppPage extends StatelessWidget { Widget _buildMainContent(BuildContext context, bool expanded) { final actions = actionsBuilder?.call(context, expanded) ?? []; final content = Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: centered ? CrossAxisAlignment.center : CrossAxisAlignment.start, children: [ - if (title != null) + if (title != null && !centered) Padding( padding: const EdgeInsets.only(left: 16.0, right: 16.0, bottom: 24.0), @@ -225,26 +218,59 @@ class AppPage extends StatelessWidget { ), ], ); + + final safeArea = SafeArea( + child: delayedContent + ? DelayedVisibility( + key: GlobalKey(), // Ensure we reset the delay on rebuild + delay: const Duration(milliseconds: 400), + child: content, + ) + : content, + ); + + if (centered) { + return Stack( + children: [ + if (title != null) + Positioned.fill( + child: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 16.0, right: 16.0, bottom: 24.0), + child: _buildTitle(context), + ), + ), + ), + Positioned.fill( + top: title != null ? 68.0 : 0, + child: Align( + alignment: Alignment.center, + child: ScrollConfiguration( + behavior: + ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: safeArea, + ), + ), + ), + ) + ], + ); + } + return SingleChildScrollView( primary: false, - child: SafeArea( - child: delayedContent - ? DelayedVisibility( - key: GlobalKey(), // Ensure we reset the delay on rebuild - delay: const Duration(milliseconds: 400), - child: content, - ) - : content, - ), + child: safeArea, ); } Scaffold _buildScaffold( BuildContext context, bool hasDrawer, bool hasRail, bool hasManage) { var body = _buildMainContent(context, hasManage); - if (centered) { - body = Center(child: body); - } + if (onFileDropped != null) { body = FileDropTarget( onFileDropped: onFileDropped!, @@ -341,9 +367,9 @@ class AppPage extends StatelessWidget { }, icon: keyActionsBadge ? const Badge( - child: Icon(Icons.more_vert_outlined), + child: Icon(Symbols.more_vert), ) - : const Icon(Icons.more_vert_outlined), + : const Icon(Symbols.more_vert), iconSize: 24, tooltip: AppLocalizations.of(context)!.s_configure_yk, padding: const EdgeInsets.all(12), @@ -362,10 +388,10 @@ class AppPage extends StatelessWidget { } } -class _CapabilityBadge extends StatelessWidget { +class CapabilityBadge extends StatelessWidget { final Capability capability; - const _CapabilityBadge(this.capability); + const CapabilityBadge(this.capability, {super.key}); @override Widget build(BuildContext context) { diff --git a/lib/app/views/device_avatar.dart b/lib/app/views/device_avatar.dart index f402df26..15fbaa02 100755 --- a/lib/app/views/device_avatar.dart +++ b/lib/app/views/device_avatar.dart @@ -16,11 +16,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../core/models.dart'; import '../../core/state.dart'; import '../../management/models.dart'; -import '../../widgets/custom_icons.dart'; import '../../widgets/product_image.dart'; import '../models.dart'; import '../state.dart'; @@ -34,12 +34,18 @@ class DeviceAvatar extends StatelessWidget { factory DeviceAvatar.yubiKeyData(YubiKeyData data, {double? radius}) => DeviceAvatar( - badge: isDesktop && data.node is NfcReaderNode ? nfcIcon : null, + badge: isDesktop && data.node is NfcReaderNode + ? const Icon(Symbols.contactless) + : null, radius: radius, - child: ProductImage( - name: data.name, - formFactor: data.info.formFactor, - isNfc: data.info.supportedCapabilities.containsKey(Transport.nfc)), + child: CircleAvatar( + backgroundColor: Colors.transparent, + child: ProductImage( + name: data.name, + formFactor: data.info.formFactor, + isNfc: + data.info.supportedCapabilities.containsKey(Transport.nfc)), + ), ); factory DeviceAvatar.deviceNode(DeviceNode node, {double? radius}) => @@ -54,16 +60,22 @@ class DeviceAvatar extends StatelessWidget { } return DeviceAvatar( radius: radius, - child: const ProductImage( - name: '', - formFactor: FormFactor.unknown, - isNfc: false, + child: const CircleAvatar( + backgroundColor: Colors.transparent, + child: ProductImage( + name: '', + formFactor: FormFactor.unknown, + isNfc: false, + ), ), ); }, nfcReader: (_) => DeviceAvatar( radius: radius, - child: nfcIcon, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Symbols.contactless), + ), ), ); @@ -84,35 +96,28 @@ class DeviceAvatar extends StatelessWidget { return DeviceAvatar( radius: radius, key: noDeviceAvatar, - child: const Icon(Icons.usb), + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Symbols.usb), + ), ); } } @override Widget build(BuildContext context) { - final radius = this.radius ?? 20; return Stack( alignment: AlignmentDirectional.bottomEnd, children: [ - CircleAvatar( - radius: radius, - backgroundColor: Colors.transparent, - child: IconTheme( - data: IconTheme.of(context).copyWith( - size: radius, - ), - child: child, - ), - ), + child, if (badge != null) CircleAvatar( - radius: radius / 3, + radius: 10, backgroundColor: Colors.transparent, child: IconTheme( data: IconTheme.of(context).copyWith( color: Theme.of(context).colorScheme.onPrimary, - size: radius * 0.5, + size: 18, ), child: badge!, ), diff --git a/lib/app/views/device_error_screen.dart b/lib/app/views/device_error_screen.dart index 7951ffaa..fe97c8bc 100755 --- a/lib/app/views/device_error_screen.dart +++ b/lib/app/views/device_error_screen.dart @@ -19,14 +19,15 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../core/models.dart'; import '../../core/state.dart'; import '../../desktop/state.dart'; +import '../../home/views/home_message_page.dart'; import '../models.dart'; import '../state.dart'; import 'elevate_fido_buttons.dart'; -import 'message_page.dart'; class DeviceErrorScreen extends ConsumerWidget { final DeviceNode node; @@ -38,10 +39,9 @@ class DeviceErrorScreen extends ConsumerWidget { if (pid.usbInterfaces == UsbInterface.fido.value) { if (Platform.isWindows && !ref.watch(rpcStateProvider.select((state) => state.isAdmin))) { - final currentApp = ref.read(currentAppProvider); - return MessagePage( - title: currentApp.getDisplayName(l10n), - capabilities: currentApp.capabilities, + final currentSection = ref.read(currentSectionProvider); + return HomeMessagePage( + capabilities: currentSection.capabilities, header: l10n.l_admin_privileges_required, message: l10n.p_elevated_permissions_required, actionsBuilder: (context, expanded) => [ @@ -51,7 +51,7 @@ class DeviceErrorScreen extends ConsumerWidget { ); } } - return MessagePage( + return HomeMessagePage( centered: true, graphic: Image.asset( 'assets/product-images/generic.png', @@ -69,16 +69,16 @@ class DeviceErrorScreen extends ConsumerWidget { return node.map( usbYubiKey: (node) => _buildUsbPid(context, ref, node.pid), nfcReader: (node) => switch (error) { - 'unknown-device' => MessagePage( + 'unknown-device' => HomeMessagePage( centered: true, graphic: Icon( - Icons.help_outlined, + Symbols.help, size: 96, color: Theme.of(context).colorScheme.error, ), header: l10n.s_unknown_device, ), - _ => MessagePage( + _ => HomeMessagePage( centered: true, graphic: Image.asset( 'assets/graphics/no-key.png', diff --git a/lib/app/views/device_picker.dart b/lib/app/views/device_picker.dart index 92d0716d..0ab99ab3 100644 --- a/lib/app/views/device_picker.dart +++ b/lib/app/views/device_picker.dart @@ -18,23 +18,17 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../../android/state.dart'; import '../../core/state.dart'; import '../../management/models.dart'; -import '../../management/views/management_screen.dart'; -import '../features.dart' as features; -import '../key_customization/models.dart'; -import '../key_customization/state.dart'; -import '../key_customization/views/key_customization_dialog.dart'; -import '../message.dart'; import '../models.dart'; import '../state.dart'; import 'device_avatar.dart'; import 'keys.dart' as keys; import 'keys.dart'; -import 'reset_dialog.dart'; final _hiddenDevicesProvider = StateNotifierProvider<_HiddenDevicesNotifier, List>( @@ -83,7 +77,11 @@ class DevicePickerContent extends ConsumerWidget { : l10n.l_insert_yk; androidNoKeyWidget = _DeviceRow( - leading: const DeviceAvatar(child: Icon(Icons.usb)), + leading: const DeviceAvatar( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Symbols.usb), + )), title: l10n.l_no_yk_present, subtitle: subtitle, onTap: () { @@ -97,7 +95,11 @@ class DevicePickerContent extends ConsumerWidget { List children = [ if (showUsb) _DeviceRow( - leading: const DeviceAvatar(child: Icon(Icons.usb)), + leading: const DeviceAvatar( + child: Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Symbols.usb), + )), title: l10n.s_usb, subtitle: l10n.l_no_yk_present, onTap: () { @@ -181,7 +183,7 @@ class _DeviceMenuButton extends ConsumerWidget { child: PopupMenuButton( key: yubikeyPopupMenuButton, enabled: menuItems.isNotEmpty, - icon: const Icon(Icons.more_horiz_outlined), + icon: const Icon(Symbols.more_horiz), tooltip: '', iconColor: Theme.of(context).listTileTheme.textColor, itemBuilder: (context) { @@ -267,14 +269,16 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { const EdgeInsets.symmetric(horizontal: 8, vertical: 0), horizontalTitleGap: 8, leading: widget.leading, - trailing: _DeviceMenuButton( - menuItems: menuItems, - opacity: widget.selected - ? 1.0 - : _showContextMenu - ? 0.3 - : 0.0, - ), + trailing: menuItems.isNotEmpty + ? _DeviceMenuButton( + menuItems: menuItems, + opacity: widget.selected + ? 1.0 + : _showContextMenu + ? 0.3 + : 0.0, + ) + : null, title: Text( widget.title, overflow: TextOverflow.fade, @@ -312,7 +316,7 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { ? IconButton.filled( tooltip: isDesktop ? tooltip : null, icon: widget.leading, - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 8), onPressed: widget.onTap, ) : IconButton( @@ -330,44 +334,9 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { List _getMenuItems( BuildContext context, WidgetRef ref, DeviceNode? node) { final l10n = AppLocalizations.of(context)!; - final keyCustomizations = ref.watch(keyCustomizationManagerProvider); - final hasFeature = ref.watch(featureProvider); final hidden = ref.watch(_hiddenDevicesProvider); - final data = ref.watch(currentDeviceDataProvider).valueOrNull; - final managementAvailability = - data == null || !hasFeature(features.management) - ? Availability.unsupported - : Application.management.getAvailability(data); - - final serial = node is UsbYubiKeyNode - ? node.info?.serial - : data != null - ? data.node.path == node?.path && node != null - ? data.info.serial - : null - : null; - return [ - if (serial != null) - PopupMenuItem( - enabled: true, - onTap: () async { - await ref.read(withContextProvider)((context) async { - await _showKeyCustomizationDialog( - keyCustomizations[serial] ?? KeyCustomization(serial: serial), - context, - node); - }); - }, - child: ListTile( - title: Text(l10n.s_customize_key_action), - leading: const Icon(Icons.palette_outlined), - key: yubikeyLabelColorMenuButton, - dense: true, - contentPadding: EdgeInsets.zero, - enabled: true), - ), if (isDesktop && hidden.isNotEmpty) PopupMenuItem( enabled: hidden.isNotEmpty, @@ -376,7 +345,7 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { }, child: ListTile( title: Text(l10n.s_show_hidden_devices), - leading: const Icon(Icons.visibility_outlined), + leading: const Icon(Symbols.visibility), dense: true, contentPadding: EdgeInsets.zero, enabled: hidden.isNotEmpty, @@ -389,64 +358,13 @@ class _DeviceRowState extends ConsumerState<_DeviceRow> { }, child: ListTile( title: Text(l10n.s_hide_device), - leading: const Icon(Icons.visibility_off_outlined), - dense: true, - contentPadding: EdgeInsets.zero, - ), - ), - if (node == data?.node && managementAvailability == Availability.enabled) - PopupMenuItem( - onTap: () { - showBlurDialog( - context: context, - builder: (context) => ManagementScreen(data), - ); - }, - child: ListTile( - title: Text(data!.info.version.major > 4 - ? l10n.s_toggle_applications - : l10n.s_toggle_interfaces), - leading: const Icon(Icons.construction), - key: yubikeyApplicationToggleMenuButton, - dense: true, - contentPadding: EdgeInsets.zero, - ), - ), - if (data != null && - node == data.node && - getResetCapabilities(hasFeature).any((c) => - c.value & - (data.info.supportedCapabilities[node!.transport] ?? 0) != - 0)) - PopupMenuItem( - onTap: () { - showBlurDialog( - context: context, - builder: (context) => ResetDialog(data), - ); - }, - child: ListTile( - title: Text(l10n.s_factory_reset), - leading: const Icon(Icons.delete_forever), - key: yubikeyFactoryResetMenuButton, + leading: const Icon(Symbols.visibility_off), dense: true, contentPadding: EdgeInsets.zero, ), ), ]; } - - Future _showKeyCustomizationDialog(KeyCustomization keyCustomization, - BuildContext context, DeviceNode? node) async { - await showBlurDialog( - context: context, - builder: (context) => KeyCustomizationDialog( - node: node, - initialCustomization: keyCustomization, - ), - routeSettings: const RouteSettings(name: 'customize'), - ); - } } _DeviceRow _buildDeviceRow( diff --git a/lib/app/views/elevate_fido_buttons.dart b/lib/app/views/elevate_fido_buttons.dart index 23181dc7..b700ed16 100644 --- a/lib/app/views/elevate_fido_buttons.dart +++ b/lib/app/views/elevate_fido_buttons.dart @@ -19,6 +19,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../desktop/state.dart'; import '../message.dart'; @@ -36,7 +37,7 @@ class ElevateFidoButtons extends ConsumerWidget { children: [ FilledButton.icon( label: Text(l10n.s_request_access), - icon: const Icon(Icons.lock_open), + icon: const Icon(Symbols.lock_open), onPressed: () async { final closeMessage = showMessage( context, l10n.l_elevating_permissions, @@ -55,7 +56,7 @@ class ElevateFidoButtons extends ConsumerWidget { ), OutlinedButton.icon( label: Text(l10n.s_open_windows_settings), - icon: const Icon(Icons.open_in_new), + icon: const Icon(Symbols.open_in_new), onPressed: () async { await Process.start('powershell.exe', [ '-NoProfile', diff --git a/lib/app/views/fs_dialog.dart b/lib/app/views/fs_dialog.dart index 029538f5..7d3f6d89 100644 --- a/lib/app/views/fs_dialog.dart +++ b/lib/app/views/fs_dialog.dart @@ -16,6 +16,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:material_symbols_icons/symbols.dart'; + import 'keys.dart' as keys; class FsDialog extends StatelessWidget { @@ -39,7 +41,7 @@ class FsDialog extends StatelessWidget { padding: const EdgeInsets.only(bottom: 16.0), child: TextButton.icon( key: keys.closeButton, - icon: const Icon(Icons.close), + icon: const Icon(Symbols.close), label: Text(l10n.s_close), onPressed: () { Navigator.of(context).pop(); diff --git a/lib/app/views/keys.dart b/lib/app/views/keys.dart index c5a2d2c4..bb0e5079 100644 --- a/lib/app/views/keys.dart +++ b/lib/app/views/keys.dart @@ -25,6 +25,7 @@ const noDeviceAvatar = Key('$_prefix.no_device_avatar'); const actionsIconButtonKey = Key('$_prefix.actions_icon_button'); // drawer items +const homeDrawer = Key('$_prefix.drawer.home'); const managementAppDrawer = Key('$_prefix.drawer.management'); const oathAppDrawer = Key('$_prefix.drawer.oath'); const u2fAppDrawer = Key('$_prefix.drawer.fido.webauthn'); diff --git a/lib/app/views/main_page.dart b/lib/app/views/main_page.dart index 6c7fdb13..48d1a204 100755 --- a/lib/app/views/main_page.dart +++ b/lib/app/views/main_page.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../android/app_methods.dart'; import '../../android/state.dart'; @@ -24,12 +25,13 @@ import '../../core/state.dart'; import '../../fido/views/fingerprints_screen.dart'; import '../../fido/views/passkeys_screen.dart'; import '../../fido/views/webauthn_page.dart'; +import '../../home/views/home_message_page.dart'; +import '../../home/views/home_screen.dart'; import '../../management/views/management_screen.dart'; import '../../oath/views/oath_screen.dart'; import '../../oath/views/utils.dart'; import '../../otp/views/otp_screen.dart'; import '../../piv/views/piv_screen.dart'; -import '../../widgets/custom_icons.dart'; import '../message.dart'; import '../models.dart'; import '../state.dart'; @@ -82,7 +84,7 @@ class MainPage extends ConsumerWidget { if (isAndroid) { var hasNfcSupport = ref.watch(androidNfcSupportProvider); var isNfcEnabled = ref.watch(androidNfcStateProvider); - return MessagePage( + return HomeMessagePage( centered: true, graphic: noKeyImage, header: hasNfcSupport && isNfcEnabled @@ -92,21 +94,20 @@ class MainPage extends ConsumerWidget { if (hasNfcSupport && !isNfcEnabled) ElevatedButton.icon( label: Text(l10n.s_enable_nfc), - icon: nfcIcon, + icon: const Icon(Symbols.contactless), onPressed: () async { await openNfcSettings(); - }) + }), + ElevatedButton.icon( + label: Text(l10n.s_add_account), + icon: const Icon(Symbols.person_add_alt), + onPressed: () async { + await addOathAccount(context, ref); + }) ], - actionButtonBuilder: (context) => IconButton( - icon: const Icon(Icons.person_add_alt_1), - tooltip: l10n.s_add_account, - onPressed: () async { - await addOathAccount(context, ref); - }, - ), ); } else { - return MessagePage( + return HomeMessagePage( centered: true, delayedContent: false, graphic: noKeyImage, @@ -116,32 +117,33 @@ class MainPage extends ConsumerWidget { } else { return ref.watch(currentDeviceDataProvider).when( data: (data) { - final app = ref.watch(currentAppProvider); - final capabilities = app.capabilities; + final section = ref.watch(currentSectionProvider); + final capabilities = section.capabilities; if (data.info.supportedCapabilities.isEmpty && data.name == 'Unrecognized device') { - return MessagePage( + return HomeMessagePage( centered: true, graphic: Icon( - Icons.help_outlined, + Symbols.help, size: 96, color: Theme.of(context).colorScheme.error, ), header: l10n.s_yk_not_recognized, ); - } else if (app.getAvailability(data) == + } else if (section.getAvailability(data) == Availability.unsupported) { return MessagePage( - title: app.getDisplayName(l10n), + title: section.getDisplayName(l10n), capabilities: capabilities, header: l10n.s_app_not_supported, message: l10n.l_app_not_supported_on_yk(capabilities .map((c) => c.getDisplayName(l10n)) .join(',')), ); - } else if (app.getAvailability(data) != Availability.enabled) { + } else if (section.getAvailability(data) != + Availability.enabled) { return MessagePage( - title: app.getDisplayName(l10n), + title: section.getDisplayName(l10n), capabilities: capabilities, header: l10n.s_app_disabled, message: l10n.l_app_disabled_desc(capabilities @@ -158,23 +160,20 @@ class MainPage extends ConsumerWidget { builder: (context) => ManagementScreen(data), ); }, - avatar: const Icon(Icons.construction), + avatar: const Icon(Symbols.construction), ) ], ); } - return switch (app) { - Application.accounts => OathScreen(data.node.path), - Application.webauthn => const WebAuthnScreen(), - Application.passkeys => PasskeysScreen(data), - Application.fingerprints => FingerprintsScreen(data), - Application.certificates => PivScreen(data.node.path), - Application.slots => OtpScreen(data.node.path), - _ => MessagePage( - header: l10n.s_app_not_supported, - message: l10n.l_app_not_supported_desc, - ), + return switch (section) { + Section.home => HomeScreen(data), + Section.accounts => OathScreen(data.node.path), + Section.securityKey => const WebAuthnScreen(), + Section.passkeys => PasskeysScreen(data), + Section.fingerprints => FingerprintsScreen(data), + Section.certificates => PivScreen(data.node.path), + Section.slots => OtpScreen(data.node.path), }; }, loading: () => DeviceErrorScreen(deviceNode), diff --git a/lib/app/views/message_page_not_initialized.dart b/lib/app/views/message_page_not_initialized.dart new file mode 100644 index 00000000..7bbbc312 --- /dev/null +++ b/lib/app/views/message_page_not_initialized.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../android/app_methods.dart'; +import '../../android/state.dart'; +import '../../core/state.dart'; +import 'message_page.dart'; + +class MessagePageNotInitialized extends ConsumerWidget { + final String title; + const MessagePageNotInitialized({super.key, required this.title}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final noKeyImage = Image.asset( + 'assets/graphics/no-key.png', + filterQuality: FilterQuality.medium, + scale: 2, + color: Theme.of(context).colorScheme.primary, + ); + + if (isAndroid) { + var hasNfcSupport = ref.watch(androidNfcSupportProvider); + var isNfcEnabled = ref.watch(androidNfcStateProvider); + return MessagePage( + title: title, + centered: true, + graphic: noKeyImage, + header: hasNfcSupport && isNfcEnabled + ? l10n.l_insert_or_tap_yk + : l10n.l_insert_yk, + actionsBuilder: (context, expanded) => [ + if (hasNfcSupport && !isNfcEnabled) + ElevatedButton.icon( + label: Text(l10n.s_enable_nfc), + icon: const Icon(Symbols.contactless), + onPressed: () async { + await openNfcSettings(); + }) + ], + ); + } else { + return MessagePage( + title: title, + centered: true, + delayedContent: false, + graphic: noKeyImage, + header: l10n.l_insert_yk, + ); + } + } +} diff --git a/lib/app/views/navigation.dart b/lib/app/views/navigation.dart index 3e504df9..6d968327 100644 --- a/lib/app/views/navigation.dart +++ b/lib/app/views/navigation.dart @@ -17,9 +17,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../models.dart'; -import '../shortcuts.dart'; import '../state.dart'; import 'device_picker.dart'; import 'keys.dart'; @@ -85,35 +85,25 @@ class NavigationItem extends StatelessWidget { } } -extension on Application { +extension on Section { IconData get _icon => switch (this) { - Application.accounts => Icons.supervisor_account_outlined, - Application.webauthn => Icons.security_outlined, - Application.passkeys => Icons.security_outlined, - Application.fingerprints => Icons.fingerprint_outlined, - Application.slots => Icons.touch_app_outlined, - Application.certificates => Icons.approval_outlined, - Application.management => Icons.construction_outlined, - }; - - IconData get _filledIcon => switch (this) { - Application.accounts => Icons.supervisor_account, - Application.webauthn => Icons.security, - Application.passkeys => Icons.security, - Application.fingerprints => Icons.fingerprint, - Application.slots => Icons.touch_app, - Application.certificates => Icons.approval, - Application.management => Icons.construction, + Section.home => Symbols.home, + Section.accounts => Symbols.supervisor_account, + Section.securityKey => Symbols.security_key, + Section.passkeys => Symbols.passkey, + Section.fingerprints => Symbols.fingerprint, + Section.slots => Symbols.touch_app, + Section.certificates => Symbols.badge, }; Key get _key => switch (this) { - Application.accounts => oathAppDrawer, - Application.webauthn => u2fAppDrawer, - Application.passkeys => fidoPasskeysAppDrawer, - Application.fingerprints => fidoFingerprintsAppDrawer, - Application.slots => otpAppDrawer, - Application.certificates => pivAppDrawer, - Application.management => managementAppDrawer, + Section.home => homeDrawer, + Section.accounts => oathAppDrawer, + Section.securityKey => u2fAppDrawer, + Section.passkeys => fidoPasskeysAppDrawer, + Section.fingerprints => fidoFingerprintsAppDrawer, + Section.slots => otpAppDrawer, + Section.certificates => pivAppDrawer, }; } @@ -126,17 +116,16 @@ class NavigationContent extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; - final supportedApps = ref.watch(supportedAppsProvider); + final supportedSections = ref.watch(supportedSectionsProvider); final data = ref.watch(currentDeviceDataProvider).valueOrNull; - final availableApps = data != null - ? supportedApps - .where( - (app) => app.getAvailability(data) != Availability.unsupported) + final availableSections = data != null + ? supportedSections + .where((section) => + section.getAvailability(data) != Availability.unsupported) .toList() - : []; - availableApps.remove(Application.management); - final currentApp = ref.watch(currentAppProvider); + : [Section.home]; + final currentSection = ref.watch(currentSectionProvider); return Padding( padding: const EdgeInsets.all(8.0), @@ -146,65 +135,36 @@ class NavigationContent extends ConsumerWidget { duration: const Duration(milliseconds: 150), child: DevicePickerContent(extended: extended), ), - const SizedBox(height: 32), - AnimatedSize( duration: const Duration(milliseconds: 150), child: Column( children: [ - if (data != null) ...[ - // Normal YubiKey Applications - ...availableApps.map((app) => NavigationItem( - key: app._key, - title: app.getDisplayName(l10n), - leading: app == currentApp - ? Icon(app._filledIcon) - : Icon(app._icon), - collapsed: !extended, - selected: app == currentApp, - onTap: app.getAvailability(data) == Availability.enabled - ? () { - ref - .read(currentAppProvider.notifier) - .setCurrentApp(app); - if (shouldPop) { - Navigator.of(context).pop(); - } + // Normal YubiKey Applications + ...availableSections.map((app) => NavigationItem( + key: app._key, + title: app.getDisplayName(l10n), + leading: Icon(app._icon, + fill: app == currentSection ? 1.0 : 0.0), + collapsed: !extended, + selected: app == currentSection, + onTap: data == null && currentSection == Section.home || + data != null && + app.getAvailability(data) == + Availability.enabled + ? () { + ref + .read(currentSectionProvider.notifier) + .setCurrentSection(app); + if (shouldPop) { + Navigator.of(context).pop(); } - : null, - )), - const SizedBox(height: 32), - ], + } + : null, + )), ], ), ), - - // Non-YubiKey pages - NavigationItem( - leading: const Icon(Icons.settings_outlined), - key: settingDrawerIcon, - title: l10n.s_settings, - collapsed: !extended, - onTap: () { - if (shouldPop) { - Navigator.of(context).pop(); - } - Actions.maybeInvoke(context, const SettingsIntent()); - }, - ), - NavigationItem( - leading: const Icon(Icons.help_outline), - key: helpDrawerIcon, - title: l10n.s_help_and_about, - collapsed: !extended, - onTap: () { - if (shouldPop) { - Navigator.of(context).pop(); - } - Actions.maybeInvoke(context, const AboutIntent()); - }, - ), ], ), ); diff --git a/lib/app/views/reset_dialog.dart b/lib/app/views/reset_dialog.dart index 60e90d01..5ebbbbce 100644 --- a/lib/app/views/reset_dialog.dart +++ b/lib/app/views/reset_dialog.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../core/models.dart'; @@ -45,9 +46,9 @@ final _log = Logger('fido.views.reset_dialog'); extension on Capability { IconData get _icon => switch (this) { - Capability.oath => Icons.supervisor_account_outlined, - Capability.fido2 => Icons.security_outlined, - Capability.piv => Icons.approval_outlined, + Capability.oath => Symbols.supervisor_account, + Capability.fido2 => Symbols.passkey, + Capability.piv => Symbols.badge, _ => throw UnsupportedError('Icon not defined'), }; } @@ -80,6 +81,12 @@ class _ResetDialogState extends ConsumerState { _totalSteps = nfc ? 2 : 3; } + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } + String _getMessage() { final l10n = AppLocalizations.of(context)!; final nfc = widget.data.node.transport == Transport.nfc; @@ -249,10 +256,10 @@ class _ResetDialogState extends ConsumerState { if (isAndroid) { // switch current app context ref - .read(currentAppProvider.notifier) - .setCurrentApp(switch (_application) { - Capability.oath => Application.accounts, - Capability.fido2 => Application.passkeys, + .read(currentSectionProvider.notifier) + .setCurrentSection(switch (_application) { + Capability.oath => Section.accounts, + Capability.fido2 => Section.passkeys, _ => throw UnimplementedError( 'Reset for $_application is not implemented') }); diff --git a/lib/desktop/fido/state.dart b/lib/desktop/fido/state.dart index c3bdf2d3..d974f199 100755 --- a/lib/desktop/fido/state.dart +++ b/lib/desktop/fido/state.dart @@ -32,14 +32,18 @@ import '../state.dart'; final _log = Logger('desktop.fido.state'); -final _pinProvider = StateProvider.autoDispose.family( - (ref, _) => null, +final _pinProvider = StateProvider.family( + (ref, _) { + // Clear PIN if current device is changed + ref.watch(currentDeviceProvider); + return null; + }, ); final _sessionProvider = Provider.autoDispose.family( (ref, devicePath) { - // Make sure the pinProvider is held for the duration of the session. + // Refresh state when PIN is changed ref.watch(_pinProvider(devicePath)); return RpcNodeSession( ref.watch(rpcProvider).requireValue, devicePath, ['fido', 'ctap2']); diff --git a/lib/desktop/init.dart b/lib/desktop/init.dart index 5ef65341..2473642a 100755 --- a/lib/desktop/init.dart +++ b/lib/desktop/init.dart @@ -26,6 +26,7 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:local_notifier/local_notifier.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:screen_retriever/screen_retriever.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:window_manager/window_manager.dart'; @@ -203,6 +204,9 @@ Future initialize(List argv) async { currentDeviceDataProvider.overrideWith( (ref) => ref.watch(desktopDeviceDataProvider), ), + currentSectionProvider.overrideWith( + (ref) => desktopCurrentSectionNotifier(ref), + ), // OATH oathStateProvider.overrideWithProvider(desktopOathState.call), credentialListProvider @@ -379,7 +383,7 @@ class _HelperWaiterState extends ConsumerState<_HelperWaiter> { message: l10n.l_helper_not_responding, actionsBuilder: (context, expanded) => [ ActionChip( - avatar: const Icon(Icons.copy), + avatar: const Icon(Symbols.content_copy), backgroundColor: Theme.of(context).colorScheme.surfaceVariant, label: Text(l10n.s_copy_log), onPressed: () async { diff --git a/lib/desktop/oath/state.dart b/lib/desktop/oath/state.dart index b2e9185c..5bd070f6 100755 --- a/lib/desktop/oath/state.dart +++ b/lib/desktop/oath/state.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/models.dart'; @@ -265,7 +266,7 @@ class DesktopCredentialListNotifier extends OathCredentialListNotifier { final l10n = AppLocalizations.of(context)!; return promptUserInteraction( context, - icon: const Icon(Icons.touch_app), + icon: const Icon(Symbols.touch_app), title: l10n.s_touch_required, description: l10n.l_touch_button_now, headless: headless, diff --git a/lib/desktop/piv/state.dart b/lib/desktop/piv/state.dart index fddaeea0..f5d36ec3 100644 --- a/lib/desktop/piv/state.dart +++ b/lib/desktop/piv/state.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/models.dart'; @@ -126,7 +127,7 @@ class _DesktopPivStateNotifier extends PivStateNotifier { final l10n = AppLocalizations.of(context)!; return promptUserInteraction( context, - icon: const Icon(Icons.touch_app), + icon: const Icon(Symbols.touch_app), title: l10n.s_touch_required, description: l10n.l_touch_button_now, ); @@ -174,7 +175,7 @@ class _DesktopPivStateNotifier extends PivStateNotifier { final l10n = AppLocalizations.of(context)!; return promptUserInteraction( context, - icon: const Icon(Icons.touch_app), + icon: const Icon(Symbols.touch_app), title: l10n.s_touch_required, description: l10n.l_touch_button_now, ); @@ -324,7 +325,7 @@ class _DesktopPivSlotsNotifier extends PivSlotsNotifier { final l10n = AppLocalizations.of(context)!; return promptUserInteraction( context, - icon: const Icon(Icons.touch_app), + icon: const Icon(Symbols.touch_app), title: l10n.s_touch_required, description: l10n.l_touch_button_now, ); diff --git a/lib/desktop/state.dart b/lib/desktop/state.dart index 1e24a143..77d2c181 100755 --- a/lib/desktop/state.dart +++ b/lib/desktop/state.dart @@ -216,3 +216,61 @@ class DesktopCurrentDeviceNotifier extends CurrentDeviceNotifier { ref.read(prefProvider).setString(_lastDevice, device?.path.key ?? ''); } } + +CurrentSectionNotifier desktopCurrentSectionNotifier(Ref ref) { + final notifier = DesktopCurrentSectionNotifier( + ref.watch(supportedSectionsProvider), ref.watch(prefProvider)); + ref.listen>(currentDeviceDataProvider, (_, data) { + notifier._notifyDeviceChanged(data.whenOrNull(data: ((data) => data))); + }, fireImmediately: true); + return notifier; +} + +class DesktopCurrentSectionNotifier extends CurrentSectionNotifier { + final List
_supportedSections; + static const String _key = 'APP_STATE_LAST_SECTION'; + final SharedPreferences _prefs; + + DesktopCurrentSectionNotifier(this._supportedSections, this._prefs) + : super(_fromName(_prefs.getString(_key), _supportedSections)); + + @override + void setCurrentSection(Section section) { + state = section; + _prefs.setString(_key, section.name); + } + + void _notifyDeviceChanged(YubiKeyData? data) { + if (data == null) { + state = _supportedSections.first; + return; + } + + String? lastAppName = _prefs.getString(_key); + if (lastAppName != null && lastAppName != state.name) { + // Try switching to saved app + state = Section.values.firstWhere((app) => app.name == lastAppName); + } + if (state == Section.passkeys && + state.getAvailability(data) != Availability.enabled) { + state = Section.securityKey; + } + if (state == Section.securityKey && + state.getAvailability(data) != Availability.enabled) { + state = Section.passkeys; + } + if (state.getAvailability(data) != Availability.unsupported) { + // Keep current app + return; + } + + state = _supportedSections.firstWhere( + (app) => app.getAvailability(data) == Availability.enabled, + orElse: () => _supportedSections.first, + ); + } + + static Section _fromName(String? name, List
supportedSections) => + supportedSections.firstWhere((element) => element.name == name, + orElse: () => supportedSections.first); +} diff --git a/lib/exception/no_data_exception.dart b/lib/exception/no_data_exception.dart new file mode 100644 index 00000000..c5498870 --- /dev/null +++ b/lib/exception/no_data_exception.dart @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +class NoDataException implements Exception { + const NoDataException(); +} diff --git a/lib/fido/models.dart b/lib/fido/models.dart index 3f3b2577..79b4d1cf 100755 --- a/lib/fido/models.dart +++ b/lib/fido/models.dart @@ -27,8 +27,7 @@ class FidoState with _$FidoState { factory FidoState( {required Map info, - required bool unlocked, - @Default(true) bool initialized}) = _FidoState; + required bool unlocked}) = _FidoState; factory FidoState.fromJson(Map json) => _$FidoStateFromJson(json); diff --git a/lib/fido/models.freezed.dart b/lib/fido/models.freezed.dart index 1b2b8949..acfc641c 100644 --- a/lib/fido/models.freezed.dart +++ b/lib/fido/models.freezed.dart @@ -22,7 +22,6 @@ FidoState _$FidoStateFromJson(Map json) { mixin _$FidoState { Map get info => throw _privateConstructorUsedError; bool get unlocked => throw _privateConstructorUsedError; - bool get initialized => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -35,7 +34,7 @@ abstract class $FidoStateCopyWith<$Res> { factory $FidoStateCopyWith(FidoState value, $Res Function(FidoState) then) = _$FidoStateCopyWithImpl<$Res, FidoState>; @useResult - $Res call({Map info, bool unlocked, bool initialized}); + $Res call({Map info, bool unlocked}); } /// @nodoc @@ -53,7 +52,6 @@ class _$FidoStateCopyWithImpl<$Res, $Val extends FidoState> $Res call({ Object? info = null, Object? unlocked = null, - Object? initialized = null, }) { return _then(_value.copyWith( info: null == info @@ -64,10 +62,6 @@ class _$FidoStateCopyWithImpl<$Res, $Val extends FidoState> ? _value.unlocked : unlocked // ignore: cast_nullable_to_non_nullable as bool, - initialized: null == initialized - ? _value.initialized - : initialized // ignore: cast_nullable_to_non_nullable - as bool, ) as $Val); } } @@ -80,7 +74,7 @@ abstract class _$$FidoStateImplCopyWith<$Res> __$$FidoStateImplCopyWithImpl<$Res>; @override @useResult - $Res call({Map info, bool unlocked, bool initialized}); + $Res call({Map info, bool unlocked}); } /// @nodoc @@ -96,7 +90,6 @@ class __$$FidoStateImplCopyWithImpl<$Res> $Res call({ Object? info = null, Object? unlocked = null, - Object? initialized = null, }) { return _then(_$FidoStateImpl( info: null == info @@ -107,10 +100,6 @@ class __$$FidoStateImplCopyWithImpl<$Res> ? _value.unlocked : unlocked // ignore: cast_nullable_to_non_nullable as bool, - initialized: null == initialized - ? _value.initialized - : initialized // ignore: cast_nullable_to_non_nullable - as bool, )); } } @@ -119,9 +108,7 @@ class __$$FidoStateImplCopyWithImpl<$Res> @JsonSerializable() class _$FidoStateImpl extends _FidoState { _$FidoStateImpl( - {required final Map info, - required this.unlocked, - this.initialized = true}) + {required final Map info, required this.unlocked}) : _info = info, super._(); @@ -138,13 +125,10 @@ class _$FidoStateImpl extends _FidoState { @override final bool unlocked; - @override - @JsonKey() - final bool initialized; @override String toString() { - return 'FidoState(info: $info, unlocked: $unlocked, initialized: $initialized)'; + return 'FidoState(info: $info, unlocked: $unlocked)'; } @override @@ -154,15 +138,13 @@ class _$FidoStateImpl extends _FidoState { other is _$FidoStateImpl && const DeepCollectionEquality().equals(other._info, _info) && (identical(other.unlocked, unlocked) || - other.unlocked == unlocked) && - (identical(other.initialized, initialized) || - other.initialized == initialized)); + other.unlocked == unlocked)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash(runtimeType, - const DeepCollectionEquality().hash(_info), unlocked, initialized); + int get hashCode => Object.hash( + runtimeType, const DeepCollectionEquality().hash(_info), unlocked); @JsonKey(ignore: true) @override @@ -181,8 +163,7 @@ class _$FidoStateImpl extends _FidoState { abstract class _FidoState extends FidoState { factory _FidoState( {required final Map info, - required final bool unlocked, - final bool initialized}) = _$FidoStateImpl; + required final bool unlocked}) = _$FidoStateImpl; _FidoState._() : super._(); factory _FidoState.fromJson(Map json) = @@ -193,8 +174,6 @@ abstract class _FidoState extends FidoState { @override bool get unlocked; @override - bool get initialized; - @override @JsonKey(ignore: true) _$$FidoStateImplCopyWith<_$FidoStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/fido/models.g.dart b/lib/fido/models.g.dart index aa2f2b9e..42ed350b 100644 --- a/lib/fido/models.g.dart +++ b/lib/fido/models.g.dart @@ -10,14 +10,12 @@ _$FidoStateImpl _$$FidoStateImplFromJson(Map json) => _$FidoStateImpl( info: json['info'] as Map, unlocked: json['unlocked'] as bool, - initialized: json['initialized'] as bool? ?? true, ); Map _$$FidoStateImplToJson(_$FidoStateImpl instance) => { 'info': instance.info, 'unlocked': instance.unlocked, - 'initialized': instance.initialized, }; _$FingerprintImpl _$$FingerprintImplFromJson(Map json) => diff --git a/lib/fido/views/actions.dart b/lib/fido/views/actions.dart index 7f1f08fe..d7bce1d9 100644 --- a/lib/fido/views/actions.dart +++ b/lib/fido/views/actions.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -112,7 +113,7 @@ List buildFingerprintActions( ActionItem( key: keys.editFingerintAction, feature: features.fingerprintsEdit, - icon: const Icon(Icons.edit), + icon: const Icon(Symbols.edit), title: l10n.s_rename_fp, subtitle: l10n.l_rename_fp_desc, intent: EditIntent(fingerprint), @@ -121,7 +122,7 @@ List buildFingerprintActions( key: keys.deleteFingerprintAction, feature: features.fingerprintsDelete, actionStyle: ActionStyle.error, - icon: const Icon(Icons.delete), + icon: const Icon(Symbols.delete), title: l10n.s_delete_fingerprint, subtitle: l10n.l_delete_fingerprint_desc, intent: DeleteIntent(fingerprint), @@ -136,7 +137,7 @@ List buildCredentialActions( key: keys.deleteCredentialAction, feature: features.credentialsDelete, actionStyle: ActionStyle.error, - icon: const Icon(Icons.delete), + icon: const Icon(Symbols.delete), title: l10n.s_delete_passkey, subtitle: l10n.l_delete_passkey_desc, intent: DeleteIntent(credential), diff --git a/lib/fido/views/add_fingerprint_dialog.dart b/lib/fido/views/add_fingerprint_dialog.dart index 0074d778..69b2392a 100755 --- a/lib/fido/views/add_fingerprint_dialog.dart +++ b/lib/fido/views/add_fingerprint_dialog.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/message.dart'; @@ -212,7 +213,9 @@ class _AddFingerprintDialogState extends ConsumerState animation: _color, builder: (context, _) { return Icon( - _fingerprint == null ? Icons.fingerprint : Icons.check, + _fingerprint == null + ? Symbols.fingerprint + : Symbols.check, size: 128.0, color: _color.value, ); @@ -244,7 +247,7 @@ class _AddFingerprintDialogState extends ConsumerState decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_name, - prefixIcon: const Icon(Icons.fingerprint_outlined), + prefixIcon: const Icon(Symbols.fingerprint), ), onChanged: (value) { setState(() { @@ -254,7 +257,7 @@ class _AddFingerprintDialogState extends ConsumerState onFieldSubmitted: (_) { _submit(); }, - ), + ).init(), ) ] ], diff --git a/lib/fido/views/credential_dialog.dart b/lib/fido/views/credential_dialog.dart index ea4f8318..f784d2eb 100644 --- a/lib/fido/views/credential_dialog.dart +++ b/lib/fido/views/credential_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/shortcuts.dart'; import '../../app/state.dart'; @@ -74,7 +75,7 @@ class CredentialDialog extends ConsumerWidget { ), ), const SizedBox(height: 16), - const Icon(Icons.person, size: 72), + const Icon(Symbols.person, size: 72), ], ), ), diff --git a/lib/fido/views/fingerprint_dialog.dart b/lib/fido/views/fingerprint_dialog.dart index 463ecd8d..76ad1764 100644 --- a/lib/fido/views/fingerprint_dialog.dart +++ b/lib/fido/views/fingerprint_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/shortcuts.dart'; @@ -80,7 +81,7 @@ class FingerprintDialog extends ConsumerWidget { textAlign: TextAlign.center, ), const SizedBox(height: 16), - const Icon(Icons.fingerprint, size: 72), + const Icon(Symbols.fingerprint, size: 72), ], ), ), diff --git a/lib/fido/views/fingerprints_screen.dart b/lib/fido/views/fingerprints_screen.dart index 73b3afb0..abe7e571 100644 --- a/lib/fido/views/fingerprints_screen.dart +++ b/lib/fido/views/fingerprints_screen.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -28,7 +29,9 @@ import '../../app/views/app_failure_page.dart'; import '../../app/views/app_list_item.dart'; import '../../app/views/app_page.dart'; import '../../app/views/message_page.dart'; +import '../../app/views/message_page_not_initialized.dart'; import '../../core/state.dart'; +import '../../exception/no_data_exception.dart'; import '../../management/models.dart'; import '../../widgets/list_title.dart'; import '../features.dart' as features; @@ -43,6 +46,7 @@ import 'pin_entry_form.dart'; class FingerprintsScreen extends ConsumerWidget { final YubiKeyData deviceData; + const FingerprintsScreen(this.deviceData, {super.key}); @override @@ -55,6 +59,9 @@ class FingerprintsScreen extends ConsumerWidget { builder: (context, _) => const CircularProgressIndicator(), ), error: (error, _) { + if (error is NoDataException) { + return MessagePageNotInitialized(title: l10n.s_fingerprints); + } final enabled = deviceData .info.config.enabledCapabilities[deviceData.node.transport] ?? 0; @@ -102,7 +109,7 @@ class _FidoLockedPage extends ConsumerWidget { context: context, builder: (context) => FidoPinDialog(node.path, state)); }, - avatar: const Icon(Icons.pin_outlined), + avatar: const Icon(Symbols.pin), ) ], title: l10n.s_fingerprints, @@ -131,7 +138,7 @@ class _FidoLockedPage extends ConsumerWidget { context: context, builder: (context) => FidoPinDialog(node.path, state)); }, - avatar: const Icon(Icons.pin_outlined), + avatar: const Icon(Symbols.pin), ) ], ); @@ -190,7 +197,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { builder: (context) => AddFingerprintDialog(widget.node.path)); }, - avatar: const Icon(Icons.fingerprint_outlined), + avatar: const Icon(Symbols.fingerprint), ) ], title: l10n.s_fingerprints, @@ -278,7 +285,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { textAlign: TextAlign.center, ), const SizedBox(height: 16), - const Icon(Icons.fingerprint, size: 72), + const Icon(Symbols.fingerprint, size: 72), ], ), ), @@ -356,14 +363,14 @@ class _FingerprintListItem extends StatelessWidget { leading: CircleAvatar( foregroundColor: Theme.of(context).colorScheme.onSecondary, backgroundColor: Theme.of(context).colorScheme.secondary, - child: const Icon(Icons.fingerprint), + child: const Icon(Symbols.fingerprint), ), title: fingerprint.label, trailing: expanded ? null : OutlinedButton( onPressed: Actions.handler(context, OpenIntent(fingerprint)), - child: const Icon(Icons.more_horiz), + child: const Icon(Symbols.more_horiz), ), tapIntent: isDesktop && !expanded ? null : OpenIntent(fingerprint), doubleTapIntent: isDesktop && !expanded ? OpenIntent(fingerprint) : null, diff --git a/lib/fido/views/key_actions.dart b/lib/fido/views/key_actions.dart index ee711f80..6ca1987d 100755 --- a/lib/fido/views/key_actions.dart +++ b/lib/fido/views/key_actions.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -58,7 +59,7 @@ Widget _fidoBuildActions(BuildContext context, DeviceNode node, FidoState state, key: keys.addFingerprintAction, feature: features.actionsAddFingerprint, actionStyle: ActionStyle.primary, - icon: const Icon(Icons.fingerprint_outlined), + icon: const Icon(Symbols.fingerprint), title: l10n.s_add_fingerprint, subtitle: state.unlocked ? l10n.l_fingerprints_used(fingerprints) @@ -66,7 +67,7 @@ Widget _fidoBuildActions(BuildContext context, DeviceNode node, FidoState state, ? l10n.l_unlock_pin_first : l10n.l_set_pin_first, trailing: fingerprints == 0 || fingerprints == -1 - ? Icon(Icons.warning_amber, + ? Icon(Symbols.warning_amber, color: state.unlocked ? colors.tertiary : null) : null, onTap: state.unlocked && fingerprints < 5 @@ -87,7 +88,7 @@ Widget _fidoBuildActions(BuildContext context, DeviceNode node, FidoState state, ActionListItem( key: keys.managePinAction, feature: features.actionsPin, - icon: const Icon(Icons.pin_outlined), + icon: const Icon(Symbols.pin), title: state.hasPin ? l10n.s_change_pin : l10n.s_set_pin, subtitle: state.hasPin ? (state.forcePinChange @@ -95,7 +96,7 @@ Widget _fidoBuildActions(BuildContext context, DeviceNode node, FidoState state, : l10n.s_fido_pin_protection) : l10n.s_fido_pin_protection, trailing: state.alwaysUv && !state.hasPin || state.forcePinChange - ? Icon(Icons.warning_amber, color: colors.tertiary) + ? Icon(Symbols.warning_amber, color: colors.tertiary) : null, onTap: (context) { Navigator.of(context).popUntil((route) => route.isFirst); diff --git a/lib/fido/views/passkeys_screen.dart b/lib/fido/views/passkeys_screen.dart index 822e11b2..52a9dca9 100644 --- a/lib/fido/views/passkeys_screen.dart +++ b/lib/fido/views/passkeys_screen.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -29,7 +30,9 @@ import '../../app/views/app_failure_page.dart'; import '../../app/views/app_list_item.dart'; import '../../app/views/app_page.dart'; import '../../app/views/message_page.dart'; +import '../../app/views/message_page_not_initialized.dart'; import '../../core/state.dart'; +import '../../exception/no_data_exception.dart'; import '../../management/models.dart'; import '../../widgets/list_title.dart'; import '../features.dart' as features; @@ -56,6 +59,9 @@ class PasskeysScreen extends ConsumerWidget { builder: (context, _) => const CircularProgressIndicator(), ), error: (error, _) { + if (error is NoDataException) { + return MessagePageNotInitialized(title: l10n.s_passkeys); + } final enabled = deviceData .info.config.enabledCapabilities[deviceData.node.transport] ?? 0; @@ -74,30 +80,13 @@ class PasskeysScreen extends ConsumerWidget { ); }, data: (fidoState) { - return fidoState.initialized - ? fidoState.unlocked - ? _FidoUnlockedPage(deviceData.node, fidoState) - : _FidoLockedPage(deviceData.node, fidoState) - : const _FidoInsertTapPage(); + return fidoState.unlocked + ? _FidoUnlockedPage(deviceData.node, fidoState) + : _FidoLockedPage(deviceData.node, fidoState); }); } } -class _FidoInsertTapPage extends ConsumerWidget { - const _FidoInsertTapPage(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final l10n = AppLocalizations.of(context)!; - return MessagePage( - title: l10n.s_passkeys, - centered: false, - capabilities: const [Capability.fido2], - header: l10n.l_insert_or_tap_yk, - ); - } -} - class _FidoLockedPage extends ConsumerWidget { final DeviceNode node; final FidoState state; @@ -123,10 +112,10 @@ class _FidoLockedPage extends ConsumerWidget { label: Text(l10n.s_setup_fingerprints), onPressed: () async { ref - .read(currentAppProvider.notifier) - .setCurrentApp(Application.fingerprints); + .read(currentSectionProvider.notifier) + .setCurrentSection(Section.fingerprints); }, - avatar: const Icon(Icons.fingerprint_outlined), + avatar: const Icon(Symbols.fingerprint), ), if (!isBio && alwaysUv && !expanded) ActionChip( @@ -136,7 +125,7 @@ class _FidoLockedPage extends ConsumerWidget { context: context, builder: (context) => FidoPinDialog(node.path, state)); }, - avatar: const Icon(Icons.pin_outlined), + avatar: const Icon(Symbols.pin), ) ]; }, @@ -177,7 +166,7 @@ class _FidoLockedPage extends ConsumerWidget { context: context, builder: (context) => FidoPinDialog(node.path, state)); }, - avatar: const Icon(Icons.pin_outlined), + avatar: const Icon(Symbols.pin), ) ], title: l10n.s_passkeys, @@ -259,10 +248,10 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { label: Text(l10n.s_setup_fingerprints), onPressed: () async { ref - .read(currentAppProvider.notifier) - .setCurrentApp(Application.fingerprints); + .read(currentSectionProvider.notifier) + .setCurrentSection(Section.fingerprints); }, - avatar: const Icon(Icons.fingerprint_outlined), + avatar: const Icon(Symbols.fingerprint), ) ]; } @@ -358,7 +347,7 @@ class _FidoUnlockedPageState extends ConsumerState<_FidoUnlockedPage> { ), ), const SizedBox(height: 16), - const Icon(Icons.person, size: 72), + const Icon(Symbols.person, size: 72), ], ), ), @@ -440,7 +429,7 @@ class _CredentialListItem extends StatelessWidget { leading: CircleAvatar( foregroundColor: Theme.of(context).colorScheme.onPrimary, backgroundColor: Theme.of(context).colorScheme.primary, - child: const Icon(Icons.person), + child: const Icon(Symbols.person), ), title: credential.userName, subtitle: credential.rpId, @@ -448,7 +437,7 @@ class _CredentialListItem extends StatelessWidget { ? null : OutlinedButton( onPressed: Actions.handler(context, OpenIntent(credential)), - child: const Icon(Icons.more_horiz), + child: const Icon(Symbols.more_horiz), ), tapIntent: isDesktop && !expanded ? null : OpenIntent(credential), doubleTapIntent: isDesktop && !expanded ? OpenIntent(credential) : null, diff --git a/lib/fido/views/pin_dialog.dart b/lib/fido/views/pin_dialog.dart index ea276b75..168c6019 100755 --- a/lib/fido/views/pin_dialog.dart +++ b/lib/fido/views/pin_dialog.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/message.dart'; @@ -45,7 +46,8 @@ class FidoPinDialog extends ConsumerStatefulWidget { } class _FidoPinDialogState extends ConsumerState { - String _currentPin = ''; + final _currentPinController = TextEditingController(); + final _currentPinFocus = FocusNode(); String _newPin = ''; String _confirmPin = ''; String? _currentPinError; @@ -55,15 +57,28 @@ class _FidoPinDialogState extends ConsumerState { bool _isObscureCurrent = true; bool _isObscureNew = true; bool _isObscureConfirm = true; + bool _isBlocked = false; + + @override + void dispose() { + _currentPinController.dispose(); + _currentPinFocus.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final hasPin = widget.state.hasPin; - final isValid = _newPin.isNotEmpty && - _newPin == _confirmPin && - (!hasPin || _currentPin.isNotEmpty); final minPinLength = widget.state.minPinLength; + final currentMinPinLen = !hasPin + ? 0 + // N.B. current PIN may be shorter than minimum if set before the minimum was increased + : (widget.state.forcePinChange ? 4 : widget.state.minPinLength); + final currentPinLenOk = + _currentPinController.text.length >= currentMinPinLen; + final newPinLenOk = _newPin.length >= minPinLength; + final isValid = currentPinLenOk && newPinLenOk && _newPin == _confirmPin; return ResponsiveDialog( title: Text(hasPin ? l10n.s_change_pin : l10n.s_set_pin), @@ -83,20 +98,22 @@ class _FidoPinDialogState extends ConsumerState { Text(l10n.p_enter_current_pin_or_reset_no_puk), AppTextFormField( key: currentPin, - initialValue: _currentPin, + controller: _currentPinController, + focusNode: _currentPinFocus, autofocus: true, obscureText: _isObscureCurrent, autofillHints: const [AutofillHints.password], decoration: AppInputDecoration( + enabled: !_isBlocked, border: const OutlineInputBorder(), labelText: l10n.s_current_pin, errorText: _currentIsWrong ? _currentPinError : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.pin_outlined), + prefixIcon: const Icon(Symbols.pin), suffixIcon: IconButton( icon: Icon(_isObscureCurrent - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureCurrent = !_isObscureCurrent; @@ -109,10 +126,9 @@ class _FidoPinDialogState extends ConsumerState { onChanged: (value) { setState(() { _currentIsWrong = false; - _currentPin = value; }); }, - ), + ).init(), ], Text(l10n.p_enter_new_fido2_pin(minPinLength)), // TODO: Set max characters based on UTF-8 bytes @@ -125,13 +141,14 @@ class _FidoPinDialogState extends ConsumerState { decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_new_pin, - enabled: !hasPin || _currentPin.isNotEmpty, + enabled: !_isBlocked && currentPinLenOk, errorText: _newIsWrong ? _newPinError : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.pin_outlined), + prefixIcon: const Icon(Symbols.pin), suffixIcon: IconButton( - icon: Icon( - _isObscureNew ? Icons.visibility : Icons.visibility_off), + icon: Icon(_isObscureNew + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureNew = !_isObscureNew; @@ -146,7 +163,7 @@ class _FidoPinDialogState extends ConsumerState { _newPin = value; }); }, - ), + ).init(), AppTextFormField( key: confirmPin, initialValue: _confirmPin, @@ -155,11 +172,11 @@ class _FidoPinDialogState extends ConsumerState { decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_confirm_pin, - prefixIcon: const Icon(Icons.pin_outlined), + prefixIcon: const Icon(Symbols.pin), suffixIcon: IconButton( icon: Icon(_isObscureConfirm - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureConfirm = !_isObscureConfirm; @@ -168,8 +185,12 @@ class _FidoPinDialogState extends ConsumerState { tooltip: _isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin, ), - enabled: - (!hasPin || _currentPin.isNotEmpty) && _newPin.isNotEmpty, + enabled: !_isBlocked && currentPinLenOk && newPinLenOk, + errorText: _newPin.length == _confirmPin.length && + _newPin != _confirmPin + ? l10n.l_pin_mismatch + : null, + helperText: '', // Prevents resizing when errorText shown ), onChanged: (value) { setState(() { @@ -181,7 +202,7 @@ class _FidoPinDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -195,15 +216,9 @@ class _FidoPinDialogState extends ConsumerState { void _submit() async { final l10n = AppLocalizations.of(context)!; - final minPinLength = widget.state.minPinLength; - final oldPin = _currentPin.isNotEmpty ? _currentPin : null; - if (_newPin.length < minPinLength) { - setState(() { - _newPinError = l10n.l_new_pin_len(minPinLength); - _newIsWrong = true; - }); - return; - } + final oldPin = _currentPinController.text.isNotEmpty + ? _currentPinController.text + : null; try { final result = await ref .read(fidoStateProvider(widget.devicePath).notifier) @@ -213,9 +228,13 @@ class _FidoPinDialogState extends ConsumerState { showMessage(context, l10n.s_pin_set); }, failed: (retries, authBlocked) { setState(() { + _currentPinController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentPinController.text.length); + _currentPinFocus.requestFocus(); if (authBlocked) { _currentPinError = l10n.l_pin_soft_locked; _currentIsWrong = true; + _isBlocked = true; } else { _currentPinError = l10n.l_wrong_pin_attempts_remaining(retries); _currentIsWrong = true; diff --git a/lib/fido/views/pin_entry_form.dart b/lib/fido/views/pin_entry_form.dart index 8db1f239..45c5d506 100644 --- a/lib/fido/views/pin_entry_form.dart +++ b/lib/fido/views/pin_entry_form.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/models.dart'; import '../../exception/cancellation_exception.dart'; @@ -37,11 +38,19 @@ class PinEntryForm extends ConsumerStatefulWidget { class _PinEntryFormState extends ConsumerState { final _pinController = TextEditingController(); + final _pinFocus = FocusNode(); bool _blocked = false; int? _retries; bool _pinIsWrong = false; bool _isObscure = true; + @override + void dispose() { + _pinController.dispose(); + _pinFocus.dispose(); + super.dispose(); + } + void _submit() async { setState(() { _pinIsWrong = false; @@ -52,8 +61,10 @@ class _PinEntryFormState extends ConsumerState { .read(fidoStateProvider(widget._deviceNode.path).notifier) .unlock(_pinController.text); result.whenOrNull(failed: (retries, authBlocked) { + _pinController.selection = TextSelection( + baseOffset: 0, extentOffset: _pinController.text.length); + _pinFocus.requestFocus(); setState(() { - _pinController.clear(); _pinIsWrong = true; _retries = retries; _blocked = authBlocked; @@ -96,16 +107,18 @@ class _PinEntryFormState extends ConsumerState { obscureText: _isObscure, autofillHints: const [AutofillHints.password], controller: _pinController, + focusNode: _pinFocus, + enabled: !_blocked && (_retries ?? 1) > 0, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, helperText: '', // Prevents dialog resizing errorText: _pinIsWrong ? _getErrorText() : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.pin_outlined), + prefixIcon: const Icon(Symbols.pin), suffixIcon: IconButton( icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off), + _isObscure ? Symbols.visibility : Symbols.visibility_off), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -120,11 +133,11 @@ class _PinEntryFormState extends ConsumerState { }); }, // Update state on change onSubmitted: (_) => _submit(), - ), + ).init(), ), ListTile( leading: noFingerprints - ? Icon(Icons.warning_amber, + ? Icon(Symbols.warning_amber, color: Theme.of(context).colorScheme.tertiary) : null, title: noFingerprints @@ -138,10 +151,14 @@ class _PinEntryFormState extends ConsumerState { minLeadingWidth: 0, trailing: FilledButton.icon( key: unlockFido2WithPin, - icon: const Icon(Icons.lock_open), + icon: const Icon(Symbols.lock_open), label: Text(l10n.s_unlock), - onPressed: - _pinController.text.isNotEmpty && !_blocked ? _submit : null, + onPressed: !_pinIsWrong && + _pinController.text.length >= + widget._state.minPinLength && + !_blocked + ? _submit + : null, ), ), ], diff --git a/lib/fido/views/rename_fingerprint_dialog.dart b/lib/fido/views/rename_fingerprint_dialog.dart index 63319345..2c6c783c 100755 --- a/lib/fido/views/rename_fingerprint_dialog.dart +++ b/lib/fido/views/rename_fingerprint_dialog.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -99,7 +100,7 @@ class _RenameAccountDialogState extends ConsumerState { decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_name, - prefixIcon: const Icon(Icons.fingerprint_outlined), + prefixIcon: const Icon(Symbols.fingerprint), ), onChanged: (value) { setState(() { @@ -111,7 +112,7 @@ class _RenameAccountDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/fido/views/webauthn_page.dart b/lib/fido/views/webauthn_page.dart index 2efbc1b0..fcaef06d 100644 --- a/lib/fido/views/webauthn_page.dart +++ b/lib/fido/views/webauthn_page.dart @@ -27,7 +27,7 @@ class WebAuthnScreen extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return MessagePage( - title: l10n.s_webauthn, + title: l10n.s_security_key, capabilities: const [Capability.u2f], header: l10n.l_ready_to_use, message: l10n.l_register_sk_on_websites, diff --git a/lib/home/views/home_message_page.dart b/lib/home/views/home_message_page.dart new file mode 100644 index 00000000..e62b9a79 --- /dev/null +++ b/lib/home/views/home_message_page.dart @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/views/message_page.dart'; +import '../../management/models.dart'; +import 'key_actions.dart'; + +class HomeMessagePage extends ConsumerWidget { + final Widget? graphic; + final String? header; + final String? message; + final String? footnote; + final bool delayedContent; + final Widget Function(BuildContext context)? actionButtonBuilder; + final List Function(BuildContext context, bool expanded)? + actionsBuilder; + final Widget? fileDropOverlay; + final Function(File file)? onFileDropped; + final List? capabilities; + final bool keyActionsBadge; + final bool centered; + + const HomeMessagePage({ + super.key, + this.graphic, + this.header, + this.message, + this.footnote, + this.actionButtonBuilder, + this.actionsBuilder, + this.fileDropOverlay, + this.onFileDropped, + this.delayedContent = false, + this.keyActionsBadge = false, + this.capabilities, + this.centered = false, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + return MessagePage( + title: l10n.s_home, + graphic: graphic, + header: header, + message: message, + footnote: footnote, + keyActionsBuilder: (context) => homeBuildActions(context, null, ref), + actionButtonBuilder: actionButtonBuilder, + actionsBuilder: actionsBuilder, + fileDropOverlay: fileDropOverlay, + onFileDropped: onFileDropped, + delayedContent: delayedContent, + keyActionsBadge: keyActionsBadge, + capabilities: capabilities, + centered: centered, + ); + } +} diff --git a/lib/home/views/home_screen.dart b/lib/home/views/home_screen.dart new file mode 100644 index 00000000..6ef74528 --- /dev/null +++ b/lib/home/views/home_screen.dart @@ -0,0 +1,351 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../android/state.dart'; +import '../../app/message.dart'; +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../../app/views/app_page.dart'; +import '../../core/models.dart'; +import '../../core/state.dart'; +import '../../management/models.dart'; +import '../../widgets/choice_filter_chip.dart'; +import '../../widgets/product_image.dart'; +import 'key_actions.dart'; +import 'manage_label_dialog.dart'; + +class HomeScreen extends ConsumerWidget { + final YubiKeyData deviceData; + const HomeScreen(this.deviceData, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + final serial = deviceData.info.serial; + final keyCustomization = ref.watch(keyCustomizationManagerProvider)[serial]; + final enabledCapabilities = + deviceData.info.config.enabledCapabilities[deviceData.node.transport] ?? + 0; + final primaryColor = ref.watch(defaultColorProvider); + + return AppPage( + title: l10n.s_home, + keyActionsBuilder: (context) => + homeBuildActions(context, deviceData, ref), + builder: (context, expanded) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DeviceContent(deviceData, keyCustomization), + const SizedBox(height: 16.0), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + flex: 8, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 4, + runSpacing: 8, + children: Capability.values + .where((c) => enabledCapabilities & c.value != 0) + .map((c) => CapabilityBadge(c)) + .toList(), + ), + if (serial != null) ...[ + const SizedBox(height: 32.0), + _DeviceColor( + deviceData: deviceData, + initialCustomization: keyCustomization ?? + KeyCustomization(serial: serial)) + ] + ], + ), + ), + Flexible( + flex: 6, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: _HeroAvatar( + color: keyCustomization?.color ?? primaryColor, + child: ProductImage( + name: deviceData.name, + formFactor: deviceData.info.formFactor, + isNfc: deviceData.info.supportedCapabilities + .containsKey(Transport.nfc), + ), + ), + ), + ) + ], + ) + ], + ), + ); + }, + ); + } +} + +class _DeviceContent extends ConsumerWidget { + final YubiKeyData deviceData; + final KeyCustomization? initialCustomization; + const _DeviceContent(this.deviceData, this.initialCustomization); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + + final name = deviceData.name; + final serial = deviceData.info.serial; + final version = deviceData.info.version; + + final label = initialCustomization?.name; + String displayName = label != null ? '$label ($name)' : name; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox( + height: 12, + ), + if (serial != null) + Text( + l10n.l_serial_number(serial), + style: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + Text( + l10n.l_firmware_version(version), + style: Theme.of(context).textTheme.titleSmall?.apply( + color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ), + ), + if (serial != null) + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: IconButton( + icon: const Icon(Symbols.edit), + onPressed: () async { + await ref.read(withContextProvider)((context) async { + await _showManageLabelDialog( + initialCustomization ?? KeyCustomization(serial: serial), + context, + ); + }); + }, + ), + ) + ], + ); + } + + Future _showManageLabelDialog( + KeyCustomization keyCustomization, BuildContext context) async { + await showBlurDialog( + context: context, + builder: (context) => ManageLabelDialog( + initialCustomization: keyCustomization, + ), + ); + } +} + +class _DeviceColor extends ConsumerWidget { + final YubiKeyData deviceData; + final KeyCustomization initialCustomization; + const _DeviceColor( + {required this.deviceData, required this.initialCustomization}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final theme = Theme.of(context); + final primaryColor = ref.watch(defaultColorProvider); + final defaultColor = + (isAndroid && ref.read(androidSdkVersionProvider) >= 31) + ? theme.colorScheme.onSurface + : primaryColor; + final customColor = initialCustomization.color; + + return ChoiceFilterChip( + disableHover: true, + value: customColor, + items: const [null], + selected: customColor != null && customColor != defaultColor, + itemBuilder: (e) => Wrap( + alignment: WrapAlignment.center, + runSpacing: 8, + spacing: 16, + children: [ + ...[ + Colors.teal, + Colors.cyan, + Colors.blueAccent, + Colors.deepPurple, + Colors.red, + Colors.orange, + Colors.yellow, + // add nice color to devices with dynamic color + if (isAndroid && ref.read(androidSdkVersionProvider) >= 31) + Colors.lightGreen + ].map((e) => _ColorButton( + color: e, + isSelected: customColor == e, + onPressed: () { + _updateColor(e, ref); + Navigator.of(context).pop(); + }, + )), + + // remove color button + RawMaterialButton( + onPressed: () { + _updateColor(null, ref); + Navigator.of(context).pop(); + }, + constraints: const BoxConstraints(minWidth: 26.0, minHeight: 26.0), + fillColor: (isAndroid && ref.read(androidSdkVersionProvider) >= 31) + ? theme.colorScheme.onSurface + : primaryColor, + hoverColor: Colors.black12, + shape: const CircleBorder(), + child: Icon( + Symbols.cancel, + size: 16, + color: customColor == null + ? theme.colorScheme.onSurface + : theme.colorScheme.surface.withOpacity(0.2), + ), + ), + ], + ), + labelBuilder: (e) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + constraints: const BoxConstraints(minWidth: 22.0, minHeight: 22.0), + decoration: BoxDecoration( + color: customColor ?? defaultColor, shape: BoxShape.circle), + ), + const SizedBox( + width: 12, + ), + Flexible(child: Text(l10n.s_color)) + ], + ), + onChanged: (e) {}, + ); + } + + void _updateColor(Color? color, WidgetRef ref) async { + final manager = ref.read(keyCustomizationManagerProvider.notifier); + await manager.set( + serial: initialCustomization.serial, + name: initialCustomization.name, + color: color, + ); + } +} + +class _ColorButton extends StatefulWidget { + final Color? color; + final bool isSelected; + final Function()? onPressed; + + const _ColorButton({ + required this.color, + required this.isSelected, + required this.onPressed, + }); + + @override + State<_ColorButton> createState() => _ColorButtonState(); +} + +class _ColorButtonState extends State<_ColorButton> { + @override + Widget build(BuildContext context) { + return RawMaterialButton( + onPressed: widget.onPressed, + constraints: const BoxConstraints(minWidth: 26.0, minHeight: 26.0), + fillColor: widget.color, + hoverColor: Colors.black12, + shape: const CircleBorder(), + child: Icon( + Symbols.circle, + fill: 1, + size: 16, + color: widget.isSelected ? Colors.white : Colors.transparent, + ), + ); + } +} + +class _HeroAvatar extends StatelessWidget { + final Widget child; + final Color color; + + const _HeroAvatar({required this.color, required this.child}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + color.withOpacity(0.6), + color.withOpacity(0.25), + (DialogTheme.of(context).backgroundColor ?? + theme.dialogBackgroundColor) + .withOpacity(0), + ], + ), + ), + padding: const EdgeInsets.all(12), + child: Theme( + // Give the avatar a transparent background + data: theme.copyWith( + colorScheme: + theme.colorScheme.copyWith(surfaceVariant: Colors.transparent)), + child: child, + ), + ); + } +} diff --git a/lib/home/views/key_actions.dart b/lib/home/views/key_actions.dart new file mode 100644 index 00000000..2c130ee0 --- /dev/null +++ b/lib/home/views/key_actions.dart @@ -0,0 +1,115 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/material_symbols_icons.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../app/features.dart' as features; +import '../../app/message.dart'; +import '../../app/models.dart'; +import '../../app/shortcuts.dart'; +import '../../app/views/action_list.dart'; +import '../../app/views/reset_dialog.dart'; +import '../../core/models.dart'; +import '../../core/state.dart'; +import '../../management/views/management_screen.dart'; + +Widget homeBuildActions( + BuildContext context, YubiKeyData? deviceData, WidgetRef ref) { + final l10n = AppLocalizations.of(context)!; + final hasFeature = ref.watch(featureProvider); + final managementAvailability = hasFeature(features.management) && + switch (deviceData?.info.version) { + Version version => (version.major > 4 || // YK5 and up + (version.major == 4 && version.minor >= 1) || // YK4.1 and up + version.major == 3), // NEO, + null => false, + }; + + return Column( + children: [ + if (deviceData != null) + ActionListSection( + l10n.s_device, + children: [ + if (managementAvailability) + ActionListItem( + feature: features.management, + icon: const Icon(Symbols.construction), + actionStyle: ActionStyle.primary, + title: deviceData.info.version.major > 4 + ? l10n.s_toggle_applications + : l10n.s_toggle_interfaces, + subtitle: deviceData.info.version.major > 4 + ? l10n.l_toggle_applications_desc + : l10n.l_toggle_interfaces_desc, + onTap: (context) { + Navigator.of(context).popUntil((route) => route.isFirst); + showBlurDialog( + context: context, + builder: (context) => ManagementScreen(deviceData), + ); + }, + ), + if (getResetCapabilities(hasFeature).any((c) => + c.value & + (deviceData.info + .supportedCapabilities[deviceData.node.transport] ?? + 0) != + 0)) + ActionListItem( + icon: const Icon(Symbols.delete_forever), + title: l10n.s_factory_reset, + subtitle: l10n.l_factory_reset_desc, + actionStyle: ActionStyle.primary, + onTap: (context) { + Navigator.of(context).popUntil((route) => route.isFirst); + showBlurDialog( + context: context, + builder: (context) => ResetDialog(deviceData), + ); + }, + ) + ], + ), + ActionListSection(l10n.s_application, children: [ + ActionListItem( + icon: const Icon(Symbols.settings), + title: l10n.s_settings, + subtitle: l10n.l_settings_desc, + actionStyle: ActionStyle.primary, + onTap: (context) { + Navigator.of(context).popUntil((route) => route.isFirst); + Actions.maybeInvoke(context, const SettingsIntent()); + }, + ), + ActionListItem( + icon: const Icon(Symbols.help), + title: l10n.s_help_and_about, + subtitle: l10n.l_help_and_about_desc, + actionStyle: ActionStyle.primary, + onTap: (context) { + Navigator.of(context).popUntil((route) => route.isFirst); + Actions.maybeInvoke(context, const AboutIntent()); + }, + ) + ]) + ], + ); +} diff --git a/lib/home/views/manage_label_dialog.dart b/lib/home/views/manage_label_dialog.dart new file mode 100644 index 00000000..76ff9536 --- /dev/null +++ b/lib/home/views/manage_label_dialog.dart @@ -0,0 +1,114 @@ +/* + * Copyright (C) 2024 Yubico. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../app/models.dart'; +import '../../app/state.dart'; +import '../../widgets/app_input_decoration.dart'; +import '../../widgets/app_text_form_field.dart'; +import '../../widgets/focus_utils.dart'; +import '../../widgets/responsive_dialog.dart'; + +class ManageLabelDialog extends ConsumerStatefulWidget { + final KeyCustomization initialCustomization; + + const ManageLabelDialog({super.key, required this.initialCustomization}); + + @override + ConsumerState createState() => _ManageLabelDialogState(); +} + +class _ManageLabelDialogState extends ConsumerState { + String? _label; + + @override + void initState() { + super.initState(); + _label = widget.initialCustomization.name; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final initialLabel = widget.initialCustomization.name; + final didChange = initialLabel != _label; + return ResponsiveDialog( + title: + Text(initialLabel != null ? l10n.s_change_label : l10n.s_set_label), + actions: [ + TextButton( + onPressed: didChange ? _submit : null, + child: Text(l10n.s_save), + ) + ], + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (initialLabel != null) Text(l10n.q_rename_target(initialLabel)), + Text(initialLabel == null + ? l10n.p_set_will_add_custom_name + : l10n.p_rename_will_change_custom_name), + AppTextFormField( + autofocus: true, + initialValue: _label, + maxLength: 20, + decoration: AppInputDecoration( + border: const OutlineInputBorder(), + labelText: l10n.s_label, + helperText: '', + prefixIcon: const Icon(Symbols.key), + ), + textInputAction: TextInputAction.done, + onChanged: (value) { + setState(() { + final trimmed = value.trim(); + _label = trimmed.isEmpty ? null : trimmed; + }); + }, + onFieldSubmitted: (_) { + _submit(); + }, + ).init() + ] + .map((e) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: e, + )) + .toList(), + ), + ), + ); + } + + void _submit() async { + final manager = ref.read(keyCustomizationManagerProvider.notifier); + await manager.set( + serial: widget.initialCustomization.serial, + name: _label, + color: widget.initialCustomization.color); + + await ref.read(withContextProvider)((context) async { + FocusUtils.unfocus(context); + Navigator.of(context).pop(); + }); + } +} diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 0df77b8b..498d4eda 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -61,12 +61,17 @@ "s_actions": null, "s_manage": "Verwalten", "s_setup": "Einrichten", + "s_device": null, + "s_application": null, "s_settings": "Einstellungen", + "l_settings_desc": null, "s_certificates": null, - "s_webauthn": "WebAuthn", + "s_security_key": null, "s_slots": null, "s_help_and_about": "Hilfe und Über", + "l_help_and_about_desc": null, "s_help_and_feedback": "Hilfe und Feedback", + "s_home": null, "s_send_feedback": "Senden Sie uns Feedback", "s_i_need_help": "Ich brauche Hilfe", "s_troubleshooting": "Problembehebung", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "YubiKey anschließen", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Anwendungen umschalten", "s_toggle_interfaces": null, + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "YubiKey wird neu konfiguriert\u2026", "s_config_updated": "Konfiguration aktualisiert", "l_config_updated_reinsert": "Konfiguration aktualisiert, entfernen Sie Ihren YubiKey und schließen ihn wieder an", @@ -188,6 +207,7 @@ "s_unknown_device": "Unbekanntes Gerät", "s_unsupported_yk": "Nicht unterstützter YubiKey", "s_yk_not_recognized": "Geräte nicht erkannt", + "p_operation_failed_try_again": null, "@_general_errors": {}, "l_error_occurred": "Es ist ein Fehler aufgetreten", @@ -214,6 +234,8 @@ "s_confirm_pin": "PIN bestätigen", "s_confirm_puk": null, "s_unblock_pin": null, + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "Neue PIN muss mindestens {length} Zeichen lang sein", "@l_new_pin_len": { "placeholders": { @@ -290,6 +312,7 @@ "s_new_password": "Neues Passwort", "s_current_password": "Aktuelles Passwort", "s_confirm_password": "Passwort bestätigen", + "l_password_mismatch": null, "s_wrong_password": "Falsches Passwort", "s_remove_password": "Passwort entfernen", "s_password_removed": "Passwort entfernt", @@ -645,6 +668,7 @@ "@_factory_reset": {}, "s_reset": "Zurücksetzen", "s_factory_reset": "Werkseinstellungen", + "l_factory_reset_desc": null, "l_oath_application_reset": "OATH Anwendung zurücksetzen", "l_fido_app_reset": "FIDO Anwendung zurückgesetzt", "l_reset_failed": "Fehler beim Zurücksetzen: {message}", @@ -721,7 +745,7 @@ "l_launch_app_on_usb_off": "Andere Anwendungen können den YubiKey über USB nutzen", "s_allow_screenshots": "Bildschirmfotos erlauben", - "s_nfc_dialog_tap_key": null, + "l_nfc_dialog_tap_key": null, "s_nfc_dialog_operation_success": null, "s_nfc_dialog_operation_failed": null, @@ -752,7 +776,12 @@ "@_key_customization": {}, "s_customize_key_action": null, + "s_set_label": null, + "s_change_label": null, "s_theme_color": null, + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index de1cf3f4..d6439e9c 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -61,12 +61,17 @@ "s_actions": "Actions", "s_manage": "Manage", "s_setup": "Setup", + "s_device": "Device", + "s_application": "Application", "s_settings": "Settings", + "l_settings_desc": "Change application preferences", "s_certificates": "Certificates", - "s_webauthn": "WebAuthn", + "s_security_key": "Security Key", "s_slots": "Slots", "s_help_and_about": "Help and about", + "l_help_and_about_desc": "Troubleshoot and support", "s_help_and_feedback": "Help and feedback", + "s_home": "Home", "s_send_feedback": "Send us feedback", "s_i_need_help": "I need help", "s_troubleshooting": "Troubleshooting", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": "Serial number: {serial}", + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": "Firmware version: {version}", "@_yubikey_interactions": {}, "l_insert_yk": "Insert your YubiKey", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Toggle applications", "s_toggle_interfaces": "Toggle interfaces", + "l_toggle_applications_desc": "Enable/disable applications", + "l_toggle_interfaces_desc": "Enable/disable interfaces", "s_reconfiguring_yk": "Reconfiguring YubiKey\u2026", "s_config_updated": "Configuration updated", "l_config_updated_reinsert": "Configuration updated, remove and reinsert your YubiKey", @@ -188,6 +207,7 @@ "s_unknown_device": "Unrecognized device", "s_unsupported_yk": "Unsupported YubiKey", "s_yk_not_recognized": "Device not recognized", + "p_operation_failed_try_again": "The operation failed, please try again.", "@_general_errors": {}, "l_error_occurred": "An error has occurred", @@ -214,6 +234,8 @@ "s_confirm_pin": "Confirm PIN", "s_confirm_puk": "Confirm PUK", "s_unblock_pin": "Unblock PIN", + "l_pin_mismatch": "PINs do not match", + "l_puk_mismatch": "PUKs do not match", "l_new_pin_len": "New PIN must be at least {length} characters", "@l_new_pin_len": { "placeholders": { @@ -290,6 +312,7 @@ "s_new_password": "New password", "s_current_password": "Current password", "s_confirm_password": "Confirm password", + "l_password_mismatch": "Passwords do not match", "s_wrong_password": "Wrong password", "s_remove_password": "Remove password", "s_password_removed": "Password removed", @@ -645,6 +668,7 @@ "@_factory_reset": {}, "s_reset": "Reset", "s_factory_reset": "Factory reset", + "l_factory_reset_desc": "Restore YubiKey defaults", "l_oath_application_reset": "OATH application reset", "l_fido_app_reset": "FIDO application reset", "l_reset_failed": "Error performing reset: {message}", @@ -721,7 +745,7 @@ "l_launch_app_on_usb_off": "Other apps can use the YubiKey over USB", "s_allow_screenshots": "Allow screenshots", - "s_nfc_dialog_tap_key": "Tap your key", + "l_nfc_dialog_tap_key": "Tap and hold your key", "s_nfc_dialog_operation_success": "Success", "s_nfc_dialog_operation_failed": "Failed", @@ -752,7 +776,12 @@ "@_key_customization": {}, "s_customize_key_action": "Set label/color", + "s_set_label": "Set label", + "s_change_label": "Change label", "s_theme_color": "Theme color", + "s_color": "Color", + "p_set_will_add_custom_name": "This will give your YubiKey a custom name.", + "p_rename_will_change_custom_name": "This will change the label of your YubiKey.", "@_eof": {} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index de5f38b9..8a3b2a33 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -61,12 +61,17 @@ "s_actions": "Actions", "s_manage": "Gérer", "s_setup": "Configuration", + "s_device": null, + "s_application": null, "s_settings": "Paramètres", + "l_settings_desc": null, "s_certificates": "Certificats", - "s_webauthn": "WebAuthn", + "s_security_key": null, "s_slots": null, "s_help_and_about": "Aide et à propos", + "l_help_and_about_desc": null, "s_help_and_feedback": "Aide et retours", + "s_home": null, "s_send_feedback": "Envoyer nous un retour", "s_i_need_help": "J'ai besoin d'aide", "s_troubleshooting": "Dépannage", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "Insérez votre YubiKey", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Changer les applications", "s_toggle_interfaces": null, + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "Reconfiguration de la YubiKey\u2026", "s_config_updated": "Configuration mise à jour", "l_config_updated_reinsert": "Configuration mise à jour; retirez et réinsérez votre YubiKey", @@ -188,6 +207,7 @@ "s_unknown_device": "Appareil non reconnu", "s_unsupported_yk": "YubiKey non supportée", "s_yk_not_recognized": "Appareil non reconnu", + "p_operation_failed_try_again": null, "@_general_errors": {}, "l_error_occurred": "Une erreur est survenue", @@ -214,6 +234,8 @@ "s_confirm_pin": "Confirmez le PIN", "s_confirm_puk": "Confirmez le PUK", "s_unblock_pin": "Débloquer le PIN", + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "Le nouveau PIN doit avoir au moins {length} caractères", "@l_new_pin_len": { "placeholders": { @@ -290,6 +312,7 @@ "s_new_password": "Nouveau mot de passe", "s_current_password": "Mot de passe actuel", "s_confirm_password": "Confirmez le mot de passe", + "l_password_mismatch": null, "s_wrong_password": "Mauvais mot de passe", "s_remove_password": "Supprimer le mot de passe", "s_password_removed": "Mot de passe supprimé", @@ -645,6 +668,7 @@ "@_factory_reset": {}, "s_reset": "Réinitialiser", "s_factory_reset": "Réinitialisation", + "l_factory_reset_desc": null, "l_oath_application_reset": "L'application OATH à été réinitialisée", "l_fido_app_reset": "L'application FIDO à été réinitialisée", "l_reset_failed": "Erreur pendant la réinitialisation: {message}", @@ -721,7 +745,7 @@ "l_launch_app_on_usb_off": "D'autres applications peuvent utiliser la YubiKey via USB", "s_allow_screenshots": "Autoriser les captures d'écrans", - "s_nfc_dialog_tap_key": "Effleurez votre YubiKey", + "l_nfc_dialog_tap_key": null, "s_nfc_dialog_operation_success": "Succès", "s_nfc_dialog_operation_failed": "Échec", @@ -752,7 +776,12 @@ "@_key_customization": {}, "s_customize_key_action": null, + "s_set_label": null, + "s_change_label": null, "s_theme_color": null, + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 8e88d135..8a037a29 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -61,12 +61,17 @@ "s_actions": "アクション", "s_manage": "管理", "s_setup": "セットアップ", + "s_device": null, + "s_application": null, "s_settings": "設定", + "l_settings_desc": null, "s_certificates": "証明書", - "s_webauthn": "WebAuthn", + "s_security_key": null, "s_slots": null, "s_help_and_about": "ヘルプと概要", + "l_help_and_about_desc": null, "s_help_and_feedback": "ヘルプとフィードバック", + "s_home": null, "s_send_feedback": "フィードバックの送信", "s_i_need_help": "ヘルプが必要", "s_troubleshooting": "トラブルシューティング", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "YubiKeyを挿入する", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "アプリケーションの切替え", "s_toggle_interfaces": null, + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "YubiKeyを再構成しています\u2026", "s_config_updated": "構成が更新されました", "l_config_updated_reinsert": "設定が更新されました。YubiKeyを取り外して再挿入してください", @@ -188,6 +207,7 @@ "s_unknown_device": "認識されないデバイス", "s_unsupported_yk": "サポートされていないYubiKey", "s_yk_not_recognized": "デバイスが認識されない", + "p_operation_failed_try_again": null, "@_general_errors": {}, "l_error_occurred": "エラーが発生しました", @@ -214,6 +234,8 @@ "s_confirm_pin": "PINの確認", "s_confirm_puk": "PUKの確認", "s_unblock_pin": "ブロックを解除", + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "新しいPINは少なくとも{length}文字である必要があります", "@l_new_pin_len": { "placeholders": { @@ -290,6 +312,7 @@ "s_new_password": "新しいパスワード", "s_current_password": "現在のパスワード", "s_confirm_password": "パスワードを確認", + "l_password_mismatch": null, "s_wrong_password": "間違ったパスワード", "s_remove_password": "パスワードの削除", "s_password_removed": "パスワードが削除されました", @@ -645,6 +668,7 @@ "@_factory_reset": {}, "s_reset": "リセット", "s_factory_reset": "工場出荷リセット", + "l_factory_reset_desc": null, "l_oath_application_reset": "OATHアプリケーションのリセット", "l_fido_app_reset": "FIDOアプリケーションのリセット", "l_reset_failed": "リセット実行中のエラー:{message}", @@ -721,7 +745,7 @@ "l_launch_app_on_usb_off": "他のアプリはUSB経由でYubiKeyを使用できます", "s_allow_screenshots": "スクリーンショットを許可する", - "s_nfc_dialog_tap_key": "キーをタップする", + "l_nfc_dialog_tap_key": null, "s_nfc_dialog_operation_success": "成功", "s_nfc_dialog_operation_failed": "失敗", @@ -752,7 +776,12 @@ "@_key_customization": {}, "s_customize_key_action": null, + "s_set_label": null, + "s_change_label": null, "s_theme_color": null, + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index eb6fc951..886a9604 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -61,12 +61,17 @@ "s_actions": "Działania", "s_manage": "Zarządzaj", "s_setup": "Konfiguruj", + "s_device": null, + "s_application": null, "s_settings": "Ustawienia", + "l_settings_desc": null, "s_certificates": "Certyfikaty", - "s_webauthn": "WebAuthn", + "s_security_key": null, "s_slots": "Sloty", "s_help_and_about": "Pomoc i informacje", + "l_help_and_about_desc": null, "s_help_and_feedback": "Pomoc i opinie", + "s_home": null, "s_send_feedback": "Prześlij opinię", "s_i_need_help": "Pomoc", "s_troubleshooting": "Rozwiązywanie problemów", @@ -128,6 +133,18 @@ "version": {} } }, + "@l_serial_number": { + "placeholders": { + "serial": {} + } + }, + "l_serial_number": null, + "@l_firmware_version": { + "placeholders": { + "version": {} + } + }, + "l_firmware_version": null, "@_yubikey_interactions": {}, "l_insert_yk": "Podłącz klucz YubiKey", @@ -154,6 +171,8 @@ "@_app_configuration": {}, "s_toggle_applications": "Przełączanie funkcji", "s_toggle_interfaces": "Przełącz interfejsy", + "l_toggle_applications_desc": null, + "l_toggle_interfaces_desc": null, "s_reconfiguring_yk": "Rekonfigurowanie YubiKey\u2026", "s_config_updated": "Zaktualizowano konfigurację", "l_config_updated_reinsert": "Zaktualizowano konfigurację, podłącz ponownie klucz YubiKey", @@ -188,6 +207,7 @@ "s_unknown_device": "Nierozpoznane urządzenie", "s_unsupported_yk": "Nieobsługiwany klucz YubiKey", "s_yk_not_recognized": "Urządzenie nie rozpoznane", + "p_operation_failed_try_again": null, "@_general_errors": {}, "l_error_occurred": "Wystąpił błąd", @@ -214,6 +234,8 @@ "s_confirm_pin": "Potwierdź PIN", "s_confirm_puk": "Potwierdź PUK", "s_unblock_pin": "Odblokuj PIN", + "l_pin_mismatch": null, + "l_puk_mismatch": null, "l_new_pin_len": "Nowy PIN musi mieć co najmniej {length} znaków", "@l_new_pin_len": { "placeholders": { @@ -290,6 +312,7 @@ "s_new_password": "Nowe hasło", "s_current_password": "Aktualne hasło", "s_confirm_password": "Potwierdź hasło", + "l_password_mismatch": null, "s_wrong_password": "Błędne hasło", "s_remove_password": "Usuń hasło", "s_password_removed": "Hasło zostało usunięte", @@ -645,6 +668,7 @@ "@_factory_reset": {}, "s_reset": "Zresetuj", "s_factory_reset": "Ustawienia fabryczne", + "l_factory_reset_desc": null, "l_oath_application_reset": "Reset funkcji OATH", "l_fido_app_reset": "Reset funkcji FIDO", "l_reset_failed": "Błąd podczas resetowania: {message}", @@ -721,7 +745,7 @@ "l_launch_app_on_usb_off": "Inne aplikacje mogą korzystać z YubiKey przez USB", "s_allow_screenshots": "Zezwalaj na zrzuty ekranu", - "s_nfc_dialog_tap_key": "Przystaw swój klucz", + "l_nfc_dialog_tap_key": null, "s_nfc_dialog_operation_success": "Powodzenie", "s_nfc_dialog_operation_failed": "Niepowodzenie", @@ -752,7 +776,12 @@ "@_key_customization": {}, "s_customize_key_action": "Dostosuj klucz", + "s_set_label": null, + "s_change_label": null, "s_theme_color": "Kolor motywu", + "s_color": null, + "p_set_will_add_custom_name": null, + "p_rename_will_change_custom_name": null, "@_eof": {} } diff --git a/lib/management/views/management_screen.dart b/lib/management/views/management_screen.dart index dda97806..421c6f6d 100755 --- a/lib/management/views/management_screen.dart +++ b/lib/management/views/management_screen.dart @@ -18,11 +18,11 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../core/models.dart'; -import '../../widgets/custom_icons.dart'; import '../../widgets/delayed_visibility.dart'; import '../../widgets/responsive_dialog.dart'; import '../models.dart'; @@ -81,7 +81,7 @@ class _ModeForm extends StatelessWidget { final l10n = AppLocalizations.of(context)!; return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( - leading: const Icon(Icons.usb), + leading: const Icon(Symbols.usb), title: Text(l10n.s_usb), contentPadding: const EdgeInsets.only(bottom: 8), ), @@ -125,7 +125,7 @@ class _CapabilitiesForm extends StatelessWidget { children: [ if (usbCapabilities != 0) ...[ ListTile( - leading: const Icon(Icons.usb), + leading: const Icon(Symbols.usb), title: Text(l10n.s_usb), contentPadding: const EdgeInsets.only(bottom: 8), ), @@ -144,7 +144,7 @@ class _CapabilitiesForm extends StatelessWidget { padding: EdgeInsets.only(top: 12, bottom: 12), ), ListTile( - leading: nfcIcon, + leading: const Icon(Symbols.contactless), title: Text(l10n.s_nfc), contentPadding: const EdgeInsets.only(bottom: 8), ), diff --git a/lib/oath/icon_provider/icon_pack_dialog.dart b/lib/oath/icon_provider/icon_pack_dialog.dart index c3f4d58e..cf7fae4d 100644 --- a/lib/oath/icon_provider/icon_pack_dialog.dart +++ b/lib/oath/icon_provider/icon_pack_dialog.dart @@ -19,6 +19,7 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../app/message.dart'; @@ -180,7 +181,7 @@ class _IconPackDescription extends ConsumerWidget { }, ); }, - icon: const Icon(Icons.delete_outline)), + icon: const Icon(Symbols.delete)), ], ) ]); @@ -202,7 +203,7 @@ class _ImportActionChip extends ConsumerWidget { _importAction(context, ref); } : null, - avatar: const Icon(Icons.download_outlined), + avatar: const Icon(Symbols.download), label: Text(_label)); } diff --git a/lib/oath/models.dart b/lib/oath/models.dart index 05adaafd..ffcd085d 100755 --- a/lib/oath/models.dart +++ b/lib/oath/models.dart @@ -105,15 +105,11 @@ class OathPair with _$OathPair { class OathState with _$OathState { const OathState._(); - factory OathState( - String deviceId, - Version version, { - required bool hasKey, - required bool remembered, - required bool locked, - required KeystoreState keystore, - @Default(true) bool initialized, - }) = _OathState; + factory OathState(String deviceId, Version version, + {required bool hasKey, + required bool remembered, + required bool locked, + required KeystoreState keystore}) = _OathState; int? get capacity => version.isAtLeast(4) ? (version.isAtLeast(5, 7) ? 64 : 32) : null; diff --git a/lib/oath/models.freezed.dart b/lib/oath/models.freezed.dart index 51ddb410..f0da383c 100644 --- a/lib/oath/models.freezed.dart +++ b/lib/oath/models.freezed.dart @@ -639,7 +639,6 @@ mixin _$OathState { bool get remembered => throw _privateConstructorUsedError; bool get locked => throw _privateConstructorUsedError; KeystoreState get keystore => throw _privateConstructorUsedError; - bool get initialized => throw _privateConstructorUsedError; Map toJson() => throw _privateConstructorUsedError; @JsonKey(ignore: true) @@ -658,8 +657,7 @@ abstract class $OathStateCopyWith<$Res> { bool hasKey, bool remembered, bool locked, - KeystoreState keystore, - bool initialized}); + KeystoreState keystore}); $VersionCopyWith<$Res> get version; } @@ -683,7 +681,6 @@ class _$OathStateCopyWithImpl<$Res, $Val extends OathState> Object? remembered = null, Object? locked = null, Object? keystore = null, - Object? initialized = null, }) { return _then(_value.copyWith( deviceId: null == deviceId @@ -710,10 +707,6 @@ class _$OathStateCopyWithImpl<$Res, $Val extends OathState> ? _value.keystore : keystore // ignore: cast_nullable_to_non_nullable as KeystoreState, - initialized: null == initialized - ? _value.initialized - : initialized // ignore: cast_nullable_to_non_nullable - as bool, ) as $Val); } @@ -740,8 +733,7 @@ abstract class _$$OathStateImplCopyWith<$Res> bool hasKey, bool remembered, bool locked, - KeystoreState keystore, - bool initialized}); + KeystoreState keystore}); @override $VersionCopyWith<$Res> get version; @@ -764,7 +756,6 @@ class __$$OathStateImplCopyWithImpl<$Res> Object? remembered = null, Object? locked = null, Object? keystore = null, - Object? initialized = null, }) { return _then(_$OathStateImpl( null == deviceId @@ -791,10 +782,6 @@ class __$$OathStateImplCopyWithImpl<$Res> ? _value.keystore : keystore // ignore: cast_nullable_to_non_nullable as KeystoreState, - initialized: null == initialized - ? _value.initialized - : initialized // ignore: cast_nullable_to_non_nullable - as bool, )); } } @@ -806,8 +793,7 @@ class _$OathStateImpl extends _OathState { {required this.hasKey, required this.remembered, required this.locked, - required this.keystore, - this.initialized = true}) + required this.keystore}) : super._(); factory _$OathStateImpl.fromJson(Map json) => @@ -825,13 +811,10 @@ class _$OathStateImpl extends _OathState { final bool locked; @override final KeystoreState keystore; - @override - @JsonKey() - final bool initialized; @override String toString() { - return 'OathState(deviceId: $deviceId, version: $version, hasKey: $hasKey, remembered: $remembered, locked: $locked, keystore: $keystore, initialized: $initialized)'; + return 'OathState(deviceId: $deviceId, version: $version, hasKey: $hasKey, remembered: $remembered, locked: $locked, keystore: $keystore)'; } @override @@ -847,15 +830,13 @@ class _$OathStateImpl extends _OathState { other.remembered == remembered) && (identical(other.locked, locked) || other.locked == locked) && (identical(other.keystore, keystore) || - other.keystore == keystore) && - (identical(other.initialized, initialized) || - other.initialized == initialized)); + other.keystore == keystore)); } @JsonKey(ignore: true) @override - int get hashCode => Object.hash(runtimeType, deviceId, version, hasKey, - remembered, locked, keystore, initialized); + int get hashCode => Object.hash( + runtimeType, deviceId, version, hasKey, remembered, locked, keystore); @JsonKey(ignore: true) @override @@ -876,8 +857,7 @@ abstract class _OathState extends OathState { {required final bool hasKey, required final bool remembered, required final bool locked, - required final KeystoreState keystore, - final bool initialized}) = _$OathStateImpl; + required final KeystoreState keystore}) = _$OathStateImpl; _OathState._() : super._(); factory _OathState.fromJson(Map json) = @@ -896,8 +876,6 @@ abstract class _OathState extends OathState { @override KeystoreState get keystore; @override - bool get initialized; - @override @JsonKey(ignore: true) _$$OathStateImplCopyWith<_$OathStateImpl> get copyWith => throw _privateConstructorUsedError; diff --git a/lib/oath/models.g.dart b/lib/oath/models.g.dart index 43014af6..e4f577cf 100644 --- a/lib/oath/models.g.dart +++ b/lib/oath/models.g.dart @@ -70,7 +70,6 @@ _$OathStateImpl _$$OathStateImplFromJson(Map json) => remembered: json['remembered'] as bool, locked: json['locked'] as bool, keystore: $enumDecode(_$KeystoreStateEnumMap, json['keystore']), - initialized: json['initialized'] as bool? ?? true, ); Map _$$OathStateImplToJson(_$OathStateImpl instance) => @@ -81,7 +80,6 @@ Map _$$OathStateImplToJson(_$OathStateImpl instance) => 'remembered': instance.remembered, 'locked': instance.locked, 'keystore': _$KeystoreStateEnumMap[instance.keystore]!, - 'initialized': instance.initialized, }; const _$KeystoreStateEnumMap = { diff --git a/lib/oath/views/account_helper.dart b/lib/oath/views/account_helper.dart index 531beb43..c82dfe87 100755 --- a/lib/oath/views/account_helper.dart +++ b/lib/oath/views/account_helper.dart @@ -19,6 +19,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/models.dart'; import '../../app/shortcuts.dart'; @@ -69,7 +70,7 @@ class AccountHelper { ActionItem( key: keys.copyAction, feature: features.accountsClipboard, - icon: const Icon(Icons.copy), + icon: const Icon(Symbols.content_copy), title: l10n.l_copy_to_clipboard, subtitle: l10n.l_copy_code_desc, shortcut: Platform.isMacOS ? '\u2318 C' : 'Ctrl+C', @@ -80,7 +81,7 @@ class AccountHelper { ActionItem( key: keys.calculateAction, actionStyle: !canCopy ? ActionStyle.primary : null, - icon: const Icon(Icons.refresh), + icon: const Icon(Symbols.refresh), title: l10n.s_calculate, subtitle: l10n.l_calculate_code_desc, shortcut: Platform.isMacOS ? '\u2318 R' : 'Ctrl+R', @@ -89,9 +90,7 @@ class AccountHelper { ActionItem( key: keys.togglePinAction, feature: features.accountsPin, - icon: pinned - ? pushPinStrokeIcon - : const Icon(Icons.push_pin_outlined), + icon: pinned ? pushPinStrokeIcon : const Icon(Symbols.push_pin), title: pinned ? l10n.s_unpin_account : l10n.s_pin_account, subtitle: l10n.l_pin_account_desc, intent: TogglePinIntent(credential), @@ -100,7 +99,7 @@ class AccountHelper { ActionItem( key: keys.editAction, feature: features.accountsRename, - icon: const Icon(Icons.edit_outlined), + icon: const Icon(Symbols.edit), title: l10n.s_rename_account, subtitle: l10n.l_rename_account_desc, intent: EditIntent(credential), @@ -109,7 +108,7 @@ class AccountHelper { key: keys.deleteAction, feature: features.accountsDelete, actionStyle: ActionStyle.error, - icon: const Icon(Icons.delete_outline), + icon: const Icon(Symbols.delete), title: l10n.s_delete_account, subtitle: l10n.l_delete_account_desc, intent: DeleteIntent(credential), @@ -125,10 +124,10 @@ class AccountHelper { child: Opacity( opacity: 0.4, child: (credential.oathType == OathType.hotp - ? (expired ? const Icon(Icons.refresh) : null) + ? (expired ? const Icon(Symbols.refresh) : null) : (expired || code == null ? (credential.touchRequired - ? const Icon(Icons.touch_app) + ? const Icon(Symbols.touch_app) : null) : Builder(builder: (context) { return SizedBox.square( diff --git a/lib/oath/views/add_account_dialog.dart b/lib/oath/views/add_account_dialog.dart index 4d58c151..d9545271 100644 --- a/lib/oath/views/add_account_dialog.dart +++ b/lib/oath/views/add_account_dialog.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -82,7 +83,7 @@ class _AddAccountDialogState extends ConsumerState { runSpacing: 8.0, children: [ ActionChip( - avatar: const Icon(Icons.qr_code_scanner_outlined), + avatar: const Icon(Symbols.qr_code_scanner), backgroundColor: Theme.of(context).colorScheme.surfaceVariant, label: Text(l10n.s_qr_scan), @@ -105,7 +106,7 @@ class _AddAccountDialogState extends ConsumerState { ), ActionChip( key: addAccountManuallyButton, - avatar: const Icon(Icons.edit_outlined), + avatar: const Icon(Symbols.edit), backgroundColor: Theme.of(context).colorScheme.surfaceVariant, label: Text(l10n.s_add_manually), diff --git a/lib/oath/views/add_account_page.dart b/lib/oath/views/add_account_page.dart index 2f67aed8..aca626c2 100755 --- a/lib/oath/views/add_account_page.dart +++ b/lib/oath/views/add_account_page.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../android/oath/state.dart'; import '../../app/logging.dart'; @@ -295,7 +296,7 @@ class _OathAddAccountPageState extends ConsumerState { context, title: l10n.l_insert_yk, description: l10n.s_add_account, - icon: const Icon(Icons.usb), + icon: const Icon(Symbols.usb), onCancel: () { _otpauthUri = null; }, @@ -378,7 +379,7 @@ class _OathAddAccountPageState extends ConsumerState { : issuerNoColon ? null : l10n.l_invalid_character_issuer, - prefixIcon: const Icon(Icons.business_outlined), + prefixIcon: const Icon(Symbols.business), ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -389,7 +390,7 @@ class _OathAddAccountPageState extends ConsumerState { onSubmitted: (_) { if (isValid) submit(); }, - ), + ).init(), AppTextField( key: keys.nameField, controller: _accountController, @@ -406,7 +407,7 @@ class _OathAddAccountPageState extends ConsumerState { : isUnique ? null : l10n.l_name_already_exists, - prefixIcon: const Icon(Icons.person_outline), + prefixIcon: const Icon(Symbols.person), ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -417,7 +418,7 @@ class _OathAddAccountPageState extends ConsumerState { onSubmitted: (_) { if (isValid) submit(); }, - ), + ).init(), AppTextField( key: keys.secretField, controller: _secretController, @@ -435,11 +436,11 @@ class _OathAddAccountPageState extends ConsumerState { ? l10n.l_invalid_format_allowed_chars( Format.base32.allowedCharacters) : null, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: IconButton( icon: Icon(_isObscure - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -459,7 +460,7 @@ class _OathAddAccountPageState extends ConsumerState { onSubmitted: (_) { if (isValid) submit(); }, - ), + ).init(), const SizedBox(height: 8), Wrap( crossAxisAlignment: WrapCrossAlignment.center, diff --git a/lib/oath/views/add_multi_account_page.dart b/lib/oath/views/add_multi_account_page.dart index 2dc18574..0db1d7f4 100644 --- a/lib/oath/views/add_multi_account_page.dart +++ b/lib/oath/views/add_multi_account_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../android/oath/state.dart'; import '../../app/logging.dart'; @@ -101,9 +102,8 @@ class _OathAddMultiAccountPageState }); } : null, - icon: Icon(touch - ? Icons.touch_app - : Icons.touch_app_outlined)), + icon: Icon(Symbols.touch_app, + fill: touch ? 1.0 : 0.0)), ), Semantics( label: l10n.s_rename_account, @@ -148,7 +148,7 @@ class _OathAddMultiAccountPageState }, icon: IconTheme( data: IconTheme.of(context), - child: const Icon(Icons.edit_outlined)), + child: const Icon(Symbols.edit)), ), ), ]), diff --git a/lib/oath/views/key_actions.dart b/lib/oath/views/key_actions.dart index 2616a0ed..5f46a1ff 100755 --- a/lib/oath/views/key_actions.dart +++ b/lib/oath/views/key_actions.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -52,7 +53,7 @@ Widget oathBuildActions( ? l10n.l_accounts_used(used, capacity) : null), actionStyle: ActionStyle.primary, - icon: const Icon(Icons.person_add_alt_1_outlined), + icon: const Icon(Symbols.person_add_alt), onTap: used != null && (capacity == null || capacity > used) ? (context) async { Navigator.of(context).popUntil((route) => route.isFirst); @@ -66,7 +67,7 @@ Widget oathBuildActions( feature: features.actionsIcons, title: l10n.s_custom_icons, subtitle: l10n.l_set_icons_for_accounts, - icon: const Icon(Icons.image_outlined), + icon: const Icon(Symbols.image), onTap: (context) async { Navigator.of(context).popUntil((route) => route.isFirst); await ref.read(withContextProvider)((context) => showBlurDialog( @@ -82,7 +83,7 @@ Widget oathBuildActions( title: oathState.hasKey ? l10n.s_manage_password : l10n.s_set_password, subtitle: l10n.l_optional_password_protection, - icon: const Icon(Icons.password_outlined), + icon: const Icon(Symbols.password), onTap: (context) { Navigator.of(context).popUntil((route) => route.isFirst); showBlurDialog( diff --git a/lib/oath/views/manage_password_dialog.dart b/lib/oath/views/manage_password_dialog.dart index f2914f6c..24a7c823 100755 --- a/lib/oath/views/manage_password_dialog.dart +++ b/lib/oath/views/manage_password_dialog.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -40,7 +41,8 @@ class ManagePasswordDialog extends ConsumerStatefulWidget { } class _ManagePasswordDialogState extends ConsumerState { - String _currentPassword = ''; + final _currentPasswordController = TextEditingController(); + final _currentPasswordFocus = FocusNode(); String _newPassword = ''; String _confirmPassword = ''; bool _currentIsWrong = false; @@ -48,12 +50,19 @@ class _ManagePasswordDialogState extends ConsumerState { bool _isObscureNew = true; bool _isObscureConfirm = true; + @override + void dispose() { + _currentPasswordController.dispose(); + _currentPasswordFocus.dispose(); + super.dispose(); + } + _submit() async { FocusUtils.unfocus(context); final result = await ref .read(oathStateProvider(widget.path).notifier) - .setPassword(_currentPassword, _newPassword); + .setPassword(_currentPasswordController.text, _newPassword); if (result) { if (mounted) { await ref.read(withContextProvider)((context) async { @@ -62,6 +71,9 @@ class _ManagePasswordDialogState extends ConsumerState { }); } } else { + _currentPasswordController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentPasswordController.text.length); + _currentPasswordFocus.requestFocus(); setState(() { _currentIsWrong = true; }); @@ -71,9 +83,10 @@ class _ManagePasswordDialogState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; - final isValid = _newPassword.isNotEmpty && + final isValid = !_currentIsWrong && + _newPassword.isNotEmpty && _newPassword == _confirmPassword && - (!widget.state.hasKey || _currentPassword.isNotEmpty); + (!widget.state.hasKey || _currentPasswordController.text.isNotEmpty); return ResponsiveDialog( title: Text( @@ -97,16 +110,18 @@ class _ManagePasswordDialogState extends ConsumerState { obscureText: _isObscureCurrent, autofillHints: const [AutofillHints.password], key: keys.currentPasswordField, + controller: _currentPasswordController, + focusNode: _currentPasswordFocus, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_current_password, errorText: _currentIsWrong ? l10n.s_wrong_password : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( icon: Icon(_isObscureCurrent - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureCurrent = !_isObscureCurrent; @@ -120,21 +135,21 @@ class _ManagePasswordDialogState extends ConsumerState { onChanged: (value) { setState(() { _currentIsWrong = false; - _currentPassword = value; }); }, - ), + ).init(), Wrap( spacing: 4.0, runSpacing: 8.0, children: [ OutlinedButton( key: keys.removePasswordButton, - onPressed: _currentPassword.isNotEmpty + onPressed: _currentPasswordController.text.isNotEmpty && + !_currentIsWrong ? () async { final result = await ref .read(oathStateProvider(widget.path).notifier) - .unsetPassword(_currentPassword); + .unsetPassword(_currentPasswordController.text); if (result) { if (mounted) { await ref.read(withContextProvider)( @@ -144,6 +159,12 @@ class _ManagePasswordDialogState extends ConsumerState { }); } } else { + _currentPasswordController.selection = + TextSelection( + baseOffset: 0, + extentOffset: _currentPasswordController + .text.length); + _currentPasswordFocus.requestFocus(); setState(() { _currentIsWrong = true; }); @@ -179,11 +200,11 @@ class _ManagePasswordDialogState extends ConsumerState { decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_new_password, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( icon: Icon(_isObscureNew - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureNew = !_isObscureNew; @@ -192,7 +213,8 @@ class _ManagePasswordDialogState extends ConsumerState { tooltip: _isObscureNew ? l10n.s_show_password : l10n.s_hide_password), - enabled: !widget.state.hasKey || _currentPassword.isNotEmpty, + enabled: !widget.state.hasKey || + _currentPasswordController.text.isNotEmpty, ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -205,7 +227,7 @@ class _ManagePasswordDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), AppTextField( key: keys.confirmPasswordField, obscureText: _isObscureConfirm, @@ -213,11 +235,11 @@ class _ManagePasswordDialogState extends ConsumerState { decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_confirm_password, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( icon: Icon(_isObscureConfirm - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureConfirm = !_isObscureConfirm; @@ -226,9 +248,14 @@ class _ManagePasswordDialogState extends ConsumerState { tooltip: _isObscureConfirm ? l10n.s_show_password : l10n.s_hide_password), - enabled: - (!widget.state.hasKey || _currentPassword.isNotEmpty) && - _newPassword.isNotEmpty, + enabled: (!widget.state.hasKey || + _currentPasswordController.text.isNotEmpty) && + _newPassword.isNotEmpty, + errorText: _newPassword.length == _confirmPassword.length && + _newPassword != _confirmPassword + ? l10n.l_password_mismatch + : null, + helperText: '', // Prevents resizing when errorText shown ), textInputAction: TextInputAction.done, onChanged: (value) { @@ -241,7 +268,7 @@ class _ManagePasswordDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/oath/views/oath_screen.dart b/lib/oath/views/oath_screen.dart index e67ad354..2c0fcf56 100755 --- a/lib/oath/views/oath_screen.dart +++ b/lib/oath/views/oath_screen.dart @@ -21,6 +21,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -30,7 +31,9 @@ import '../../app/views/action_list.dart'; import '../../app/views/app_failure_page.dart'; import '../../app/views/app_page.dart'; import '../../app/views/message_page.dart'; +import '../../app/views/message_page_not_initialized.dart'; import '../../core/state.dart'; +import '../../exception/no_data_exception.dart'; import '../../management/models.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_form_field.dart'; @@ -53,38 +56,23 @@ class OathScreen extends ConsumerWidget { const OathScreen(this.devicePath, {super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { - return ref.watch(oathStateProvider(devicePath)).when( - loading: () => const MessagePage( - centered: true, - graphic: CircularProgressIndicator(), - delayedContent: true, - ), - error: (error, _) => AppFailurePage( - cause: error, - ), - data: (oathState) => oathState.initialized - ? oathState.locked - ? _LockedView(devicePath, oathState) - : _UnlockedView(devicePath, oathState) - : const _InsertTapView(), - ); - } -} - -class _InsertTapView extends ConsumerWidget { - const _InsertTapView(); - @override Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context)!; - return MessagePage( - title: AppLocalizations.of(context)!.s_accounts, - centered: false, - capabilities: const [Capability.oath], - header: l10n.l_insert_or_tap_yk, - ); + return ref.watch(oathStateProvider(devicePath)).when( + loading: () => const MessagePage( + centered: true, + graphic: CircularProgressIndicator(), + delayedContent: true, + ), + error: (error, _) => error is NoDataException + ? MessagePageNotInitialized(title: l10n.s_accounts) + : AppFailurePage( + cause: error, + ), + data: (oathState) => oathState.locked + ? _LockedView(devicePath, oathState) + : _UnlockedView(devicePath, oathState)); } } @@ -187,7 +175,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { widget.oathState, ); }, - avatar: const Icon(Icons.person_add_alt_1_outlined), + avatar: const Icon(Symbols.person_add_alt), ) ], title: l10n.s_accounts, @@ -430,11 +418,11 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { isDense: true, prefixIcon: const Padding( padding: EdgeInsetsDirectional.only(start: 8.0), - child: Icon(Icons.search_outlined), + child: Icon(Symbols.search), ), suffixIcon: searchController.text.isNotEmpty ? IconButton( - icon: const Icon(Icons.clear), + icon: const Icon(Symbols.clear), iconSize: 16, onPressed: () { searchController.clear(); @@ -455,7 +443,7 @@ class _UnlockedViewState extends ConsumerState<_UnlockedView> { Focus.of(context) .focusInDirection(TraversalDirection.down); }, - ), + ).init(), ); }), ), diff --git a/lib/oath/views/rename_account_dialog.dart b/lib/oath/views/rename_account_dialog.dart index d94036eb..bf6a26ce 100755 --- a/lib/oath/views/rename_account_dialog.dart +++ b/lib/oath/views/rename_account_dialog.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/message.dart'; @@ -184,7 +185,7 @@ class _RenameAccountDialogState extends ConsumerState { border: const OutlineInputBorder(), labelText: l10n.s_issuer_optional, helperText: '', // Prevents dialog resizing when disabled - prefixIcon: const Icon(Icons.business_outlined), + prefixIcon: const Icon(Symbols.business), ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -192,7 +193,7 @@ class _RenameAccountDialogState extends ConsumerState { _issuer = value.trim(); }); }, - ), + ).init(), AppTextFormField( initialValue: _name, maxLength: nameRemaining, @@ -208,7 +209,7 @@ class _RenameAccountDialogState extends ConsumerState { : !isUnique ? l10n.l_name_already_exists : null, - prefixIcon: const Icon(Icons.people_alt_outlined), + prefixIcon: const Icon(Symbols.people_alt), ), textInputAction: TextInputAction.done, onChanged: (value) { @@ -221,7 +222,7 @@ class _RenameAccountDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/oath/views/unlock_form.dart b/lib/oath/views/unlock_form.dart index 58a40ae0..92eddab1 100755 --- a/lib/oath/views/unlock_form.dart +++ b/lib/oath/views/unlock_form.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -37,6 +38,7 @@ class UnlockForm extends ConsumerStatefulWidget { class _UnlockFormState extends ConsumerState { final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); bool _remember = false; bool _passwordIsWrong = false; bool _isObscure = true; @@ -50,9 +52,11 @@ class _UnlockFormState extends ConsumerState { .unlock(_passwordController.text, remember: _remember); if (!mounted) return; if (!success) { + _passwordController.selection = TextSelection( + baseOffset: 0, extentOffset: _passwordController.text.length); + _passwordFocus.requestFocus(); setState(() { _passwordIsWrong = true; - _passwordController.clear(); }); } else if (_remember && !remembered) { showMessage(context, AppLocalizations.of(context)!.l_remember_pw_failed); @@ -78,6 +82,7 @@ class _UnlockFormState extends ConsumerState { child: AppTextField( key: keys.passwordField, controller: _passwordController, + focusNode: _passwordFocus, autofocus: true, obscureText: _isObscure, autofillHints: const [AutofillHints.password], @@ -86,10 +91,11 @@ class _UnlockFormState extends ConsumerState { labelText: l10n.s_password, errorText: _passwordIsWrong ? l10n.s_wrong_password : null, helperText: '', // Prevents resizing when errorText shown - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( - icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off), + icon: Icon(_isObscure + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -104,7 +110,7 @@ class _UnlockFormState extends ConsumerState { _passwordIsWrong = false; }), // Update state on change onSubmitted: (_) => _submit(), - ), + ).init(), ), const SizedBox(height: 3.0), Column( @@ -122,7 +128,7 @@ class _UnlockFormState extends ConsumerState { spacing: 4.0, runSpacing: 8.0, children: [ - Icon(Icons.warning_amber, + Icon(Symbols.warning_amber, color: Theme.of(context).colorScheme.tertiary), Text(l10n.l_keystore_unavailable) @@ -140,8 +146,9 @@ class _UnlockFormState extends ConsumerState { FilledButton.icon( key: keys.unlockButton, label: Text(l10n.s_unlock), - icon: const Icon(Icons.lock_open), - onPressed: _passwordController.text.isNotEmpty + icon: const Icon(Symbols.lock_open), + onPressed: _passwordController.text.isNotEmpty && + !_passwordIsWrong ? _submit : null, ), diff --git a/lib/otp/views/actions.dart b/lib/otp/views/actions.dart index 87f3369b..527e9246 100644 --- a/lib/otp/views/actions.dart +++ b/lib/otp/views/actions.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -151,7 +152,7 @@ List buildSlotActions(OtpSlot slot, AppLocalizations l10n) { ActionItem( key: keys.configureYubiOtp, feature: features.slotsConfigureYubiOtp, - icon: const Icon(Icons.shuffle_outlined), + icon: const Icon(Symbols.shuffle), title: l10n.s_capability_otp, subtitle: l10n.l_yubiotp_desc, intent: ConfigureYubiOtpIntent(slot), @@ -159,21 +160,21 @@ List buildSlotActions(OtpSlot slot, AppLocalizations l10n) { ActionItem( key: keys.configureChalResp, feature: features.slotsConfigureChalResp, - icon: const Icon(Icons.key_outlined), + icon: const Icon(Symbols.key), title: l10n.s_challenge_response, subtitle: l10n.l_challenge_response_desc, intent: ConfigureChalRespIntent(slot)), ActionItem( key: keys.configureStatic, feature: features.slotsConfigureStatic, - icon: const Icon(Icons.password_outlined), + icon: const Icon(Symbols.password), title: l10n.s_static_password, subtitle: l10n.l_static_password_desc, intent: ConfigureStaticIntent(slot)), ActionItem( key: keys.configureHotp, feature: features.slotsConfigureHotp, - icon: const Icon(Icons.tag_outlined), + icon: const Icon(Symbols.tag), title: l10n.s_hotp, subtitle: l10n.l_hotp_desc, intent: ConfigureHotpIntent(slot)), @@ -181,7 +182,7 @@ List buildSlotActions(OtpSlot slot, AppLocalizations l10n) { key: keys.deleteAction, feature: features.slotsDelete, actionStyle: ActionStyle.error, - icon: const Icon(Icons.delete_outline), + icon: const Icon(Symbols.delete), title: l10n.s_delete_slot, subtitle: l10n.l_delete_slot_desc, intent: slot.isConfigured ? DeleteIntent(slot) : null, diff --git a/lib/otp/views/configure_chalresp_dialog.dart b/lib/otp/views/configure_chalresp_dialog.dart index 0554ee11..2170d54e 100644 --- a/lib/otp/views/configure_chalresp_dialog.dart +++ b/lib/otp/views/configure_chalresp_dialog.dart @@ -20,6 +20,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/message.dart'; @@ -139,10 +140,10 @@ class _ConfigureChalrespDialogState ? l10n.l_invalid_format_allowed_chars( Format.hex.allowedCharacters) : null, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: IconButton( key: keys.generateSecretKey, - icon: const Icon(Icons.refresh), + icon: const Icon(Symbols.refresh), onPressed: () { setState(() { final random = Random.secure(); @@ -165,7 +166,7 @@ class _ConfigureChalrespDialogState _validateSecret = false; }); }, - ), + ).init(), FilterChip( label: Text(l10n.s_require_touch), selected: _requireTouch, diff --git a/lib/otp/views/configure_hotp_dialog.dart b/lib/otp/views/configure_hotp_dialog.dart index 74f45057..3c7b4910 100644 --- a/lib/otp/views/configure_hotp_dialog.dart +++ b/lib/otp/views/configure_hotp_dialog.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/message.dart'; @@ -126,6 +127,7 @@ class _ConfigureHotpDialogState extends ConsumerState { key: keys.secretField, controller: _secretController, obscureText: _isObscure, + autofocus: true, autofillHints: isAndroid ? [] : const [AutofillHints.password], decoration: AppInputDecoration( border: const OutlineInputBorder(), @@ -137,10 +139,11 @@ class _ConfigureHotpDialogState extends ConsumerState { ? l10n.l_invalid_format_allowed_chars( Format.base32.allowedCharacters) : null, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: IconButton( - icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off), + icon: Icon(_isObscure + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -156,7 +159,7 @@ class _ConfigureHotpDialogState extends ConsumerState { _validateSecret = false; }); }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, diff --git a/lib/otp/views/configure_static_dialog.dart b/lib/otp/views/configure_static_dialog.dart index 1f9faf39..2a1491dd 100644 --- a/lib/otp/views/configure_static_dialog.dart +++ b/lib/otp/views/configure_static_dialog.dart @@ -18,6 +18,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/message.dart'; @@ -158,11 +159,11 @@ class _ConfigureStaticDialogState extends ConsumerState { : _validatePassword && !passwordFormatValid ? l10n.l_invalid_keyboard_character : null, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: IconButton( key: keys.generateSecretKey, tooltip: l10n.s_generate_random, - icon: const Icon(Icons.refresh), + icon: const Icon(Symbols.refresh), onPressed: () async { final password = await ref .read(otpStateProvider(widget.devicePath).notifier) @@ -180,7 +181,7 @@ class _ConfigureStaticDialogState extends ConsumerState { _validatePassword = false; }); }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, diff --git a/lib/otp/views/configure_yubiotp_dialog.dart b/lib/otp/views/configure_yubiotp_dialog.dart index daa939a0..81643834 100644 --- a/lib/otp/views/configure_yubiotp_dialog.dart +++ b/lib/otp/views/configure_yubiotp_dialog.dart @@ -22,6 +22,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:logging/logging.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/logging.dart'; import '../../app/message.dart'; @@ -213,11 +214,11 @@ class _ConfigureYubiOtpDialogState ? l10n.l_invalid_format_allowed_chars( Format.modhex.allowedCharacters) : null, - prefixIcon: const Icon(Icons.public_outlined), + prefixIcon: const Icon(Symbols.public), suffixIcon: IconButton( key: keys.useSerial, tooltip: l10n.s_use_serial, - icon: const Icon(Icons.auto_awesome_outlined), + icon: const Icon(Symbols.auto_awesome), onPressed: (info?.serial != null) ? () async { final publicId = await ref @@ -236,7 +237,7 @@ class _ConfigureYubiOtpDialogState _validatePublicIdFormat = false; }); }, - ), + ).init(), AppTextField( key: keys.privateIdField, controller: _privateIdController, @@ -249,11 +250,11 @@ class _ConfigureYubiOtpDialogState ? l10n.l_invalid_format_allowed_chars( Format.hex.allowedCharacters) : null, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: IconButton( key: keys.generatePrivateId, tooltip: l10n.s_generate_random, - icon: const Icon(Icons.refresh), + icon: const Icon(Symbols.refresh), onPressed: () { final random = Random.secure(); final key = List.generate( @@ -273,7 +274,7 @@ class _ConfigureYubiOtpDialogState _validatePrivateIdFormat = false; }); }, - ), + ).init(), AppTextField( key: keys.secretField, controller: _secretController, @@ -286,11 +287,11 @@ class _ConfigureYubiOtpDialogState ? l10n.l_invalid_format_allowed_chars( Format.hex.allowedCharacters) : null, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: IconButton( key: keys.generateSecretKey, tooltip: l10n.s_generate_random, - icon: const Icon(Icons.refresh), + icon: const Icon(Symbols.refresh), onPressed: () { final random = Random.secure(); final key = List.generate( @@ -310,7 +311,7 @@ class _ConfigureYubiOtpDialogState _validateSecretFormat = false; }); }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, @@ -330,7 +331,7 @@ class _ConfigureYubiOtpDialogState tooltip: outputFile?.path ?? l10n.s_no_export, selected: outputFile != null, avatar: outputFile != null - ? Icon(Icons.check, + ? Icon(Symbols.check, color: Theme.of(context).colorScheme.secondary) : null, value: _action, diff --git a/lib/otp/views/key_actions.dart b/lib/otp/views/key_actions.dart index a4d08b2e..30f7bac8 100644 --- a/lib/otp/views/key_actions.dart +++ b/lib/otp/views/key_actions.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -38,7 +39,7 @@ Widget otpBuildActions(BuildContext context, DevicePath devicePath, feature: features.actionsSwap, title: l10n.s_swap_slots, subtitle: l10n.l_swap_slots_desc, - icon: const Icon(Icons.swap_vert_outlined), + icon: const Icon(Symbols.swap_vert), onTap: (otpState.slot1Configured || otpState.slot2Configured) ? (context) { Navigator.of(context).popUntil((route) => route.isFirst); diff --git a/lib/otp/views/otp_screen.dart b/lib/otp/views/otp_screen.dart index 5577a4c3..fa44ab12 100644 --- a/lib/otp/views/otp_screen.dart +++ b/lib/otp/views/otp_screen.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -121,7 +122,7 @@ class _OtpScreenState extends ConsumerState { ), const SizedBox(height: 8), const Icon( - Icons.touch_app, + Symbols.touch_app, size: 100.0, ), const SizedBox(height: 8), @@ -213,7 +214,7 @@ class _SlotListItem extends ConsumerWidget { : OutlinedButton( key: getOpenMenuButtonKey(slot), onPressed: Actions.handler(context, OpenIntent(otpSlot)), - child: const Icon(Icons.more_horiz), + child: const Icon(Symbols.more_horiz), ), tapIntent: isDesktop && !expanded ? null : OpenIntent(otpSlot), doubleTapIntent: isDesktop && !expanded ? OpenIntent(otpSlot) : null, diff --git a/lib/otp/views/slot_dialog.dart b/lib/otp/views/slot_dialog.dart index 92830e06..15eca2f1 100644 --- a/lib/otp/views/slot_dialog.dart +++ b/lib/otp/views/slot_dialog.dart @@ -18,6 +18,7 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/shortcuts.dart'; import '../../app/state.dart'; @@ -73,7 +74,7 @@ class SlotDialog extends ConsumerWidget { ), const SizedBox(height: 8), const Icon( - Icons.touch_app, + Symbols.touch_app, size: 100.0, ), const SizedBox(height: 8), diff --git a/lib/piv/state.dart b/lib/piv/state.dart index ec3361b5..4a4b6771 100644 --- a/lib/piv/state.dart +++ b/lib/piv/state.dart @@ -35,8 +35,7 @@ abstract class PivStateNotifier extends ApplicationStateNotifier { bool storeKey = false, }); - Future verifyPin( - String pin); //TODO: Maybe return authenticated? + Future verifyPin(String pin); Future changePin(String pin, String newPin); Future changePuk(String puk, String newPuk); Future unblockPin(String puk, String newPin); diff --git a/lib/piv/views/actions.dart b/lib/piv/views/actions.dart index 6c975609..5790f774 100644 --- a/lib/piv/views/actions.dart +++ b/lib/piv/views/actions.dart @@ -20,6 +20,7 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -51,24 +52,26 @@ class ExportIntent extends Intent { const ExportIntent(this.slot); } -Future _authenticate( - BuildContext context, DevicePath devicePath, PivState pivState) async { - return await showBlurDialog( - context: context, - builder: (context) => pivState.protectedKey - ? PinDialog(devicePath) - : AuthenticationDialog( - devicePath, - pivState, - ), - ) ?? - false; -} - -Future _authIfNeeded( - BuildContext context, DevicePath devicePath, PivState pivState) async { +Future _authIfNeeded(BuildContext context, WidgetRef ref, + DevicePath devicePath, PivState pivState) async { if (pivState.needsAuth) { - return await _authenticate(context, devicePath, pivState); + if (pivState.protectedKey && + pivState.metadata?.pinMetadata.defaultValue == true) { + final status = await ref + .read(pivStateProvider(devicePath).notifier) + .verifyPin(defaultPin); + return status.when(success: () => true, failure: (_) => false); + } + return await showBlurDialog( + context: context, + builder: (context) => pivState.protectedKey + ? PinDialog(devicePath) + : AuthenticationDialog( + devicePath, + pivState, + ), + ) ?? + false; } return true; } @@ -95,21 +98,32 @@ class PivActions extends ConsumerWidget { if (hasFeature(features.slotsGenerate)) GenerateIntent: CallbackAction(onInvoke: (intent) async { - if (!pivState.protectedKey && - !await withContext((context) => - _authIfNeeded(context, devicePath, pivState))) { + //Verify management key and maybe PIN + if (!await withContext((context) => + _authIfNeeded(context, ref, devicePath, pivState))) { return false; } - + // Verify PIN, unless already done above // TODO: Avoid asking for PIN if not needed? - final verified = await withContext((context) async => - await showBlurDialog( - context: context, - builder: (context) => PinDialog(devicePath))) ?? - false; + if (!pivState.protectedKey) { + bool verified; + if (pivState.metadata?.pinMetadata.defaultValue == true) { + final status = await ref + .read(pivStateProvider(devicePath).notifier) + .verifyPin(defaultPin); + verified = + status.when(success: () => true, failure: (_) => false); + } else { + verified = await withContext((context) async => + await showBlurDialog( + context: context, + builder: (context) => PinDialog(devicePath))) ?? + false; + } - if (!verified) { - return false; + if (!verified) { + return false; + } } return await withContext((context) async { @@ -157,8 +171,8 @@ class PivActions extends ConsumerWidget { }), if (hasFeature(features.slotsImport)) ImportIntent: CallbackAction(onInvoke: (intent) async { - if (!await withContext( - (context) => _authIfNeeded(context, devicePath, pivState))) { + if (!await withContext((context) => + _authIfNeeded(context, ref, devicePath, pivState))) { return false; } @@ -242,8 +256,8 @@ class PivActions extends ConsumerWidget { if (hasFeature(features.slotsDelete)) DeleteIntent: CallbackAction>(onInvoke: (intent) async { - if (!await withContext( - (context) => _authIfNeeded(context, devicePath, pivState))) { + if (!await withContext((context) => + _authIfNeeded(context, ref, devicePath, pivState))) { return false; } @@ -279,7 +293,7 @@ List buildSlotActions(PivSlot slot, AppLocalizations l10n) { ActionItem( key: keys.generateAction, feature: features.slotsGenerate, - icon: const Icon(Icons.add_outlined), + icon: const Icon(Symbols.add), actionStyle: ActionStyle.primary, title: l10n.s_generate_key, subtitle: l10n.l_generate_desc, @@ -288,7 +302,7 @@ List buildSlotActions(PivSlot slot, AppLocalizations l10n) { ActionItem( key: keys.importAction, feature: features.slotsImport, - icon: const Icon(Icons.file_download_outlined), + icon: const Icon(Symbols.file_download), title: l10n.l_import_file, subtitle: l10n.l_import_desc, intent: ImportIntent(slot), @@ -297,7 +311,7 @@ List buildSlotActions(PivSlot slot, AppLocalizations l10n) { ActionItem( key: keys.exportAction, feature: features.slotsExport, - icon: const Icon(Icons.file_upload_outlined), + icon: const Icon(Symbols.file_upload), title: l10n.l_export_certificate, subtitle: l10n.l_export_certificate_desc, intent: ExportIntent(slot), @@ -306,7 +320,7 @@ List buildSlotActions(PivSlot slot, AppLocalizations l10n) { key: keys.deleteAction, feature: features.slotsDelete, actionStyle: ActionStyle.error, - icon: const Icon(Icons.delete_outline), + icon: const Icon(Symbols.delete), title: l10n.l_delete_certificate, subtitle: l10n.l_delete_certificate_desc, intent: DeleteIntent(slot), @@ -315,7 +329,7 @@ List buildSlotActions(PivSlot slot, AppLocalizations l10n) { ActionItem( key: keys.exportAction, feature: features.slotsExport, - icon: const Icon(Icons.file_upload_outlined), + icon: const Icon(Symbols.file_upload), title: l10n.l_export_public_key, subtitle: l10n.l_export_public_key_desc, intent: ExportIntent(slot), diff --git a/lib/piv/views/authentication_dialog.dart b/lib/piv/views/authentication_dialog.dart index b527831b..3f2ed339 100644 --- a/lib/piv/views/authentication_dialog.dart +++ b/lib/piv/views/authentication_dialog.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/models.dart'; import '../../core/models.dart'; @@ -43,10 +44,12 @@ class _AuthenticationDialogState extends ConsumerState { bool _keyIsWrong = false; bool _keyFormatInvalid = false; final _keyController = TextEditingController(); + final _keyFocus = FocusNode(); @override void dispose() { _keyController.dispose(); + _keyFocus.dispose(); super.dispose(); } @@ -64,7 +67,7 @@ class _AuthenticationDialogState extends ConsumerState { actions: [ TextButton( key: keys.unlockButton, - onPressed: _keyController.text.length == keyLen + onPressed: !_keyIsWrong && _keyController.text.length == keyLen ? () async { if (keyFormatInvalid) { setState(() { @@ -80,6 +83,10 @@ class _AuthenticationDialogState extends ConsumerState { if (status) { navigator.pop(true); } else { + _keyController.selection = TextSelection( + baseOffset: 0, + extentOffset: _keyController.text.length); + _keyFocus.requestFocus(); setState(() { _keyIsWrong = true; }); @@ -87,6 +94,10 @@ class _AuthenticationDialogState extends ConsumerState { } on CancellationException catch (_) { navigator.pop(false); } catch (_) { + _keyController.selection = TextSelection( + baseOffset: 0, + extentOffset: _keyController.text.length); + _keyFocus.requestFocus(); // TODO: More error cases setState(() { _keyIsWrong = true; @@ -108,6 +119,7 @@ class _AuthenticationDialogState extends ConsumerState { autofocus: true, autofillHints: const [AutofillHints.password], controller: _keyController, + focusNode: _keyFocus, readOnly: _defaultKeyUsed, maxLength: !_defaultKeyUsed ? keyLen : null, decoration: AppInputDecoration( @@ -121,13 +133,12 @@ class _AuthenticationDialogState extends ConsumerState { Format.hex.allowedCharacters) : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: hasMetadata ? null : IconButton( - icon: Icon(_defaultKeyUsed - ? Icons.auto_awesome - : Icons.auto_awesome_outlined), + icon: Icon(Symbols.auto_awesome, + fill: _defaultKeyUsed ? 1.0 : 0.0), tooltip: l10n.s_use_default, onPressed: () { setState(() { @@ -149,7 +160,7 @@ class _AuthenticationDialogState extends ConsumerState { _keyFormatInvalid = false; }); }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/piv/views/generate_key_dialog.dart b/lib/piv/views/generate_key_dialog.dart index f4451452..11d85f04 100644 --- a/lib/piv/views/generate_key_dialog.dart +++ b/lib/piv/views/generate_key_dialog.dart @@ -174,7 +174,7 @@ class _GenerateKeyDialogState extends ConsumerState { _subject = value; }); }, - ), + ).init(), Text( l10n.rfc4514_examples, style: subtitleStyle, diff --git a/lib/piv/views/import_file_dialog.dart b/lib/piv/views/import_file_dialog.dart index 7f19c94f..f65a3e51 100644 --- a/lib/piv/views/import_file_dialog.dart +++ b/lib/piv/views/import_file_dialog.dart @@ -19,6 +19,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -139,10 +140,11 @@ class _ImportFileDialogState extends ConsumerState { labelText: l10n.s_password, errorText: _passwordIsWrong ? l10n.s_wrong_password : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( - icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off), + icon: Icon(_isObscure + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -160,7 +162,7 @@ class _ImportFileDialogState extends ConsumerState { }); }, onSubmitted: (_) => _examine(), - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), @@ -244,7 +246,7 @@ class _ImportFileDialogState extends ConsumerState { if (keyType == null && certInfo == null) ...[ Row( children: [ - Icon(Icons.error, color: colorScheme.error), + Icon(Symbols.error, color: colorScheme.error), const SizedBox(width: 8), Text( l10n.l_import_nothing, @@ -274,7 +276,7 @@ class _ImportFileDialogState extends ConsumerState { if (unsupportedKey) Row( children: [ - Icon(Icons.error, color: colorScheme.error), + Icon(Symbols.error, color: colorScheme.error), const SizedBox(width: 8), Text( l10n.l_unsupported_key_type, diff --git a/lib/piv/views/key_actions.dart b/lib/piv/views/key_actions.dart index 3cdb1475..d6916a21 100644 --- a/lib/piv/views/key_actions.dart +++ b/lib/piv/views/key_actions.dart @@ -17,6 +17,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -49,7 +50,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, final pinBlocked = pivState.pinAttempts == 0; final pukAttempts = pivState.metadata?.pukMetadata.attemptsRemaining; - final alertIcon = Icon(Icons.warning_amber, color: colors.tertiary); + final alertIcon = Icon(Symbols.warning_amber, color: colors.tertiary); return Column( children: [ @@ -59,7 +60,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, ActionListItem( key: keys.managePinAction, feature: features.actionsPin, - title: l10n.s_pin, + title: l10n.s_change_pin, subtitle: pinBlocked ? (pukAttempts != 0 ? l10n.l_piv_pin_blocked @@ -67,7 +68,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, : usingDefaultPin ? '${l10n.l_attempts_remaining(pivState.pinAttempts)}\n${l10n.l_warning_default_pin}' : l10n.l_attempts_remaining(pivState.pinAttempts), - icon: const Icon(Icons.pin_outlined), + icon: const Icon(Symbols.pin), trailing: pinBlocked || usingDefaultPin ? alertIcon : null, onTap: !(pinBlocked && pukAttempts == 0) ? (context) { @@ -87,7 +88,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, ActionListItem( key: keys.managePukAction, feature: features.actionsPuk, - title: l10n.s_puk, + title: l10n.s_change_puk, subtitle: pukAttempts != null ? (pukAttempts == 0 ? l10n.l_piv_pin_puk_blocked @@ -97,7 +98,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, : usingDefaultPuk ? l10n.l_warning_default_puk : null, - icon: const Icon(Icons.pin_outlined), + icon: const Icon(Symbols.pin), trailing: pukAttempts == 0 || usingDefaultPuk ? alertIcon : null, onTap: pukAttempts != 0 ? (context) { @@ -119,7 +120,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, : (pivState.protectedKey ? l10n.l_pin_protected_key : l10n.l_change_management_key), - icon: const Icon(Icons.key_outlined), + icon: const Icon(Symbols.key), trailing: usingDefaultMgmtKey ? alertIcon : null, onTap: (context) { Navigator.of(context).popUntil((route) => route.isFirst); @@ -141,7 +142,7 @@ Widget pivBuildActions(BuildContext context, DevicePath devicePath, leading: CircleAvatar( backgroundColor: theme.secondary, foregroundColor: theme.onSecondary, - child: const Icon(Icons.laptop), + child: const Icon(Symbols.laptop), ), onTap: () async { Navigator.of(context).pop(); diff --git a/lib/piv/views/manage_key_dialog.dart b/lib/piv/views/manage_key_dialog.dart index a3f02044..700c28a9 100644 --- a/lib/piv/views/manage_key_dialog.dart +++ b/lib/piv/views/manage_key_dialog.dart @@ -19,6 +19,7 @@ import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -29,6 +30,7 @@ import '../../widgets/app_text_field.dart'; import '../../widgets/app_text_form_field.dart'; import '../../widgets/choice_filter_chip.dart'; import '../../widgets/responsive_dialog.dart'; +import '../../widgets/utf8_utils.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; @@ -47,6 +49,7 @@ class ManageKeyDialog extends ConsumerStatefulWidget { class _ManageKeyDialogState extends ConsumerState { late bool _hasMetadata; late bool _defaultKeyUsed; + late bool _defaultPinUsed; late bool _usesStoredKey; late bool _storeKey; bool _currentIsWrong = false; @@ -55,6 +58,7 @@ class _ManageKeyDialogState extends ConsumerState { int _attemptsRemaining = -1; late ManagementKeyType _keyType; final _currentController = TextEditingController(); + final _currentFocus = FocusNode(); final _keyController = TextEditingController(); bool _isObscure = true; @@ -67,9 +71,13 @@ class _ManageKeyDialogState extends ConsumerState { defaultManagementKeyType; _defaultKeyUsed = widget.pivState.metadata?.managementKeyMetadata.defaultValue ?? false; + _defaultPinUsed = + widget.pivState.metadata?.pinMetadata.defaultValue ?? false; _usesStoredKey = widget.pivState.protectedKey; if (!_usesStoredKey && _defaultKeyUsed) { _currentController.text = defaultManagementKey; + } else if (_usesStoredKey && _defaultPinUsed) { + _currentController.text = defaultPin; } _storeKey = _usesStoredKey; } @@ -78,16 +86,18 @@ class _ManageKeyDialogState extends ConsumerState { void dispose() { _keyController.dispose(); _currentController.dispose(); + _currentFocus.dispose(); super.dispose(); } _submit() async { - final currentInvalidFormat = Format.hex.isValid(_currentController.text); - final newInvalidFormat = Format.hex.isValid(_keyController.text); - if (!currentInvalidFormat || !newInvalidFormat) { + final currentValidFormat = + _usesStoredKey || Format.hex.isValid(_currentController.text); + final newValidFormat = Format.hex.isValid(_keyController.text); + if (!currentValidFormat || !newValidFormat) { setState(() { - _currentInvalidFormat = !currentInvalidFormat; - _newInvalidFormat = !newInvalidFormat; + _currentInvalidFormat = !currentValidFormat; + _newInvalidFormat = !newValidFormat; }); return; } @@ -97,6 +107,9 @@ class _ManageKeyDialogState extends ConsumerState { final status = (await notifier.verifyPin(_currentController.text)).when( success: () => true, failure: (attemptsRemaining) { + _currentController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentController.text.length); + _currentFocus.requestFocus(); setState(() { _attemptsRemaining = attemptsRemaining; _currentIsWrong = true; @@ -109,6 +122,9 @@ class _ManageKeyDialogState extends ConsumerState { } } else { if (!await notifier.authenticate(_currentController.text)) { + _currentController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentController.text.length); + _currentFocus.requestFocus(); setState(() { _currentIsWrong = true; }); @@ -117,15 +133,19 @@ class _ManageKeyDialogState extends ConsumerState { } if (_storeKey && !_usesStoredKey) { - final withContext = ref.read(withContextProvider); - final verified = await withContext((context) async => - await showBlurDialog( - context: context, - builder: (context) => PinDialog(widget.path))) ?? - false; + if (_defaultPinUsed) { + await notifier.verifyPin(defaultPin); + } else { + final withContext = ref.read(withContextProvider); + final verified = await withContext((context) async => + await showBlurDialog( + context: context, + builder: (context) => PinDialog(widget.path))) ?? + false; - if (!verified) { - return; + if (!verified) { + return; + } } } @@ -146,9 +166,8 @@ class _ManageKeyDialogState extends ConsumerState { widget.pivState.metadata?.managementKeyMetadata.keyType ?? defaultManagementKeyType; final hexLength = _keyType.keyLength * 2; - final protected = widget.pivState.protectedKey; final currentKeyOrPin = _currentController.text; - final currentLenOk = protected + final currentLenOk = _usesStoredKey ? currentKeyOrPin.length >= 4 : currentKeyOrPin.length == currentType.keyLength * 2; final newLenOk = _keyController.text.length == hexLength; @@ -157,7 +176,8 @@ class _ManageKeyDialogState extends ConsumerState { title: Text(l10n.l_change_management_key), actions: [ TextButton( - onPressed: currentLenOk && newLenOk ? _submit : null, + onPressed: + !_currentIsWrong && currentLenOk && newLenOk ? _submit : null, key: keys.saveButton, child: Text(l10n.s_save), ) @@ -168,28 +188,31 @@ class _ManageKeyDialogState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.p_change_management_key_desc), - if (protected) + if (_usesStoredKey) AppTextField( autofocus: true, obscureText: _isObscure, autofillHints: const [AutofillHints.password], key: keys.pinPukField, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_currentController.text), controller: _currentController, + focusNode: _currentFocus, + readOnly: _defaultPinUsed, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, + helperText: _defaultPinUsed ? l10n.l_default_pin_used : null, errorText: _currentIsWrong ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) - : _currentInvalidFormat - ? l10n.l_invalid_format_allowed_chars( - Format.hex.allowedCharacters) - : null, + : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.pin_outlined), + prefixIcon: const Icon(Symbols.pin), suffixIcon: IconButton( - icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off), + icon: Icon(_isObscure + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -204,13 +227,14 @@ class _ManageKeyDialogState extends ConsumerState { _currentInvalidFormat = false; }); }, - ), - if (!protected) + ).init(), + if (!_usesStoredKey) AppTextFormField( key: keys.managementKeyField, autofocus: !_defaultKeyUsed, autofillHints: const [AutofillHints.password], controller: _currentController, + focusNode: _currentFocus, readOnly: _defaultKeyUsed, maxLength: !_defaultKeyUsed ? currentType.keyLength * 2 : null, decoration: AppInputDecoration( @@ -224,13 +248,12 @@ class _ManageKeyDialogState extends ConsumerState { Format.hex.allowedCharacters) : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: _hasMetadata ? null : IconButton( - icon: Icon(_defaultKeyUsed - ? Icons.auto_awesome - : Icons.auto_awesome_outlined), + icon: Icon(Symbols.auto_awesome, + fill: _defaultKeyUsed ? 1.0 : 0.0), tooltip: l10n.s_use_default, onPressed: () { setState(() { @@ -250,7 +273,7 @@ class _ManageKeyDialogState extends ConsumerState { _currentIsWrong = false; }); }, - ), + ).init(), AppTextField( key: keys.newPinPukField, autofocus: _defaultKeyUsed, @@ -265,10 +288,10 @@ class _ManageKeyDialogState extends ConsumerState { Format.hex.allowedCharacters) : null, enabled: currentLenOk, - prefixIcon: const Icon(Icons.key_outlined), + prefixIcon: const Icon(Symbols.key), suffixIcon: IconButton( key: keys.managementKeyRefresh, - icon: const Icon(Icons.refresh), + icon: const Icon(Symbols.refresh), tooltip: l10n.s_generate_random, onPressed: currentLenOk ? () { @@ -298,7 +321,7 @@ class _ManageKeyDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), Wrap( crossAxisAlignment: WrapCrossAlignment.center, spacing: 4.0, diff --git a/lib/piv/views/manage_pin_puk_dialog.dart b/lib/piv/views/manage_pin_puk_dialog.dart index a289de1d..a874751c 100644 --- a/lib/piv/views/manage_pin_puk_dialog.dart +++ b/lib/piv/views/manage_pin_puk_dialog.dart @@ -17,12 +17,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; +import '../../widgets/utf8_utils.dart'; import '../keys.dart' as keys; import '../models.dart'; import '../state.dart'; @@ -43,20 +45,25 @@ class ManagePinPukDialog extends ConsumerStatefulWidget { class _ManagePinPukDialogState extends ConsumerState { final _currentPinController = TextEditingController(); + final _currentPinFocus = FocusNode(); String _newPin = ''; String _confirmPin = ''; + bool _pinIsBlocked = false; bool _currentIsWrong = false; int _attemptsRemaining = -1; bool _isObscureCurrent = true; bool _isObscureNew = true; bool _isObscureConfirm = true; - late bool _defaultPinUsed; - late bool _defaultPukUsed; + late final bool _defaultPinUsed; + late final bool _defaultPukUsed; + late final int _minPinLen; @override void initState() { super.initState(); + // Old YubiKeys allowed a 4 digit PIN + _minPinLen = widget.pivState.version.isAtLeast(4, 3, 1) ? 6 : 4; _defaultPinUsed = widget.pivState.metadata?.pinMetadata.defaultValue ?? false; _defaultPukUsed = @@ -72,6 +79,7 @@ class _ManagePinPukDialogState extends ConsumerState { @override void dispose() { _currentPinController.dispose(); + _currentPinFocus.dispose(); super.dispose(); } @@ -97,11 +105,16 @@ class _ManagePinPukDialogState extends ConsumerState { _ => l10n.s_pin_set, }); }, failure: (attemptsRemaining) { + _currentPinController.selection = TextSelection( + baseOffset: 0, extentOffset: _currentPinController.text.length); + _currentPinFocus.requestFocus(); setState(() { _attemptsRemaining = attemptsRemaining; _currentIsWrong = true; + if (_attemptsRemaining == 0) { + _pinIsBlocked = true; + } }); - _currentPinController.clear(); }); } @@ -109,8 +122,12 @@ class _ManagePinPukDialogState extends ConsumerState { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; final currentPin = _currentPinController.text; - final isValid = - _newPin.isNotEmpty && _newPin == _confirmPin && currentPin.isNotEmpty; + final currentPinLen = byteLength(currentPin); + final newPinLen = byteLength(_newPin); + final isValid = !_currentIsWrong && + _newPin.isNotEmpty && + _newPin == _confirmPin && + currentPin.isNotEmpty; final titleText = switch (widget.target) { ManageTarget.pin => l10n.s_change_pin, @@ -137,7 +154,6 @@ class _ManagePinPukDialogState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - //TODO fix string Text(widget.target == ManageTarget.pin ? l10n.p_enter_current_pin_or_reset : l10n.p_enter_current_puk_or_reset), @@ -145,10 +161,14 @@ class _ManagePinPukDialogState extends ConsumerState { autofocus: !(showDefaultPinUsed || showDefaultPukUsed), obscureText: _isObscureCurrent, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(currentPin), autofillHints: const [AutofillHints.password], key: keys.pinPukField, readOnly: showDefaultPinUsed || showDefaultPukUsed, controller: _currentPinController, + focusNode: _currentPinFocus, + enabled: !_pinIsBlocked, decoration: AppInputDecoration( border: const OutlineInputBorder(), helperText: showDefaultPinUsed @@ -159,19 +179,23 @@ class _ManagePinPukDialogState extends ConsumerState { labelText: widget.target == ManageTarget.pin ? l10n.s_current_pin : l10n.s_current_puk, - errorText: _currentIsWrong + errorText: _pinIsBlocked ? (widget.target == ManageTarget.pin - ? l10n - .l_wrong_pin_attempts_remaining(_attemptsRemaining) - : l10n - .l_wrong_puk_attempts_remaining(_attemptsRemaining)) - : null, + ? l10n.l_piv_pin_blocked + : l10n.l_piv_pin_puk_blocked) + : (_currentIsWrong + ? (widget.target == ManageTarget.pin + ? l10n.l_wrong_pin_attempts_remaining( + _attemptsRemaining) + : l10n.l_wrong_puk_attempts_remaining( + _attemptsRemaining)) + : null), errorMaxLines: 3, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( icon: Icon(_isObscureCurrent - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureCurrent = !_isObscureCurrent; @@ -188,7 +212,7 @@ class _ManagePinPukDialogState extends ConsumerState { _currentIsWrong = false; }); }, - ), + ).init(), Text(l10n.p_enter_new_piv_pin_puk( widget.target == ManageTarget.puk ? l10n.s_puk : l10n.s_pin)), AppTextField( @@ -196,16 +220,19 @@ class _ManagePinPukDialogState extends ConsumerState { autofocus: showDefaultPinUsed || showDefaultPukUsed, obscureText: _isObscureNew, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_newPin), autofillHints: const [AutofillHints.newPassword], decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: widget.target == ManageTarget.puk ? l10n.s_new_puk : l10n.s_new_pin, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( - icon: Icon( - _isObscureNew ? Icons.visibility : Icons.visibility_off), + icon: Icon(_isObscureNew + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureNew = !_isObscureNew; @@ -215,8 +242,7 @@ class _ManagePinPukDialogState extends ConsumerState { ? (_isObscureNew ? l10n.s_show_pin : l10n.s_hide_pin) : (_isObscureNew ? l10n.s_show_puk : l10n.s_hide_puk), ), - // Old YubiKeys allowed a 4 digit PIN - enabled: currentPin.length >= 4, + enabled: currentPinLen >= _minPinLen, ), textInputAction: TextInputAction.next, onChanged: (value) { @@ -229,22 +255,24 @@ class _ManagePinPukDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), AppTextField( key: keys.confirmPinPukField, obscureText: _isObscureConfirm, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_confirmPin), autofillHints: const [AutofillHints.newPassword], decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: widget.target == ManageTarget.puk ? l10n.s_confirm_puk : l10n.s_confirm_pin, - prefixIcon: const Icon(Icons.password_outlined), + prefixIcon: const Icon(Symbols.password), suffixIcon: IconButton( icon: Icon(_isObscureConfirm - ? Icons.visibility - : Icons.visibility_off), + ? Symbols.visibility + : Symbols.visibility_off), onPressed: () { setState(() { _isObscureConfirm = !_isObscureConfirm; @@ -254,7 +282,14 @@ class _ManagePinPukDialogState extends ConsumerState { ? (_isObscureConfirm ? l10n.s_show_pin : l10n.s_hide_pin) : (_isObscureConfirm ? l10n.s_show_puk : l10n.s_hide_puk), ), - enabled: currentPin.length >= 4 && _newPin.length >= 6, + enabled: currentPinLen >= _minPinLen && newPinLen >= 6, + errorText: + newPinLen == _confirmPin.length && _newPin != _confirmPin + ? (widget.target == ManageTarget.pin + ? l10n.l_pin_mismatch + : l10n.l_puk_mismatch) + : null, + helperText: '', // Prevents resizing when errorText shown ), textInputAction: TextInputAction.done, onChanged: (value) { @@ -267,7 +302,7 @@ class _ManagePinPukDialogState extends ConsumerState { _submit(); } }, - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/piv/views/pin_dialog.dart b/lib/piv/views/pin_dialog.dart index a01ec9d7..fab45816 100644 --- a/lib/piv/views/pin_dialog.dart +++ b/lib/piv/views/pin_dialog.dart @@ -17,12 +17,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/models.dart'; import '../../exception/cancellation_exception.dart'; import '../../widgets/app_input_decoration.dart'; import '../../widgets/app_text_field.dart'; import '../../widgets/responsive_dialog.dart'; +import '../../widgets/utf8_utils.dart'; import '../keys.dart' as keys; import '../state.dart'; @@ -36,6 +38,7 @@ class PinDialog extends ConsumerStatefulWidget { class _PinDialogState extends ConsumerState { final _pinController = TextEditingController(); + final _pinFocus = FocusNode(); bool _pinIsWrong = false; int _attemptsRemaining = -1; bool _isObscure = true; @@ -43,6 +46,7 @@ class _PinDialogState extends ConsumerState { @override void dispose() { _pinController.dispose(); + _pinFocus.dispose(); super.dispose(); } @@ -57,8 +61,10 @@ class _PinDialogState extends ConsumerState { navigator.pop(true); }, failure: (attemptsRemaining) { + _pinController.selection = TextSelection( + baseOffset: 0, extentOffset: _pinController.text.length); + _pinFocus.requestFocus(); setState(() { - _pinController.clear(); _attemptsRemaining = attemptsRemaining; _pinIsWrong = true; }); @@ -72,12 +78,15 @@ class _PinDialogState extends ConsumerState { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final version = ref.watch(pivStateProvider(widget.devicePath)).valueOrNull; + final minPinLen = version?.version.isAtLeast(4, 3, 1) == true ? 6 : 4; + final currentPinLen = byteLength(_pinController.text); return ResponsiveDialog( title: Text(l10n.s_pin_required), actions: [ TextButton( key: keys.unlockButton, - onPressed: _pinController.text.length >= 4 ? _submit : null, + onPressed: currentPinLen >= minPinLen ? _submit : null, child: Text(l10n.s_unlock), ), ], @@ -91,9 +100,12 @@ class _PinDialogState extends ConsumerState { autofocus: true, obscureText: _isObscure, maxLength: 8, + inputFormatters: [limitBytesLength(8)], + buildCounter: buildByteCounterFor(_pinController.text), autofillHints: const [AutofillHints.password], key: keys.managementKeyField, controller: _pinController, + focusNode: _pinFocus, decoration: AppInputDecoration( border: const OutlineInputBorder(), labelText: l10n.s_pin, @@ -101,10 +113,10 @@ class _PinDialogState extends ConsumerState { ? l10n.l_wrong_pin_attempts_remaining(_attemptsRemaining) : null, errorMaxLines: 3, - prefixIcon: const Icon(Icons.pin_outlined), + prefixIcon: const Icon(Symbols.pin), suffixIcon: IconButton( icon: Icon( - _isObscure ? Icons.visibility : Icons.visibility_off), + _isObscure ? Symbols.visibility : Symbols.visibility_off), onPressed: () { setState(() { _isObscure = !_isObscure; @@ -120,7 +132,7 @@ class _PinDialogState extends ConsumerState { }); }, onSubmitted: (_) => _submit(), - ), + ).init(), ] .map((e) => Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), diff --git a/lib/piv/views/piv_screen.dart b/lib/piv/views/piv_screen.dart index e6864203..a4a06c3e 100644 --- a/lib/piv/views/piv_screen.dart +++ b/lib/piv/views/piv_screen.dart @@ -19,6 +19,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../../app/message.dart'; import '../../app/models.dart'; @@ -225,7 +226,7 @@ class _CertificateListItem extends ConsumerWidget { leading: CircleAvatar( foregroundColor: colorScheme.onSecondary, backgroundColor: colorScheme.secondary, - child: const Icon(Icons.approval), + child: const Icon(Symbols.badge), ), title: slot.getDisplayName(l10n), subtitle: certInfo != null @@ -239,7 +240,7 @@ class _CertificateListItem extends ConsumerWidget { : OutlinedButton( key: _getMeatballKey(slot), onPressed: Actions.handler(context, OpenIntent(pivSlot)), - child: const Icon(Icons.more_horiz), + child: const Icon(Symbols.more_horiz), ), tapIntent: isDesktop && !expanded ? null : OpenIntent(pivSlot), doubleTapIntent: isDesktop && !expanded ? OpenIntent(pivSlot) : null, diff --git a/lib/widgets/app_input_decoration.dart b/lib/widgets/app_input_decoration.dart index a2cb922f..d5996f17 100644 --- a/lib/widgets/app_input_decoration.dart +++ b/lib/widgets/app_input_decoration.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; class AppInputDecoration extends InputDecoration { final List? suffixIcons; @@ -67,7 +68,7 @@ class AppInputDecoration extends InputDecoration { final icons = [ if (super.suffixIcon != null) super.suffixIcon!, if (suffixIcons != null) ...suffixIcons!, - if (errorText != null) const Icon(Icons.error_outlined), + if (errorText != null) const Icon(Symbols.error), ]; return switch (icons.length) { diff --git a/lib/widgets/app_text_field.dart b/lib/widgets/app_text_field.dart index ff0c49e3..4355083f 100644 --- a/lib/widgets/app_text_field.dart +++ b/lib/widgets/app_text_field.dart @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import 'package:flutter/material.dart'; import 'app_input_decoration.dart'; @@ -86,4 +85,13 @@ class AppTextField extends TextField { super.spellCheckConfiguration, super.magnifierConfiguration, }) : super(decoration: decoration); + + Widget init() => Builder( + builder: (context) => DefaultSelectionStyle( + selectionColor: decoration?.errorText != null + ? Theme.of(context).colorScheme.error + : null, + child: this, + ), + ); } diff --git a/lib/widgets/app_text_form_field.dart b/lib/widgets/app_text_form_field.dart index 61e0540b..5736b21e 100644 --- a/lib/widgets/app_text_form_field.dart +++ b/lib/widgets/app_text_form_field.dart @@ -20,6 +20,7 @@ import 'app_input_decoration.dart'; /// TextFormField without autocorrect and suggestions class AppTextFormField extends TextFormField { + final AppInputDecoration? decoration; AppTextFormField({ // default settings to turn off autocorrect super.autocorrect = false, @@ -30,7 +31,7 @@ class AppTextFormField extends TextFormField { super.controller, super.initialValue, super.focusNode, - AppInputDecoration? decoration, + this.decoration, super.textCapitalization, super.textInputAction, super.style, @@ -90,4 +91,13 @@ class AppTextFormField extends TextFormField { super.scribbleEnabled, super.canRequestFocus, }) : super(decoration: decoration); + + Widget init() => Builder( + builder: (context) => DefaultSelectionStyle( + selectionColor: decoration?.errorText != null + ? Theme.of(context).colorScheme.error + : null, + child: this, + ), + ); } diff --git a/lib/widgets/choice_filter_chip.dart b/lib/widgets/choice_filter_chip.dart index acae8262..93ef92d6 100755 --- a/lib/widgets/choice_filter_chip.dart +++ b/lib/widgets/choice_filter_chip.dart @@ -17,6 +17,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; class ChoiceFilterChip extends StatefulWidget { final T value; @@ -27,6 +28,7 @@ class ChoiceFilterChip extends StatefulWidget { final void Function(T value)? onChanged; final Widget? avatar; final bool selected; + final bool? disableHover; const ChoiceFilterChip({ super.key, required this.value, @@ -36,6 +38,7 @@ class ChoiceFilterChip extends StatefulWidget { this.tooltip, this.avatar, this.selected = false, + this.disableHover, this.labelBuilder, }); @@ -68,6 +71,8 @@ class _ChoiceFilterChipState extends State> { color: Theme.of(context).colorScheme.background, items: widget.items .map((e) => PopupMenuItem( + enabled: + widget.disableHover != null ? !widget.disableHover! : true, value: e, height: chipBox.size.height, textStyle: ChipTheme.of(context).labelStyle, @@ -91,7 +96,7 @@ class _ChoiceFilterChipState extends State> { Padding( padding: const EdgeInsets.only(left: 6), child: Icon( - _showing ? Icons.arrow_drop_up : Icons.arrow_drop_down, + _showing ? Symbols.arrow_drop_up : Symbols.arrow_drop_down, color: ChipTheme.of(context).checkmarkColor, size: 18, ), diff --git a/lib/widgets/custom_icons.dart b/lib/widgets/custom_icons.dart index 23db687f..44a053d9 100755 --- a/lib/widgets/custom_icons.dart +++ b/lib/widgets/custom_icons.dart @@ -14,15 +14,14 @@ * limitations under the License. */ -import 'dart:math'; - import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; final Widget pushPinStrokeIcon = Builder(builder: (context) { return CustomPaint( painter: _StrikethroughPainter(IconTheme.of(context).color ?? Colors.black), child: ClipPath( - clipper: _StrikethroughClipper(), child: const Icon(Icons.push_pin)), + clipper: _StrikethroughClipper(), child: const Icon(Symbols.push_pin)), ); }); @@ -67,39 +66,3 @@ class _StrikethroughPainter extends CustomPainter { return false; } } - -final Widget nfcIcon = Builder(builder: (context) { - final theme = IconTheme.of(context); - return CustomPaint( - size: Size.square(theme.size ?? 24), - painter: _NfcIconPainter(theme.color ?? Colors.black), - ); -}); - -class _NfcIconPainter extends CustomPainter { - final Color color; - _NfcIconPainter(this.color); - - @override - void paint(Canvas canvas, Size size) { - final step = size.width / 4; - const sweep = pi / 4; - - final paint = Paint() - ..color = color - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeWidth = step / 2; - - final rect = - Offset(size.width * -1.7, 0) & Size(size.width * 2, size.height); - for (var i = 0; i < 3; i++) { - canvas.drawArc(rect.inflate(i * step), -sweep / 2, sweep, false, paint); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; - } -} diff --git a/lib/widgets/file_drop_overlay.dart b/lib/widgets/file_drop_overlay.dart index 6e85cc6b..228bc985 100644 --- a/lib/widgets/file_drop_overlay.dart +++ b/lib/widgets/file_drop_overlay.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; class FileDropOverlay extends StatelessWidget { final Widget? graphic; @@ -22,7 +23,7 @@ class FileDropOverlay extends StatelessWidget { children: [ graphic ?? Icon( - Icons.upload_file, + Symbols.upload_file, size: 120, color: Theme.of(context).colorScheme.primary, ), diff --git a/lib/widgets/responsive_dialog.dart b/lib/widgets/responsive_dialog.dart index 226bc36c..f7039e38 100755 --- a/lib/widgets/responsive_dialog.dart +++ b/lib/widgets/responsive_dialog.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:material_symbols_icons/symbols.dart'; import '../core/state.dart'; @@ -56,7 +57,7 @@ class _ResponsiveDialogState extends State { actions: widget.actions, leading: IconButton( tooltip: AppLocalizations.of(context)!.s_close, - icon: const Icon(Icons.close), + icon: const Icon(Symbols.close), onPressed: widget.allowCancel ? () { widget.onCancel?.call(); diff --git a/lint/lib/lint.dart b/lint/lib/lint.dart index 4f871b98..c3ffc036 100644 --- a/lint/lib/lint.dart +++ b/lint/lib/lint.dart @@ -14,6 +14,7 @@ * limitations under the License. */ +import 'package:analyzer/dart/ast/token.dart'; import 'package:analyzer/error/listener.dart'; import 'package:custom_lint_builder/custom_lint_builder.dart'; @@ -30,6 +31,8 @@ class _AppLinter extends PluginBase { discouraged: 'TextFormField', recommended: 'AppTextFormField', ), + const CallInitAfterCreation(className: 'AppTextField'), + const CallInitAfterCreation(className: 'AppTextFormField'), ]; } @@ -59,3 +62,35 @@ class UseRecommendedWidget extends DartLintRule { }); } } + +class CallInitAfterCreation extends DartLintRule { + final String className; + + const CallInitAfterCreation({required this.className}) + : super( + code: const LintCode( + name: 'call_init_after_creation', + problemMessage: 'Call init() after creation', + )); + + @override + void run( + CustomLintResolver resolver, + ErrorReporter reporter, + CustomLintContext context, + ) { + context.registry.addInstanceCreationExpression((node) { + if (node.constructorName.toString() == className) { + final dot = node.endToken.next; + final next = dot?.next; + if (dot?.type == TokenType.PERIOD) { + if (next?.type == TokenType.IDENTIFIER && + next?.toString() == 'init') { + return; + } + } + reporter.reportErrorForNode(code, node.constructorName); + } + }); + } +} diff --git a/pubspec.lock b/pubspec.lock index 5c079867..8012ec4e 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -531,6 +531,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.8.0" + material_symbols_icons: + dependency: "direct main" + description: + name: material_symbols_icons + sha256: fee941060cb717c74d370dbcb7a735f224d1e93d705dc5b552ee40e0697d5691 + url: "https://pub.dev" + source: hosted + version: "4.2719.1" menu_base: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c3beb27d..6909753c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -70,6 +70,7 @@ dependencies: io: ^1.0.4 base32: ^2.1.3 convert: ^3.1.1 + material_symbols_icons: ^4.2719.1 dev_dependencies: integration_test: