mirror of https://github.com/cutefishos/settings
refactor(settings): list the users as rows with their details in a dialog
The Users page shows its accounts in a list card, as Wi-Fi does, the one logged in first. Each row keeps the picture, the names and whether it is logged in, and ends in an info button that opens the account's type, the way to a new password and deleting it.main
parent
b0dc37ea12
commit
45a0f687cd
@ -1,477 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "pages/accounts/usercard.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
#include <unicode/unistr.h>
|
||||
#include <unicode/utf16.h>
|
||||
|
||||
#include "base/transform.h"
|
||||
#include "ui/animation.h"
|
||||
#include "ui/color_provider.h"
|
||||
#include "ui/icon.h"
|
||||
#include "ui/image_view.h"
|
||||
#include "ui/label.h"
|
||||
#include "ui/layout.h"
|
||||
#include "ui/cell_layout.h"
|
||||
#include "ui/painter.h"
|
||||
#include "ui/path.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/window.h"
|
||||
#include "widgets/colors.h"
|
||||
#include "widgets/metrics.h"
|
||||
#include "widgets/standardbutton.h"
|
||||
|
||||
using namespace cutefish;
|
||||
using namespace cutefish::ui;
|
||||
|
||||
namespace settings {
|
||||
|
||||
namespace {
|
||||
|
||||
// FishUI's RoundImageButton with a glyph: a round plate on hover and press,
|
||||
// kept on the glyph's pixel parity so both share a centre.
|
||||
class DisclosureButton : public View
|
||||
{
|
||||
public:
|
||||
DisclosureButton(double size, double iconMargins)
|
||||
: m_size(size)
|
||||
, m_iconSize(std::round(size - iconMargins * 2))
|
||||
{
|
||||
m_icon = addChild<Icon>(std::string(), m_iconSize);
|
||||
}
|
||||
|
||||
void setIconName(std::string name) { m_icon->setIconName(std::move(name)); }
|
||||
const std::string& iconName() const { return m_icon->iconName(); }
|
||||
double iconSize() const { return m_iconSize; }
|
||||
|
||||
SizeF sizeHint() const override { return {m_size, m_size}; }
|
||||
bool acceptsPointer() const override { return true; }
|
||||
|
||||
std::function<void()> onClicked;
|
||||
|
||||
protected:
|
||||
void paint(Painter& painter) override
|
||||
{
|
||||
const Rect box = painter.deviceRect(localRect());
|
||||
const int icon = int(std::lround(m_iconSize * painter.scale()));
|
||||
if (isEnabled() && (m_hovered || m_pressed)) {
|
||||
const int margin = int(std::lround(std::round(m_size * 0.1) * painter.scale()));
|
||||
int plate = std::min(box.width, box.height) - 2 * margin;
|
||||
if ((plate - icon) % 2 != 0)
|
||||
plate -= 1;
|
||||
painter.fillDeviceRoundedRect({box.x + (box.width - plate) / 2, box.y + (box.height - plate) / 2, plate,
|
||||
plate},
|
||||
plate / 2.0,
|
||||
color(m_pressed ? ColorId::RoundButtonPressed : ColorId::RoundButtonHovered));
|
||||
}
|
||||
}
|
||||
|
||||
void layout() override
|
||||
{
|
||||
m_icon->setGeometry({std::round((m_size - m_iconSize) / 2), std::round((m_size - m_iconSize) / 2), m_iconSize,
|
||||
m_iconSize});
|
||||
}
|
||||
|
||||
void pointerEnter() override
|
||||
{
|
||||
m_hovered = true;
|
||||
requestPaint();
|
||||
}
|
||||
void pointerLeave() override
|
||||
{
|
||||
m_hovered = false;
|
||||
requestPaint();
|
||||
}
|
||||
void pointerPress(const PointerEvent& event) override
|
||||
{
|
||||
if (event.button != MouseButton::Left)
|
||||
return;
|
||||
m_pressed = true;
|
||||
requestPaint();
|
||||
}
|
||||
void pointerRelease(const PointerEvent& event) override
|
||||
{
|
||||
const bool clicked = m_pressed && localRect().contains(event.position);
|
||||
m_pressed = false;
|
||||
requestPaint();
|
||||
if (clicked && onClicked)
|
||||
onClicked();
|
||||
}
|
||||
|
||||
private:
|
||||
double m_size;
|
||||
double m_iconSize;
|
||||
Icon* m_icon;
|
||||
bool m_hovered = false;
|
||||
bool m_pressed = false;
|
||||
};
|
||||
|
||||
// The account's picture in a circle, or its initial, as on the lock screen,
|
||||
// for an account with no picture: the icon theme has no avatar that size.
|
||||
// Hovering dims it under a pencil, as the lock screen's avatar shows its
|
||||
// swap arrows, since a click changes the picture.
|
||||
class UserAvatar : public View
|
||||
{
|
||||
public:
|
||||
static constexpr double kSize = 48;
|
||||
|
||||
UserAvatar()
|
||||
{
|
||||
m_initial = addChild<Label>();
|
||||
m_initial->setFontSize(22);
|
||||
// Drawn once decoded; the initial shows until then.
|
||||
m_picture = addChild<ImageView>();
|
||||
m_picture->setCornerRadius(kSize / 2);
|
||||
m_picture->onStatusChanged = [this](ImageView::Status) {
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
};
|
||||
m_overlay = addChild<EditOverlay>(this);
|
||||
}
|
||||
|
||||
void setUserName(const std::string& name)
|
||||
{
|
||||
// The first character, upper case, as QString::charAt(0).toUpper() gives it.
|
||||
icu::UnicodeString text = icu::UnicodeString::fromUTF8(name);
|
||||
std::string initial = "?";
|
||||
if (!text.isEmpty()) {
|
||||
icu::UnicodeString first = text.tempSubString(0, text.length() > 0 && U16_IS_LEAD(text[0]) ? 2 : 1);
|
||||
first.toUpper();
|
||||
initial.clear();
|
||||
first.toUTF8String(initial);
|
||||
}
|
||||
m_initial->setText(initial);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void setPicture(const std::string& path)
|
||||
{
|
||||
if (path == m_picture->source())
|
||||
return;
|
||||
m_picture->setSource(path);
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
SizeF sizeHint() const override { return {kSize, kSize}; }
|
||||
bool acceptsPointer() const override { return true; }
|
||||
CursorShape cursorShape() const override { return CursorShape::Pointer; }
|
||||
|
||||
std::function<void()> onClicked;
|
||||
|
||||
protected:
|
||||
void layout() override
|
||||
{
|
||||
m_initial->setColor(Theme::current().disabledText);
|
||||
const SizeF size = m_initial->sizeHint();
|
||||
m_initial->setGeometry({rounding::centeredStart(kSize, size.width), rounding::centeredStart(kSize, size.height), size.width, size.height});
|
||||
m_initial->setVisible(!hasPicture());
|
||||
m_picture->setGeometry(localRect());
|
||||
m_overlay->setGeometry(localRect());
|
||||
}
|
||||
|
||||
void paint(Painter& painter) override
|
||||
{
|
||||
// The account's initial, as on the lock screen, for an account with
|
||||
// no picture: the icon theme has no avatar the size of a picture.
|
||||
if (!hasPicture())
|
||||
painter.fillEllipse(localRect(), Theme::current().text.withAlpha(0.12));
|
||||
}
|
||||
|
||||
void pointerRelease(const PointerEvent& event) override
|
||||
{
|
||||
if (event.button == MouseButton::Left && localRect().contains(event.position) && onClicked)
|
||||
onClicked();
|
||||
}
|
||||
|
||||
void pointerEnter() override { setHovered(true); }
|
||||
void pointerLeave() override { setHovered(false); }
|
||||
|
||||
private:
|
||||
class EditOverlay : public View
|
||||
{
|
||||
public:
|
||||
explicit EditOverlay(UserAvatar* avatar)
|
||||
: m_avatar(avatar)
|
||||
{
|
||||
}
|
||||
|
||||
protected:
|
||||
void paint(Painter& painter) override
|
||||
{
|
||||
const double t = m_avatar->m_hover;
|
||||
if (t <= 0)
|
||||
return;
|
||||
painter.fillEllipse(localRect(), Color {0, 0, 0, 255}.withAlpha(0.35 * t));
|
||||
// The pencil on its 24 by 24 grid, drawn 18 across and centred.
|
||||
constexpr double kIcon = 18;
|
||||
Path pencil;
|
||||
pencil.moveTo(13.26, 3.59997);
|
||||
pencil.lineTo(5.04997, 12.29);
|
||||
pencil.cubicTo(4.73997, 12.62, 4.43997, 13.27, 4.37997, 13.72);
|
||||
pencil.lineTo(4.00997, 16.96);
|
||||
pencil.cubicTo(3.87997, 18.13, 4.71997, 18.93, 5.87997, 18.73);
|
||||
pencil.lineTo(9.09997, 18.18);
|
||||
pencil.cubicTo(9.54997, 18.1, 10.18, 17.77, 10.49, 17.43);
|
||||
pencil.lineTo(18.7, 8.73997);
|
||||
pencil.cubicTo(20.12, 7.23997, 20.76, 5.52997, 18.55, 3.43997);
|
||||
pencil.cubicTo(16.35, 1.36997, 14.68, 2.09997, 13.26, 3.59997);
|
||||
pencil.close();
|
||||
pencil.moveTo(11.89, 5.05005);
|
||||
pencil.cubicTo(12.32, 7.81005, 14.56, 9.92005, 17.34, 10.2);
|
||||
pencil.moveTo(3, 22);
|
||||
pencil.lineTo(21, 22);
|
||||
const double scale = kIcon / 24;
|
||||
const base::AffineTransform place(scale, 0, 0, scale, rounding::centeredStart(geometry().width, kIcon),
|
||||
rounding::centeredStart(geometry().height, kIcon));
|
||||
painter.strokePath(pencil.transformed(place), Color {255, 255, 255, 255}.withAlpha(t), 1.5,
|
||||
LineCap::Round, LineJoin::Round);
|
||||
}
|
||||
|
||||
private:
|
||||
UserAvatar* m_avatar;
|
||||
};
|
||||
|
||||
bool hasPicture() const { return m_picture->status() == ImageView::Status::Ready; }
|
||||
|
||||
void setHovered(bool hovered)
|
||||
{
|
||||
Window* window = this->window();
|
||||
if (!window) {
|
||||
setHover(hovered ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
if (!m_hoverAnimation) {
|
||||
m_hoverAnimation = std::make_unique<Animation>(window);
|
||||
m_hoverAnimation->setDuration(std::chrono::milliseconds(80));
|
||||
m_hoverAnimation->setEasing(Animation::Easing::OutQuad);
|
||||
m_hoverAnimation->onUpdate = [this](double value) {
|
||||
setHover(m_hoverFrom + (m_hoverTarget - m_hoverFrom) * value);
|
||||
};
|
||||
}
|
||||
// From wherever it is now, as a Behavior retargets.
|
||||
m_hoverFrom = m_hover;
|
||||
m_hoverTarget = hovered ? 1 : 0;
|
||||
m_hoverAnimation->stop();
|
||||
m_hoverAnimation->start();
|
||||
}
|
||||
|
||||
void setHover(double value)
|
||||
{
|
||||
m_hover = value;
|
||||
// A picture shows through the dim; an initial would cross the pencil.
|
||||
m_initial->setOpacity(1 - value);
|
||||
m_overlay->requestPaint();
|
||||
}
|
||||
|
||||
Label* m_initial;
|
||||
ImageView* m_picture;
|
||||
EditOverlay* m_overlay;
|
||||
std::unique_ptr<Animation> m_hoverAnimation;
|
||||
double m_hover = 0;
|
||||
double m_hoverFrom = 0;
|
||||
double m_hoverTarget = 0;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// The card's top row: an item as tall as its row plus a margin, holding the
|
||||
// picture, the user name, the real name, then "Logged in" and the
|
||||
// disclosure button at the end.
|
||||
class UserCard::Header : public View
|
||||
{
|
||||
public:
|
||||
Header()
|
||||
{
|
||||
row = addChild<RowLayout>();
|
||||
row->setSpacing(metrics::rowSpacing);
|
||||
avatar = row->addChild<UserAvatar>();
|
||||
userName = row->addChild<Label>();
|
||||
realName = row->addChild<Label>();
|
||||
row->addChild<View>()->setLayoutHints({.fillWidth = true});
|
||||
loggedIn = row->addChild<Label>("Logged in");
|
||||
disclosure = row->addChild<DisclosureButton>(26, 5);
|
||||
disclosure->setIconName("chevron-down");
|
||||
}
|
||||
|
||||
SizeF sizeHint() const override { return {0, row->sizeHint().height + metrics::m}; }
|
||||
bool acceptsPointer() const override { return true; }
|
||||
|
||||
RowLayout* row;
|
||||
UserAvatar* avatar;
|
||||
Label* userName;
|
||||
Label* realName;
|
||||
Label* loggedIn;
|
||||
DisclosureButton* disclosure;
|
||||
std::function<void()> onDoubleClicked;
|
||||
|
||||
protected:
|
||||
void pointerPress(const PointerEvent& event) override
|
||||
{
|
||||
if (event.button == MouseButton::Left && event.clickCount == 2 && onDoubleClicked)
|
||||
onDoubleClicked();
|
||||
}
|
||||
|
||||
void layout() override
|
||||
{
|
||||
const Theme& theme = Theme::current();
|
||||
realName->setColor(theme.disabledText);
|
||||
loggedIn->setColor(theme.disabledText);
|
||||
// As close to the arrow as a row choice's value is to its arrow.
|
||||
loggedIn->setLayoutHints({.margins = {0, 0, theme.mediumSpacing - metrics::rowSpacing - 5, 0}});
|
||||
disclosure->setLayoutHints(
|
||||
{.margins = {0, 0, metrics::trailingGlyphMargin(disclosure->iconName(), disclosure->iconSize(), 5), 0}});
|
||||
row->setGeometry(localRect());
|
||||
}
|
||||
};
|
||||
|
||||
Unfold::Unfold()
|
||||
{
|
||||
m_content = addChild<ColumnLayout>();
|
||||
setClipsChildren(true);
|
||||
setLayoutHints({.fillWidth = true});
|
||||
}
|
||||
|
||||
Unfold::~Unfold() = default;
|
||||
|
||||
void Unfold::setShown(bool shown)
|
||||
{
|
||||
if (m_shown == shown)
|
||||
return;
|
||||
m_shown = shown;
|
||||
Window* window = this->window();
|
||||
if (!window) {
|
||||
m_progress = 1;
|
||||
requestLayout();
|
||||
return;
|
||||
}
|
||||
if (!m_animation) {
|
||||
m_animation = std::make_unique<Animation>(window);
|
||||
m_animation->setDuration(std::chrono::milliseconds(200));
|
||||
m_animation->setEasing(Animation::Easing::OutSine);
|
||||
m_animation->onUpdate = [this](double value) {
|
||||
m_progress = value;
|
||||
requestLayout();
|
||||
};
|
||||
}
|
||||
// From wherever it is now, as a Behavior retargets.
|
||||
m_from = m_height;
|
||||
m_progress = 0;
|
||||
m_animation->start();
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
double Unfold::targetHeight(double width) const
|
||||
{
|
||||
return m_shown ? m_content->heightForWidth(width) : 0;
|
||||
}
|
||||
|
||||
SizeF Unfold::sizeHint() const
|
||||
{
|
||||
return {m_content->sizeHint().width, heightForWidth(geometry().width)};
|
||||
}
|
||||
|
||||
double Unfold::heightForWidth(double width) const
|
||||
{
|
||||
return m_from + (targetHeight(width) - m_from) * m_progress;
|
||||
}
|
||||
|
||||
void Unfold::layout()
|
||||
{
|
||||
const double width = geometry().width;
|
||||
m_height = heightForWidth(width);
|
||||
if (m_progress >= 1)
|
||||
m_from = m_height;
|
||||
// Filling the unfolding height, as the anchored layout did: it squeezes
|
||||
// its rows while short.
|
||||
m_content->setGeometry({0, 0, width, m_height});
|
||||
m_content->setVisible(m_height > 0);
|
||||
}
|
||||
|
||||
UserCard::UserCard(std::shared_ptr<accounts::UserAccount> account, int64_t loggedInUid)
|
||||
: Card(Card::Style::Padded)
|
||||
, m_account(std::move(account))
|
||||
, m_loggedInUid(loggedInUid)
|
||||
{
|
||||
ColumnLayout* main = addChild<ColumnLayout>();
|
||||
main->setSpacing(0);
|
||||
RowLayout* itemRow = main->addChild<RowLayout>();
|
||||
itemRow->setSpacing(0);
|
||||
m_header = itemRow->addChild<Header>();
|
||||
m_header->setLayoutHints({.fillWidth = true});
|
||||
m_unfold = main->addChild<Unfold>();
|
||||
m_unfold->content()->setSpacing(metrics::cardSpacing);
|
||||
|
||||
GridLayout* grid = m_unfold->content()->addChild<GridLayout>(2);
|
||||
grid->setColumnSpacing(metrics::formColumnSpacing);
|
||||
grid->setRowSpacing(metrics::formRowSpacing);
|
||||
grid->setLayoutHints({.fillWidth = true, .margins = {0, metrics::cardSpacing, 0, 0}});
|
||||
grid->addChild<Label>("Account type");
|
||||
m_accountType = grid->addChild<Label>();
|
||||
|
||||
m_password = m_unfold->content()->addChild<StandardButton>("Change password");
|
||||
m_password->onClicked = [this] {
|
||||
if (onChangePassword)
|
||||
onChangePassword();
|
||||
};
|
||||
m_delete = m_unfold->content()->addChild<StandardButton>("Delete this user");
|
||||
m_delete->onClicked = [this] {
|
||||
if (onDelete)
|
||||
onDelete();
|
||||
};
|
||||
|
||||
m_header->avatar->onClicked = [this] {
|
||||
if (onPictureClicked)
|
||||
onPictureClicked();
|
||||
};
|
||||
m_header->disclosure->onClicked = [this] {
|
||||
m_unfold->toggle();
|
||||
update();
|
||||
};
|
||||
m_header->onDoubleClicked = [this] {
|
||||
m_unfold->toggle();
|
||||
update();
|
||||
};
|
||||
|
||||
m_changed = m_account->changed.connect([this] { update(); });
|
||||
update();
|
||||
}
|
||||
|
||||
UserCard::~UserCard() = default;
|
||||
|
||||
void UserCard::layout()
|
||||
{
|
||||
const Color background = color(SettingsColor::UserCardButtonBackground);
|
||||
m_password->setBackgroundColor(background);
|
||||
m_delete->setBackgroundColor(background);
|
||||
Card::layout();
|
||||
}
|
||||
|
||||
void UserCard::showPicture(const std::string& path)
|
||||
{
|
||||
m_header->avatar->setPicture(path);
|
||||
}
|
||||
|
||||
void UserCard::update()
|
||||
{
|
||||
const std::string userName = m_account->userName();
|
||||
const std::string realName = m_account->realName();
|
||||
m_header->avatar->setUserName(userName);
|
||||
m_header->avatar->setPicture(m_account->iconFileName());
|
||||
m_header->userName->setText(userName);
|
||||
m_header->realName->setText(realName);
|
||||
m_header->realName->setVisible(realName != userName);
|
||||
m_header->loggedIn->setVisible(m_account->userId() == m_loggedInUid);
|
||||
m_header->disclosure->setIconName(m_unfold->isShown() ? "chevron-up" : "chevron-down");
|
||||
m_accountType->setText(m_account->accountType() == accounts::UserAccount::Standard ? "Standard"
|
||||
: "Administrator");
|
||||
m_delete->setEnabled(m_account->userId() != m_loggedInUid);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
} // namespace settings
|
||||
@ -1,88 +0,0 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
#include "base/signal.h"
|
||||
#include "cutefish/accounts/user_account.h"
|
||||
#include "widgets/card.h"
|
||||
|
||||
namespace cutefish::ui {
|
||||
class Animation;
|
||||
class ColumnLayout;
|
||||
class Label;
|
||||
}
|
||||
|
||||
namespace settings {
|
||||
|
||||
class StandardButton;
|
||||
|
||||
// The part of a card that unfolds below its header, its height animated.
|
||||
class Unfold : public cutefish::ui::View
|
||||
{
|
||||
public:
|
||||
Unfold();
|
||||
~Unfold() override;
|
||||
|
||||
cutefish::ui::ColumnLayout* content() const { return m_content; }
|
||||
bool isShown() const { return m_shown; }
|
||||
void setShown(bool shown);
|
||||
void toggle() { setShown(!m_shown); }
|
||||
|
||||
cutefish::ui::SizeF sizeHint() const override;
|
||||
double heightForWidth(double width) const override;
|
||||
|
||||
protected:
|
||||
void layout() override;
|
||||
|
||||
private:
|
||||
double targetHeight(double width) const;
|
||||
|
||||
cutefish::ui::ColumnLayout* m_content;
|
||||
std::unique_ptr<cutefish::ui::Animation> m_animation;
|
||||
bool m_shown = false;
|
||||
// The height drawn now, and the one the animation started from.
|
||||
double m_height = 0;
|
||||
double m_from = 0;
|
||||
double m_progress = 1;
|
||||
};
|
||||
|
||||
// One account on the Users page: its picture, names and whether it is the
|
||||
// one logged in, unfolding to its type and the password and delete buttons.
|
||||
class UserCard : public Card
|
||||
{
|
||||
public:
|
||||
UserCard(std::shared_ptr<cutefish::accounts::UserAccount> account, int64_t loggedInUid);
|
||||
~UserCard() override;
|
||||
|
||||
const std::shared_ptr<cutefish::accounts::UserAccount>& account() const { return m_account; }
|
||||
// Shows a picture before the account has it.
|
||||
void showPicture(const std::string& path);
|
||||
|
||||
std::function<void()> onPictureClicked;
|
||||
std::function<void()> onChangePassword;
|
||||
std::function<void()> onDelete;
|
||||
|
||||
protected:
|
||||
void layout() override;
|
||||
|
||||
private:
|
||||
class Header;
|
||||
|
||||
void update();
|
||||
|
||||
std::shared_ptr<cutefish::accounts::UserAccount> m_account;
|
||||
int64_t m_loggedInUid;
|
||||
cutefish::base::Subscription m_changed;
|
||||
Header* m_header;
|
||||
Unfold* m_unfold;
|
||||
cutefish::ui::Label* m_accountType;
|
||||
StandardButton* m_password;
|
||||
StandardButton* m_delete;
|
||||
};
|
||||
|
||||
} // namespace settings
|
||||
@ -0,0 +1,85 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "pages/accounts/userdetailsdialog.h"
|
||||
|
||||
#include "ui/cell_layout.h"
|
||||
#include "ui/dialog_window.h"
|
||||
#include "ui/keys.h"
|
||||
#include "ui/window.h"
|
||||
#include "widgets/card.h"
|
||||
#include "widgets/listrow.h"
|
||||
#include "widgets/metrics.h"
|
||||
#include "window/settingswindow.h"
|
||||
|
||||
using namespace cutefish;
|
||||
using namespace cutefish::ui;
|
||||
|
||||
namespace settings {
|
||||
|
||||
static constexpr double kWidth = 400;
|
||||
|
||||
UserDetailsDialog::UserDetailsDialog(SettingsWindow& window, std::shared_ptr<accounts::UserAccount> account,
|
||||
int64_t loggedInUid)
|
||||
: m_account(std::move(account))
|
||||
, m_loggedInUid(loggedInUid)
|
||||
, m_dialog(std::make_unique<DialogWindow>(&window.window(), m_account->userName(), kWidth))
|
||||
{
|
||||
auto content = std::make_unique<ColumnLayout>();
|
||||
content->setPadding({0, metrics::dialogPadding, 0, 0});
|
||||
content->setSpacing(metrics::dialogSpacing);
|
||||
|
||||
Card* card = content->addChild<Card>(Card::Style::List);
|
||||
m_type = card->addChild<InfoRow>("Account type");
|
||||
m_password = card->addChild<ListRow>("Change password…");
|
||||
m_password->setName("user:changePassword");
|
||||
m_password->onClicked = [this] {
|
||||
close();
|
||||
if (onChangePassword)
|
||||
onChangePassword();
|
||||
};
|
||||
|
||||
RowLayout* buttons = content->addChild<RowLayout>();
|
||||
buttons->setSpacing(metrics::dialogButtonSpacing);
|
||||
buttons->setLayoutHints({.horizontalAlignment = Alignment::End});
|
||||
m_delete = buttons->addChild<DialogButton>("Delete this user", true);
|
||||
m_delete->setName("user:delete");
|
||||
m_delete->setDestructive(true);
|
||||
m_delete->onClicked = [this] {
|
||||
close();
|
||||
if (onDelete)
|
||||
onDelete();
|
||||
};
|
||||
|
||||
m_dialog->setContentView(std::move(content));
|
||||
m_dialog->onClosed = [this] {
|
||||
m_changed.reset();
|
||||
if (onClosed)
|
||||
onClosed();
|
||||
};
|
||||
m_dialog->window()->addShortcut(NoModifier, key::Escape, [this] { close(); });
|
||||
|
||||
m_changed = m_account->changed.connect([this] { update(); });
|
||||
update();
|
||||
}
|
||||
|
||||
UserDetailsDialog::~UserDetailsDialog() = default;
|
||||
|
||||
void UserDetailsDialog::show()
|
||||
{
|
||||
m_dialog->show();
|
||||
}
|
||||
|
||||
void UserDetailsDialog::close()
|
||||
{
|
||||
m_dialog->close();
|
||||
}
|
||||
|
||||
void UserDetailsDialog::update()
|
||||
{
|
||||
m_dialog->setTitle(m_account->userName());
|
||||
m_type->setValue(m_account->accountType() == accounts::UserAccount::Standard ? "Standard" : "Administrator");
|
||||
m_delete->setEnabled(m_account->userId() != m_loggedInUid);
|
||||
}
|
||||
|
||||
} // namespace settings
|
||||
@ -0,0 +1,58 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
|
||||
#include "base/signal.h"
|
||||
#include "cutefish/accounts/user_account.h"
|
||||
|
||||
namespace cutefish::ui {
|
||||
class DialogButton;
|
||||
class DialogWindow;
|
||||
}
|
||||
|
||||
namespace settings {
|
||||
|
||||
class InfoRow;
|
||||
class ListRow;
|
||||
class SettingsWindow;
|
||||
|
||||
// An account's details: its type, the way to a new password, and deleting
|
||||
// it, which the account logged in cannot do to itself. Either action closes
|
||||
// the details first.
|
||||
class UserDetailsDialog
|
||||
{
|
||||
public:
|
||||
UserDetailsDialog(SettingsWindow& window, std::shared_ptr<cutefish::accounts::UserAccount> account,
|
||||
int64_t loggedInUid);
|
||||
~UserDetailsDialog();
|
||||
|
||||
UserDetailsDialog(const UserDetailsDialog&) = delete;
|
||||
UserDetailsDialog& operator=(const UserDetailsDialog&) = delete;
|
||||
|
||||
const std::shared_ptr<cutefish::accounts::UserAccount>& account() const { return m_account; }
|
||||
cutefish::ui::DialogWindow* dialog() const { return m_dialog.get(); }
|
||||
|
||||
void show();
|
||||
void close();
|
||||
|
||||
std::function<void()> onChangePassword;
|
||||
std::function<void()> onDelete;
|
||||
std::function<void()> onClosed;
|
||||
|
||||
private:
|
||||
void update();
|
||||
|
||||
std::shared_ptr<cutefish::accounts::UserAccount> m_account;
|
||||
int64_t m_loggedInUid;
|
||||
std::unique_ptr<cutefish::ui::DialogWindow> m_dialog;
|
||||
cutefish::base::Subscription m_changed;
|
||||
InfoRow* m_type = nullptr;
|
||||
ListRow* m_password = nullptr;
|
||||
cutefish::ui::DialogButton* m_delete = nullptr;
|
||||
};
|
||||
|
||||
} // namespace settings
|
||||
@ -0,0 +1,265 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#include "pages/accounts/userrow.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
|
||||
#include <unicode/unistr.h>
|
||||
#include <unicode/utf16.h>
|
||||
|
||||
#include "base/transform.h"
|
||||
#include "ui/animation.h"
|
||||
#include "ui/cell_layout.h"
|
||||
#include "ui/image_view.h"
|
||||
#include "ui/label.h"
|
||||
#include "ui/painter.h"
|
||||
#include "ui/path.h"
|
||||
#include "ui/theme.h"
|
||||
#include "ui/window.h"
|
||||
#include "widgets/iconbutton.h"
|
||||
#include "widgets/metrics.h"
|
||||
|
||||
using namespace cutefish;
|
||||
using namespace cutefish::ui;
|
||||
|
||||
namespace settings {
|
||||
|
||||
// The account's picture in a circle, or its initial, as on the lock screen,
|
||||
// for an account with no picture: the icon theme has no avatar that size.
|
||||
// Hovering dims it under a pencil, as the lock screen's avatar shows its
|
||||
// swap arrows, since a click changes the picture.
|
||||
class UserAvatar : public View
|
||||
{
|
||||
public:
|
||||
static constexpr double kSize = 48;
|
||||
|
||||
UserAvatar()
|
||||
{
|
||||
m_initial = addChild<Label>();
|
||||
m_initial->setFontSize(22);
|
||||
// Drawn once decoded; the initial shows until then.
|
||||
m_picture = addChild<ImageView>();
|
||||
m_picture->setCornerRadius(kSize / 2);
|
||||
m_picture->onStatusChanged = [this](ImageView::Status) {
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
};
|
||||
m_overlay = addChild<EditOverlay>(this);
|
||||
}
|
||||
|
||||
void setUserName(const std::string& name)
|
||||
{
|
||||
// The first character, upper case, as QString::charAt(0).toUpper() gives it.
|
||||
icu::UnicodeString text = icu::UnicodeString::fromUTF8(name);
|
||||
std::string initial = "?";
|
||||
if (!text.isEmpty()) {
|
||||
icu::UnicodeString first = text.tempSubString(0, text.length() > 0 && U16_IS_LEAD(text[0]) ? 2 : 1);
|
||||
first.toUpper();
|
||||
initial.clear();
|
||||
first.toUTF8String(initial);
|
||||
}
|
||||
m_initial->setText(initial);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
void setPicture(const std::string& path)
|
||||
{
|
||||
if (path == m_picture->source())
|
||||
return;
|
||||
m_picture->setSource(path);
|
||||
requestLayout();
|
||||
requestPaint();
|
||||
}
|
||||
|
||||
SizeF sizeHint() const override { return {kSize, kSize}; }
|
||||
bool acceptsPointer() const override { return true; }
|
||||
CursorShape cursorShape() const override { return CursorShape::Pointer; }
|
||||
|
||||
std::function<void()> onClicked;
|
||||
|
||||
protected:
|
||||
void layout() override
|
||||
{
|
||||
m_initial->setColor(Theme::current().disabledText);
|
||||
const SizeF size = m_initial->sizeHint();
|
||||
m_initial->setGeometry({rounding::centeredStart(kSize, size.width), rounding::centeredStart(kSize, size.height), size.width, size.height});
|
||||
m_initial->setVisible(!hasPicture());
|
||||
m_picture->setGeometry(localRect());
|
||||
m_overlay->setGeometry(localRect());
|
||||
}
|
||||
|
||||
void paint(Painter& painter) override
|
||||
{
|
||||
// The account's initial, as on the lock screen, for an account with
|
||||
// no picture: the icon theme has no avatar the size of a picture.
|
||||
if (!hasPicture())
|
||||
painter.fillEllipse(localRect(), Theme::current().text.withAlpha(0.12));
|
||||
}
|
||||
|
||||
void pointerRelease(const PointerEvent& event) override
|
||||
{
|
||||
if (event.button == MouseButton::Left && localRect().contains(event.position) && onClicked)
|
||||
onClicked();
|
||||
}
|
||||
|
||||
void pointerEnter() override { setHovered(true); }
|
||||
void pointerLeave() override { setHovered(false); }
|
||||
|
||||
private:
|
||||
class EditOverlay : public View
|
||||
{
|
||||
public:
|
||||
explicit EditOverlay(UserAvatar* avatar)
|
||||
: m_avatar(avatar)
|
||||
{
|
||||
}
|
||||
|
||||
protected:
|
||||
void paint(Painter& painter) override
|
||||
{
|
||||
const double t = m_avatar->m_hover;
|
||||
if (t <= 0)
|
||||
return;
|
||||
painter.fillEllipse(localRect(), Color {0, 0, 0, 255}.withAlpha(0.35 * t));
|
||||
// The pencil on its 24 by 24 grid, drawn 18 across and centred.
|
||||
constexpr double kIcon = 18;
|
||||
Path pencil;
|
||||
pencil.moveTo(13.26, 3.59997);
|
||||
pencil.lineTo(5.04997, 12.29);
|
||||
pencil.cubicTo(4.73997, 12.62, 4.43997, 13.27, 4.37997, 13.72);
|
||||
pencil.lineTo(4.00997, 16.96);
|
||||
pencil.cubicTo(3.87997, 18.13, 4.71997, 18.93, 5.87997, 18.73);
|
||||
pencil.lineTo(9.09997, 18.18);
|
||||
pencil.cubicTo(9.54997, 18.1, 10.18, 17.77, 10.49, 17.43);
|
||||
pencil.lineTo(18.7, 8.73997);
|
||||
pencil.cubicTo(20.12, 7.23997, 20.76, 5.52997, 18.55, 3.43997);
|
||||
pencil.cubicTo(16.35, 1.36997, 14.68, 2.09997, 13.26, 3.59997);
|
||||
pencil.close();
|
||||
pencil.moveTo(11.89, 5.05005);
|
||||
pencil.cubicTo(12.32, 7.81005, 14.56, 9.92005, 17.34, 10.2);
|
||||
pencil.moveTo(3, 22);
|
||||
pencil.lineTo(21, 22);
|
||||
const double scale = kIcon / 24;
|
||||
const base::AffineTransform place(scale, 0, 0, scale, rounding::centeredStart(geometry().width, kIcon),
|
||||
rounding::centeredStart(geometry().height, kIcon));
|
||||
painter.strokePath(pencil.transformed(place), Color {255, 255, 255, 255}.withAlpha(t), 1.5,
|
||||
LineCap::Round, LineJoin::Round);
|
||||
}
|
||||
|
||||
private:
|
||||
UserAvatar* m_avatar;
|
||||
};
|
||||
|
||||
bool hasPicture() const { return m_picture->status() == ImageView::Status::Ready; }
|
||||
|
||||
void setHovered(bool hovered)
|
||||
{
|
||||
Window* window = this->window();
|
||||
if (!window) {
|
||||
setHover(hovered ? 1 : 0);
|
||||
return;
|
||||
}
|
||||
if (!m_hoverAnimation) {
|
||||
m_hoverAnimation = std::make_unique<Animation>(window);
|
||||
m_hoverAnimation->setDuration(std::chrono::milliseconds(80));
|
||||
m_hoverAnimation->setEasing(Animation::Easing::OutQuad);
|
||||
m_hoverAnimation->onUpdate = [this](double value) {
|
||||
setHover(m_hoverFrom + (m_hoverTarget - m_hoverFrom) * value);
|
||||
};
|
||||
}
|
||||
// From wherever it is now, as a Behavior retargets.
|
||||
m_hoverFrom = m_hover;
|
||||
m_hoverTarget = hovered ? 1 : 0;
|
||||
m_hoverAnimation->stop();
|
||||
m_hoverAnimation->start();
|
||||
}
|
||||
|
||||
void setHover(double value)
|
||||
{
|
||||
m_hover = value;
|
||||
// A picture shows through the dim; an initial would cross the pencil.
|
||||
m_initial->setOpacity(1 - value);
|
||||
m_overlay->requestPaint();
|
||||
}
|
||||
|
||||
Label* m_initial;
|
||||
ImageView* m_picture;
|
||||
EditOverlay* m_overlay;
|
||||
std::unique_ptr<Animation> m_hoverAnimation;
|
||||
double m_hover = 0;
|
||||
double m_hoverFrom = 0;
|
||||
double m_hoverTarget = 0;
|
||||
};
|
||||
|
||||
UserRow::UserRow(std::shared_ptr<accounts::UserAccount> account, int64_t loggedInUid)
|
||||
: m_account(std::move(account))
|
||||
, m_loggedInUid(loggedInUid)
|
||||
{
|
||||
// As much room above and below the picture as before it.
|
||||
setMinimumRowHeight(UserAvatar::kSize + 2 * metrics::cardHorizontalPadding);
|
||||
m_avatar = setLeading<UserAvatar>();
|
||||
m_avatar->onClicked = [this] {
|
||||
if (onPictureClicked)
|
||||
onPictureClicked();
|
||||
};
|
||||
m_userName = addContent<Label>();
|
||||
m_realName = addContent<Label>();
|
||||
addContent<View>()->setLayoutHints({.fillWidth = true});
|
||||
m_loggedIn = addContent<Label>("Logged in");
|
||||
m_info = addContent<IconButton>("info", 20);
|
||||
m_info->onClicked = [this] {
|
||||
if (onInfo)
|
||||
onInfo();
|
||||
};
|
||||
onClicked = [this] {
|
||||
if (onInfo)
|
||||
onInfo();
|
||||
};
|
||||
|
||||
m_changed = m_account->changed.connect([this] { update(); });
|
||||
update();
|
||||
}
|
||||
|
||||
UserRow::~UserRow() = default;
|
||||
|
||||
void UserRow::showPicture(const std::string& path)
|
||||
{
|
||||
m_avatar->setPicture(path);
|
||||
}
|
||||
|
||||
void UserRow::layout()
|
||||
{
|
||||
const Theme& theme = Theme::current();
|
||||
m_realName->setColor(theme.disabledText);
|
||||
m_loggedIn->setColor(theme.disabledText);
|
||||
m_info->setColor(theme.highlight);
|
||||
ListRow::layout();
|
||||
}
|
||||
|
||||
void UserRow::update()
|
||||
{
|
||||
const std::string userName = m_account->userName();
|
||||
const std::string realName = m_account->realName();
|
||||
setName("user:" + userName);
|
||||
m_avatar->setUserName(userName);
|
||||
m_avatar->setPicture(m_account->iconFileName());
|
||||
m_userName->setText(userName);
|
||||
m_realName->setText(realName);
|
||||
m_realName->setVisible(realName != userName);
|
||||
m_loggedIn->setVisible(m_account->userId() == m_loggedInUid);
|
||||
requestLayout();
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<accounts::UserAccount>>
|
||||
loggedInFirst(const std::vector<std::shared_ptr<accounts::UserAccount>>& accounts, int64_t loggedInUid)
|
||||
{
|
||||
std::vector<std::shared_ptr<accounts::UserAccount>> ordered = accounts;
|
||||
std::stable_partition(ordered.begin(), ordered.end(),
|
||||
[loggedInUid](const auto& account) { return account->userId() == loggedInUid; });
|
||||
return ordered;
|
||||
}
|
||||
|
||||
} // namespace settings
|
||||
@ -0,0 +1,59 @@
|
||||
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "base/signal.h"
|
||||
#include "cutefish/accounts/user_account.h"
|
||||
#include "widgets/listrow.h"
|
||||
|
||||
namespace cutefish::ui {
|
||||
class Label;
|
||||
}
|
||||
|
||||
namespace settings {
|
||||
|
||||
class IconButton;
|
||||
class UserAvatar;
|
||||
|
||||
// One account on the Users page: its picture, names and whether it is the
|
||||
// one logged in, with an info button at the end for its details.
|
||||
class UserRow : public ListRow
|
||||
{
|
||||
public:
|
||||
UserRow(std::shared_ptr<cutefish::accounts::UserAccount> account, int64_t loggedInUid);
|
||||
~UserRow() override;
|
||||
|
||||
const std::shared_ptr<cutefish::accounts::UserAccount>& account() const { return m_account; }
|
||||
// Shows a picture before the account has it.
|
||||
void showPicture(const std::string& path);
|
||||
|
||||
std::function<void()> onPictureClicked;
|
||||
std::function<void()> onInfo;
|
||||
|
||||
protected:
|
||||
void layout() override;
|
||||
|
||||
private:
|
||||
void update();
|
||||
|
||||
std::shared_ptr<cutefish::accounts::UserAccount> m_account;
|
||||
int64_t m_loggedInUid;
|
||||
cutefish::base::Subscription m_changed;
|
||||
UserAvatar* m_avatar;
|
||||
cutefish::ui::Label* m_userName;
|
||||
cutefish::ui::Label* m_realName;
|
||||
cutefish::ui::Label* m_loggedIn;
|
||||
IconButton* m_info;
|
||||
};
|
||||
|
||||
// The accounts in the service's order, the one logged in first.
|
||||
std::vector<std::shared_ptr<cutefish::accounts::UserAccount>>
|
||||
loggedInFirst(const std::vector<std::shared_ptr<cutefish::accounts::UserAccount>>& accounts, int64_t loggedInUid);
|
||||
|
||||
} // namespace settings
|
||||
Loading…
Reference in New Issue