From 4d12f1c0ef2819a30380aab88bf4d24542ffbc39 Mon Sep 17 00:00:00 2001 From: John Preston Date: Wed, 21 Feb 2024 22:25:52 +0400 Subject: [PATCH] Initial working hours editing. --- Telegram/CMakeLists.txt | 3 + Telegram/Resources/langs/lang.strings | 8 + .../data/business/data_business_common.cpp | 158 +++++ .../data/business/data_business_common.h | 99 +++ .../data/business/data_business_info.cpp | 71 +++ .../data/business/data_business_info.h | 41 ++ Telegram/SourceFiles/data/data_session.cpp | 4 +- Telegram/SourceFiles/data/data_session.h | 5 + .../business/settings_working_hours.cpp | 580 +++++++++++++++++- Telegram/SourceFiles/settings/settings.style | 7 + .../settings/settings_business.cpp | 4 + 11 files changed, 978 insertions(+), 2 deletions(-) create mode 100644 Telegram/SourceFiles/data/business/data_business_common.cpp create mode 100644 Telegram/SourceFiles/data/business/data_business_info.cpp create mode 100644 Telegram/SourceFiles/data/business/data_business_info.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 9b3caa4ef0..6010bf8332 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -450,7 +450,10 @@ PRIVATE countries/countries_manager.h data/business/data_business_chatbots.cpp data/business/data_business_chatbots.h + data/business/data_business_common.cpp data/business/data_business_common.h + data/business/data_business_info.cpp + data/business/data_business_info.h data/notify/data_notify_settings.cpp data/notify/data_notify_settings.h data/notify/data_peer_notify_settings.cpp diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 89508d8c5d..dd5746e446 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -2189,6 +2189,14 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_hours_saturday" = "Saturday"; "lng_hours_sunday" = "Sunday"; "lng_hours_closed" = "Closed"; +"lng_hours_open_full" = "Open 24 hours"; +"lng_hours_next_day" = "Next day, {time}"; +"lng_hours_time_zone_title" = "Choose Time Zone"; +"lng_hours_add_button" = "Add a Set of Hours"; +"lng_hours_opening" = "Opening Time"; +"lng_hours_closing" = "Closing Time"; +"lng_hours_remove" = "Remove"; +"lng_hours_about_day" = "Specify your working hours during the day."; "lng_replies_title" = "Quick Replies"; "lng_replies_about" = "Set up shortcuts with rich text and media to respond to messages faster."; diff --git a/Telegram/SourceFiles/data/business/data_business_common.cpp b/Telegram/SourceFiles/data/business/data_business_common.cpp new file mode 100644 index 0000000000..1de65c9521 --- /dev/null +++ b/Telegram/SourceFiles/data/business/data_business_common.cpp @@ -0,0 +1,158 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "data/business/data_business_common.h" + +namespace Data { +namespace { + +constexpr auto kDay = WorkingInterval::kDay; +constexpr auto kWeek = WorkingInterval::kWeek; +constexpr auto kInNextDayMax = WorkingInterval::kInNextDayMax; + +[[nodiscard]] WorkingIntervals SortAndMerge(WorkingIntervals intervals) { + auto &list = intervals.list; + ranges::sort(list, ranges::less(), &WorkingInterval::start); + for (auto i = 0, count = int(list.size()); i != count; ++i) { + if (i && list[i].intersected(list[i - 1])) { + list[i - 1] = list[i - 1].united(list[i]); + list[i] = {}; + } + if (!list[i]) { + list.erase(list.begin() + i); + --i; + --count; + } + } + return intervals; +} + +[[nodiscard]] WorkingIntervals MoveTailToFront(WorkingIntervals intervals) { + auto &list = intervals.list; + auto after = WorkingInterval{ kWeek, kWeek + kDay }; + while (!list.empty()) { + if (const auto tail = list.back().intersected(after)) { + list.back().end = tail.start; + if (!list.back()) { + list.pop_back(); + } + list.insert(begin(list), tail.shifted(-kWeek)); + } else { + break; + } + } + return intervals; +} + +} // namespace + +WorkingIntervals WorkingIntervals::normalized() const { + return SortAndMerge(MoveTailToFront(SortAndMerge(*this))); +} + +Data::WorkingIntervals ExtractDayIntervals( + const Data::WorkingIntervals &intervals, + int dayIndex) { + Expects(dayIndex >= 0 && dayIndex < 7); + + auto result = Data::WorkingIntervals(); + auto &list = result.list; + for (const auto &interval : intervals.list) { + const auto now = interval.intersected( + { (dayIndex - 1) * kDay, (dayIndex + 2) * kDay }); + const auto after = interval.intersected( + { (dayIndex + 6) * kDay, (dayIndex + 9) * kDay }); + const auto before = interval.intersected( + { (dayIndex - 8) * kDay, (dayIndex - 5) * kDay }); + if (now) { + list.push_back(now.shifted(-dayIndex * kDay)); + } + if (after) { + list.push_back(after.shifted(-(dayIndex + 7) * kDay)); + } + if (before) { + list.push_back(before.shifted(-(dayIndex - 7) * kDay)); + } + } + result = result.normalized(); + + const auto outside = [&](Data::WorkingInterval interval) { + return (interval.end <= 0) || (interval.start >= kDay); + }; + list.erase(ranges::remove_if(list, outside), end(list)); + + if (!list.empty() && list.back().start <= 0 && list.back().end >= kDay) { + list.back() = { 0, kDay }; + } else if (!list.empty() && (list.back().end > kDay + kInNextDayMax)) { + list.back() = list.back().intersected({ 0, kDay }); + } + if (!list.empty() && list.front().start <= 0) { + if (list.front().start < 0 + && list.front().end <= kInNextDayMax + && list.front().start > -kDay) { + list.erase(begin(list)); + } else { + list.front() = list.front().intersected({ 0, kDay }); + if (!list.front()) { + list.erase(begin(list)); + } + } + } + + return result; +} + +Data::WorkingIntervals RemoveDayIntervals( + const Data::WorkingIntervals &intervals, + int dayIndex) { + auto result = intervals.normalized(); + auto &list = result.list; + const auto day = Data::WorkingInterval{ 0, kDay }; + const auto shifted = day.shifted(dayIndex * kDay); + auto before = Data::WorkingInterval{ 0, shifted.start }; + auto after = Data::WorkingInterval{ shifted.end, kWeek }; + for (auto i = 0, count = int(list.size()); i != count; ++i) { + if (list[i].end <= shifted.start || list[i].start >= shifted.end) { + continue; + } else if (list[i].end <= shifted.start + kInNextDayMax + && (list[i].start < shifted.start + || (!dayIndex // This 'Sunday' finishing on next day <= 6:00. + && list[i].start == shifted.start + && list.back().end >= kWeek))) { + continue; + } else if (const auto first = list[i].intersected(before)) { + list[i] = first; + if (const auto second = list[i].intersected(after)) { + list.push_back(second); + } + } else if (const auto second = list[i].intersected(after)) { + list[i] = second; + } else { + list.erase(list.begin() + i); + --i; + --count; + } + } + return result.normalized(); +} + +Data::WorkingIntervals ReplaceDayIntervals( + const Data::WorkingIntervals &intervals, + int dayIndex, + Data::WorkingIntervals replacement) { + auto result = RemoveDayIntervals(intervals, dayIndex); + const auto first = result.list.insert( + end(result.list), + begin(replacement.list), + end(replacement.list)); + for (auto &interval : ranges::subrange(first, end(result.list))) { + interval = interval.shifted(dayIndex * kDay); + } + return result.normalized(); +} + +} // namespace Data diff --git a/Telegram/SourceFiles/data/business/data_business_common.h b/Telegram/SourceFiles/data/business/data_business_common.h index 743ddaa124..41fcca431f 100644 --- a/Telegram/SourceFiles/data/business/data_business_common.h +++ b/Telegram/SourceFiles/data/business/data_business_common.h @@ -42,4 +42,103 @@ struct BusinessRecipients { const BusinessRecipients &b) = default; }; +struct Timezone { + QString id; + QString name; + TimeId utcOffset = 0; + + friend inline bool operator==( + const Timezone &a, + const Timezone &b) = default; +}; + +struct Timezones { + std::vector list; + + friend inline bool operator==( + const Timezones &a, + const Timezones &b) = default; +};; + +struct WorkingInterval { + static constexpr auto kDay = 24 * 3600; + static constexpr auto kWeek = 7 * kDay; + static constexpr auto kInNextDayMax = 6 * 3600; + + TimeId start = 0; + TimeId end = 0; + + explicit operator bool() const { + return start < end; + } + + [[nodiscard]] WorkingInterval shifted(TimeId offset) const { + return { start + offset, end + offset }; + } + [[nodiscard]] WorkingInterval united(WorkingInterval other) const { + if (!*this) { + return other; + } else if (!other) { + return *this; + } + return { + std::min(start, other.start), + std::max(end, other.end), + }; + } + [[nodiscard]] WorkingInterval intersected(WorkingInterval other) const { + const auto result = WorkingInterval{ + std::max(start, other.start), + std::min(end, other.end), + }; + return result ? result : WorkingInterval(); + } + + friend inline bool operator==( + const WorkingInterval &a, + const WorkingInterval &b) = default; +}; + +struct WorkingIntervals { + std::vector list; + + [[nodiscard]] WorkingIntervals normalized() const; + + explicit operator bool() const { + for (const auto &interval : list) { + if (interval) { + return true; + } + } + return false; + } + friend inline bool operator==( + const WorkingIntervals &a, + const WorkingIntervals &b) = default; +}; + +struct WorkingHours { + WorkingIntervals intervals; + QString timezoneId; + + [[nodiscard]] WorkingHours normalized() const { + return { intervals.normalized(), timezoneId }; + } + + friend inline bool operator==( + const WorkingHours &a, + const WorkingHours &b) = default; +}; + +[[nodiscard]] Data::WorkingIntervals ExtractDayIntervals( + const Data::WorkingIntervals &intervals, + int dayIndex); +[[nodiscard]] Data::WorkingIntervals RemoveDayIntervals( + const Data::WorkingIntervals &intervals, + int dayIndex); +[[nodiscard]] Data::WorkingIntervals ReplaceDayIntervals( + const Data::WorkingIntervals &intervals, + int dayIndex, + Data::WorkingIntervals replacement); + } // namespace Data diff --git a/Telegram/SourceFiles/data/business/data_business_info.cpp b/Telegram/SourceFiles/data/business/data_business_info.cpp new file mode 100644 index 0000000000..c4623bb1f9 --- /dev/null +++ b/Telegram/SourceFiles/data/business/data_business_info.cpp @@ -0,0 +1,71 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#include "data/business/data_business_info.h" + +#include "apiwrap.h" +#include "data/data_session.h" +#include "main/main_session.h" + +namespace Data { + +BusinessInfo::BusinessInfo(not_null owner) +: _owner(owner) { +} + +BusinessInfo::~BusinessInfo() = default; + +const WorkingHours &BusinessInfo::workingHours() const { + return _workingHours.current(); +} + +rpl::producer BusinessInfo::workingHoursValue() const { + return _workingHours.value(); +} + +void BusinessInfo::saveWorkingHours(WorkingHours data) { + _workingHours = std::move(data); +} + +void BusinessInfo::preload() { + preloadTimezones(); +} + +void BusinessInfo::preloadTimezones() { + if (!_timezones.current().list.empty() || _timezonesRequestId) { + return; + } + _timezonesRequestId = _owner->session().api().request( + MTPhelp_GetTimezonesList(MTP_int(_timezonesHash)) + ).done([=](const MTPhelp_TimezonesList &result) { + result.match([&](const MTPDhelp_timezonesList &data) { + _timezonesHash = data.vhash().v; + const auto proj = [](const MTPtimezone &result) { + return Timezone{ + .id = qs(result.data().vid()), + .name = qs(result.data().vname()), + .utcOffset = result.data().vutc_offset().v, + }; + }; + _timezones = Timezones{ + .list = ranges::views::all( + data.vtimezones().v + ) | ranges::views::transform( + proj + ) | ranges::to_vector, + }; + }, [](const MTPDhelp_timezonesListNotModified &) { + }); + }).send(); +} + +rpl::producer BusinessInfo::timezonesValue() const { + const_cast(this)->preloadTimezones(); + return _timezones.value(); +} + +} // namespace Data diff --git a/Telegram/SourceFiles/data/business/data_business_info.h b/Telegram/SourceFiles/data/business/data_business_info.h new file mode 100644 index 0000000000..f109165d70 --- /dev/null +++ b/Telegram/SourceFiles/data/business/data_business_info.h @@ -0,0 +1,41 @@ +/* +This file is part of Telegram Desktop, +the official desktop application for the Telegram messaging service. + +For license and copyright information please follow this link: +https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL +*/ +#pragma once + +#include "data/business/data_business_common.h" + +namespace Data { + +class Session; + +class BusinessInfo final { +public: + explicit BusinessInfo(not_null owner); + ~BusinessInfo(); + + [[nodiscard]] const WorkingHours &workingHours() const; + [[nodiscard]] rpl::producer workingHoursValue() const; + void saveWorkingHours(WorkingHours data); + + void preload(); + void preloadTimezones(); + [[nodiscard]] rpl::producer timezonesValue() const; + +private: + const not_null _owner; + + rpl::variable _workingHours; + + rpl::variable _timezones; + + mtpRequestId _timezonesRequestId = 0; + int32 _timezonesHash = 0; + +}; + +} // namespace Data diff --git a/Telegram/SourceFiles/data/data_session.cpp b/Telegram/SourceFiles/data/data_session.cpp index 1b07cf5680..37fb68e86b 100644 --- a/Telegram/SourceFiles/data/data_session.cpp +++ b/Telegram/SourceFiles/data/data_session.cpp @@ -38,6 +38,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "passport/passport_form_controller.h" #include "lang/lang_keys.h" // tr::lng_deleted(tr::now) in user name #include "data/business/data_business_chatbots.h" +#include "data/business/data_business_info.h" #include "data/stickers/data_stickers.h" #include "data/notify/data_notify_settings.h" #include "data/data_bot_app.h" @@ -270,7 +271,8 @@ Session::Session(not_null session) , _customEmojiManager(std::make_unique(this)) , _stories(std::make_unique(this)) , _savedMessages(std::make_unique(this)) -, _chatbots(std::make_unique(this)) { +, _chatbots(std::make_unique(this)) +, _businessInfo(std::make_unique(this)) { _cache->open(_session->local().cacheKey()); _bigFileCache->open(_session->local().cacheBigFileKey()); diff --git a/Telegram/SourceFiles/data/data_session.h b/Telegram/SourceFiles/data/data_session.h index 4fc7b1db1e..aab939cb4e 100644 --- a/Telegram/SourceFiles/data/data_session.h +++ b/Telegram/SourceFiles/data/data_session.h @@ -63,6 +63,7 @@ class CustomEmojiManager; class Stories; class SavedMessages; class Chatbots; +class BusinessInfo; struct ReactionId; struct RepliesReadTillUpdate { @@ -146,6 +147,9 @@ public: [[nodiscard]] Chatbots &chatbots() const { return *_chatbots; } + [[nodiscard]] BusinessInfo &businessInfo() const { + return *_businessInfo; + } [[nodiscard]] MsgId nextNonHistoryEntryId() { return ++_nonHistoryEntryId; @@ -1070,6 +1074,7 @@ private: const std::unique_ptr _stories; const std::unique_ptr _savedMessages; const std::unique_ptr _chatbots; + const std::unique_ptr _businessInfo; MsgId _nonHistoryEntryId = ServerMaxMsgId.bare + ScheduledMsgIdsRange; diff --git a/Telegram/SourceFiles/settings/business/settings_working_hours.cpp b/Telegram/SourceFiles/settings/business/settings_working_hours.cpp index 7b2e878735..fbe4ccc607 100644 --- a/Telegram/SourceFiles/settings/business/settings_working_hours.cpp +++ b/Telegram/SourceFiles/settings/business/settings_working_hours.cpp @@ -7,22 +7,35 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "settings/business/settings_working_hours.h" +#include "base/event_filter.h" +#include "base/unixtime.h" #include "core/application.h" +#include "data/business/data_business_info.h" #include "data/data_session.h" #include "lang/lang_keys.h" #include "main/main_session.h" #include "settings/business/settings_recipients_helper.h" +#include "ui/layers/generic_box.h" #include "ui/text/text_utilities.h" #include "ui/widgets/buttons.h" +#include "ui/widgets/checkbox.h" +#include "ui/widgets/labels.h" +#include "ui/widgets/vertical_drum_picker.h" #include "ui/wrap/vertical_layout.h" #include "ui/wrap/slide_wrap.h" #include "ui/vertical_list.h" #include "window/window_session_controller.h" +#include "styles/style_boxes.h" +#include "styles/style_layers.h" #include "styles/style_settings.h" namespace Settings { namespace { +constexpr auto kDay = Data::WorkingInterval::kDay; +constexpr auto kWeek = Data::WorkingInterval::kWeek; +constexpr auto kInNextDayMax = Data::WorkingInterval::kInNextDayMax; + class WorkingHours : public BusinessSection { public: WorkingHours( @@ -36,8 +49,492 @@ private: void setupContent(not_null controller); void save(); + rpl::variable _hours; + }; +[[nodiscard]] QString TimezoneFullName(const Data::Timezone &data) { + const auto abs = std::abs(data.utcOffset); + const auto hours = abs / 3600; + const auto minutes = (abs % 3600) / 60; + const auto seconds = abs % 60; + const auto sign = (data.utcOffset < 0) ? '-' : '+'; + const auto prefix = u"(UTC"_q + + sign + + QString::number(hours) + + u":"_q + + QString::number(minutes).rightJustified(2, u'0') + + u")"_q; + return prefix + ' ' + data.name; +} + +[[nodiscard]] QString FindClosestTimezoneId( + const std::vector &list) { + const auto local = QDateTime::currentDateTime(); + const auto utc = QDateTime(local.date(), local.time(), Qt::UTC); + const auto shift = base::unixtime::now() - (TimeId)::time(nullptr); + const auto delta = int(utc.toSecsSinceEpoch()) + - int(local.toSecsSinceEpoch()) + - shift; + const auto proj = [&](const Data::Timezone &value) { + auto distance = value.utcOffset - delta; + while (distance > 12 * 3600) { + distance -= 24 * 3600; + } + while (distance < -12 * 3600) { + distance += 24 * 3600; + } + return std::abs(distance); + }; + return ranges::min_element(list, ranges::less(), proj)->id; +} + +[[nodiscard]] QString FormatDayTime( + TimeId time, + bool showEndAsNextDay = false) { + const auto wrap = [](TimeId value) { + const auto hours = value / 3600; + const auto minutes = (value % 3600) / 60; + return QString::number(hours).rightJustified(2, u'0') + + ':' + + QString::number(minutes).rightJustified(2, u'0'); + }; + return (time > kDay || (showEndAsNextDay && time == kDay)) + ? tr::lng_hours_next_day(tr::now, lt_time, wrap(time - kDay)) + : wrap(time == kDay ? 0 : time); +} + +[[nodiscard]] QString JoinIntervals(const Data::WorkingIntervals &data) { + auto result = QStringList(); + result.reserve(data.list.size()); + for (const auto &interval : data.list) { + const auto start = FormatDayTime(interval.start); + const auto end = FormatDayTime(interval.end); + result.push_back(start + u" - "_q + end); + } + return result.join(u", "_q); +} + +void EditTimeBox( + not_null box, + TimeId low, + TimeId high, + TimeId value, + Fn save) { + Expects(low <= high); + + const auto values = (high - low + 60) / 60; + const auto startIndex = (value - low) / 60; + + const auto content = box->addRow(object_ptr( + box, + st::settingsWorkingHoursPicker)); + + const auto font = st::boxTextFont; + const auto itemHeight = st::settingsWorkingHoursPickerItemHeight; + auto paintCallback = [=]( + QPainter &p, + int index, + float64 y, + float64 distanceFromCenter, + int outerWidth) { + const auto r = QRectF(0, y, outerWidth, itemHeight); + const auto progress = std::abs(distanceFromCenter); + const auto revProgress = 1. - progress; + p.save(); + p.translate(r.center()); + constexpr auto kMinYScale = 0.2; + const auto yScale = kMinYScale + + (1. - kMinYScale) * anim::easeOutCubic(1., revProgress); + p.scale(1., yScale); + p.translate(-r.center()); + p.setOpacity(revProgress); + p.setFont(font); + p.setPen(st::defaultFlatLabel.textFg); + p.drawText(r, FormatDayTime(low + index * 60, true), style::al_center); + p.restore(); + }; + + const auto picker = Ui::CreateChild( + content, + std::move(paintCallback), + values, + itemHeight, + startIndex); + + content->sizeValue( + ) | rpl::start_with_next([=](const QSize &s) { + picker->resize(s.width(), s.height()); + picker->moveToLeft((s.width() - picker->width()) / 2, 0); + }, content->lifetime()); + + content->paintRequest( + ) | rpl::start_with_next([=](const QRect &r) { + auto p = QPainter(content); + + p.fillRect(r, Qt::transparent); + + const auto lineRect = QRect( + 0, + content->height() / 2, + content->width(), + st::defaultInputField.borderActive); + p.fillRect(lineRect.translated(0, itemHeight / 2), st::activeLineFg); + p.fillRect(lineRect.translated(0, -itemHeight / 2), st::activeLineFg); + }, content->lifetime()); + + base::install_event_filter(content, [=](not_null e) { + if ((e->type() == QEvent::MouseButtonPress) + || (e->type() == QEvent::MouseButtonRelease) + || (e->type() == QEvent::MouseMove)) { + picker->handleMouseEvent(static_cast(e.get())); + } else if (e->type() == QEvent::Wheel) { + picker->handleWheelEvent(static_cast(e.get())); + } + return base::EventFilterResult::Continue; + }); + base::install_event_filter(box, [=](not_null e) { + if (e->type() == QEvent::KeyPress) { + picker->handleKeyEvent(static_cast(e.get())); + } + return base::EventFilterResult::Continue; + }); + + box->addButton(tr::lng_settings_save(), [=] { + const auto weak = Ui::MakeWeak(box); + save(std::clamp(low + picker->index() * 60, low, high)); + if (const auto strong = weak.data()) { + strong->closeBox(); + } + }); + box->addButton(tr::lng_cancel(), [=] { + box->closeBox(); + }); +} + +void EditDayBox( + not_null box, + rpl::producer title, + Data::WorkingIntervals intervals, + Fn save) { + box->setTitle(std::move(title)); + box->setWidth(st::boxWideWidth); + struct State { + rpl::variable data; + }; + const auto state = box->lifetime().make_state(State{ + .data = std::move(intervals), + }); + + const auto container = box->verticalLayout(); + const auto rows = container->add( + object_ptr(container)); + const auto makeRow = [=]( + Data::WorkingInterval interval, + TimeId min, + TimeId max) { + auto result = object_ptr(rows); + const auto raw = result.data(); + AddDivider(raw); + AddSkip(raw); + AddButtonWithLabel( + raw, + tr::lng_hours_opening(), + rpl::single(FormatDayTime(interval.start, true)), + st::settingsButtonNoIcon + )->setClickedCallback([=] { + const auto max = std::max(min, interval.end - 60); + const auto now = std::clamp(interval.start, min, max); + const auto save = crl::guard(box, [=](TimeId value) { + auto now = state->data.current(); + const auto i = ranges::find(now.list, interval); + if (i != end(now.list)) { + i->start = value; + state->data = now.normalized(); + } + }); + box->getDelegate()->show(Box(EditTimeBox, min, max, now, save)); + }); + AddButtonWithLabel( + raw, + tr::lng_hours_closing(), + rpl::single(FormatDayTime(interval.end, true)), + st::settingsButtonNoIcon + )->setClickedCallback([=] { + const auto min = std::min(max, interval.start + 60); + const auto now = std::clamp(interval.end, min, max); + const auto save = crl::guard(box, [=](TimeId value) { + auto now = state->data.current(); + const auto i = ranges::find(now.list, interval); + if (i != end(now.list)) { + i->end = value; + state->data = now.normalized(); + } + }); + box->getDelegate()->show(Box(EditTimeBox, min, max, now, save)); + }); + raw->add(object_ptr( + raw, + tr::lng_hours_remove(), + st::settingsAttentionButton + ))->setClickedCallback([=] { + auto now = state->data.current(); + const auto i = ranges::find(now.list, interval); + if (i != end(now.list)) { + now.list.erase(i); + state->data = std::move(now); + } + }); + AddSkip(raw); + + return result; + }; + + const auto addWrap = container->add( + object_ptr>( + container, + object_ptr(container))); + AddDivider(addWrap->entity()); + AddSkip(addWrap->entity()); + const auto add = addWrap->entity()->add( + object_ptr( + container, + tr::lng_hours_add_button(), + st::settingsButtonLightNoIcon)); + add->setClickedCallback([=] { + auto now = state->data.current(); + if (now.list.empty()) { + now.list.push_back({ 8 * 3600, 20 * 3600 }); + } else if (const auto last = now.list.back().end; last + 60 < kDay) { + const auto from = std::max( + std::min(last + 30 * 60, kDay - 30 * 60), + last + 60); + const auto till = std::min(from + 4 * 3600, kDay + 30 * 60); + now.list.push_back({ from, from + 4 * 3600 }); + } + state->data = std::move(now); + }); + + state->data.value( + ) | rpl::start_with_next([=](const Data::WorkingIntervals &data) { + const auto count = int(data.list.size()); + for (auto i = 0; i != count; ++i) { + const auto min = (i == 0) ? 0 : (data.list[i - 1].end + 60); + const auto max = (i == count - 1) + ? (kDay + kInNextDayMax) + : (data.list[i + 1].start - 60); + rows->insert(i, makeRow(data.list[i], min, max)); + if (rows->count() > i + 1) { + delete rows->widgetAt(i + 1); + } + } + while (rows->count() > count) { + delete rows->widgetAt(count); + } + rows->resizeToWidth(st::boxWideWidth); + addWrap->toggle(data.list.empty() + || data.list.back().end + 60 < kDay, anim::type::instant); + add->clearState(); + }, add->lifetime()); + addWrap->finishAnimating(); + + AddSkip(container); + AddDividerText(container, tr::lng_hours_about_day()); + + box->addButton(tr::lng_settings_save(), [=] { + const auto weak = Ui::MakeWeak(box); + save(state->data.current()); + if (const auto strong = weak.data()) { + strong->closeBox(); + } + }); + box->addButton(tr::lng_cancel(), [=] { + box->closeBox(); + }); +} + +void ChooseTimezoneBox( + not_null box, + std::vector list, + QString id, + Fn save) { + Expects(!list.empty()); + box->setWidth(st::boxWideWidth); + box->setTitle(tr::lng_hours_time_zone_title()); + + const auto height = st::boxWideWidth; + box->setMaxHeight(height); + + ranges::sort(list, ranges::less(), [](const Data::Timezone &value) { + return std::pair(value.utcOffset, value.name); + }); + + if (!ranges::contains(list, id, &Data::Timezone::id)) { + id = FindClosestTimezoneId(list); + } + const auto i = ranges::find(list, id, &Data::Timezone::id); + const auto value = int(i - begin(list)); + const auto group = std::make_shared(value); + const auto radioPadding = st::defaultCheckbox.margin; + const auto max = std::max(radioPadding.top(), radioPadding.bottom()); + auto index = 0; + auto padding = st::boxRowPadding + QMargins(0, max, 0, max); + auto selected = (Ui::Radiobutton*)nullptr; + for (const auto &entry : list) { + const auto button = box->addRow( + object_ptr( + box, + group, + index++, + TimezoneFullName(entry)), + padding); + if (index == value + 1) { + selected = button; + } + padding = st::boxRowPadding + QMargins(0, 0, 0, max); + } + if (selected) { + box->verticalLayout()->resizeToWidth(st::boxWideWidth); + const auto y = selected->y() - (height - selected->height()) / 2; + box->setInitScrollCallback([=] { + box->scrollToY(y); + }); + } + group->setChangedCallback([=](int index) { + const auto weak = Ui::MakeWeak(box); + save(list[index].id); + if (const auto strong = weak.data()) { + strong->closeBox(); + } + }); + box->addButton(tr::lng_close(), [=] { + box->closeBox(); + }); +} + +void AddWeekButton( + not_null container, + not_null controller, + int index, + not_null*> data) { + auto label = [&] { + switch (index) { + case 0: return tr::lng_hours_monday(); + case 1: return tr::lng_hours_tuesday(); + case 2: return tr::lng_hours_wednesday(); + case 3: return tr::lng_hours_thursday(); + case 4: return tr::lng_hours_friday(); + case 5: return tr::lng_hours_saturday(); + case 6: return tr::lng_hours_sunday(); + } + Unexpected("Index in AddWeekButton."); + }(); + const auto &st = st::settingsWorkingHoursWeek; + const auto button = AddButtonWithIcon( + container, + rpl::duplicate(label), + st); + button->setClickedCallback([=] { + const auto done = [=](Data::WorkingIntervals intervals) { + auto now = data->current(); + now.intervals = ReplaceDayIntervals( + now.intervals, + index, + std::move(intervals)); + *data = now.normalized(); + }; + controller->show(Box( + EditDayBox, + rpl::duplicate(label), + ExtractDayIntervals(data->current().intervals, index), + crl::guard(button, done))); + }); + + const auto toggleButton = Ui::CreateChild( + container.get(), + nullptr, + st); + const auto checkView = button->lifetime().make_state( + st.toggle, + false, + [=] { toggleButton->update(); }); + + auto status = data->value( + ) | rpl::map([=](const Data::WorkingHours &data) { + using namespace Data; + + const auto intervals = ExtractDayIntervals(data.intervals, index); + const auto empty = intervals.list.empty(); + if (checkView->checked() == empty) { + checkView->setChecked(!empty, anim::type::instant); + } + if (!intervals) { + return tr::lng_hours_closed(); + } else if (intervals.list.front() == WorkingInterval{ 0, kDay }) { + return tr::lng_hours_open_full(); + } + return rpl::single(JoinIntervals(intervals)); + }) | rpl::flatten_latest(); + const auto details = Ui::CreateChild( + button.get(), + std::move(status), + st::settingsWorkingHoursDetails); + details->show(); + details->moveToLeft( + st.padding.left(), + st.padding.top() + st.height - details->height()); + details->setAttribute(Qt::WA_TransparentForMouseEvents); + + const auto separator = Ui::CreateChild(container.get()); + separator->paintRequest( + ) | rpl::start_with_next([=, bg = st.textBgOver] { + auto p = QPainter(separator); + p.fillRect(separator->rect(), bg); + }, separator->lifetime()); + const auto separatorHeight = st.height - 2 * st.toggle.border; + button->geometryValue( + ) | rpl::start_with_next([=](const QRect &r) { + const auto w = st::rightsButtonToggleWidth; + toggleButton->setGeometry( + r.x() + r.width() - w, + r.y(), + w, + r.height()); + separator->setGeometry( + toggleButton->x() - st::lineWidth, + r.y() + (r.height() - separatorHeight) / 2, + st::lineWidth, + separatorHeight); + }, toggleButton->lifetime()); + + const auto checkWidget = Ui::CreateChild(toggleButton); + checkWidget->resize(checkView->getSize()); + checkWidget->paintRequest( + ) | rpl::start_with_next([=] { + auto p = QPainter(checkWidget); + checkView->paint(p, 0, 0, checkWidget->width()); + }, checkWidget->lifetime()); + toggleButton->sizeValue( + ) | rpl::start_with_next([=](const QSize &s) { + checkWidget->moveToRight( + st.toggleSkip, + (s.height() - checkWidget->height()) / 2); + }, toggleButton->lifetime()); + + toggleButton->setClickedCallback([=] { + const auto enabled = !checkView->checked(); + checkView->setChecked(enabled, anim::type::normal); + auto now = data->current(); + now.intervals = ReplaceDayIntervals( + now.intervals, + index, + (enabled + ? Data::WorkingIntervals{ { { 0, kDay } } } + : Data::WorkingIntervals())); + *data = now.normalized(); + }); +} + WorkingHours::WorkingHours( QWidget *parent, not_null controller) @@ -56,11 +553,21 @@ rpl::producer WorkingHours::title() { } void WorkingHours::setupContent( - not_null controller) { + not_null controller) { using namespace rpl::mappers; const auto content = Ui::CreateChild(this); + struct State { + rpl::variable timezones; + bool timezoneEditPending = false; + }; + const auto info = &controller->session().data().businessInfo(); + const auto state = content->lifetime().make_state(State{ + .timezones = info->timezonesValue(), + }); + _hours = info->workingHours(); + AddDividerTextWithLottie(content, { .lottie = u"hours"_q, .lottieSize = st::settingsCloudPasswordIconSize, @@ -85,6 +592,75 @@ void WorkingHours::setupContent( Ui::AddSkip(inner); Ui::AddDivider(inner); + Ui::AddSkip(inner); + + for (auto i = 0; i != 7; ++i) { + AddWeekButton(inner, controller, i, &_hours); + } + + Ui::AddSkip(inner); + Ui::AddDivider(inner); + Ui::AddSkip(inner); + + state->timezones.value( + ) | rpl::filter([=](const Data::Timezones &value) { + return !value.list.empty(); + }) | rpl::start_with_next([=](const Data::Timezones &value) { + const auto now = _hours.current().timezoneId; + if (!ranges::contains(value.list, now, &Data::Timezone::id)) { + auto copy = _hours.current(); + copy.timezoneId = FindClosestTimezoneId(value.list); + _hours = std::move(copy); + } + }, inner->lifetime()); + + auto timezoneLabel = rpl::combine( + _hours.value(), + state->timezones.value() + ) | rpl::map([]( + const Data::WorkingHours &hours, + const Data::Timezones &timezones) { + const auto i = ranges::find( + timezones.list, + hours.timezoneId, + &Data::Timezone::id); + return (i != end(timezones.list)) ? TimezoneFullName(*i) : QString(); + }); + const auto editTimezone = [=](const std::vector &list) { + const auto was = _hours.current().timezoneId; + controller->show(Box(ChooseTimezoneBox, list, was, [=](QString id) { + if (id != was) { + auto copy = _hours.current(); + copy.timezoneId = id; + _hours = std::move(copy); + } + })); + }; + AddButtonWithLabel( + inner, + tr::lng_hours_time_zone(), + std::move(timezoneLabel), + st::settingsButtonNoIcon + )->setClickedCallback([=] { + const auto &list = state->timezones.current().list; + if (!list.empty()) { + editTimezone(list); + } else { + state->timezoneEditPending = true; + } + }); + + if (state->timezones.current().list.empty()) { + state->timezones.value( + ) | rpl::filter([](const Data::Timezones &value) { + return !value.list.empty(); + }) | rpl::start_with_next([=](const Data::Timezones &value) { + if (state->timezoneEditPending) { + state->timezoneEditPending = false; + editTimezone(value.list); + } + }, inner->lifetime()); + } wrap->toggleOn(enabled->toggledValue()); wrap->finishAnimating(); @@ -93,6 +669,8 @@ void WorkingHours::setupContent( } void WorkingHours::save() { + controller()->session().data().businessInfo().saveWorkingHours( + _hours.current()); } } // namespace diff --git a/Telegram/SourceFiles/settings/settings.style b/Telegram/SourceFiles/settings/settings.style index ef519dced4..3e57d64ccc 100644 --- a/Telegram/SourceFiles/settings/settings.style +++ b/Telegram/SourceFiles/settings/settings.style @@ -607,3 +607,10 @@ settingsChatbotsBottomTextMargin: margins(22px, 8px, 22px, 3px); settingsChatbotsAdd: SettingsButton(settingsButton) { iconLeft: 22px; } +settingsWorkingHoursWeek: SettingsButton(settingsButtonNoIcon) { + height: 40px; + padding: margins(22px, 4px, 22px, 4px); +} +settingsWorkingHoursDetails: settingsNotificationTypeDetails; +settingsWorkingHoursPicker: 200px; +settingsWorkingHoursPickerItemHeight: 40px; diff --git a/Telegram/SourceFiles/settings/settings_business.cpp b/Telegram/SourceFiles/settings/settings_business.cpp index e72391272f..f936466068 100644 --- a/Telegram/SourceFiles/settings/settings_business.cpp +++ b/Telegram/SourceFiles/settings/settings_business.cpp @@ -10,6 +10,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "boxes/premium_preview_box.h" #include "core/click_handler_types.h" #include "data/data_peer_values.h" // AmPremiumValue. +#include "data/data_session.h" +#include "data/business/data_business_info.h" #include "info/info_wrap_widget.h" // Info::Wrap. #include "info/settings/info_settings_widget.h" // SectionCustomTopBarData. #include "lang/lang_keys.h" @@ -356,6 +358,8 @@ void Business::setStepDataReference(std::any &data) { void Business::setupContent() { const auto content = Ui::CreateChild(this); + _controller->session().data().businessInfo().preloadTimezones(); + Ui::AddSkip(content, st::settingsFromFileTop); AddBusinessSummary(content, _controller, [=](BusinessFeature feature) {