Members of the KDE Community are recommended to subscribe to the kde-community mailing list at https://mail.kde.org/mailman/listinfo/kde-community to allow them to participate in important discussions and receive other important announcements

Commit bf67a42c authored by Johan Ouwerkerk's avatar Johan Ouwerkerk

Rework account entry list delegate QML

 - Refactor code to have delegates per account-type & simplify code
 - Rework layouting in terms of anchors
 - Fix positioning of account name and token labels to be vertically
   centered
 - Make health indicator/token life timer for TOTP accounts sit flush
   with the bottom of the TOTP delegate UI itself and extend along the
   whole width. This resolves issue #11
parent 1dc35133
Pipeline #19553 passed with stage
in 6 minutes and 7 seconds
......@@ -13,45 +13,19 @@ import Keysmith.Models 1.0 as Models
Kirigami.SwipeListItem {
id: root
property Models.Account account: null
property int phase : account && account.isTotp ? account.millisecondsLeftForToken() : 0
property int interval: account && account.isTotp ? 1000 * account.timeStep : 0
property bool tokenAvailable: account && account.token && account.token.length > 0
signal actionTriggered
property real healthIndicator: 0
property bool alive: true
property Models.Account account: null
property bool alive: account !== null
property bool tokenAvailable: alive && account.token && account.token.length > 0
property Kirigami.Action advanceCounter : Kirigami.Action {
iconName: "go-next" // "view-refresh"
text: "Next token"
onTriggered: {
// TODO convert to C++ helper, have proper logging?
if (alive && account && account.isHotp) {
root.actionTriggered();
account.advanceCounter(1);
}
// TODO warn if not
}
}
visible: alive
enabled: alive
property Kirigami.Action deleteAccount : Kirigami.Action {
iconName: "edit-delete"
text: i18nc("Button for removal of a single account", "Delete account")
onTriggered: {
// TODO convert to C++ helper, have proper logging?
if (alive && account) {
root.sheet.open();
root.actionTriggered();
}
// TODO warn if not
}
}
property Kirigami.OverlaySheet sheet: Kirigami.OverlaySheet {
readonly property Kirigami.OverlaySheet sheet: Kirigami.OverlaySheet {
sheetOpen: false
header: Kirigami.Heading {
text: i18nc("Confirm dialog title: %1 is the name of the account to remove", "Removing account: %1", account ? account.name : "")
text: i18nc("Confirm dialog title: %1 is the name of the account to remove", "Removing account: %1", account.name)
}
ColumnLayout {
spacing: Kirigami.Units.largeSpacing * 5
......@@ -79,13 +53,10 @@ Kirigami.SwipeListItem {
action: Kirigami.Action {
iconName: "edit-delete"
text: i18nc("Button confirming account removal", "Delete account")
enabled: alive
onTriggered: {
// TODO convert to C++ helper, have proper logging?
if (alive && account) {
alive = false;
account.remove();
}
// TODO warn if not
alive = false;
account.remove();
sheet.close();
}
}
......@@ -93,81 +64,10 @@ Kirigami.SwipeListItem {
}
}
actions: account && account.isHotp ? [deleteAccount, advanceCounter] : [deleteAccount]
contentItem: ColumnLayout {
id: mainLayout
RowLayout {
Controls.Label {
id: accountNameLabel
horizontalAlignment: Text.AlignLeft
font.weight: Font.Light
elide: Text.ElideRight
text: account ? account.name : i18nc("placeholder text if no account name is available", "(untitled)")
}
Controls.Label {
id: tokenValueLabel
horizontalAlignment: Text.AlignRight
Layout.fillWidth: true
font.weight: Font.Bold
text: tokenAvailable ? account.token : i18nc("placeholder text if no token is available", "(refresh)")
}
}
Timer {
id: timer
running: account && account.isTotp
interval: phase
onTriggered: {
// TODO convert to C++ helper, have proper logging?
if (alive && account) {
if (account.isTotp) {
timer.stop()
timeoutIndicatorAnimation.stop();
account.recompute();
timer.interval = account.millisecondsLeftForToken(); // root.interval;
timer.restart();
timeoutIndicatorAnimation.restart();
}
}
// TODO warn if not
}
}
Rectangle {
id: health
Layout.fillWidth: true
color: Kirigami.Theme.positiveTextColor
height: Kirigami.Units.smallSpacing
opacity: timer.running ? 0.6 : 0
radius: health.height
/*
* Don't use mainLayout.width because that doesn't seem to be affected by hovering which uncovers 'hidden'
* action buttons. Compute the correct width manually, to avoid 'flashes' where the health indicator may
* appear to be 'reset' to (near) full width.
*/
width: (accountNameLabel.width + tokenValueLabel.width) * healthIndicator
NumberAnimation {
id: timeoutIndicatorAnimation
/*
* Don't animate the rectangle directly: instead animate a proxy property to track the desired ratio.
* This way the property binding for the width of the rectangle is fully re-evaluated whenever the
* app window size changes. In turn, that then ensures the health indicator rectangle is also resized
* accordingly to the correct proportion of the new width of the layout.
*/
target: root
property: "healthIndicator"
from: timer.interval / root.interval
to: 0
duration: timer.interval
running: model.account && model.account.isTotp && Kirigami.Units.longDuration > 1
}
}
}
onClicked: {
// TODO convert to C++ helper, have proper logging?
if (alive && tokenAvailable) {
root.actionTriggered();
actionTriggered();
if (tokenAvailable) {
Keysmith.copyToClipboard(account.token);
}
// TODO warn if not
......
......@@ -5,7 +5,6 @@
import QtQuick 2.1
import QtQuick.Layouts 1.2
import QtQuick.Controls 2.2 as Controls
import org.kde.kirigami 2.8 as Kirigami
import Keysmith.Application 1.0
......@@ -28,18 +27,6 @@ Kirigami.ScrollablePage {
property string loadingErrorMessage: i18nc("error message shown when loading accounts from storage failed", "Some accounts failed to load.")
property string errorMessage: loadingErrorMessage
Component {
id: mainListDelegate
AccountEntryView {
id: entry
account: model.account
onActionTriggered: {
root.accounts.error = false;
root.errorMessage = root.accountErrorMessage;
}
}
}
header: ColumnLayout {
id: column
Layout.margins: 0
......@@ -77,17 +64,78 @@ Kirigami.ScrollablePage {
}
}
Component {
id: hotpDelegate
HOTPAccountEntryView {
account: value
onActionTriggered: {
root.accounts.error = false;
root.errorMessage = root.accountErrorMessage;
}
}
}
Component {
id: totpDelegate
TOTPAccountEntryView {
account: value
onActionTriggered: {
root.accounts.error = false;
root.errorMessage = root.accountErrorMessage;
}
}
}
ListView {
id: mainList
model: accounts
Layout.fillWidth: true
delegate: mainListDelegate
/*
* Use a Loader to get a switch-like statement to select an
* appropriate delegate based on properties of the account model.
*/
delegate: Loader {
/*
* Fix up width manually.
* It doesn't seem to be propagated correctly by itself otherwise.
*/
width: mainList.width
/*
* The `model` and related properties from the ListView delegate
* context will not survive into the actual delegate components.
* However Loader will also inject properties in *its* context
* which will be observed by those delegate components.
*
* Fill the Loader's context with properties from the model passed
* by ListView, to make these values propagate into delegates.
*
* See also: https://doc.qt.io/qt-5/qml-qtquick-loader.html#using-a-loader-within-a-view-delegate
*/
property Models.Account value: model.account
property int index: model.index
sourceComponent: {
/*
* Guard against a broken account model.
* Will simply render nothing at all in that case.
*/
if (!value) {
// TODO warn about this
return null;
}
if (value.isHotp) {
return hotpDelegate;
}
if (value.isTotp) {
return totpDelegate;
}
// TODO warn about this
return null;
}
}
}
topPadding: 0
leftPadding: 0
rightPadding: 0
bottomPadding: 0
actions.main: Kirigami.Action {
id: addAction
text: i18n("Add")
......
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
import org.kde.kirigami 2.4 as Kirigami
AccountEntryViewBase {
id: root
actions: [
Kirigami.Action {
iconName: "edit-delete"
text: i18nc("Button for removal of a single account", "Delete account")
enabled: root.alive
onTriggered: {
root.actionTriggered();
root.sheet.open();
}
},
Kirigami.Action {
iconName: "go-next" // "view-refresh"
text: "Next token"
enabled: root.alive
onTriggered: {
root.actionTriggered();
root.account.advanceCounter(1);
}
}
]
TokenEntryViewLabels {
id: mainLayout
accountName: account.name
tokenValue: account.token
}
}
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
import QtQuick 2.1
import org.kde.kirigami 2.4 as Kirigami
AccountEntryViewBase {
id: root
property real healthIndicator: 0
property real interval: root.alive ? 1000 * root.account.timeStep : 0
actions: [
Kirigami.Action {
iconName: "edit-delete"
text: i18nc("Button for removal of a single account", "Delete account")
enabled: root.alive
onTriggered: {
root.actionTriggered();
root.sheet.open();
}
}
]
TokenEntryViewLabels {
id: mainLayout
accountName: account.name
tokenValue: account.token
/*
* For some reason the running NumberAnimation seems to trigger very sluggish QML UI when the window is resized.
* This behaviour persists until the animation is 'reset', so work around by resetting the animation whenever this
* could have occurred. The easiest proxy for detecting this is whenever the width of the content item changes.
*
* Note that this work-around triggers a lot of false positive 'hits' as well: hovering the cursor over the UI
* also triggers a change on the width property.
*
* The particular sluggish behaviour of QML can be reproduced using the following steps:
*
* - commenting out the signal handler
* - rebuilding the app and starting it
* - with some (multiple) accounts pre-defined, with at least one TOTP account
* - resize the app while a health indicator animation is running
* - hovering over account entries: observe how QML takes a while to 'catch' up with the cursor, to display the
* hover effect in the accounts list view.
*/
onWidthChanged: {
if (timeoutIndicatorAnimation.running) {
timeoutIndicatorAnimation.stop();
var phase = root.account.millisecondsLeftForToken();
root.healthIndicator = phase;
timeoutIndicatorAnimation.from = phase;
timeoutIndicatorAnimation.duration = phase;
timeoutIndicatorAnimation.restart();
}
}
Rectangle {
id: health
x: - root.leftPadding
y: Math.max(mainLayout.height, mainLayout.implicitHeight)
radius: health.height
height: Kirigami.Units.smallSpacing
opacity: timeoutIndicatorAnimation.running ? 0.6 : 0
width: root.alive && root.interval > 0 ? root.width * root.healthIndicator / root.interval : 0
color: Kirigami.Theme.positiveTextColor
NumberAnimation {
id: timeoutIndicatorAnimation
target: root
property: "healthIndicator"
from: timer.interval
to: 0
duration: timer.interval
running: root.alive
}
Timer {
id: timer
running: root.alive
interval: root.alive ? root.account.millisecondsLeftForToken() : 0
onTriggered: {
if (root.alive) {
timer.stop()
timeoutIndicatorAnimation.stop();
root.account.recompute();
var phase = root.account.millisecondsLeftForToken();
timer.interval = phase;
root.healthIndicator = phase;
timeoutIndicatorAnimation.duration = phase;
timeoutIndicatorAnimation.from = phase;
timer.restart();
timeoutIndicatorAnimation.restart();
}
}
}
}
}
}
/*
* SPDX-License-Identifier: GPL-3.0-or-later
* SPDX-FileCopyrightText: 2020 Johan Ouwerkerk <jm.ouwerkerk@gmail.com>
*/
import QtQuick 2.1
import QtQuick.Controls 2.0 as Controls
Item {
property string accountName
property string tokenValue
id: root
Controls.Label {
id: accountNameLabel
horizontalAlignment: Text.AlignLeft
font.weight: Font.Light
elide: Text.ElideRight
text: accountName
anchors.left: root.left
anchors.verticalCenter: root.verticalCenter
}
Controls.Label {
id: tokenValueLabel
horizontalAlignment: Text.AlignRight
font.weight: Font.Bold
text: tokenValue && tokenValue.length > 0 ? tokenValue : i18nc("placeholder text if no token is available", "(refresh)")
anchors.right: root.right
anchors.verticalCenter: root.verticalCenter
}
}
......@@ -7,10 +7,13 @@
<qresource prefix="/">
<file alias="main.qml">contents/ui/main.qml</file>
<file alias="TokenDetailsForm.qml">contents/ui/TokenDetailsForm.qml</file>
<file alias="AccountEntryView.qml">contents/ui/AccountEntryView.qml</file>
<file alias="AccountsOverview.qml">contents/ui/AccountsOverview.qml</file>
<file alias="AddAccount.qml">contents/ui/AddAccount.qml</file>
<file alias="UnlockAccounts.qml">contents/ui/UnlockAccounts.qml</file>
<file alias="SetupPassword.qml">contents/ui/SetupPassword.qml</file>
<file alias="TokenEntryViewLabels.qml">contents/ui/TokenEntryViewLabels.qml</file>
<file alias="AccountEntryViewBase.qml">contents/ui/AccountEntryViewBase.qml</file>
<file alias="HOTPAccountEntryView.qml">contents/ui/HOTPAccountEntryView.qml</file>
<file alias="TOTPAccountEntryView.qml">contents/ui/TOTPAccountEntryView.qml</file>
</qresource>
</RCC>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment