mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
Merge branch 'adamve/android_fido' into adamve/android_fido_bio
This commit is contained in:
commit
d58e9ff225
@ -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'
|
||||
|
@ -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"
|
||||
|
@ -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
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
@ -28,6 +28,7 @@ class FidoPinStore {
|
||||
}
|
||||
|
||||
fun setPin(newPin: CharArray?) {
|
||||
pin?.fill(0.toChar())
|
||||
pin = newPin?.clone()
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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>>()
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -40,8 +40,7 @@ class SerializationTest {
|
||||
Version(1, 2, 3),
|
||||
isAccessKeySet = false,
|
||||
isRemembered = false,
|
||||
isLocked = false,
|
||||
initialized = true
|
||||
isLocked = false
|
||||
)
|
||||
|
||||
@Test
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
});
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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(
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
_ => ''
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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()));
|
||||
}
|
||||
}
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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) {
|
||||
|
@ -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!,
|
||||
),
|
||||
|
@ -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',
|
||||
|
@ -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(
|
||||
|
@ -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',
|
||||
|
@ -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();
|
||||
|
@ -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');
|
||||
|
@ -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),
|
||||
|
55
lib/app/views/message_page_not_initialized.dart
Normal file
55
lib/app/views/message_page_not_initialized.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
@ -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')
|
||||
});
|
||||
|
@ -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']);
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
);
|
||||
|
@ -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);
|
||||
}
|
||||
|
19
lib/exception/no_data_exception.dart
Normal file
19
lib/exception/no_data_exception.dart
Normal 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();
|
||||
}
|
@ -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);
|
||||
|
@ -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;
|
||||
|
@ -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) =>
|
||||
|
@ -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),
|
||||
|
@ -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(),
|
||||
)
|
||||
]
|
||||
],
|
||||
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
79
lib/home/views/home_message_page.dart
Normal file
79
lib/home/views/home_message_page.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
351
lib/home/views/home_screen.dart
Normal file
351
lib/home/views/home_screen.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
115
lib/home/views/key_actions.dart
Normal file
115
lib/home/views/key_actions.dart
Normal 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());
|
||||
},
|
||||
)
|
||||
])
|
||||
],
|
||||
);
|
||||
}
|
114
lib/home/views/manage_label_dialog.dart
Normal file
114
lib/home/views/manage_label_dialog.dart
Normal 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();
|
||||
});
|
||||
}
|
||||
}
|
@ -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": {}
|
||||
}
|
||||
|
@ -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": {}
|
||||
}
|
||||
|
@ -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": {}
|
||||
}
|
||||
|
@ -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": {}
|
||||
}
|
||||
|
@ -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": {}
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 = {
|
||||
|
@ -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(
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
|
@ -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)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
|
@ -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(
|
||||
|
@ -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),
|
||||
|
@ -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(),
|
||||
);
|
||||
}),
|
||||
),
|
||||
|
@ -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),
|
||||
|
@ -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,
|
||||
),
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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);
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user