feat: 推拉流功能重构,新增全局消息通知库

shiyi
cbwu 4 months ago
parent 434323ec8d
commit 8adacff1c3

Binary file not shown.

Binary file not shown.

@ -0,0 +1,88 @@
#include "ArrangedWidget.h"
// 动画
static QPropertyAnimation *propertyAnimationOnTarget(
QObject *target, const QByteArray &propertyName, const QVariant &endValue,
int duration) {
QPropertyAnimation *animation =
new QPropertyAnimation(target, propertyName, target);
animation->setStartValue(target->property(propertyName));
animation->setEndValue(endValue);
animation->setDuration(duration);
animation->start(QAbstractAnimation::DeleteWhenStopped);
return animation;
}
// 动画模板
template <typename func>
static inline void propertyAnimationOnTarget(QObject *target,
const QByteArray &propertyName,
const QVariant &endValue,
int duration, func onFinished) {
QPropertyAnimation *animation =
propertyAnimationOnTarget(target, propertyName, endValue, duration);
QObject::connect(animation, &QPropertyAnimation::finished, target,
onFinished);
}
ArrangedWidget::ArrangedWidget(NotifyManager *manager, QWidget *parent)
: QWidget(parent) {
// 初始化界面
this->setWindowFlags(Qt::FramelessWindowHint | Qt::Tool |
Qt::WindowStaysOnTopHint); // 必须设置为Qt::Tools
this->setAttribute(Qt::WA_NoSystemBackground, true);
this->setAttribute(Qt::WA_TranslucentBackground, true);
this->setFixedSize(manager->notifyWndSize());
// 初始化成员变量
m_manager = manager; // NotifyWidget创建的时候传入
m_posIndex = 0;
// manager销毁界面也跟着销毁
connect(manager, &QObject::destroyed, this, &QObject::deleteLater);
}
// 根据通知在队列中的索引,来设置其的位置,相当于更新排序
void ArrangedWidget::showArranged(int posIndex) {
if (m_posIndex == posIndex) return;
m_posIndex = posIndex;
// 索引小于等于0则隐藏
if (posIndex <= 0) {
// 如果已经隐藏,则返回
if (!isVisible()) return;
// 隐藏动画
propertyAnimationOnTarget(this, "windowOpacity", 0,
m_manager->animateTime(), [this]() {
this->hide();
emit visibleChanged(false); // 隐藏信号
});
return;
}
// 计算提醒框的位置
QSize wndsize = m_manager->notifyWndSize();
QSize offset =
QSize(wndsize.width(), wndsize.height() * posIndex +
m_manager->spacing() * (posIndex - 1));
QPoint pos =
m_manager->cornerPos() - QPoint(offset.width() / 2, -offset.height());
// 如果原先是隐藏的,现在显示
if (!isVisible()) {
this->show();
this->move(pos);
this->setWindowOpacity(0);
propertyAnimationOnTarget(this, "windowOpacity", 1,
m_manager->animateTime(), [this]() {
emit visibleChanged(true);
});
} else // 否则,移动位置
{
propertyAnimationOnTarget(this, "pos", pos, m_manager->animateTime());
}
}

@ -0,0 +1,28 @@
#ifndef ARRANGEWND_H
#define ARRANGEWND_H
#include <QWidget>
#include <QMouseEvent>
#include <QPropertyAnimation>
#include "NotifyManager.h"
class NotifyManager;
// 排列父界面为什么要定义这个类就是要同时作为NotifyWidget和NotifyCountWidget的父类好让NotifyCountWidget始终在NotifyWidget的上方
class ArrangedWidget : public QWidget
{
Q_OBJECT
public:
explicit ArrangedWidget(NotifyManager *manager, QWidget *parent = 0);
void showArranged(int posIndex); // 根据通知在队列中的索引,来设置其的位置,相当于更新排序
signals:
void visibleChanged(bool visible); // 界面可见状态改变的信号
protected:
NotifyManager *m_manager;
int m_posIndex;
};
#endif // ARRANGEWND_H

@ -0,0 +1,304 @@
#include "NotifyManager.h"
#include "NotifyWidget.h" // NotifyWidget.h没有放到NotifyManager.h文件是因为NotifyWidget.cpp也include了NotifyManager.h为了能互相include才这么做
NotifyManager::NotifyManager(QWidget *widget, QObject *parent)
: m_parentWidget(widget), QObject(parent) {
m_maxCount = 5; // 可见的最大通知数目设置为5
m_displayTime =
3000; // 显示时间默认设置为3秒通知显示出来过了10秒之后隐藏并自动销毁
m_animateTime = 300; // 动画时间设置为300ms
m_spacing = 10; // 通知框之间的间距设置为10
m_notifyWndSize = QSize(300, 40); // 通知框大小
m_defaultIcon = ":/message1.png"; // 默认图标
initStyleSheet();
m_isShowQueueCount = true; // 默认显示队列的通知数目
// 传入边距以设置最下面的通知的右下角坐标
this->setCornerMargins(10, 10);
// 初始化队列的剩余通知数目界面
m_notifyCount = new NotifyCountWidget(this, m_parentWidget);
}
// 弹出通知框
void NotifyManager::notify(const QString &title, const QString &body, int type,
int displayTime, const QVariantMap &data) {
// 将标题栏和内容数据添加到队列中
QVariantMap tmp = data;
tmp.insert("title", title);
tmp.insert("body", body);
// tmp.insert("type", type);
switch (type) {
case NotificationType::SUCCESS:
m_defaultIcon = ":/success.png";
tmp.insert("theme", "success");
break;
case NotificationType::WARNING:
m_defaultIcon = ":/warning.png";
tmp.insert("theme", "warn");
break;
case NotificationType::ERROR:
m_defaultIcon = ":/error.png";
tmp.insert("theme", "error");
break;
default:
break;
}
setDisplayTime(displayTime);
m_dataQueue.enqueue(tmp);
// 显示下一条通知
showNext();
}
// 设置通知框的最大数目
void NotifyManager::setMaxCount(int count) {
m_maxCount = count;
}
// 获取通知框显示时间
int NotifyManager::displayTime() const {
return m_displayTime;
}
// 设置通知框显示时间
void NotifyManager::setDisplayTime(int displayTime) {
m_displayTime = displayTime;
}
// 获取动画时间
int NotifyManager::animateTime() const {
return m_animateTime;
}
// 设置动画时间
void NotifyManager::setAnimateTime(int animateTime) {
m_animateTime = animateTime;
}
// 获取通知栏之间的间距
int NotifyManager::spacing() const {
return m_spacing;
}
// 设置通知栏之间的间距
void NotifyManager::setSpacing(int spacing) {
m_spacing = spacing;
}
// 获取最下面的通知的右下角坐标
QPoint NotifyManager::cornerPos() {
QScreen *screen = QGuiApplication::primaryScreen();
QRect screenGeometry = screen->availableGeometry();
qreal dpiScale = screen->devicePixelRatio();
QRect widgetGeometry = m_parentWidget->geometry();
QPoint pt = widgetGeometry.topLeft();
m_cornerPos.setX((pt.x() + m_parentWidget->width() / 2));
m_cornerPos.setY(widgetGeometry.top() - m_notifyWndSize.height());
return m_cornerPos;
}
// 传入边距以设置最下面的通知的右下角坐标
void NotifyManager::setCornerMargins(int right, int bottom) {
// QRect desktopRect = QApplication::primaryScreen()->availableGeometry();
// QPoint bottomRignt = desktopRect.bottomRight();
// m_cornerPos = QPoint(bottomRignt.x() - right, bottomRignt.y() - bottom);
// 获取当前屏幕的DPI和缩放比例
QScreen *screen = QGuiApplication::primaryScreen();
QRect screenGeometry = screen->availableGeometry();
qreal dpiScale = screen->devicePixelRatio();
QPoint bottomRignt;
QPoint pt;
QRect widgetGeometry = m_parentWidget->geometry();
int y = widgetGeometry.top();
QPoint lpt = widgetGeometry.topLeft();
pt = m_parentWidget->mapToGlobal(lpt);
bottomRignt.setX((screenGeometry.width() - m_parentWidget->width()) / 2);
bottomRignt.setY((screenGeometry.height()) / 2);
m_cornerPos =
QPoint(bottomRignt.x() * dpiScale, bottomRignt.y() * dpiScale);
}
// 获取通知框的尺寸
QSize NotifyManager::notifyWndSize() const {
return m_notifyWndSize;
}
// 设置通知框的尺寸
void NotifyManager::setNotifyWndSize(int width, int height) {
m_notifyWndSize = QSize(width, height);
}
// 获取默认图标的路径
QString NotifyManager::defaultIcon() const {
return m_defaultIcon;
}
// 设置默认图标
void NotifyManager::setDefaultIcon(const QString &defaultIcon) {
m_defaultIcon = defaultIcon;
}
// 获取指定theme的样式
QString NotifyManager::styleSheet(const QString &theme) const {
if (!m_styleSheets.contains(theme)) return m_styleSheets.value("default");
return m_styleSheets.value(theme);
}
// 设置指定theme的样式
void NotifyManager::setStyleSheet(const QString &styleSheet,
const QString &theme) {
m_styleSheets[theme] = styleSheet;
}
// 设置是否显示队列的通知数目
void NotifyManager::setShowQueueCount(bool isShowQueueCount) {
m_isShowQueueCount = isShowQueueCount;
if (!m_isShowQueueCount) m_notifyCount->showArranged(0);
}
// 显示下一条通知
void NotifyManager::showNext() {
// 如果通知数目超出限制,则显示"通知当前数目界面"
if (m_notifyList.size() >= m_maxCount || m_dataQueue.isEmpty()) {
showQueueCount();
return;
}
// 创建并显示新的通知框
NotifyWidget *notifyWidget =
new NotifyWidget(this); // 将管理员自身传给notifyWidget的m_manager
m_notifyList.append(notifyWidget); // 添加到通知框列表
notifyWidget->showArranged(m_notifyList.size()); // 设置新通知的显示位置
notifyWidget->setData(
m_dataQueue
.dequeue()); // 设置数据队列的第一个数据(dequeue删除队列第一个元素并返回这个元素)
showQueueCount(); // 显示队列的剩余通知数目
// 通知过了displayTime时间之后隐藏之后销毁然后触发下面槽函数
connect(notifyWidget, &QObject::destroyed, this, [notifyWidget, this]() {
// 找到被销毁的通知在队列中的索引,然后移除该通知
int index = m_notifyList.indexOf(notifyWidget);
m_notifyList.removeAt(index);
// 旧消息被移出后,就要显示通知队列中的下一个新消息,并排序
for (; index < m_notifyList.size(); index++)
m_notifyList[index]->showArranged(index + 1);
// 这里是为了实现周期提示功能,一般不用到,可以注释
QTimer::singleShot(m_animateTime, this, [this]() {
showNext();
});
});
}
// 显示队列的剩余通知数目
void NotifyManager::showQueueCount() {
// 判断是否允许显示队列的剩余通知数目
if (!m_isShowQueueCount) return;
// 数据队列大于0说明还有未显示的剩余通知则显示数目否则隐藏"剩余通知数目"
if (!m_dataQueue.isEmpty()) {
m_notifyCount->showArranged(m_maxCount + 1);
m_notifyCount->setCount(m_dataQueue.size());
} else {
m_notifyCount->showArranged(0);
}
}
void NotifyManager::initStyleSheet() {
m_styleSheets["default"] =
"#notify-background {"
"background: white;"
"border-radius: 6px;"
"}"
"#notify-title{"
"font-weight: bold;"
"font-size: 14px;"
"color: #333333;"
"}"
"#notify-body{"
"font-size: 12px;"
"color: #444444;"
"}"
"#notify-close-btn{ "
"border: 0;"
"color: #999999;"
"}"
"#notify-close-btn:hover{ "
"background: #cccccc;"
"}";
m_styleSheets["success"] =
"#notify-background {"
"background: rgba(236, 253, 245, 0.95);"
"border-radius: 6px;"
"border: 1px solid #A7F3D0;"
"}"
"#notify-title{"
"font-weight: bold;"
"font-size: 14px;"
"color: #059669;"
"}"
"#notify-body{"
"font-size: 12px;"
"color: #059669;"
"}"
"#notify-close-btn{ "
"border: 0;"
"color: #059669;"
"}"
"#notify-close-btn:hover{ "
"background: #D1FAE5;"
"}";
m_styleSheets["warn"] =
"#notify-background {"
"background: rgba(254, 252, 232, 0.95);"
"border-radius: 6px;"
"border: 1px solid #FEF08A;"
"}"
"#notify-title{"
"font-weight: bold;"
"font-size: 14px;"
"color: #D97706;"
"}"
"#notify-body{"
"font-size: 12px;"
"color: #D97706;"
"}"
"#notify-close-btn{ "
"border: 0;"
"color: #D97706;"
"}"
"#notify-close-btn:hover{ "
"background: #FEF9C3;"
"}";
m_styleSheets["error"] =
"#notify-background {"
"background: rgba(254, 242, 242, 0.95);"
"border-radius: 6px;"
"border: 1px solid #FECACA;"
"}"
"#notify-title{"
"font-weight: bold;"
"font-size: 14px;"
"color: #DC2626;"
"}"
"#notify-body{"
"font-size: 12px;"
"color: #DC2626;"
"}"
"#notify-close-btn{ "
"border: 0;"
"color: #DC2626;"
"}"
"#notify-close-btn:hover{ "
"background: #FEE2E2;"
"}";
}

@ -0,0 +1,78 @@
#ifndef NOTIFYMANAGER_H
#define NOTIFYMANAGER_H
#include <QApplication>
#include <QScreen>
#include <QtCore>
// #include <iostream>
#include "QNotify_global.h"
class NotifyWidget;
class NotifyCountWidget;
QNOTIFY_EXPORT enum NotificationType {
NOTIFICATION_INFORMATION = 0,
NOTIFICATION_SUCCESS = 1,
NOTIFICATION_ERROR = 2,
NOTIFICATION_WARNING = 3
};
class QNOTIFY_EXPORT NotifyManager : public QObject {
Q_OBJECT
public:
explicit NotifyManager(QWidget *widget, QObject *parent = 0);
void notify(const QString &title, const QString &body, int type = 0,
int displayTime = 3000,
const QVariantMap &data = QVariantMap()); // 弹出通知框
void setMaxCount(int count); // 设置通知框的最大数目
int displayTime() const; // 获取通知框显示时间
void setDisplayTime(int displayTime); // 设置通知框显示时间
int animateTime() const; // 获取动画时间
void setAnimateTime(int animateTime); // 设置动画时间
int spacing() const; // 获取通知框之间的间距
void setSpacing(int spacing); // 设置通知框之间的间距
QPoint cornerPos(); // 获取最下面的通知框的右下角坐标
void setCornerMargins(
int right, int bottom); // 传入边距以设置最下面的通知的右下角坐标
QSize notifyWndSize() const; // 获取通知框的尺寸
void setNotifyWndSize(int width, int height); // 设置通知框的尺寸
QString defaultIcon() const; // 获取默认图标的路径
void setDefaultIcon(const QString &defaultIcon); // 设置默认图标
QString styleSheet(
const QString &theme = "default") const; // 获取指定theme的样式
void setStyleSheet(
const QString &styleSheet,
const QString &theme = "default"); // 设置指定theme的样式
void setShowQueueCount(
bool isShowQueueCount); // 设置是否显示队列的通知数目
public:
signals:
void notifyDetail(const QVariantMap &data);
private:
void showNext(); // 显示下一条通知
void showQueueCount(); // 显示队列的通知数目
void initStyleSheet();
QWidget *m_parentWidget;
QQueue<QVariantMap> m_dataQueue; // 存放标题栏和内容数据的队列
QList<NotifyWidget *> m_notifyList; // 通知框列表
NotifyCountWidget *m_notifyCount; // 队列的剩余通知数目界面
int m_maxCount; // 通知框的最大数目
bool m_isShowQueueCount; // 是否显示队列的剩余通知数目
int m_displayTime; // 通知框显示时间
int m_animateTime; // 动画时间
int m_spacing; // 通知框之间的间距
QPoint m_cornerPos; // 最下面的通知框的右下角坐标
QSize m_notifyWndSize; // 通知框的尺寸
QString m_defaultIcon; // 默认图标
QMap<QString, QString> m_styleSheets; // 存放多个theme的样式的map
};
#endif // NOTIFYMANAGER_H

@ -0,0 +1,181 @@
#include "NotifyWidget.h"
#include "NotifyManager.h"
NotifyWidget::NotifyWidget(NotifyManager *manager, QWidget *parent)
: ArrangedWidget(
manager,
parent) // 这里将manager传入到父类ArrangedWidget中的m_manager了同时继承了可以使用父类manager来进行管理
{
// 初始化背景界面
m_pFrameBack = new QFrame(this);
m_pFrameBack->setGeometry(3, 3, width() - 6, height() - 6);
m_pFrameBack->setObjectName("notify-background");
// 初始化图标标签
m_pLabIcon = new QLabel(m_pFrameBack);
m_pLabIcon->setFixedSize(40, 40);
m_pLabIcon->setAlignment(Qt::AlignCenter);
m_pLabIcon->setWordWrap(true);
// 初始化标题栏标签
m_pLabTitle = new QLabel(m_pFrameBack);
m_pLabTitle->setObjectName("notify-title");
// 初始化内容标签
m_pLabBody = new QLabel(m_pFrameBack);
m_pLabBody->setObjectName("notify-body");
m_pLabBody->setAlignment(Qt::AlignLeft | Qt::AlignTop);
m_pLabBody->setWordWrap(true);
// 内容布局
QVBoxLayout *pLayoutContent = new QVBoxLayout;
pLayoutContent->addWidget(m_pLabTitle);
pLayoutContent->addWidget(m_pLabBody);
pLayoutContent->setStretch(1, 1);
// 主布局
QHBoxLayout *pLayoutMain = new QHBoxLayout(m_pFrameBack);
pLayoutMain->addWidget(m_pLabIcon);
pLayoutMain->addLayout(pLayoutContent);
pLayoutMain->setAlignment(m_pLabIcon, Qt::AlignTop);
// 初始化关闭按钮
m_pBtnClose = new QPushButton("×", m_pFrameBack);
m_pBtnClose->setObjectName("notify-close-btn");
m_pBtnClose->setFixedSize(24, 24);
m_pBtnClose->move(m_pFrameBack->width() - m_pBtnClose->width(), 0);
connect(m_pBtnClose, &QPushButton::clicked, this, &QObject::deleteLater);
// 设置样式
this->setStyleSheet(m_manager->styleSheet());
#ifdef Q_OS_WIN // linuxFb下设置边框阴影有问题故屏蔽
// 设置边框阴影
QGraphicsDropShadowEffect *shadow = new QGraphicsDropShadowEffect(this);
shadow->setOffset(0, 0);
shadow->setBlurRadius(5);
m_pFrameBack->setGraphicsEffect(shadow);
#endif
// 可见状态改变信号槽
connect(this, &ArrangedWidget::visibleChanged, [this](bool visible) {
if (visible) {
// 通知显示出来过了displayTime之后隐藏并自动销毁
int displayTime =
m_data.value("displayTime", m_manager->displayTime()).toInt();
QTimer::singleShot(displayTime, this, [this]() {
showArranged(0); // 隐藏
});
} else {
// 不可见,即隐藏了,则自动销毁
this->deleteLater();
}
});
}
// 获取数据
QVariantMap NotifyWidget::data() const {
return m_data;
}
// 设置数据
void NotifyWidget::setData(const QVariantMap &data) {
m_data = data;
// 设置默认图标
QPixmap icon;
QVariant iconv = data.value("icon");
if (iconv.type() == QVariant::Pixmap) icon = iconv.value<QPixmap>();
if (iconv.type() == QVariant::String)
icon = QPixmap(iconv.toString());
else
icon = QPixmap(m_manager->defaultIcon());
icon = icon.scaled(QSize(32, 32), Qt::KeepAspectRatio,
Qt::SmoothTransformation);
m_pLabIcon->setPixmap(icon);
// 设置内容
QString title = data.value("title").toString();
m_pLabTitle->setText(title);
// 计算可显示行数及长度
QString body = m_data.value("body").toString();
if (!body.isEmpty()) {
QSize s1 = m_pLabBody->size();
QSize s2 = m_pLabBody->fontMetrics().size(Qt::TextSingleLine, body);
int linecount = s1.height() / s2.height();
int charcount =
qFloor(1.0 * body.size() * s1.width() / s2.width()) * linecount;
QString bodyElid =
charcount > body.size() ? body : (body.left(charcount - 1) + "");
m_pLabBody->setText(bodyElid);
} else {
m_pLabBody->setVisible(false);
}
// 设置样式
if (data.contains("styleSheet"))
setStyleSheet(data.value("styleSheet").toString());
else if (data.contains("theme"))
setStyleSheet(m_manager->styleSheet(data.value("theme").toString()));
}
// 队列中的剩余通知数目
NotifyCountWidget::NotifyCountWidget(NotifyManager *manager, QWidget *parent)
: ArrangedWidget(manager, parent) {
// 初始化界面
this->setAttribute(Qt::WA_TransparentForMouseEvents, true);
// 初始化图标标签
m_pLabIcon = new QLabel(this);
m_pLabIcon->setFixedSize(32, 32);
m_pLabIcon->setAlignment(Qt::AlignCenter);
// 初始化剩余数目标签
m_pLabCount = new QLabel(this);
m_pLabCount->setObjectName("notify-count");
m_pLabCount->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
m_parentWidget = parent;
// 主布局
QHBoxLayout *pLayoutMain = new QHBoxLayout(this);
pLayoutMain->addWidget(m_pLabIcon);
pLayoutMain->addWidget(m_pLabCount);
// 文字阴影
QGraphicsDropShadowEffect *shadow = new QGraphicsDropShadowEffect(this);
shadow->setOffset(2, 2);
shadow->setBlurRadius(5);
setGraphicsEffect(shadow);
setStyleSheet(
"#notify-count {"
"font: 20px Verdana;"
"color: #dd424d;"
"}");
// 设置图标
// QPixmap icon = QPixmap(m_manager->defaultIcon());
QPixmap icon = QPixmap(":/message.png");
icon = icon.scaled(QSize(32, 32), Qt::KeepAspectRatio,
Qt::SmoothTransformation);
m_pLabIcon->setPixmap(icon);
// 闪烁动画
flickerAnim = new QPropertyAnimation(this, "windowOpacity", this);
flickerAnim->setStartValue(1);
flickerAnim->setKeyValueAt(0.25, 0.1);
flickerAnim->setKeyValueAt(0.5, 1);
flickerAnim->setEndValue(1);
flickerAnim->setDuration(2000);
flickerAnim->setLoopCount(-1);
// 显示或隐藏的时候,显示动画
connect(this, &ArrangedWidget::visibleChanged, [this](bool visible) {
if (visible)
flickerAnim->start();
else
flickerAnim->stop();
});
}
// 设置剩余通知数目
void NotifyCountWidget::setCount(int count) {
m_pLabCount->setNum(count);
}

@ -0,0 +1,45 @@
#ifndef NOTIFYWND_H
#define NOTIFYWND_H
#include <QBoxLayout>
#include <QGraphicsDropShadowEffect>
#include <QLabel>
#include <QPushButton>
#include "ArrangedWidget.h"
// 通知框
class NotifyWidget : public ArrangedWidget {
Q_OBJECT
public:
explicit NotifyWidget(NotifyManager *manager, QWidget *parent = 0);
QVariantMap data() const; // 获取数据
void setData(const QVariantMap &data); // 设置数据
private:
QVariantMap m_data; // 存放数据的map
QFrame *m_pFrameBack; // 背景界面
QLabel *m_pLabIcon; // 图标标签
QLabel *m_pLabTitle; // 标题标签
QLabel *m_pLabBody; // 内容标签
QPushButton *m_pBtnClose; // 关闭按钮
};
// 队列中的剩余通知数目
class NotifyCountWidget : public ArrangedWidget {
Q_OBJECT
public:
explicit NotifyCountWidget(NotifyManager *manager, QWidget *parent = 0);
void setCount(int count); // 设置剩余通知数目
private:
QLabel *m_pLabIcon;
QLabel *m_pLabCount;
QPropertyAnimation *flickerAnim;
QWidget *m_parentWidget;
};
#endif // NOTIFYWND_H

@ -0,0 +1,12 @@
#ifndef QNOTIFY_GLOBAL_H
#define QNOTIFY_GLOBAL_H
#include <QtCore/qglobal.h>
#if defined(QNOTIFY_LIBRARY)
#define QNOTIFY_EXPORT Q_DECL_EXPORT
#else
#define QNOTIFY_EXPORT Q_DECL_IMPORT
#endif
#endif // QNOTIFY_GLOBAL_H

Binary file not shown.

Binary file not shown.

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

Loading…
Cancel
Save