From 3f304ce2aad15f4015a880b24e2a5b1b40f9f056 Mon Sep 17 00:00:00 2001 From: Roy Date: Sun, 18 Jan 2026 20:14:55 +0800 Subject: [PATCH 1/4] feat(kanban): basic kanban pages and component support Some Kanban board components and pages rendered directly in app.rs are needed to build a working project. --- Cargo.lock | 93 ++--- Cargo.toml | 3 +- resources/icons/kanban.svg | 7 + src/app.rs | 253 ++++++++++++++ src/home/home_screen.rs | 39 ++- src/home/navigation_tab_bar.rs | 37 +- src/kanban/api/kanban_requests.rs | 57 +++ src/kanban/api/mod.rs | 5 + src/kanban/api/repositories.rs | 2 + src/kanban/data/mod.rs | 5 + src/kanban/data/models.rs | 348 +++++++++++++++++++ src/kanban/data/repositories.rs | 104 ++++++ src/kanban/drag_drop/drag_handler.rs | 2 + src/kanban/drag_drop/mod.rs | 5 + src/kanban/drag_drop/order_manager.rs | 58 ++++ src/kanban/kanban_app.rs | 278 +++++++++++++++ src/kanban/kanban_app/state.rs | 34 ++ src/kanban/kanban_app/view.rs | 51 +++ src/kanban/mod.rs | 22 ++ src/kanban/state/kanban_actions.rs | 123 +++++++ src/kanban/state/kanban_state.rs | 94 +++++ src/kanban/state/mod.rs | 6 + src/kanban/ui/components/board_background.rs | 180 ++++++++++ src/kanban/ui/components/board_header.rs | 151 ++++++++ src/kanban/ui/components/board_members.rs | 237 +++++++++++++ src/kanban/ui/components/board_menu.rs | 149 ++++++++ src/kanban/ui/components/board_toolbar.rs | 205 +++++++++++ src/kanban/ui/components/boards_sidebar.rs | 150 ++++++++ src/kanban/ui/components/kanban_card.rs | 192 ++++++++++ src/kanban/ui/components/kanban_list.rs | 211 +++++++++++ src/kanban/ui/components/mod.rs | 19 + src/kanban/ui/mod.rs | 15 + src/kanban/ui/workspace/kanban_workspace.rs | 32 ++ src/kanban/ui/workspace/mod.rs | 5 + src/lib.rs | 3 +- src/main.rs | 2 +- src/shared/styles.rs | 1 + 37 files changed, 3121 insertions(+), 57 deletions(-) create mode 100644 resources/icons/kanban.svg create mode 100644 src/kanban/api/kanban_requests.rs create mode 100644 src/kanban/api/mod.rs create mode 100644 src/kanban/api/repositories.rs create mode 100644 src/kanban/data/mod.rs create mode 100644 src/kanban/data/models.rs create mode 100644 src/kanban/data/repositories.rs create mode 100644 src/kanban/drag_drop/drag_handler.rs create mode 100644 src/kanban/drag_drop/mod.rs create mode 100644 src/kanban/drag_drop/order_manager.rs create mode 100644 src/kanban/kanban_app.rs create mode 100644 src/kanban/kanban_app/state.rs create mode 100644 src/kanban/kanban_app/view.rs create mode 100644 src/kanban/mod.rs create mode 100644 src/kanban/state/kanban_actions.rs create mode 100644 src/kanban/state/kanban_state.rs create mode 100644 src/kanban/state/mod.rs create mode 100644 src/kanban/ui/components/board_background.rs create mode 100644 src/kanban/ui/components/board_header.rs create mode 100644 src/kanban/ui/components/board_members.rs create mode 100644 src/kanban/ui/components/board_menu.rs create mode 100644 src/kanban/ui/components/board_toolbar.rs create mode 100644 src/kanban/ui/components/boards_sidebar.rs create mode 100644 src/kanban/ui/components/kanban_card.rs create mode 100644 src/kanban/ui/components/kanban_list.rs create mode 100644 src/kanban/ui/components/mod.rs create mode 100644 src/kanban/ui/mod.rs create mode 100644 src/kanban/ui/workspace/kanban_workspace.rs create mode 100644 src/kanban/ui/workspace/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 4aee574c0..5156fe586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3787,6 +3787,53 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "netrix" +version = "0.0.1-pre-alpha-4" +dependencies = [ + "anyhow", + "aws-lc-rs", + "bitflags 2.10.0", + "blurhash", + "bytesize", + "chrono", + "clap", + "crossbeam-channel", + "crossbeam-queue", + "eyeball", + "eyeball-im", + "futures-util", + "htmlize", + "imbl", + "imghdr", + "indexmap 2.13.0", + "linkify", + "makepad-widgets", + "matrix-sdk", + "matrix-sdk-base", + "matrix-sdk-ui", + "percent-encoding", + "quinn", + "rand 0.8.5", + "rangemap", + "reqwest", + "robius-directories", + "robius-location", + "robius-open", + "robius-use-makepad", + "ruma", + "sanitize-filename", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing-subscriber", + "tsp_sdk", + "unicode-segmentation", + "url", + "uuid", +] + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -4834,52 +4881,6 @@ dependencies = [ "robius-android-env", ] -[[package]] -name = "robrix" -version = "0.0.1-pre-alpha-4" -dependencies = [ - "anyhow", - "aws-lc-rs", - "bitflags 2.10.0", - "blurhash", - "bytesize", - "chrono", - "clap", - "crossbeam-channel", - "crossbeam-queue", - "eyeball", - "eyeball-im", - "futures-util", - "htmlize", - "imbl", - "imghdr", - "indexmap 2.13.0", - "linkify", - "makepad-widgets", - "matrix-sdk", - "matrix-sdk-base", - "matrix-sdk-ui", - "percent-encoding", - "quinn", - "rand 0.8.5", - "rangemap", - "reqwest", - "robius-directories", - "robius-location", - "robius-open", - "robius-use-makepad", - "ruma", - "sanitize-filename", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tracing-subscriber", - "tsp_sdk", - "unicode-segmentation", - "url", -] - [[package]] name = "roxmltree" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index 33a601224..433dc4f1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "robrix" +name = "netrix" authors = [ "Kevin Boos ", "Robius Project Maintainers", @@ -79,6 +79,7 @@ reqwest = { version = "0.12", default-features = false, features = [ "macos-system-configuration", ] } thiserror = "2.0.16" +uuid = { version = "1.18.1", features = ["v4"] } [features] diff --git a/resources/icons/kanban.svg b/resources/icons/kanban.svg new file mode 100644 index 000000000..8daa992e9 --- /dev/null +++ b/resources/icons/kanban.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app.rs b/src/app.rs index 481e74f19..b840a14a5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use makepad_widgets::*; use matrix_sdk::{RoomState, ruma::{OwnedRoomId, RoomId}}; use serde::{Deserialize, Serialize}; +use uuid::Uuid; use crate::{ avatar_cache::clear_avatar_cache, home::{ @@ -27,6 +28,7 @@ use crate::{ VerificationModalWidgetRefExt, } }; +use crate::kanban::{KanbanActions, KanbanAppState, KanbanBoard, KanbanCard, KanbanFilterState, KanbanList}; live_design! { use link::theme::*; @@ -196,6 +198,7 @@ impl LiveRegister for App { crate::room::live_design(cx); crate::join_leave_room_modal::live_design(cx); crate::verification_modal::live_design(cx); + crate::kanban::live_design(cx); crate::home::live_design(cx); crate::profile::live_design(cx); crate::login::live_design(cx); @@ -247,6 +250,12 @@ impl MatchEvent for App { } for action in actions { + if let Some(kanban_action) = action.downcast_ref::() { + self.handle_kanban_action(cx, kanban_action.clone()); + self.ui.redraw(cx); + continue; + } + if let Some(logout_modal_action) = action.downcast_ref::() { match logout_modal_action { LogoutConfirmModalAction::Open => { @@ -642,6 +651,248 @@ impl App { closure(cx); } } + + fn handle_kanban_action(&mut self, _cx: &mut Cx, action: KanbanActions) { + let state = &mut self.app_state.kanban_state; + match action { + KanbanActions::LoadBoards => { + if state.boards.is_empty() { + let board = Self::seed_board(state, "demo kanban", None); + state.current_board_id = Some(board.id.clone()); + } + } + KanbanActions::SelectBoard(board_id) => { + if state.boards.contains_key(&board_id) { + state.current_board_id = Some(board_id); + } + } + KanbanActions::CreateBoard { name, description } => { + let board = Self::seed_board(state, &name, description.clone()); + state.current_board_id = Some(board.id.clone()); + } + + KanbanActions::UpdateBoard { board_id, updates } => { + if let Some(board) = state.boards.get_mut(&board_id) { + if let Some(name) = updates.name { + board.name = name; + } + if let Some(description) = updates.description { + board.description = description; + } + if let Some(background_color) = updates.background_color { + board.background_color = background_color; + } + if let Some(background_image) = updates.background_image { + board.background_image = background_image; + } + board.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + KanbanActions::DeleteBoard { board_id } => { + if let Some(board) = state.boards.remove(&board_id) { + for list_id in board.list_ids { + if let Some(list) = state.lists.remove(&list_id) { + for card_id in list.card_ids { + state.cards.remove(&card_id); + } + } + } + if state.current_board_id.as_ref() == Some(&board_id) { + state.current_board_id = None; + } + } + } + KanbanActions::LoadLists { .. } | KanbanActions::LoadCards { .. } => {} + KanbanActions::CreateList { board_id, name } => { + if let Some(board) = state.boards.get_mut(&board_id) { + let list = KanbanList::new(&name, board_id.clone()); + board.list_ids.push(list.id.clone()); + state.lists.insert(list.id.clone(), list); + board.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + KanbanActions::UpdateList { + board_id, + list_id, + updates, + } => { + if state.boards.contains_key(&board_id) { + if let Some(list) = state.lists.get_mut(&list_id) { + if let Some(name) = updates.name { + list.name = name; + } + if let Some(archived) = updates.archived { + list.is_archived = archived; + } + list.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + } + KanbanActions::DeleteList { board_id, list_id } => { + if let Some(board) = state.boards.get_mut(&board_id) { + board.list_ids.retain(|id| id != &list_id); + if let Some(list) = state.lists.remove(&list_id) { + for card_id in list.card_ids { + state.cards.remove(&card_id); + } + } + board.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + KanbanActions::MoveList { + board_id, + list_id, + new_position, + } => { + if let Some(list) = state.lists.get_mut(&list_id) { + if list.board_id == board_id { + list.position = new_position; + list.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + } + KanbanActions::CreateCard { + board_id, + list_id, + name, + } => { + if let Some(list) = state.lists.get_mut(&list_id) { + if list.board_id == board_id { + let card = KanbanCard::new(&name, list_id.clone(), board_id.clone()); + list.card_ids.push(card.id.clone()); + state.cards.insert(card.id.clone(), card); + list.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + } + KanbanActions::UpdateCard { + board_id, + card_id, + updates, + } => { + if let Some(card) = state.cards.get_mut(&card_id) { + if card.board_id == board_id { + if let Some(title) = updates.title { + card.title = title; + } + if let Some(description) = updates.description { + card.description = description; + } + if let Some(label_ids) = updates.label_ids { + card.label_ids = label_ids; + } + if let Some(member_ids) = updates.member_ids { + card.member_ids = member_ids; + } + if let Some(due_date) = updates.due_date { + card.due_date = due_date; + } + if let Some(is_starred) = updates.is_starred { + card.is_starred = is_starred; + } + if let Some(archived) = updates.archived { + card.is_archived = archived; + } + card.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + } + KanbanActions::DeleteCard { board_id, card_id } => { + if let Some(card) = state.cards.remove(&card_id) { + if card.board_id == board_id { + if let Some(list) = state.lists.get_mut(&card.list_id) { + list.card_ids.retain(|id| id != &card_id); + list.updated_at = chrono::Utc::now().to_rfc3339(); + } + } + } + } + KanbanActions::MoveCard { + board_id, + card_id, + from_list, + to_list, + new_position, + } => { + if let Some(card) = state.cards.get_mut(&card_id) { + if card.board_id == board_id { + card.list_id = to_list.clone(); + card.position = new_position; + card.updated_at = chrono::Utc::now().to_rfc3339(); + if let Some(list) = state.lists.get_mut(&from_list) { + list.card_ids.retain(|id| id != &card_id); + } + if let Some(list) = state.lists.get_mut(&to_list) { + if !list.card_ids.contains(&card_id) { + list.card_ids.push(card_id.clone()); + } + } + } + } + } + KanbanActions::ArchiveCard { + board_id, + card_id, + archived, + } => { + if let Some(card) = state.cards.get_mut(&card_id) { + if card.board_id == board_id { + card.is_archived = archived; + } + } + } + KanbanActions::SetFilter(filter) => { + state.filter_state = Some(filter); + } + KanbanActions::SetSort(sort) => { + state.sort_state = Some(sort); + } + KanbanActions::Search { board_id, query } => { + if state.current_board_id.as_ref() == Some(&board_id) { + state.filter_state = Some(KanbanFilterState { + keyword: Some(query), + label_ids: Vec::new(), + member_ids: Vec::new(), + due_date: None, + }); + } + } + KanbanActions::Error(message) => { + state.error = Some(message); + } + KanbanActions::Loading(loading) => { + state.loading = loading; + } + } + } + + fn seed_board( + state: &mut KanbanAppState, + name: &str, + description: Option, + ) -> KanbanBoard { + let board_id = Self::create_local_board_id(); + let mut board = KanbanBoard::new(name); + board.id = board_id.clone(); + board.description = description; + + let todo = KanbanList::new("ready", board_id.clone()); + let doing = KanbanList::new("doing", board_id.clone()); + let done = KanbanList::new("success", board_id.clone()); + + board.list_ids = vec![todo.id.clone(), doing.id.clone(), done.id.clone()]; + state.lists.insert(todo.id.clone(), todo); + state.lists.insert(doing.id.clone(), doing); + state.lists.insert(done.id.clone(), done); + state.boards.insert(board_id, board.clone()); + board + } + + fn create_local_board_id() -> OwnedRoomId { + let room_id = format!("!kanban-{}:local", Uuid::new_v4()); + OwnedRoomId::try_from(room_id) + .unwrap_or_else(|_| OwnedRoomId::try_from("!kanban:local").expect("fallback board id")) + } } /// App-wide state that is stored persistently across multiple app runs @@ -667,6 +918,8 @@ pub struct AppState { pub saved_dock_state_per_space: HashMap, /// Whether a user is currently logged in to Robrix or not. pub logged_in: bool, + #[serde(skip)] + pub kanban_state: KanbanAppState, } /// A snapshot of the main dock: all state needed to restore the dock tabs/layout. diff --git a/src/home/home_screen.rs b/src/home/home_screen.rs index be3948340..039ce4fea 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -1,6 +1,11 @@ use makepad_widgets::*; -use crate::{app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, settings::settings_screen::SettingsScreenWidgetRefExt}; +use crate::{ + app::AppState, + home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, + settings::settings_screen::SettingsScreenWidgetRefExt, +}; +use crate::kanban::KanbanActions; live_design! { use link::theme::*; @@ -13,6 +18,7 @@ live_design! { use crate::home::search_messages::*; use crate::home::spaces_bar::*; use crate::home::add_room::*; + use crate::kanban::kanban_app::KanbanApp; use crate::shared::styles::*; use crate::shared::room_filter_input_bar::RoomFilterInputBar; use crate::home::main_desktop_ui::MainDesktopUI; @@ -128,6 +134,20 @@ live_design! { } } + kanban_page = { + width: Fill, height: Fill + show_bg: true, + draw_bg: { + color: #F4F5F7 + }, + + flow: Down, + + kanban_app = { + width: Fill, height: Fill + } + } + add_room_page = { width: Fill, height: Fill show_bg: true, @@ -234,7 +254,6 @@ live_design! { } } - /// A simple wrapper around the SpacesBar that allows us to animate showing or hiding it. #[derive(Live, LiveHook, Widget)] pub struct SpacesBarWrapper { @@ -274,7 +293,6 @@ impl SpacesBarWrapperRef { } } - #[derive(Live, LiveHook, Widget)] pub struct HomeScreen { #[deref] view: View, @@ -341,7 +359,19 @@ impl Widget for HomeScreen { Some(NavigationBarAction::CloseSettings) => { if matches!(app_state.selected_tab, SelectedTab::Settings) { app_state.selected_tab = self.previous_selection.clone(); - cx.action(NavigationBarAction::TabSelected(app_state.selected_tab.clone())); + cx.action(NavigationBarAction::TabSelected( + app_state.selected_tab.clone(), + )); + self.update_active_page_from_selection(cx, app_state); + self.view.redraw(cx); + } + } + Some(NavigationBarAction::GoToKanban) => { + if !matches!(app_state.selected_tab, SelectedTab::Kanban) { + self.previous_selection = app_state.selected_tab.clone(); + app_state.selected_tab = SelectedTab::Kanban; + cx.action(NavigationBarAction::TabSelected(SelectedTab::Kanban)); + cx.action(KanbanActions::LoadBoards); self.update_active_page_from_selection(cx, app_state); self.view.redraw(cx); } @@ -388,6 +418,7 @@ impl HomeScreen { | SelectedTab::Home => id!(home_page), SelectedTab::Settings => id!(settings_page), SelectedTab::AddRoom => id!(add_room_page), + SelectedTab::Kanban => id!(kanban_page), }, ) } diff --git a/src/home/navigation_tab_bar.rs b/src/home/navigation_tab_bar.rs index bc23d32d9..f93925632 100644 --- a/src/home/navigation_tab_bar.rs +++ b/src/home/navigation_tab_bar.rs @@ -210,6 +210,10 @@ live_design! { draw_icon: { svg_file: (ICON_ADD) } } + KanbanButton = { + draw_icon: { svg_file: (ICON_SQUARES) } + } + Separator = { margin: 8 } pub NavigationTabBar = {{NavigationTabBar}} { @@ -237,6 +241,10 @@ live_design! { add_room_button = {} } + { + kanban_button = {} + } + {} { @@ -270,6 +278,10 @@ live_design! { add_room_button = {} } + { + kanban_button = {} + } + toggle_spaces_bar_button = {} { @@ -428,12 +440,14 @@ impl Widget for NavigationTabBar { let radio_button_set = self.view.radio_button_set(ids_array!( home_button, add_room_button, + kanban_button, settings_button, )); match radio_button_set.selected(cx, actions) { Some(0) => cx.action(NavigationBarAction::GoToHome), Some(1) => cx.action(NavigationBarAction::GoToAddRoom), - Some(2) => cx.action(NavigationBarAction::OpenSettings), + Some(2) => cx.action(NavigationBarAction::GoToKanban), + Some(3) => cx.action(NavigationBarAction::OpenSettings), _ => { } } @@ -447,9 +461,21 @@ impl Widget for NavigationTabBar { // update our radio buttons accordingly. if let Some(NavigationBarAction::TabSelected(tab)) = action.downcast_ref() { match tab { - SelectedTab::Home => self.view.radio_button(ids!(home_button)).select(cx, scope), - SelectedTab::AddRoom => self.view.radio_button(ids!(add_room_button)).select(cx, scope), - SelectedTab::Settings => self.view.radio_button(ids!(settings_button)).select(cx, scope), + SelectedTab::Home => { + self.view.radio_button(ids!(home_button)).select(cx, scope) + } + SelectedTab::AddRoom => self + .view + .radio_button(ids!(add_room_button)) + .select(cx, scope), + SelectedTab::Kanban => self + .view + .radio_button(ids!(kanban_button)) + .select(cx, scope), + SelectedTab::Settings => self + .view + .radio_button(ids!(settings_button)) + .select(cx, scope), SelectedTab::Space { .. } => { for rb in radio_button_set.iter() { if let Some(mut rb_inner) = rb.borrow_mut() { @@ -477,6 +503,7 @@ pub enum SelectedTab { Home, AddRoom, Settings, + Kanban, // AlertsInbox, Space { space_name_id: RoomNameId }, } @@ -522,6 +549,8 @@ pub enum NavigationBarAction { CloseSettings, /// Go the space screen for the given space. GoToSpace { space_name_id: RoomNameId }, + /// Go to Kanban board view. + GoToKanban, // TODO: add GoToAlertsInbox, once we add that button/screen diff --git a/src/kanban/api/kanban_requests.rs b/src/kanban/api/kanban_requests.rs new file mode 100644 index 000000000..457df3d71 --- /dev/null +++ b/src/kanban/api/kanban_requests.rs @@ -0,0 +1,57 @@ +use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId}; +use crate::kanban::data::models::{KanbanBoard, KanbanList, KanbanCard}; + +#[derive(Debug, Clone)] +pub enum KanbanRequest { + CreateBoard { + name: String, + description: Option, + background_color: Option, + invite: Vec, + }, + + GetBoards { + include_archived: bool, + }, + + GetBoard { + board_id: OwnedRoomId, + }, + + UpdateBoard { + board_id: OwnedRoomId, + updates: crate::kanban::state::kanban_actions::BoardUpdateRequest, + }, + + DeleteBoard { + board_id: OwnedRoomId, + permanent: bool, + }, + + ArchiveBoard { + board_id: OwnedRoomId, + archived: bool, + }, + + AddMember { + board_id: OwnedRoomId, + user_id: OwnedUserId, + }, + + RemoveMember { + board_id: OwnedRoomId, + user_id: OwnedUserId, + }, +} + +#[derive(Debug, Clone)] +pub enum KanbanResponse { + Board(KanbanBoard), + Boards(Vec), + List(KanbanList), + Lists(Vec), + Card(KanbanCard), + Cards(Vec), + Success, + Error(String), +} diff --git a/src/kanban/api/mod.rs b/src/kanban/api/mod.rs new file mode 100644 index 000000000..a28bd8d42 --- /dev/null +++ b/src/kanban/api/mod.rs @@ -0,0 +1,5 @@ +pub mod kanban_requests; +pub mod repositories; + +// Re-export main types +pub use kanban_requests::*; diff --git a/src/kanban/api/repositories.rs b/src/kanban/api/repositories.rs new file mode 100644 index 000000000..54e1240ec --- /dev/null +++ b/src/kanban/api/repositories.rs @@ -0,0 +1,2 @@ +// Placeholder for repositories module +// This will be implemented in Phase 2 diff --git a/src/kanban/data/mod.rs b/src/kanban/data/mod.rs new file mode 100644 index 000000000..ee9eec4c4 --- /dev/null +++ b/src/kanban/data/mod.rs @@ -0,0 +1,5 @@ +pub mod models; +pub mod repositories; + +// Re-export main types +pub use models::*; diff --git a/src/kanban/data/models.rs b/src/kanban/data/models.rs new file mode 100644 index 000000000..6701bb1c9 --- /dev/null +++ b/src/kanban/data/models.rs @@ -0,0 +1,348 @@ +use serde::{Deserialize, Serialize}; +use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KanbanBoard { + pub id: OwnedRoomId, + + pub name: String, + + pub description: Option, + + pub background_color: String, + + pub background_image: Option, + + pub labels: Vec, + + pub member_ids: Vec, + + pub list_ids: Vec, + + pub is_archived: bool, + + pub created_at: String, + + pub updated_at: String, + + pub extensions: BoardExtensions, +} + +impl Default for KanbanBoard { + fn default() -> Self { + Self { + id: matrix_sdk::ruma::OwnedRoomId::try_from("!dummy:matrix.local").unwrap(), + name: String::new(), + description: None, + background_color: "#0079BF".to_string(), + background_image: None, + labels: Vec::new(), + member_ids: Vec::new(), + list_ids: Vec::new(), + is_archived: false, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + extensions: BoardExtensions::default(), + } + } +} + +impl KanbanBoard { + pub fn new(name: &str) -> Self { + Self { + name: name.to_string(), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + } + } + + pub fn member_count(&self) -> usize { + self.member_ids.len() + } + + pub fn list_count(&self) -> usize { + self.list_ids.len() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KanbanLabel { + pub id: String, + + pub name: String, + + pub color: LabelColor, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum LabelColor { + Green, + Yellow, + Orange, + Red, + Purple, + Blue, + Sky, + Lime, + Pink, + Black, +} + +impl LabelColor { + pub fn to_hex(&self) -> &'static str { + match self { + LabelColor::Green => "#61BD4F", + LabelColor::Yellow => "#F2D600", + LabelColor::Orange => "#FF9F1A", + LabelColor::Red => "#EB5A46", + LabelColor::Purple => "#9775FA", + LabelColor::Blue => "#0079BF", + LabelColor::Sky => "#00C2E0", + LabelColor::Lime => "#51E898", + LabelColor::Pink => "#FF78CB", + LabelColor::Black => "#343434", + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct BoardExtensions { + pub view_settings: ViewSettings, + + pub filter_state: Option, + + pub sort_state: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ViewSettings { + pub card_view_mode: CardViewMode, + + pub show_completed: bool, + + pub page_size: u32, +} + +impl Default for ViewSettings { + fn default() -> Self { + Self { + card_view_mode: CardViewMode::Detailed, + show_completed: true, + page_size: 50, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum CardViewMode { + Compact, + Detailed, + Cover, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KanbanList { + pub id: String, + + pub name: String, + + pub board_id: OwnedRoomId, + + pub position: f64, + + pub is_archived: bool, + + pub card_ids: Vec, + + pub created_at: String, + + pub updated_at: String, +} + +impl Default for KanbanList { + fn default() -> Self { + Self { + id: String::new(), + name: String::new(), + board_id: matrix_sdk::ruma::OwnedRoomId::try_from("!dummy:matrix.local").unwrap(), + position: 1000.0, + is_archived: false, + card_ids: Vec::new(), + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +impl KanbanList { + pub fn new(name: &str, board_id: OwnedRoomId) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + name: name.to_string(), + board_id, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + } + } + + pub fn card_count(&self) -> usize { + self.card_ids.len() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KanbanCard { + pub id: String, + + pub title: String, + + pub description: Option, + + pub list_id: String, + + pub board_id: OwnedRoomId, + + pub position: f64, + + pub label_ids: Vec, + + pub member_ids: Vec, + + pub due_date: Option, + + pub cover: Option, + + pub attachment_count: u32, + + pub comment_count: u32, + + pub checklists: Vec, + + pub is_starred: bool, + + pub is_archived: bool, + + pub created_at: String, + + pub updated_at: String, +} + +impl Default for KanbanCard { + fn default() -> Self { + Self { + id: String::new(), + title: String::new(), + description: None, + list_id: String::new(), + board_id: matrix_sdk::ruma::OwnedRoomId::try_from("!dummy:matrix.local").unwrap(), + position: 1000.0, + label_ids: Vec::new(), + member_ids: Vec::new(), + due_date: None, + cover: None, + attachment_count: 0, + comment_count: 0, + checklists: Vec::new(), + is_starred: false, + is_archived: false, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +impl KanbanCard { + pub fn new(title: &str, list_id: String, board_id: OwnedRoomId) -> Self { + Self { + id: uuid::Uuid::new_v4().to_string(), + title: title.to_string(), + list_id, + board_id, + created_at: chrono::Utc::now().to_rfc3339(), + updated_at: chrono::Utc::now().to_rfc3339(), + ..Default::default() + } + } + + pub fn is_completed(&self) -> bool { + self.checklists.iter().all(|cl| cl.is_completed()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardDueDate { + pub date: String, + pub is_completed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardCover { + pub url: String, + pub height: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CardChecklist { + pub id: String, + pub name: String, + pub items: Vec, +} + +impl CardChecklist { + pub fn is_completed(&self) -> bool { + self.items.iter().all(|item| item.is_checked) + } + + pub fn completed_count(&self) -> usize { + self.items.iter().filter(|item| item.is_checked).count() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChecklistItem { + pub id: String, + pub name: String, + pub is_checked: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KanbanFilterState { + pub keyword: Option, + pub label_ids: Vec, + pub member_ids: Vec, + pub due_date: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum DueDateFilter { + Overdue, + Today, + Tomorrow, + ThisWeek, + NextWeek, + NoDue, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KanbanSortState { + pub field: SortField, + pub direction: SortDirection, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum SortField { + Position, + CreatedAt, + UpdatedAt, + Title, + DueDate, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub enum SortDirection { + Ascending, + Descending, +} diff --git a/src/kanban/data/repositories.rs b/src/kanban/data/repositories.rs new file mode 100644 index 000000000..c6f0635ba --- /dev/null +++ b/src/kanban/data/repositories.rs @@ -0,0 +1,104 @@ +use std::sync::Arc; +use tokio::sync::Mutex; +use crate::kanban::data::models::{KanbanBoard, KanbanList, KanbanCard, CardDueDate}; + +#[derive(Debug, Clone, Default)] +pub struct BoardUpdateRequest { + pub name: Option, + pub description: Option>, + pub background_color: Option, + pub background_image: Option>, +} + +#[derive(Debug, Clone, Default)] +pub struct ListUpdateRequest { + pub name: Option, + pub archived: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CardUpdateRequest { + pub title: Option, + pub description: Option>, + pub label_ids: Option>, + pub member_ids: Option>, + pub due_date: Option>, + pub is_starred: Option, + pub archived: Option, +} + +pub struct MatrixBoardRepository { + local_boards: Arc>>, +} + +impl MatrixBoardRepository { + pub fn new() -> Self { + Self { + local_boards: Arc::new(Mutex::new(Vec::new())), + } + } +} + +impl Default for MatrixBoardRepository { + fn default() -> Self { + Self::new() + } +} + +pub struct MatrixListRepository { + local_lists: Arc>>, +} + +impl MatrixListRepository { + pub fn new() -> Self { + Self { + local_lists: Arc::new(Mutex::new(Vec::new())), + } + } +} + +impl Default for MatrixListRepository { + fn default() -> Self { + Self::new() + } +} + +pub struct MatrixCardRepository { + local_cards: Arc>>, +} + +impl MatrixCardRepository { + pub fn new() -> Self { + Self { + local_cards: Arc::new(Mutex::new(Vec::new())), + } + } +} + +impl Default for MatrixCardRepository { + fn default() -> Self { + Self::new() + } +} + +pub struct RepositoryFactory { + pub board_repository: MatrixBoardRepository, + pub list_repository: MatrixListRepository, + pub card_repository: MatrixCardRepository, +} + +impl RepositoryFactory { + pub fn new() -> Self { + Self { + board_repository: MatrixBoardRepository::new(), + list_repository: MatrixListRepository::new(), + card_repository: MatrixCardRepository::new(), + } + } +} + +impl Default for RepositoryFactory { + fn default() -> Self { + Self::new() + } +} diff --git a/src/kanban/drag_drop/drag_handler.rs b/src/kanban/drag_drop/drag_handler.rs new file mode 100644 index 000000000..97d243985 --- /dev/null +++ b/src/kanban/drag_drop/drag_handler.rs @@ -0,0 +1,2 @@ +// Placeholder for drag handler +// This will be implemented in Phase 4 diff --git a/src/kanban/drag_drop/mod.rs b/src/kanban/drag_drop/mod.rs new file mode 100644 index 000000000..ca71bdcda --- /dev/null +++ b/src/kanban/drag_drop/mod.rs @@ -0,0 +1,5 @@ +pub mod order_manager; +pub mod drag_handler; + +// Re-export main types +pub use order_manager::*; diff --git a/src/kanban/drag_drop/order_manager.rs b/src/kanban/drag_drop/order_manager.rs new file mode 100644 index 000000000..b4e0686d1 --- /dev/null +++ b/src/kanban/drag_drop/order_manager.rs @@ -0,0 +1,58 @@ +use std::cmp::Ordering; + +#[derive(Debug, Default)] +pub struct SimpleOrderManager; + +impl SimpleOrderManager { + const INITIAL_ORDER: f64 = 1000.0; + + const ORDER_INTERVAL: f64 = 1000.0; + + pub fn calculate_new_position( + &self, + before_order: Option, + after_order: Option, + ) -> f64 { + match (before_order, after_order) { + (None, Some(after)) => { + if after > Self::ORDER_INTERVAL { + after - Self::ORDER_INTERVAL + } else { + self.reorder_and_insert(None, Some(after)) + } + } + (Some(before), None) => before + Self::ORDER_INTERVAL, + (Some(before), Some(after)) => { + let middle = (before + after) / 2.0; + if middle != before && middle != after { + middle + } else { + self.reorder_and_insert(Some(before), Some(after)) + } + } + (None, None) => Self::INITIAL_ORDER, + } + } + + fn reorder_and_insert(&self, before_order: Option, after_order: Option) -> f64 { + match (before_order, after_order) { + (Some(before), Some(after)) => (before + after) / 2.0, + (Some(before), None) => before + Self::ORDER_INTERVAL, + (None, Some(after)) => after - Self::ORDER_INTERVAL, + (None, None) => Self::INITIAL_ORDER, + } + } + + pub fn reorder_all(&self, orders: &mut [f64]) { + orders.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal)); + for (i, order) in orders.iter_mut().enumerate() { + *order = Self::INITIAL_ORDER + i as f64 * Self::ORDER_INTERVAL; + } + } +} + +pub trait Sortable { + fn id(&self) -> &str; + fn order(&self) -> f64; + fn set_order(&mut self, order: f64); +} diff --git a/src/kanban/kanban_app.rs b/src/kanban/kanban_app.rs new file mode 100644 index 000000000..30e267d1f --- /dev/null +++ b/src/kanban/kanban_app.rs @@ -0,0 +1,278 @@ +use makepad_widgets::*; +use matrix_sdk::ruma::OwnedRoomId; + +use crate::{ + app::AppState, + kanban::{KanbanActions, KanbanFilterState, KanbanSortState, SortDirection, SortField}, +}; +use crate::kanban::data::models::KanbanBoard; + +live_design! { + + use link::theme::*; + use link::shaders::*; + use link::widgets::*; + + use crate::shared::styles::*; + use crate::kanban::ui::components::boards_sidebar::BoardsSidebar; + use crate::kanban::ui::components::board_header::BoardHeader; + use crate::kanban::ui::components::board_toolbar::BoardToolbar; + + pub KanbanApp = {{KanbanApp}} { + width: Fill, height: Fill + flow: Right + + sidebar = {} + + main_content = { + width: Fill, height: Fill + flow: Down + + board_header = {} + + board_toolbar = {} + + board_canvas = { + width: Fill, height: Fill + flow: Right + scroll: vec2(1.0, 0.0) + + padding: 12 + spacing: 12 + + lists_container = { + width: Fill, height: Fit + flow: Right + spacing: 8 + align: {x: 0.0, y: 0.0} + } + } + } + } +} + +#[derive(Debug, Clone, Default)] +pub enum KanbanViewMode { + #[default] + Board, + Calendar, + Timeline, + Gallery, + Table, +} + +#[derive(Live, LiveHook, Widget)] +pub struct KanbanApp { + #[deref] + view: View, + + #[rust] + current_board_id: Option, + + #[rust] + current_board: Option, + + #[rust] + boards: Vec, + + #[rust] + view_mode: KanbanViewMode, + + #[rust] + sidebar_visible: bool, + + #[rust] + on_board_select: Option>, + #[rust] + on_create_board: Option>, + #[rust] + on_add_list: Option>, + #[rust] + on_add_card: Option>, + #[rust] + on_card_click: Option>, + #[rust] + on_menu_open: Option>, +} + +impl KanbanApp { + pub fn new(cx: &mut Cx) -> Self { + Self { + view: View::new(cx), + current_board_id: None, + current_board: None, + boards: Vec::new(), + view_mode: KanbanViewMode::Board, + sidebar_visible: true, + on_board_select: None, + on_create_board: None, + on_add_list: None, + on_add_card: None, + on_card_click: None, + on_menu_open: None, + } + } + + pub fn set_current_board(&mut self, board: Option<&KanbanBoard>) { + self.current_board = board.cloned(); + self.current_board_id = board.as_ref().map(|b| b.id.clone()); + } + + pub fn set_boards(&mut self, boards: Vec) { + self.boards = boards; + } + + pub fn set_view_mode(&mut self, mode: KanbanViewMode) { + self.view_mode = mode; + } + + pub fn toggle_sidebar(&mut self) { + self.sidebar_visible = !self.sidebar_visible; + } + + pub fn set_on_board_select(&mut self, callback: F) + where + F: FnMut(&OwnedRoomId) + 'static, + { + self.on_board_select = Some(Box::new(callback)); + } + + pub fn set_on_create_board(&mut self, callback: F) + where + F: FnMut() + 'static, + { + self.on_create_board = Some(Box::new(callback)); + } + + pub fn set_on_add_list(&mut self, callback: F) + where + F: FnMut() + 'static, + { + self.on_add_list = Some(Box::new(callback)); + } + + pub fn set_on_add_card(&mut self, callback: F) + where + F: FnMut(&str) + 'static, + { + self.on_add_card = Some(Box::new(callback)); + } + + pub fn set_on_card_click(&mut self, callback: F) + where + F: FnMut(&str) + 'static, + { + self.on_card_click = Some(Box::new(callback)); + } + + pub fn set_on_menu_open(&mut self, callback: F) + where + F: FnMut() + 'static, + { + self.on_menu_open = Some(Box::new(callback)); + } +} + +impl KanbanApp { + fn sync_from_state(&mut self, cx: &mut Cx, app_state: &AppState) { + self.current_board_id = app_state.kanban_state.current_board_id.clone(); + self.current_board = app_state.kanban_state.current_board().cloned(); + self.boards = app_state.kanban_state.boards.values().cloned().collect(); + + let board_title = self + .current_board + .as_ref() + .map(|board| board.name.as_str()) + .unwrap_or("no chose"); + self.view + .label(ids!(main_content.board_header.title_area.board_title)) + .set_text(cx, board_title); + } +} + +impl Widget for KanbanApp { + fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { + if let Event::Actions(actions) = event { + if self + .view + .button(ids!(sidebar.sidebar_header.add_board_button)) + .clicked(actions) + { + cx.action(KanbanActions::CreateBoard { + name: "new kanban ".to_string(), + description: None, + }); + } + + if self + .view + .button(ids!(main_content.board_header.action_buttons.filter_button)) + .clicked(actions) + || self + .view + .button(ids!(main_content.board_toolbar.filter_button)) + .clicked(actions) + { + cx.action(KanbanActions::SetFilter(KanbanFilterState { + keyword: None, + label_ids: Vec::new(), + member_ids: Vec::new(), + due_date: None, + })); + } + + if self + .view + .button(ids!(main_content.board_header.action_buttons.sort_button)) + .clicked(actions) + || self + .view + .button(ids!(main_content.board_toolbar.sort_button)) + .clicked(actions) + { + cx.action(KanbanActions::SetSort(KanbanSortState { + field: SortField::Position, + direction: SortDirection::Ascending, + })); + } + + let search_input = self.view.text_input(ids!( + main_content.board_toolbar.filter_button.search_input + )); + if let Some(query) = search_input.changed(actions) { + if let Some(board_id) = self.current_board_id.clone() { + cx.action(KanbanActions::Search { board_id, query }); + } + } + + if self + .view + .button(ids!(main_content.board_toolbar.view_toggle.board_view_btn)) + .clicked(actions) + { + self.view_mode = KanbanViewMode::Board; + } + + if self + .view + .button(ids!(main_content.board_toolbar.view_toggle.list_view_btn)) + .clicked(actions) + { + self.view_mode = KanbanViewMode::Table; + } + } + + if let Some(app_state) = scope.data.get::() { + self.sync_from_state(cx, app_state); + } + + self.view.handle_event(cx, event, scope); + } + + fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + if let Some(app_state) = scope.data.get::() { + self.sync_from_state(cx, app_state); + } + self.view.draw_walk(cx, scope, walk) + } +} diff --git a/src/kanban/kanban_app/state.rs b/src/kanban/kanban_app/state.rs new file mode 100644 index 000000000..168c5a40e --- /dev/null +++ b/src/kanban/kanban_app/state.rs @@ -0,0 +1,34 @@ +use matrix_sdk::ruma::OwnedRoomId; + +use crate::kanban::data::models::KanbanBoard; + +#[derive(Debug, Clone, Default)] +pub enum KanbanViewMode { + #[default] + Board, + Calendar, + Timeline, + Gallery, + Table, +} + +#[derive(Debug, Clone)] +pub struct KanbanViewState { + pub current_board_id: Option, + pub current_board: Option, + pub boards: Vec, + pub view_mode: KanbanViewMode, + pub sidebar_visible: bool, +} + +impl Default for KanbanViewState { + fn default() -> Self { + Self { + current_board_id: None, + current_board: None, + boards: Vec::new(), + view_mode: KanbanViewMode::Board, + sidebar_visible: true, + } + } +} diff --git a/src/kanban/kanban_app/view.rs b/src/kanban/kanban_app/view.rs new file mode 100644 index 000000000..6f64ec427 --- /dev/null +++ b/src/kanban/kanban_app/view.rs @@ -0,0 +1,51 @@ +use makepad_widgets::*; + +use crate::kanban::ui::components::board_header::BoardHeader; +use crate::kanban::ui::components::board_toolbar::BoardToolbar; +use crate::kanban::ui::components::boards_sidebar::BoardsSidebar; +use crate::shared::styles::*; + +use super::KanbanApp; + +live_design! { + use link::theme::*; + use link::shaders::*; + use link::widgets::*; + + use crate::shared::styles::*; + use crate::kanban::ui::components::boards_sidebar::BoardsSidebar; + use crate::kanban::ui::components::board_header::BoardHeader; + use crate::kanban::ui::components::board_toolbar::BoardToolbar; + + pub KanbanApp = {{KanbanApp}} { + width: Fill, height: Fill + flow: Right + + sidebar = {} + + main_content = { + width: Fill, height: Fill + flow: Down + + board_header = {} + + board_toolbar = {} + + board_canvas = { + width: Fill, height: Fill + flow: Right + scroll: vec2(1.0, 0.0) + + padding: 12 + spacing: 12 + + lists_container = { + width: Fill, height: Fit + flow: Right + spacing: 8 + align: {x: 0.0, y: 0.0} + } + } + } + } +} diff --git a/src/kanban/mod.rs b/src/kanban/mod.rs new file mode 100644 index 000000000..21ef97f79 --- /dev/null +++ b/src/kanban/mod.rs @@ -0,0 +1,22 @@ +// Kanban module for Toona Matrix chat client +// Implements Trello-style kanban boards using Matrix rooms as backend + +use makepad_widgets::Cx; + +pub mod kanban_app; +pub mod data; +pub mod state; +pub mod api; +pub mod ui; +pub mod drag_drop; + +// Re-export main types for convenience +pub use kanban_app::KanbanApp; +pub use data::models::*; +pub use state::kanban_state::*; +pub use state::kanban_actions::*; + +pub fn live_design(cx: &mut Cx) { + ui::live_design(cx); + kanban_app::live_design(cx); +} diff --git a/src/kanban/state/kanban_actions.rs b/src/kanban/state/kanban_actions.rs new file mode 100644 index 000000000..e81b5a32e --- /dev/null +++ b/src/kanban/state/kanban_actions.rs @@ -0,0 +1,123 @@ +use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId}; +use crate::kanban::data::models::*; + +#[derive(Debug, Clone)] +pub enum KanbanActions { + LoadBoards, + + SelectBoard(OwnedRoomId), + + CreateBoard { + name: String, + description: Option, + }, + + UpdateBoard { + board_id: OwnedRoomId, + updates: BoardUpdateRequest, + }, + + DeleteBoard { + board_id: OwnedRoomId, + }, + + LoadLists { + board_id: OwnedRoomId, + }, + + CreateList { + board_id: OwnedRoomId, + name: String, + }, + + UpdateList { + board_id: OwnedRoomId, + list_id: String, + updates: ListUpdateRequest, + }, + + DeleteList { + board_id: OwnedRoomId, + list_id: String, + }, + + MoveList { + board_id: OwnedRoomId, + list_id: String, + new_position: f64, + }, + + LoadCards { + board_id: OwnedRoomId, + list_id: String, + }, + + CreateCard { + board_id: OwnedRoomId, + list_id: String, + name: String, + }, + + UpdateCard { + board_id: OwnedRoomId, + card_id: String, + updates: CardUpdateRequest, + }, + + DeleteCard { + board_id: OwnedRoomId, + card_id: String, + }, + + MoveCard { + board_id: OwnedRoomId, + card_id: String, + from_list: String, + to_list: String, + new_position: f64, + }, + + ArchiveCard { + board_id: OwnedRoomId, + card_id: String, + archived: bool, + }, + + SetFilter(KanbanFilterState), + + SetSort(KanbanSortState), + + Search { + board_id: OwnedRoomId, + query: String, + }, + + Error(String), + + Loading(bool), +} + +#[derive(Debug, Clone, Default)] +pub struct BoardUpdateRequest { + pub name: Option, + pub description: Option>, + pub background_color: Option, + pub background_image: Option>, +} + +#[derive(Debug, Clone, Default)] +pub struct ListUpdateRequest { + pub name: Option, + pub archived: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct CardUpdateRequest { + pub title: Option, + pub description: Option>, + pub label_ids: Option>, + pub member_ids: Option>, + pub due_date: Option>, + pub is_starred: Option, + pub archived: Option, +} diff --git a/src/kanban/state/kanban_state.rs b/src/kanban/state/kanban_state.rs new file mode 100644 index 000000000..59829eb85 --- /dev/null +++ b/src/kanban/state/kanban_state.rs @@ -0,0 +1,94 @@ +use std::collections::HashMap; +use matrix_sdk::ruma::{OwnedRoomId, OwnedUserId}; +use crate::kanban::data::models::*; + +#[derive(Debug, Clone, Default)] +pub struct KanbanAppState { + pub current_user_id: Option, + + pub current_board_id: Option, + + pub boards: HashMap, + + pub lists: HashMap, + + pub cards: HashMap, + + pub loading: bool, + + pub error: Option, + + pub filter_state: Option, + + pub sort_state: Option, +} + +impl KanbanAppState { + pub fn new() -> Self { + Self::default() + } + + pub fn current_board(&self) -> Option<&KanbanBoard> { + self.current_board_id + .as_ref() + .and_then(|id| self.boards.get(id)) + } + + pub fn current_board_lists(&self) -> Vec<&KanbanList> { + if let Some(board) = self.current_board() { + board + .list_ids + .iter() + .filter_map(|list_id| self.lists.get(list_id)) + .collect() + } else { + Vec::new() + } + } + + pub fn list_cards(&self, list_id: &str) -> Vec<&KanbanCard> { + if let Some(list) = self.lists.get(list_id) { + list.card_ids + .iter() + .filter_map(|card_id| self.cards.get(card_id)) + .collect() + } else { + Vec::new() + } + } + + pub fn set_loading(&mut self, loading: bool) { + self.loading = loading; + } + + pub fn set_error(&mut self, error: Option) { + self.error = error; + } + + pub fn upsert_board(&mut self, board: KanbanBoard) { + self.boards.insert(board.id.clone(), board); + } + + pub fn upsert_list(&mut self, list: KanbanList) { + self.lists.insert(list.id.clone(), list); + } + + pub fn upsert_card(&mut self, card: KanbanCard) { + self.cards.insert(card.id.clone(), card); + } + + pub fn remove_board(&mut self, board_id: &OwnedRoomId) { + self.boards.remove(board_id); + if self.current_board_id.as_ref() == Some(board_id) { + self.current_board_id = None; + } + } + + pub fn remove_list(&mut self, list_id: &str) { + self.lists.remove(list_id); + } + + pub fn remove_card(&mut self, card_id: &str) { + self.cards.remove(card_id); + } +} diff --git a/src/kanban/state/mod.rs b/src/kanban/state/mod.rs new file mode 100644 index 000000000..2c9a88d9b --- /dev/null +++ b/src/kanban/state/mod.rs @@ -0,0 +1,6 @@ +pub mod kanban_state; +pub mod kanban_actions; + +// Re-export main types +pub use kanban_state::*; +pub use kanban_actions::*; diff --git a/src/kanban/ui/components/board_background.rs b/src/kanban/ui/components/board_background.rs new file mode 100644 index 000000000..1ae1e50b8 --- /dev/null +++ b/src/kanban/ui/components/board_background.rs @@ -0,0 +1,180 @@ +use makepad_widgets::*; + +live_design! { + use link::theme::*; + use link::shaders::*; + use link::widgets::*; + + pub BoardBackgroundModal = { + width: 560, + height: Fit, + max_height: 600, + show_bg: true, + draw_bg: { + color: #FFFFFF + border_radius: 8 + }, + flow: Down, + + modal_header = { + flow: Right, + width: Fill, + height: 56, + align: {x: 0.5, y: 0.5}, + border_bottom: 1.0, + border_color: #DFE1E6, + padding: 16, + + header_title =