Merge branch 'adamve/android_fido' into adamve/android_fido_bio

This commit is contained in:
Adam Velebil 2024-03-19 10:18:14 +01:00
commit d58e9ff225
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
121 changed files with 2525 additions and 1810 deletions

View File

@ -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'

View File

@ -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"

View File

@ -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<T : JsonSerializable>(val data: T) : ViewModelData
}
/**
* Observes a LiveData value, sending each change to Flutter via an EventChannel.
*/
@ -61,6 +71,48 @@ inline fun <reified T> LiveData<T>.streamTo(lifecycleOwner: LifecycleOwner, mess
}
}
/**
* Observes a ViewModelData LiveData value, sending each change to Flutter via an EventChannel.
*/
@JvmName("streamViewModelData")
inline fun <reified T : ViewModelData> LiveData<T>.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<T> {
sink?.success(get(it))
}
observe(lifecycleOwner, observer)
return Closeable {
removeObserver(observer)
channel.setStreamHandler(null)
}
}
typealias MethodHandler = suspend (method: String, args: Map<String, Any?>) -> String
/**

View File

@ -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

View File

@ -285,6 +285,10 @@ class MainActivity : FlutterFragmentActivity() {
switchContext(preferredContext)
}
if (contextManager == null) {
switchContext(DeviceManager.getPreferredContext(supportedApps))
}
contextManager?.let {
try {
it.processYubiKey(device)
@ -336,7 +340,19 @@ class MainActivity : FlutterFragmentActivity() {
}
private fun switchContext(appContext: OperationContext) {
// 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
}
if (contextManager == null) {
contextManager = when (appContext) {
OperationContext.Oath -> OathManager(
this,
@ -359,6 +375,7 @@ class MainActivity : FlutterFragmentActivity() {
else -> null
}
}
}
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
flutterStreams.forEach { it.close() }
@ -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)
}
}

View File

@ -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 {

View File

@ -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<OperationContext> {
fun getSupportedContexts(device: YubiKeyDevice) : ArraySet<OperationContext> = try {
val operationContexts = ArraySet<OperationContext>()
@ -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<OperationContext>()
}
fun getPreferredContext(contexts: ArraySet<OperationContext>) : OperationContext {

View File

@ -43,6 +43,13 @@ class FidoConnectionHelper(
}
}
fun cancelPending() {
pendingAction?.let { action ->
action.invoke(Result.failure(CancellationException()))
pendingAction = null
}
}
suspend fun <T> useSession(
actionDescription: FidoActionDescription,
action: (YubiKitFidoSession) -> T

View File

@ -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()
}
}

View File

@ -28,6 +28,7 @@ class FidoPinStore {
}
fun setPin(newPin: CharArray?) {
pin?.fill(0.toChar())
pin = newPin?.clone()
}
}

View File

@ -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)

View File

@ -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<Session?>(null)
val sessionState: LiveData<Session?> = _sessionState
private val _sessionState = MutableLiveData<ViewModelData>()
val sessionState: LiveData<ViewModelData> = _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<List<FidoCredential>>()

View File

@ -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<String, Any?>.getBoolean(
key: String,
default: Boolean = false
): Boolean = get(key) as? Boolean ?: default
fun Map<String, Any?>.getOptionalBoolean(
companion object {
private fun InfoData.getOptionsBoolean(
key: String
): Boolean? = get(key) as? Boolean
): 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
) {
val unlocked: Boolean
) : JsonSerializable {
constructor(infoData: InfoData, unlocked: Boolean) : this(
SessionInfo(infoData), unlocked, true
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)
}
}

View File

@ -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<SmartCardConnection, Unit> { 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()
}
}

View File

@ -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<Session?>()
val sessionState: LiveData<Session?> = _sessionState
private val _sessionState = MutableLiveData<ViewModelData>()
val sessionState: LiveData<ViewModelData> = _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<Credential, Code?>) {
_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<List<CredentialWithCode>?>()
val credentials: LiveData<List<CredentialWithCode>?> = _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 {

View File

@ -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)
}
}

View File

@ -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
@ -44,8 +45,7 @@ class ModelTest {
Version(1, 2, 3),
isAccessKeySet = false,
isRemembered = false,
isLocked = false,
initialized = true
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)
}
}

View File

@ -40,8 +40,7 @@ class SerializationTest {
Version(1, 2, 3),
isAccessKeySet = false,
isRemembered = false,
isLocked = false,
initialized = true
isLocked = false
)
@Test

View File

@ -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"
}
}

View File

@ -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"

View File

@ -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) {

View File

@ -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<Level>(
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 {

View File

@ -53,6 +53,10 @@ Future<int> getAndroidSdkVersion() async {
return await appMethodsChannel.invokeMethod('getAndroidSdkVersion');
}
Future<bool> getAndroidIsArc() async {
return await appMethodsChannel.invokeMethod('isArc');
}
Future<Color> getPrimaryColor() async {
final value = await appMethodsChannel.invokeMethod('getPrimaryColor');
return value != null ? Color(value) : defaultPrimaryColor;

View File

@ -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<InteractionEvent>();
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<PinResult> setPin(String newPin, {String? oldPin}) async {
try {
final setPinResponse = jsonDecode(await _methods.invokeMethod('set_pin', {
final response = jsonDecode(await _methods.invokeMethod(
'setPin',
{
'pin': oldPin,
'new_pin': newPin,
}));
if (setPinResponse['success'] == true) {
'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<PinResult> 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<void> 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;
}
}
}
}

View File

@ -54,17 +54,10 @@ Future<Widget> 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<Widget> initialize() async {
oathStateProvider.overrideWithProvider(androidOathStateProvider.call),
credentialListProvider
.overrideWithProvider(androidCredentialListProvider.call),
currentAppProvider.overrideWith((ref) {
final notifier =
AndroidSubPageNotifier(ref, ref.watch(supportedAppsProvider));
ref.listen<AsyncValue<YubiKeyData>>(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<Widget> 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<Widget> initialize() async {
// Disable unimplemented feature
..setFeature(features.piv, false)
..setFeature(features.otp, false)
..setFeature(features.fido, !isArc)
..setFeature(features.management, false);
});

View File

@ -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,
);

View File

@ -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(

View File

@ -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<bool> {
}
}
final androidSectionPriority = Provider<List<Section>>((ref) => []);
final androidSdkVersionProvider = Provider<int>((ref) => -1);
final androidNfcSupportProvider = Provider<bool>((ref) => false);
@ -90,32 +96,57 @@ final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) {
}
});
class _AndroidAppContextHandler {
Future<void> switchAppContext(Application subPage) async {
await _contextChannel.invokeMethod('setContext', {'index': subPage.index});
class AndroidAppContextHandler {
Future<void> switchAppContext(Section section) async {
await _contextChannel.invokeMethod('setContext', {'index': section.index});
}
}
final androidAppContextHandler =
Provider<_AndroidAppContextHandler>((ref) => _AndroidAppContextHandler());
Provider<AndroidAppContextHandler>((ref) => AndroidAppContextHandler());
class AndroidSubPageNotifier extends CurrentAppNotifier {
final StateNotifierProviderRef<CurrentAppNotifier, Application> _ref;
CurrentSectionNotifier androidCurrentSectionNotifier(Ref ref) {
final notifier = AndroidCurrentSectionNotifier(
ref.watch(androidSectionPriority), ref.watch(androidAppContextHandler));
ref.listen<AsyncValue<YubiKeyData>>(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<Section> _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);
}
}

View File

@ -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,
_ => ''

View File

@ -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');

View File

@ -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<String, dynamic> json) =>
_$KeyCustomizationFromJson(json);
}
class _ColorConverter implements JsonConverter<Color?, int?> {
const _ColorConverter();
@override
Color? fromJson(int? json) => json != null ? Color(json) : null;
@override
int? toJson(Color? object) => object?.value;
}

View File

@ -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>(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<String, dynamic> 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<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$KeyCustomizationCopyWith<KeyCustomization> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View File

@ -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<KeyCustomizationNotifier, Map<int, KeyCustomization>>(
(ref) => KeyCustomizationNotifier(ref.watch(prefProvider)));
final _log = Logger('key_customization_manager');
class KeyCustomizationNotifier
extends StateNotifier<Map<int, KeyCustomization>> {
static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS';
final SharedPreferences _prefs;
KeyCustomizationNotifier(this._prefs)
: super(_readCustomizations(_prefs.getString(_prefKeyCustomizations)));
static Map<int, KeyCustomization> _readCustomizations(String? pref) {
if (pref == null) {
return {};
}
try {
final retval = <int, KeyCustomization>{};
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<void> 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()));
}
}

View File

@ -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<KeyCustomizationDialog> createState() =>
_KeyCustomizationDialogState();
}
class _KeyCustomizationDialogState
extends ConsumerState<KeyCustomizationDialog> {
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<String> _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!)]
: <String>[]
: data.hasValue
? data.value?.node.path == node.path
? [
data.value!.name,
_getDeviceInfoString(context, data.value!.info)
]
: <String>[]
: <String>[];
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,
),
);
}
}

View File

@ -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<Capability> 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<String, dynamic> json) =>
_$KeyCustomizationFromJson(json);
}
class _ColorConverter implements JsonConverter<Color?, int?> {
const _ColorConverter();
@override
Color? fromJson(int? json) => json != null ? Color(json) : null;
@override
int? toJson(Color? object) => object?.value;
}

View File

@ -1084,3 +1084,195 @@ abstract class _WindowState implements WindowState {
_$$WindowStateImplCopyWith<_$WindowStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}
KeyCustomization _$KeyCustomizationFromJson(Map<String, dynamic> 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<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$KeyCustomizationCopyWith<KeyCustomization> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View File

@ -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<List<Application>>(
final supportedSectionsProvider = Provider<List<Section>>(
(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<DeviceNode?> {
setCurrentDevice(DeviceNode? device);
}
final currentAppProvider =
StateNotifierProvider<CurrentAppNotifier, Application>((ref) {
final notifier = CurrentAppNotifier(ref.watch(supportedAppsProvider));
ref.listen<AsyncValue<YubiKeyData>>(currentDeviceDataProvider, (_, data) {
notifier.notifyDeviceChanged(data.whenOrNull(data: ((data) => data)));
}, fireImmediately: true);
return notifier;
});
final currentSectionProvider =
StateNotifierProvider<CurrentSectionNotifier, Section>(
(ref) => throw UnimplementedError());
class CurrentAppNotifier extends StateNotifier<Application> {
final List<Application> _supportedApps;
abstract class CurrentSectionNotifier extends StateNotifier<Section> {
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<T> Function<T>(
final withContextProvider = Provider<WithContext>(
(ref) => ref.watch(contextConsumer.notifier).withContext);
final keyCustomizationManagerProvider =
StateNotifierProvider<KeyCustomizationNotifier, Map<int, KeyCustomization>>(
(ref) => KeyCustomizationNotifier(ref.watch(prefProvider)));
class KeyCustomizationNotifier
extends StateNotifier<Map<int, KeyCustomization>> {
static const _prefKeyCustomizations = 'KEY_CUSTOMIZATIONS';
final SharedPreferences _prefs;
KeyCustomizationNotifier(this._prefs)
: super(_readCustomizations(_prefs.getString(_prefKeyCustomizations)));
static Map<int, KeyCustomization> _readCustomizations(String? pref) {
if (pref == null) {
return {};
}
try {
final retval = <int, KeyCustomization>{};
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<void> 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()));
}
}

View File

@ -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;

View File

@ -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,
children: [
Wrap(
alignment: WrapAlignment.spaceBetween,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 2.0,
runSpacing: 8.0,
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title!,
style: Theme.of(context).textTheme.displaySmall!.copyWith(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.9))),
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,9 +218,8 @@ class AppPage extends StatelessWidget {
),
],
);
return SingleChildScrollView(
primary: false,
child: SafeArea(
final safeArea = SafeArea(
child: delayedContent
? DelayedVisibility(
key: GlobalKey(), // Ensure we reset the delay on rebuild
@ -235,16 +227,50 @@ class AppPage extends StatelessWidget {
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,
);
}
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) {

View File

@ -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: CircleAvatar(
backgroundColor: Colors.transparent,
child: ProductImage(
name: data.name,
formFactor: data.info.formFactor,
isNfc: data.info.supportedCapabilities.containsKey(Transport.nfc)),
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(
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!,
),

View File

@ -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',

View File

@ -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<String>>(
@ -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<Widget> 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(
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<PopupMenuItem> _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<void> _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(

View File

@ -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',

View File

@ -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();

View File

@ -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');

View File

@ -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();
})
],
actionButtonBuilder: (context) => IconButton(
icon: const Icon(Icons.person_add_alt_1),
tooltip: l10n.s_add_account,
}),
ElevatedButton.icon(
label: Text(l10n.s_add_account),
icon: const Icon(Symbols.person_add_alt),
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),

View File

@ -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,
);
}
}
}

View File

@ -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()
: <Application>[];
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(
...availableSections.map((app) => NavigationItem(
key: app._key,
title: app.getDisplayName(l10n),
leading: app == currentApp
? Icon(app._filledIcon)
: Icon(app._icon),
leading: Icon(app._icon,
fill: app == currentSection ? 1.0 : 0.0),
collapsed: !extended,
selected: app == currentApp,
onTap: app.getAvailability(data) == Availability.enabled
selected: app == currentSection,
onTap: data == null && currentSection == Section.home ||
data != null &&
app.getAvailability(data) ==
Availability.enabled
? () {
ref
.read(currentAppProvider.notifier)
.setCurrentApp(app);
.read(currentSectionProvider.notifier)
.setCurrentSection(app);
if (shouldPop) {
Navigator.of(context).pop();
}
}
: null,
)),
const SizedBox(height: 32),
],
],
),
),
// 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());
},
),
],
),
);

View File

@ -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<ResetDialog> {
_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<ResetDialog> {
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')
});

View File

@ -32,14 +32,18 @@ import '../state.dart';
final _log = Logger('desktop.fido.state');
final _pinProvider = StateProvider.autoDispose.family<String?, DevicePath>(
(ref, _) => null,
final _pinProvider = StateProvider.family<String?, DevicePath>(
(ref, _) {
// Clear PIN if current device is changed
ref.watch(currentDeviceProvider);
return null;
},
);
final _sessionProvider =
Provider.autoDispose.family<RpcNodeSession, DevicePath>(
(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']);

View File

@ -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<Widget> initialize(List<String> 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 {

View File

@ -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,

View File

@ -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,
);

View File

@ -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<AsyncValue<YubiKeyData>>(currentDeviceDataProvider, (_, data) {
notifier._notifyDeviceChanged(data.whenOrNull(data: ((data) => data)));
}, fireImmediately: true);
return notifier;
}
class DesktopCurrentSectionNotifier extends CurrentSectionNotifier {
final List<Section> _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<Section> supportedSections) =>
supportedSections.firstWhere((element) => element.name == name,
orElse: () => supportedSections.first);
}

View File

@ -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();
}

View File

@ -27,8 +27,7 @@ class FidoState with _$FidoState {
factory FidoState(
{required Map<String, dynamic> info,
required bool unlocked,
@Default(true) bool initialized}) = _FidoState;
required bool unlocked}) = _FidoState;
factory FidoState.fromJson(Map<String, dynamic> json) =>
_$FidoStateFromJson(json);

View File

@ -22,7 +22,6 @@ FidoState _$FidoStateFromJson(Map<String, dynamic> json) {
mixin _$FidoState {
Map<String, dynamic> get info => throw _privateConstructorUsedError;
bool get unlocked => throw _privateConstructorUsedError;
bool get initialized => throw _privateConstructorUsedError;
Map<String, dynamic> 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<String, dynamic> info, bool unlocked, bool initialized});
$Res call({Map<String, dynamic> 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<String, dynamic> info, bool unlocked, bool initialized});
$Res call({Map<String, dynamic> 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<String, dynamic> info,
required this.unlocked,
this.initialized = true})
{required final Map<String, dynamic> 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<String, dynamic> info,
required final bool unlocked,
final bool initialized}) = _$FidoStateImpl;
required final bool unlocked}) = _$FidoStateImpl;
_FidoState._() : super._();
factory _FidoState.fromJson(Map<String, dynamic> 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;

View File

@ -10,14 +10,12 @@ _$FidoStateImpl _$$FidoStateImplFromJson(Map<String, dynamic> json) =>
_$FidoStateImpl(
info: json['info'] as Map<String, dynamic>,
unlocked: json['unlocked'] as bool,
initialized: json['initialized'] as bool? ?? true,
);
Map<String, dynamic> _$$FidoStateImplToJson(_$FidoStateImpl instance) =>
<String, dynamic>{
'info': instance.info,
'unlocked': instance.unlocked,
'initialized': instance.initialized,
};
_$FingerprintImpl _$$FingerprintImplFromJson(Map<String, dynamic> json) =>

View File

@ -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<ActionItem> 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<ActionItem> 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<ActionItem> 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),

View File

@ -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<AddFingerprintDialog>
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<AddFingerprintDialog>
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<AddFingerprintDialog>
onFieldSubmitted: (_) {
_submit();
},
),
).init(),
)
]
],

View File

@ -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),
],
),
),

View File

@ -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),
],
),
),

View File

@ -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,

View File

@ -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);

View File

@ -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
return fidoState.unlocked
? _FidoUnlockedPage(deviceData.node, fidoState)
: _FidoLockedPage(deviceData.node, fidoState)
: const _FidoInsertTapPage();
: _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,

View File

@ -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<FidoPinDialog> {
String _currentPin = '';
final _currentPinController = TextEditingController();
final _currentPinFocus = FocusNode();
String _newPin = '';
String _confirmPin = '';
String? _currentPinError;
@ -55,15 +57,28 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
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<FidoPinDialog> {
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<FidoPinDialog> {
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<FidoPinDialog> {
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<FidoPinDialog> {
_newPin = value;
});
},
),
).init(),
AppTextFormField(
key: confirmPin,
initialValue: _confirmPin,
@ -155,11 +172,11 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
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<FidoPinDialog> {
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<FidoPinDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
@ -195,15 +216,9 @@ class _FidoPinDialogState extends ConsumerState<FidoPinDialog> {
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<FidoPinDialog> {
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;

View File

@ -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<PinEntryForm> {
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<PinEntryForm> {
.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<PinEntryForm> {
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<PinEntryForm> {
});
}, // 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<PinEntryForm> {
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,
),
),
],

View File

@ -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<RenameFingerprintDialog> {
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<RenameFingerprintDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -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,

View File

@ -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<Widget> Function(BuildContext context, bool expanded)?
actionsBuilder;
final Widget? fileDropOverlay;
final Function(File file)? onFileDropped;
final List<Capability>? 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,
);
}
}

View File

@ -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<void> _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<Color?>(
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,
),
);
}
}

View File

@ -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());
},
)
])
],
);
}

View File

@ -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<ManageLabelDialog> createState() => _ManageLabelDialogState();
}
class _ManageLabelDialogState extends ConsumerState<ManageLabelDialog> {
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();
});
}
}

View File

@ -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": {}
}

View File

@ -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": {}
}

View File

@ -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": {}
}

View File

@ -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": {}
}

View File

@ -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": {}
}

View File

@ -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),
),

View File

@ -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));
}

View File

@ -105,15 +105,11 @@ class OathPair with _$OathPair {
class OathState with _$OathState {
const OathState._();
factory OathState(
String deviceId,
Version version, {
required bool hasKey,
factory OathState(String deviceId, Version version,
{required bool hasKey,
required bool remembered,
required bool locked,
required KeystoreState keystore,
@Default(true) bool initialized,
}) = _OathState;
required KeystoreState keystore}) = _OathState;
int? get capacity =>
version.isAtLeast(4) ? (version.isAtLeast(5, 7) ? 64 : 32) : null;

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;

View File

@ -70,7 +70,6 @@ _$OathStateImpl _$$OathStateImplFromJson(Map<String, dynamic> json) =>
remembered: json['remembered'] as bool,
locked: json['locked'] as bool,
keystore: $enumDecode(_$KeystoreStateEnumMap, json['keystore']),
initialized: json['initialized'] as bool? ?? true,
);
Map<String, dynamic> _$$OathStateImplToJson(_$OathStateImpl instance) =>
@ -81,7 +80,6 @@ Map<String, dynamic> _$$OathStateImplToJson(_$OathStateImpl instance) =>
'remembered': instance.remembered,
'locked': instance.locked,
'keystore': _$KeystoreStateEnumMap[instance.keystore]!,
'initialized': instance.initialized,
};
const _$KeystoreStateEnumMap = {

View File

@ -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(

View File

@ -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<AddAccountDialog> {
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<AddAccountDialog> {
),
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),

View File

@ -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<OathAddAccountPage> {
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<OathAddAccountPage> {
: 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<OathAddAccountPage> {
onSubmitted: (_) {
if (isValid) submit();
},
),
).init(),
AppTextField(
key: keys.nameField,
controller: _accountController,
@ -406,7 +407,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
: 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<OathAddAccountPage> {
onSubmitted: (_) {
if (isValid) submit();
},
),
).init(),
AppTextField(
key: keys.secretField,
controller: _secretController,
@ -435,11 +436,11 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
? 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<OathAddAccountPage> {
onSubmitted: (_) {
if (isValid) submit();
},
),
).init(),
const SizedBox(height: 8),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,

View File

@ -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)),
),
),
]),

View File

@ -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(

View File

@ -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<ManagePasswordDialog> {
String _currentPassword = '';
final _currentPasswordController = TextEditingController();
final _currentPasswordFocus = FocusNode();
String _newPassword = '';
String _confirmPassword = '';
bool _currentIsWrong = false;
@ -48,12 +50,19 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
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<ManagePasswordDialog> {
});
}
} else {
_currentPasswordController.selection = TextSelection(
baseOffset: 0, extentOffset: _currentPasswordController.text.length);
_currentPasswordFocus.requestFocus();
setState(() {
_currentIsWrong = true;
});
@ -71,9 +83,10 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
@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<ManagePasswordDialog> {
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<ManagePasswordDialog> {
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<ManagePasswordDialog> {
});
}
} else {
_currentPasswordController.selection =
TextSelection(
baseOffset: 0,
extentOffset: _currentPasswordController
.text.length);
_currentPasswordFocus.requestFocus();
setState(() {
_currentIsWrong = true;
});
@ -179,11 +200,11 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
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<ManagePasswordDialog> {
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<ManagePasswordDialog> {
_submit();
}
},
),
).init(),
AppTextField(
key: keys.confirmPasswordField,
obscureText: _isObscureConfirm,
@ -213,11 +235,11 @@ class _ManagePasswordDialogState extends ConsumerState<ManagePasswordDialog> {
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<ManagePasswordDialog> {
tooltip: _isObscureConfirm
? l10n.s_show_password
: l10n.s_hide_password),
enabled:
(!widget.state.hasKey || _currentPassword.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<ManagePasswordDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -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';
@ -55,36 +58,21 @@ class OathScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
return ref.watch(oathStateProvider(devicePath)).when(
loading: () => const MessagePage(
centered: true,
graphic: CircularProgressIndicator(),
delayedContent: true,
),
error: (error, _) => AppFailurePage(
error: (error, _) => error is NoDataException
? MessagePageNotInitialized(title: l10n.s_accounts)
: AppFailurePage(
cause: error,
),
data: (oathState) => oathState.initialized
? oathState.locked
data: (oathState) => 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,
);
: _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(),
);
}),
),

View File

@ -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<RenameAccountDialog> {
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<RenameAccountDialog> {
_issuer = value.trim();
});
},
),
).init(),
AppTextFormField(
initialValue: _name,
maxLength: nameRemaining,
@ -208,7 +209,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
: !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<RenameAccountDialog> {
_submit();
}
},
),
).init(),
]
.map((e) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),

View File

@ -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<UnlockForm> {
final _passwordController = TextEditingController();
final _passwordFocus = FocusNode();
bool _remember = false;
bool _passwordIsWrong = false;
bool _isObscure = true;
@ -50,9 +52,11 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
.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<UnlockForm> {
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<UnlockForm> {
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<UnlockForm> {
_passwordIsWrong = false;
}), // Update state on change
onSubmitted: (_) => _submit(),
),
).init(),
),
const SizedBox(height: 3.0),
Column(
@ -122,7 +128,7 @@ class _UnlockFormState extends ConsumerState<UnlockForm> {
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<UnlockForm> {
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,
),

View File

@ -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<ActionItem> 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<ActionItem> 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<ActionItem> 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,

View File

@ -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,

View File

@ -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<ConfigureHotpDialog> {
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<ConfigureHotpDialog> {
? 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<ConfigureHotpDialog> {
_validateSecret = false;
});
},
),
).init(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,

View File

@ -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<ConfigureStaticDialog> {
: _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<ConfigureStaticDialog> {
_validatePassword = false;
});
},
),
).init(),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4.0,

View File

@ -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,

View File

@ -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);

View File

@ -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<OtpScreen> {
),
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,

Some files were not shown because too many files have changed in this diff Show More