From 47eaab722def843aca234630a08c5dbe96043803 Mon Sep 17 00:00:00 2001 From: Mairon <3043462073@qq.com> Date: Mon, 26 Aug 2024 19:15:21 +0800 Subject: [PATCH] feat(ui): add event card, card group and conversion function --- src/Controller/Convert.hh | 124 ++++++++++++++++ ui/assets/image/icon/pentagon.svg | 1 + ui/assets/image/image_token.slint | 2 + ui/components/event_card.slint | 227 ++++++++++++++++++++++++++++++ ui/components/event_struct.slint | 21 +++ ui/components/index.slint | 12 +- ui/views/page/discovery.slint | 36 +++++ 7 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/Controller/Convert.hh create mode 100644 ui/assets/image/icon/pentagon.svg create mode 100644 ui/components/event_card.slint create mode 100644 ui/components/event_struct.slint diff --git a/src/Controller/Convert.hh b/src/Controller/Convert.hh new file mode 100644 index 00000000..b0f971fb --- /dev/null +++ b/src/Controller/Convert.hh @@ -0,0 +1,124 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace evento::convert { + +namespace details { + +slint::SharedString convertTimeRange(const std::string& startTimeStr, + const std::string& endTimeStr) { + std::istringstream ssStart(startTimeStr); + std::istringstream ssEnd(endTimeStr); + std::chrono::sys_seconds startTp, endTp; + + ssStart >> std::chrono::parse("%Y-%m-%dT%H:%M:%SZ", startTp); + if (ssStart.fail()) { + spdlog::warn("Failed to parse start-time string: {}", startTimeStr); + return " "; + } + ssEnd >> std::chrono::parse("%Y-%m-%dT%H:%M:%SZ", endTp); + if (ssEnd.fail()) { + spdlog::warn("Failed to parse end-time string: {}", endTimeStr); + return " "; + } + + auto startTimer = std::chrono::system_clock::to_time_t(startTp); + auto startDate = *std::gmtime(&startTimer); + + auto endTimer = std::chrono::system_clock::to_time_t(endTp); + auto endDate = *std::gmtime(&endTimer); + + std::string startStr = std::format("{:02}.{:02} {:02}:{:02}", + startDate.tm_mon + 1, + startDate.tm_mday, + startDate.tm_hour, + startDate.tm_min); + std::string endStr = std::format("{:02}.{:02} {:02}:{:02}", + endDate.tm_mon + 1, + endDate.tm_mday, + endDate.tm_hour, + endDate.tm_min); + + if (startDate.tm_year != endDate.tm_year) { + return slint::SharedString{std::format("{:04} {} - {:04} {}", + startDate.tm_year + 1900, + startStr, + endDate.tm_year + 1900, + endStr)}; + } + if (startDate.tm_mon != endDate.tm_mon || startDate.tm_mday != endDate.tm_mday) { + return slint::SharedString{startStr + " - " + endStr}; + } + return slint::SharedString{startStr + " - " + endStr.substr(6)}; +} + +slint::SharedString firstUnicode(const std::string& str) { + if (str.empty()) { + return " "; + } + size_t length = 0; + auto firstByte = static_cast(str[0]); + if ((firstByte & 0x80) == 0) { + // ASCII character + length = 1; + } else if ((firstByte & 0xE0) == 0xC0) { + // Two-byte character + length = 2; + } else if ((firstByte & 0xF0) == 0xE0) { + // Three-byte character + length = 3; + } else if ((firstByte & 0xF8) == 0xF0) { + // Four-byte character + length = 4; + } else { + // Invalid Unicode + return " "; + } + if (str.size() < length) { + return " "; + } + return slint::SharedString{str.substr(0, length)}; +} + +} // namespace details + +using EventEntityList = std::vector; +using EventStructModel = std::shared_ptr>; + +auto from(const auto& obj) { + return obj; +} + +EventStruct from(const EventEntity& entity) { + return { + .id = entity.id, + .summary = slint::SharedString(entity.summary), + .summary_abbr = details::firstUnicode(entity.summary), + .description = slint::SharedString(entity.description), + .time = details::convertTimeRange(entity.start, entity.end), + .location = slint::SharedString(entity.location), + .tag = slint::SharedString(entity.tag), + .larkMeetingRoomName = slint::SharedString(entity.larkMeetingRoomName), + .larkDepartmentName = slint::SharedString(entity.larkDepartmentName), + .state = static_cast(entity.state), + .is_subscribed = entity.isSubscribed, + .is_checkIn = entity.isCheckedIn, + }; +} + +EventStructModel from(const EventEntityList& list) { + std::vector model; + model.reserve(list.size()); + for (auto& entity : list) { + model.push_back(from(entity)); + } + return std::make_shared>(model); +} + +} // namespace evento::convert \ No newline at end of file diff --git a/ui/assets/image/icon/pentagon.svg b/ui/assets/image/icon/pentagon.svg new file mode 100644 index 00000000..b9b0c6a8 --- /dev/null +++ b/ui/assets/image/icon/pentagon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/ui/assets/image/image_token.slint b/ui/assets/image/image_token.slint index 25894344..dbd19a70 100644 --- a/ui/assets/image/image_token.slint +++ b/ui/assets/image/image_token.slint @@ -30,6 +30,7 @@ struct EventoIconCollection { topic: image, tree-list: image, account-box: image, + pentagon: image, } struct EventoDisplayCollection { @@ -94,6 +95,7 @@ export global EventoImageToken { topic: @image-url("./icon/topic.svg"), tree-list: @image-url("./icon/tree-list.svg"), account-box: @image-url("./icon/account-box-outline.svg"), + pentagon: @image-url("./icon/pentagon.svg"), }; // display used as images bigger than icon // most display image won't change according to darkmode switch diff --git a/ui/components/event_card.slint b/ui/components/event_card.slint new file mode 100644 index 00000000..25d1db94 --- /dev/null +++ b/ui/components/event_card.slint @@ -0,0 +1,227 @@ +import { Token } from "../global.slint"; +import { EventStruct } from "./event_struct.slint"; + +export component EventCard inherits Rectangle { + property surface: Token.color.surface-container-low; + property on-surface: Token.color.on-surface-variant; + property inverse-surface: Token.color.inverse-surface; + property inverse-on-surface: Token.color.inverse-on-surface; + property vertical-padding: 14px; + property horizontal-padding: 14px; + property press-x; + property press-y; + callback clicked; + in-out property event: { + summary: "活动标题", + summary-abbr: "活", + time: "活动时间", + location: "活动地点", + description: "活动内容", + id: 0 + }; + width: 303px; + height: 188px; + clip: true; + background: surface; + border-radius: 12px; + + states [ + ripple when touch-area.pressed: { + drop-shadow-color: Token.color.surface; + drop-shadow-blur: 0px; + drop-shadow-offset-y: 0px; + animate-circle.radius: root.width * 1.1; + in { + animate drop-shadow-color, drop-shadow-blur, drop-shadow-offset-y, animate-circle.radius { duration: 200ms; } + } + out { + animate drop-shadow-color, drop-shadow-blur, drop-shadow-offset-y, animate-circle.radius { duration: 200ms; } + } + } + origin when !touch-area.pressed: { + drop-shadow-color: touch-area.has-hover ? #070707ce : Token.color.surface; + drop-shadow-blur: touch-area.has-hover ? 4px : 0px; + drop-shadow-offset-y: touch-area.has-hover ? 2px : 0px; + animate-circle.radius: 0px; + } + ] + + touch-area := TouchArea { + width: 100%; + height: 100%; + clicked => { + root.clicked(); + } + } + + animate-circle := Rectangle { + in-out property radius; + in-out property center-x; + in-out property center-y; + center-x: touch-area.pressed-x; + center-y: touch-area.pressed-y; + radius: 0px; + x: center-x - radius; + y: center-y - radius; + width: radius * 2; + height: radius * 2; + border-radius: radius; + background: Token.color.surface-variant; + } + + abbr-circle := Rectangle { + x: horizontal-padding; + y: vertical-padding; + width: 28px; + height: self.width; + border-radius: self.width / 2; + background: on-surface; + abbr-text := Text { + color: inverse-on-surface; + text: event.summary-abbr; + font-size: Token.font.label.small.size; + font-weight: Token.font.label.small.weight; + } + } + + pentagon := Image { + x: parent.width - self.width - horizontal-padding; + y: abbr-circle.y + (abbr-circle.height - self.height) / 2; + width: 10px; + height: self.width; + colorize: on-surface; + source: Token.image.icon.pentagon; + } + + title-layout := VerticalLayout { + x: horizontal-padding; + y: abbr-circle.y + abbr-circle.height; + width: root.width - horizontal-padding * 2; + height: info-layout.y - self.y; + alignment: center; + event-title := Text { + color: inverse-surface; + text: event.summary; + wrap: word-wrap; + width: parent.width; + font-size: Token.font.body.large.size; + font-weight: Token.font.headline.large.weight; + } + } + + info-layout := VerticalLayout { + x: horizontal-padding; + y: root.height - self.height - vertical-padding; + width: root.width - horizontal-padding * 2; + height: time-label.height * 3 + self.spacing * 2; + alignment: center; + spacing: 2px; + time-label := Rectangle { + width: root.width - root.horizontal-padding * 2; + height: 20px; + HorizontalLayout { + spacing: 6px; + time-icon := Image { + width: 16px; + height: self.width; + source: Token.image.icon.time; + colorize: time-text.color; + } + + time-text := Text { + font-size: Token.font.label.small.size; + text: event.time; + width: parent.width - time-icon.width - parent.spacing; + overflow: elide; + } + } + } + + location-label := Rectangle { + width: root.width - root.horizontal-padding * 2; + height: 20px; + HorizontalLayout { + spacing: 6px; + location-icon := Image { + width: 16px; + height: self.width; + source: Token.image.icon.locate; + colorize: location-text.color; + } + + location-text := Text { + font-size: Token.font.label.small.size; + text: event.location; + width: parent.width - location-icon.width - parent.spacing; + overflow: elide; + } + } + } + + description-label := Rectangle { + width: root.width - root.horizontal-padding * 2; + height: 20px; + HorizontalLayout { + spacing: 6px; + description-icon := Image { + width: 16px; + height: self.width; + source: Token.image.icon.topic; + colorize: location-text.color; + } + + description-text := Text { + font-size: Token.font.label.small.size; + text: event.description; + width: parent.width - description-icon.width - parent.spacing; + overflow: elide; + } + } + } + } +} + +export component EventCardGroup inherits Rectangle { + in-out property <[EventStruct]> events; + in-out property horizontal-spacing: 20px; + in-out property vertical-spacing: 20px; + in-out property card-width: 236px; + out property card-height: 188px; + property row-count: floor(events.length / 3) * 3 == events.length ? events.length / 3 : floor(events.length / 3) + 1; + callback card-clicked(EventStruct); + width: card-width * 3 + horizontal-spacing * 4; + height: card-height * 3 + vertical-spacing * (row-count + 1); + + function row-indexes(start-idx: int) -> [int] { + if start-idx + 1 < root.events.length { + if start-idx + 2 < root.events.length { + return [start-idx, start-idx + 1, start-idx + 2]; + } else { + return [start-idx, start-idx + 1]; + } + } + return [start-idx]; + } + + VerticalLayout { + width: 100%; + height: 100%; + alignment: center; + spacing: root.vertical-spacing; + for row-idx in row-count: HorizontalLayout { + property start-idx: row-idx * 3; + width: 100%; + height: root.card-height; + alignment: start; + padding: (self.width - self.spacing * 2 - root.card-width * 3) / 2; + spacing: root.horizontal-spacing; + for idx in root.row-indexes(start-idx): EventCard { + event: root.events[idx]; + width: root.card-width; + clicked => { + root.card-clicked(self.event); + } + } + } + } +} diff --git a/ui/components/event_struct.slint b/ui/components/event_struct.slint new file mode 100644 index 00000000..ca61e9cb --- /dev/null +++ b/ui/components/event_struct.slint @@ -0,0 +1,21 @@ +export enum EventState { + SigningUp, + Active, + Completed, + Cancelled, +} + +export struct EventStruct { + id: int, + summary: string, + summary-abbr: string, + description: string, + time: string, + location: string, + tag: string, + larkMeetingRoomName: string, + larkDepartmentName: string, + state: EventState, + is-subscribed: bool, + is-checkIn: bool +} diff --git a/ui/components/index.slint b/ui/components/index.slint index 48db3c64..885831cc 100644 --- a/ui/components/index.slint +++ b/ui/components/index.slint @@ -18,4 +18,14 @@ export { export { LoadingButton -} from "./button.slint"; \ No newline at end of file +} from "./button.slint"; + +export { + EventCard, + EventCardGroup +} from "./event_card.slint"; + +export { + EventState, + EventStruct +} from "./event_struct.slint"; \ No newline at end of file diff --git a/ui/views/page/discovery.slint b/ui/views/page/discovery.slint index deb890c0..35817173 100644 --- a/ui/views/page/discovery.slint +++ b/ui/views/page/discovery.slint @@ -1,9 +1,45 @@ import { Token } from "../../global.slint"; import { Page, Empty } from "../../components/index.slint"; +import { EventCardGroup } from "../../components/event_card.slint"; export global DiscoveryPageBridge { } export component DiscoveryPage inherits Page { + /* demo code for generating corresponding C++ code correctly + remove this when EventCard&EventCardGroup is truly used + otherwise "Convert.h/Convert.cc" will fail to compile */ + EventCardGroup { + card-width: parent.width * 0.25; + events: [ + { + id: 1, + summary-abbr: "软", + summary: "软件研发部 C++组授课", + time: "09.30 14:30 - 09.30 15:30", + location: "仙林校区 大学生活动中心101", + description: "组内授课" + }, + { + id: 2, + summary-abbr: "软", + summary: "软件研发部 Web组授课", + time: "09.30 14:30 - 09.30 15:30", + location: "仙林校区 大学生活动中心101", + description: "组内授课" + }, + { + id: 3, + summary-abbr: "软", + summary: "软件研发部 C#组授课", + time: "09.30 14:30 - 09.30 15:30", + location: "仙林校区 大学生活动中心101", + description: "组内授课" + } + ]; + card-clicked(event-struct) => { + debug("id: " + event-struct.id + "title: " + event-struct.summary); + } + } // TODO: implement Discovery // optional background := Empty { }