From fb96d2eef808ecab45801f5fc71d706597e63346 Mon Sep 17 00:00:00 2001 From: John Preston Date: Tue, 13 Aug 2019 21:01:39 +0100 Subject: [PATCH] Improve schedule box design. --- Telegram/SourceFiles/boxes/calendar_box.cpp | 3 +- Telegram/SourceFiles/boxes/send_files_box.cpp | 11 +- Telegram/SourceFiles/boxes/share_box.cpp | 11 +- Telegram/SourceFiles/history/history.style | 28 +- .../SourceFiles/history/history_widget.cpp | 13 +- .../view/history_view_schedule_box.cpp | 580 +++++++++++++++++- .../history/view/history_view_schedule_box.h | 6 +- 7 files changed, 605 insertions(+), 47 deletions(-) diff --git a/Telegram/SourceFiles/boxes/calendar_box.cpp b/Telegram/SourceFiles/boxes/calendar_box.cpp index d4aea75bb..b7db290c2 100644 --- a/Telegram/SourceFiles/boxes/calendar_box.cpp +++ b/Telegram/SourceFiles/boxes/calendar_box.cpp @@ -389,7 +389,8 @@ void CalendarBox::Inner::mouseReleaseEvent(QMouseEvent *e) { auto pressed = _pressed; setPressed(kEmptySelection); if (pressed != kEmptySelection && pressed == _selected) { - _dateChosenCallback(_context->dateFromIndex(pressed)); + const auto onstack = _dateChosenCallback; + onstack(_context->dateFromIndex(pressed)); } } diff --git a/Telegram/SourceFiles/boxes/send_files_box.cpp b/Telegram/SourceFiles/boxes/send_files_box.cpp index 8a468fb23..c2e88250e 100644 --- a/Telegram/SourceFiles/boxes/send_files_box.cpp +++ b/Telegram/SourceFiles/boxes/send_files_box.cpp @@ -1963,10 +1963,15 @@ void SendFilesBox::sendSilent() { } void SendFilesBox::sendScheduled() { - Ui::show(Box(HistoryView::ScheduleBox, crl::guard(this, [=]( - Api::SendOptions options) { + const auto callback = crl::guard(this, [=](Api::SendOptions options) { send(options); - })), LayerOption::KeepOther); + }); + Ui::show( + Box( + HistoryView::ScheduleBox, + callback, + HistoryView::DefaultScheduleTime()), + LayerOption::KeepOther); } SendFilesBox::~SendFilesBox() = default; diff --git a/Telegram/SourceFiles/boxes/share_box.cpp b/Telegram/SourceFiles/boxes/share_box.cpp index c32cbef35..2b455a0b8 100644 --- a/Telegram/SourceFiles/boxes/share_box.cpp +++ b/Telegram/SourceFiles/boxes/share_box.cpp @@ -469,10 +469,15 @@ void ShareBox::submitSilent() { } void ShareBox::submitScheduled() { - Ui::show(Box(HistoryView::ScheduleBox, crl::guard(this, [=]( - Api::SendOptions options) { + const auto callback = crl::guard(this, [=](Api::SendOptions options) { submit(options); - })), LayerOption::KeepOther); + }); + Ui::show( + Box( + HistoryView::ScheduleBox, + callback, + HistoryView::DefaultScheduleTime()), + LayerOption::KeepOther); } void ShareBox::copyLink() { diff --git a/Telegram/SourceFiles/history/history.style b/Telegram/SourceFiles/history/history.style index 92f38594d..347ad57a9 100644 --- a/Telegram/SourceFiles/history/history.style +++ b/Telegram/SourceFiles/history/history.style @@ -592,17 +592,31 @@ largeEmojiOutline: 1px; largeEmojiPadding: margins(0px, 0px, 0px, 0px); largeEmojiSkip: 4px; -scheduleHeight: 120px; -scheduleDateTop: 44px; +scheduleHeight: 95px; +scheduleDateTop: 38px; scheduleDateField: InputField(defaultInputField) { textMargins: margins(2px, 0px, 2px, 0px); placeholderScale: 0.; - placeholderFont: normalFont; - heightMin: 24px; - font: normalFont; + heightMin: 30px; + textAlign: align(top); + font: font(14px); } -scheduleDateWidth: 96px; +scheduleTimeField: InputField(scheduleDateField) { + border: 0px; + borderActive: 0px; + heightMin: 28px; + placeholderFont: font(14px); + placeholderFgActive: placeholderFgActive; +} +scheduleDateWidth: 136px; +scheduleTimeWidth: 72px; scheduleAtSkip: 24px; -scheduleAtTop: 48px; +scheduleAtTop: 42px; scheduleAtLabel: FlatLabel(defaultFlatLabel) { } +scheduleTimeSeparator: FlatLabel(defaultFlatLabel) { + style: TextStyle(defaultTextStyle) { + font: font(14px); + } +} +scheduleTimeSeparatorPadding: margins(2px, 0px, 2px, 0px); diff --git a/Telegram/SourceFiles/history/history_widget.cpp b/Telegram/SourceFiles/history/history_widget.cpp index 19d032be5..ef7a94a55 100644 --- a/Telegram/SourceFiles/history/history_widget.cpp +++ b/Telegram/SourceFiles/history/history_widget.cpp @@ -2960,9 +2960,18 @@ void HistoryWidget::sendSilent() { } void HistoryWidget::sendScheduled() { - Ui::show(Box(HistoryView::ScheduleBox, [=](Api::SendOptions options) { + if (!_list) { + return; + } + const auto callback = crl::guard(_list, [=](Api::SendOptions options) { send(options); - })); + }); + Ui::show( + Box( + HistoryView::ScheduleBox, + callback, + HistoryView::DefaultScheduleTime()), + LayerOption::KeepOther); } void HistoryWidget::unblockUser() { diff --git a/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp b/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp index 87dc6a34e..faad6b7be 100644 --- a/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp +++ b/Telegram/SourceFiles/history/view/history_view_schedule_box.cpp @@ -14,81 +14,601 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/widgets/input_fields.h" #include "ui/widgets/labels.h" #include "ui/wrap/padding_wrap.h" +#include "styles/style_boxes.h" #include "styles/style_history.h" namespace HistoryView { +namespace { + +constexpr auto kMinimalSchedule = TimeId(30); + +tr::phrase<> MonthDay(int index) { + switch (index) { + case 1: return tr::lng_month_day1; + case 2: return tr::lng_month_day2; + case 3: return tr::lng_month_day3; + case 4: return tr::lng_month_day4; + case 5: return tr::lng_month_day5; + case 6: return tr::lng_month_day6; + case 7: return tr::lng_month_day7; + case 8: return tr::lng_month_day8; + case 9: return tr::lng_month_day9; + case 10: return tr::lng_month_day10; + case 11: return tr::lng_month_day11; + case 12: return tr::lng_month_day12; + } + Unexpected("Index in MonthDay."); +} + +QString DayString(const QDate &date) { + return tr::lng_month_day( + tr::now, + lt_month, + MonthDay(date.month())(tr::now), + lt_day, + QString::number(date.day())); +} + +QString TimeString(TimeId time) { + const auto parsed = base::unixtime::parse(time).time(); + return QString("%1:%2" + ).arg(parsed.hour() + ).arg(parsed.minute(), 2, 10, QLatin1Char('0')); +} + +class TimePart final : public Ui::MaskedInputField { +public: + using MaskedInputField::MaskedInputField; + + void setMaxValue(int value); + + rpl::producer<> erasePrevious() const; + rpl::producer putNext() const; + +protected: + void keyPressEvent(QKeyEvent *e) override; + + void correctValue( + const QString &was, + int wasCursor, + QString &now, + int &nowCursor) override; + +private: + int _maxValue = 0; + int _maxDigits = 0; + rpl::event_stream<> _erasePrevious; + rpl::event_stream _putNext; + +}; + +class TimeInput final : public Ui::RpWidget { +public: + TimeInput(QWidget *parent, const QString &value); + + bool setFocusFast(); + rpl::producer value() const; + QString valueCurrent() const; + void showError(); + + int resizeGetHeight(int width) override; + +protected: + void paintEvent(QPaintEvent *e) override; + void mousePressEvent(QMouseEvent *e) override; + void mouseMoveEvent(QMouseEvent *e) override; + +private: + void setInnerFocus(); + void putNext(const object_ptr &field, QChar ch); + void erasePrevious(const object_ptr &field); + void finishInnerAnimating(); + void setErrorShown(bool error); + void setFocused(bool focused); + void startBorderAnimation(); + template + bool insideSeparator(QPoint position, const Widget &widget) const; + + int hour() const; + int minute() const; + int number(const object_ptr &field) const; + + object_ptr _hour; + object_ptr> _separator1; + object_ptr _minute; + rpl::variable _value; + + style::cursor _cursor = style::cur_default; + Ui::Animations::Simple _a_borderShown; + int _borderAnimationStart = 0; + Ui::Animations::Simple _a_borderOpacity; + bool _borderVisible = false; + + Ui::Animations::Simple _a_error; + bool _error = false; + Ui::Animations::Simple _a_focused; + bool _focused = false; + +}; + +QTime ValidateTime(const QString &value) { + const auto match = QRegularExpression( + "^(\\d\\d)\\:(\\d\\d)$").match(value); + if (!match.hasMatch()) { + return QTime(); + } + const auto readInt = [](const QString &value) { + auto ref = value.midRef(0); + while (!ref.isEmpty() && ref.at(0) == '0') { + ref = ref.mid(1); + } + return ref.toInt(); + }; + return QTime(readInt(match.captured(1)), readInt(match.captured(2))); +} + +QString GetHour(const QString &value) { + if (const auto time = ValidateTime(value); time.isValid()) { + return QString::number(time.hour()); + } + return QString(); +} + +QString GetMinute(const QString &value) { + if (const auto time = ValidateTime(value); time.isValid()) { + return QString("%1").arg(time.minute(), 2, 10, QChar('0')); + } + return QString(); +} + +void TimePart::setMaxValue(int value) { + _maxValue = value; + _maxDigits = 0; + while (value > 0) { + ++_maxDigits; + value /= 10; + } +} + +rpl::producer<> TimePart::erasePrevious() const { + return _erasePrevious.events(); +} + +rpl::producer TimePart::putNext() const { + return _putNext.events(); +} + +void TimePart::keyPressEvent(QKeyEvent *e) { + const auto isBackspace = (e->key() == Qt::Key_Backspace); + const auto isBeginning = (cursorPosition() == 0); + if (isBackspace && isBeginning && !hasSelectedText()) { + _erasePrevious.fire({}); + } else { + MaskedInputField::keyPressEvent(e); + } +} + +void TimePart::correctValue( + const QString &was, + int wasCursor, + QString &now, + int &nowCursor) { + auto newText = QString(); + auto newCursor = -1; + const auto oldCursor = nowCursor; + const auto oldLength = now.size(); + auto accumulated = 0; + auto limit = 0; + for (; limit != oldLength; ++limit) { + if (now[limit].isDigit()) { + accumulated *= 10; + accumulated += (now[limit].unicode() - '0'); + if (accumulated > _maxValue || limit == _maxDigits) { + break; + } + } + } + for (auto i = 0; i != limit;) { + if (now[i].isDigit()) { + newText += now[i]; + } + if (++i == oldCursor) { + newCursor = newText.size(); + } + } + if (newCursor < 0) { + newCursor = newText.size(); + } + if (newText != now) { + now = newText; + setText(now); + startPlaceholderAnimation(); + } + if (newCursor != nowCursor) { + nowCursor = newCursor; + setCursorPosition(nowCursor); + } + if (accumulated > _maxValue + || (limit == _maxDigits && oldLength > _maxDigits)) { + if (oldCursor > limit) { + _putNext.fire('0' + (accumulated % 10)); + } else { + _putNext.fire(0); + } + } +} + +TimeInput::TimeInput(QWidget *parent, const QString &value) +: RpWidget(parent) +, _hour( + this, + st::scheduleTimeField, + rpl::never(), + GetHour(value)) +, _separator1( + this, + object_ptr( + this, + QString(":"), + st::scheduleTimeSeparator), + st::scheduleTimeSeparatorPadding) +, _minute( + this, + st::scheduleTimeField, + rpl::never(), + GetMinute(value)) +, _value(valueCurrent()) { + const auto focused = [=](const object_ptr &field) { + return [this, pointer = make_weak(field.data())]{ + _borderAnimationStart = pointer->borderAnimationStart() + + pointer->x() + - _hour->x(); + setFocused(true); + }; + }; + const auto blurred = [=] { + setFocused(false); + }; + const auto changed = [=] { + _value = valueCurrent(); + }; + connect(_hour, &Ui::MaskedInputField::focused, focused(_hour)); + connect(_minute, &Ui::MaskedInputField::focused, focused(_minute)); + connect(_hour, &Ui::MaskedInputField::blurred, blurred); + connect(_minute, &Ui::MaskedInputField::blurred, blurred); + connect(_hour, &Ui::MaskedInputField::changed, changed); + connect(_minute, &Ui::MaskedInputField::changed, changed); + _hour->setMaxValue(23); + _hour->putNext() | rpl::start_with_next([=](QChar ch) { + putNext(_minute, ch); + }, lifetime()); + _minute->setMaxValue(59); + _minute->erasePrevious() | rpl::start_with_next([=] { + erasePrevious(_hour); + }, lifetime()); + _separator1->setAttribute(Qt::WA_TransparentForMouseEvents); + setMouseTracking(true); + + _value.changes( + ) | rpl::start_with_next([=] { + setErrorShown(false); + }, lifetime()); +} + +void TimeInput::putNext(const object_ptr &field, QChar ch) { + field->setCursorPosition(0); + if (ch.unicode()) { + field->setText(ch + field->getLastText()); + field->setCursorPosition(1); + } + field->setFocus(); +} + +void TimeInput::erasePrevious(const object_ptr &field) { + const auto text = field->getLastText(); + if (!text.isEmpty()) { + field->setCursorPosition(text.size() - 1); + field->setText(text.mid(0, text.size() - 1)); + } + field->setFocus(); +} + +bool TimeInput::setFocusFast() { + if (hour()) { + _minute->setFocusFast(); + } else { + _hour->setFocusFast(); + } + return true; +} + +int TimeInput::number(const object_ptr &field) const { + const auto text = field->getLastText(); + auto ref = text.midRef(0); + while (!ref.isEmpty() && ref.at(0) == '0') { + ref = ref.mid(1); + } + return ref.toInt(); +} + +int TimeInput::hour() const { + return number(_hour); +} + +int TimeInput::minute() const { + return number(_minute); +} + +QString TimeInput::valueCurrent() const { + const auto result = QString("%1:%2" + ).arg(hour() + ).arg(minute(), 2, 10, QChar('0')); + return ValidateTime(result).isValid() ? result : QString(); +} + +rpl::producer TimeInput::value() const { + return _value.value(); +} + +void TimeInput::paintEvent(QPaintEvent *e) { + Painter p(this); + + const auto &_st = st::scheduleDateField; + const auto height = _st.heightMin; + if (_st.border) { + p.fillRect(0, height - _st.border, width(), _st.border, _st.borderFg); + } + auto errorDegree = _a_error.value(_error ? 1. : 0.); + auto focusedDegree = _a_focused.value(_focused ? 1. : 0.); + auto borderShownDegree = _a_borderShown.value(1.); + auto borderOpacity = _a_borderOpacity.value(_borderVisible ? 1. : 0.); + if (_st.borderActive && (borderOpacity > 0.)) { + auto borderStart = snap(_borderAnimationStart, 0, width()); + auto borderFrom = qRound(borderStart * (1. - borderShownDegree)); + auto borderTo = borderStart + qRound((width() - borderStart) * borderShownDegree); + if (borderTo > borderFrom) { + auto borderFg = anim::brush(_st.borderFgActive, _st.borderFgError, errorDegree); + p.setOpacity(borderOpacity); + p.fillRect(borderFrom, height - _st.borderActive, borderTo - borderFrom, _st.borderActive, borderFg); + p.setOpacity(1); + } + } +} + +template +bool TimeInput::insideSeparator(QPoint position, const Widget &widget) const { + const auto x = position.x(); + const auto y = position.y(); + return (x >= widget->x() && x < widget->x() + widget->width()) + && (y >= _hour->y() && y < _hour->y() + _hour->height()); +} + +void TimeInput::mouseMoveEvent(QMouseEvent *e) { + const auto cursor = insideSeparator(e->pos(), _separator1) + ? style::cur_text + : style::cur_default; + if (_cursor != cursor) { + _cursor = cursor; + setCursor(_cursor); + } +} + +void TimeInput::mousePressEvent(QMouseEvent *e) { + const auto x = e->pos().x(); + const auto focus1 = [&] { + if (_hour->getLastText().size() > 1) { + _minute->setFocus(); + } else { + _hour->setFocus(); + } + }; + if (insideSeparator(e->pos(), _separator1)) { + focus1(); + _borderAnimationStart = x - _hour->x(); + } +} + +int TimeInput::resizeGetHeight(int width) { + const auto &_st = st::scheduleTimeField; + const auto &font = _st.placeholderFont; + const auto addToWidth = st::scheduleTimeSeparatorPadding.left(); + const auto hourWidth = _st.textMargins.left() + + _st.placeholderMargins.left() + + font->width(QString("23")) + + _st.placeholderMargins.right() + + _st.textMargins.right() + + addToWidth; + const auto minuteWidth = _st.textMargins.left() + + _st.placeholderMargins.left() + + font->width(QString("59")) + + _st.placeholderMargins.right() + + _st.textMargins.right() + + addToWidth; + const auto full = hourWidth + - addToWidth + + _separator1->width() + + minuteWidth + - addToWidth; + auto left = (width - full) / 2; + auto top = 0; + _hour->setGeometry(left, top, hourWidth, _hour->height()); + left += hourWidth - addToWidth; + _separator1->resizeToNaturalWidth(width); + _separator1->move(left, top); + left += _separator1->width(); + _minute->setGeometry(left, top, minuteWidth, _minute->height()); + return st::scheduleDateField.heightMin; +} + +void TimeInput::showError() { + setErrorShown(true); + if (!_focused) { + setInnerFocus(); + } +} + +void TimeInput::setInnerFocus() { + if (hour()) { + _minute->setFocus(); + } else { + _hour->setFocus(); + } +} + +void TimeInput::setErrorShown(bool error) { + if (_error != error) { + _error = error; + _a_error.start( + [=] { update(); }, + _error ? 0. : 1., + _error ? 1. : 0., + st::scheduleDateField.duration); + startBorderAnimation(); + } +} + +void TimeInput::setFocused(bool focused) { + if (_focused != focused) { + _focused = focused; + _a_focused.start( + [=] { update(); }, + _focused ? 0. : 1., + _focused ? 1. : 0., + st::scheduleDateField.duration); + startBorderAnimation(); + } +} + +void TimeInput::finishInnerAnimating() { + _hour->finishAnimating(); + _minute->finishAnimating(); + _a_borderOpacity.stop(); + _a_borderShown.stop(); + _a_error.stop(); +} + +void TimeInput::startBorderAnimation() { + auto borderVisible = (_error || _focused); + if (_borderVisible != borderVisible) { + _borderVisible = borderVisible; + const auto duration = st::scheduleDateField.duration; + if (_borderVisible) { + if (_a_borderOpacity.animating()) { + _a_borderOpacity.start([=] { update(); }, 0., 1., duration); + } else { + _a_borderShown.start([=] { update(); }, 0., 1., duration); + } + } else { + _a_borderOpacity.start([=] { update(); }, 1., 0., duration); + } + } +} + +} // namespace + +TimeId DefaultScheduleTime() { + const auto result = base::unixtime::now() + 3600; + const auto time = base::unixtime::parse(result).time(); + return result - (time.minute() % 5) * 60 - time.second(); +} void ScheduleBox( not_null box, - Fn done) { + Fn done, + TimeId time) { box->setTitle(tr::lng_schedule_title()); + box->setWidth(st::boxWideWidth); + const auto date = Ui::CreateChild>( + box.get(), + base::unixtime::parse(time).date()); const auto content = box->addRow( object_ptr(box, st::scheduleHeight)); - const auto day = Ui::CreateChild( + const auto dayInput = Ui::CreateChild( content, st::scheduleDateField); - const auto time = Ui::CreateChild( + const auto timeInput = Ui::CreateChild( content, - st::scheduleDateField); + TimeString(time)); const auto at = Ui::CreateChild( content, tr::lng_schedule_at(), st::scheduleAtLabel); + date->value( + ) | rpl::start_with_next([=](QDate date) { + dayInput->setText(DayString(date)); + timeInput->setFocusFast(); + }, dayInput->lifetime()); + content->widthValue( ) | rpl::start_with_next([=](int width) { const auto paddings = width - at->width() - 2 * st::scheduleAtSkip - - 2 * st::scheduleDateWidth; + - st::scheduleDateWidth + - st::scheduleTimeWidth; const auto left = paddings / 2; - day->resizeToWidth(st::scheduleDateWidth); - day->moveToLeft(left, st::scheduleDateTop, width); + dayInput->resizeToWidth(st::scheduleDateWidth); + dayInput->moveToLeft(left, st::scheduleDateTop, width); at->moveToLeft( left + st::scheduleDateWidth + st::scheduleAtSkip, st::scheduleAtTop, width); - time->resizeToWidth(st::scheduleDateWidth); - time->moveToLeft( - width - left - st::scheduleDateWidth, + timeInput->resizeToWidth(st::scheduleTimeWidth); + timeInput->moveToLeft( + width - left - st::scheduleTimeWidth, st::scheduleDateTop, width); }, content->lifetime()); + QObject::connect(dayInput, &Ui::InputField::focused, [=] { + const auto calendar = std::make_shared>(); + const auto chosen = [=](QDate chosen) { + *date = chosen; + (*calendar)->closeBox(); + }; + const auto finalize = [=](not_null box) { + const auto now = QDate::currentDate(); + box->setMinDate(now); + box->setMaxDate(now.addYears(1).addDays(-1)); + }; + *calendar = box->getDelegate()->show(Box( + date->current(), + date->current(), + crl::guard(box, chosen), + finalize)); + (*calendar)->boxClosing( + ) | rpl::start_with_next(crl::guard(timeInput, [=] { + timeInput->setFocusFast(); + }), (*calendar)->lifetime()); + }); + const auto save = [=] { auto result = Api::SendOptions(); - const auto dayValue = day->getLastText().trimmed(); - const auto dayMatch = QRegularExpression("(\\d\\d)\\.(\\d\\d)\\.(\\d\\d\\d\\d)").match(dayValue); - const auto timeValue = time->getLastText().trimmed(); - const auto timeMatch = QRegularExpression("(\\d\\d):(\\d\\d)").match(timeValue); - - if (!dayMatch.hasMatch()) { - day->showError(); + const auto timeValue = timeInput->valueCurrent().split(':'); + if (timeValue.size() != 2) { + timeInput->showError(); return; } - - if (!timeMatch.hasMatch()) { - time->showError(); + const auto time = QTime(timeValue[0].toInt(), timeValue[1].toInt()); + if (!time.isValid()) { + timeInput->showError(); + return; + } + result.scheduled = QDateTime(date->current(), time).toTime_t(); + if (result.scheduled <= base::unixtime::now() + kMinimalSchedule) { + timeInput->showError(); return; } - - const auto date = QDateTime( - QDate( - dayMatch.captured(3).toInt(), - dayMatch.captured(2).toInt(), - dayMatch.captured(1).toInt()), - QTime( - timeMatch.captured(1).toInt(), - timeMatch.captured(2).toInt())); - result.scheduled = date.toTime_t(); auto copy = done; box->closeBox(); copy(result); }; + box->setFocusCallback([=] { timeInput->setFocusFast(); }); box->addButton(tr::lng_settings_save(), save); box->addButton(tr::lng_cancel(), [=] { box->closeBox(); }); } diff --git a/Telegram/SourceFiles/history/view/history_view_schedule_box.h b/Telegram/SourceFiles/history/view/history_view_schedule_box.h index 51fe3fe7a..001d8e96e 100644 --- a/Telegram/SourceFiles/history/view/history_view_schedule_box.h +++ b/Telegram/SourceFiles/history/view/history_view_schedule_box.h @@ -15,6 +15,10 @@ struct SendOptions; namespace HistoryView { -void ScheduleBox(not_null box, Fn done); +[[nodiscard]] TimeId DefaultScheduleTime(); +void ScheduleBox( + not_null box, + Fn done, + TimeId time); } // namespace HistoryView