diff --git a/Cargo.lock b/Cargo.lock index 4aee574c..ba9bf7cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3787,6 +3787,54 @@ 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", + "async-trait", + "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" @@ -4664,6 +4712,7 @@ dependencies = [ "base64", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -4834,52 +4883,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 33a60122..eb3703dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "robrix" +name = "netrix" authors = [ "Kevin Boos ", "Robius Project Maintainers", @@ -75,10 +75,13 @@ reqwest = { version = "0.12", default-features = false, features = [ "json", "stream", "charset", + "blocking", "http2", "macos-system-configuration", ] } thiserror = "2.0.16" +uuid = { version = "1.18.1", features = ["v4"] } +async-trait = "0.1.89" [features] diff --git a/resources/icons/kanban.svg b/resources/icons/kanban.svg new file mode 100644 index 00000000..8daa992e --- /dev/null +++ b/resources/icons/kanban.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/app.rs b/src/app.rs index 481e74f1..b840a14a 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 be394834..fe08d5c1 100644 --- a/src/home/home_screen.rs +++ b/src/home/home_screen.rs @@ -1,6 +1,17 @@ use makepad_widgets::*; -use crate::{app::AppState, home::navigation_tab_bar::{NavigationBarAction, SelectedTab}, settings::settings_screen::SettingsScreenWidgetRefExt}; +use crate::{ + app::AppState, + home::{ + kanban_card::KanbanCardAction, + kanban_card_detail::KanbanCardDetailWidgetExt, + kanban_list_view::KanbanCardSummary, + navigation_tab_bar::{NavigationBarAction, SelectedTab}, + }, + kanban::KanbanActions, + settings::settings_screen::SettingsScreenWidgetRefExt, +}; +use crate::home::kanban_list_view::KanbanListViewWidgetExt; live_design! { use link::theme::*; @@ -13,16 +24,19 @@ live_design! { use crate::home::search_messages::*; use crate::home::spaces_bar::*; use crate::home::add_room::*; + use crate::home::kanban_list_view::KanbanListView; + use crate::home::kanban_card::KanbanCard; + use crate::home::kanban_card_detail::KanbanCardDetail; use crate::shared::styles::*; use crate::shared::room_filter_input_bar::RoomFilterInputBar; use crate::home::main_desktop_ui::MainDesktopUI; use crate::settings::settings_screen::SettingsScreen; + StackNavigationWrapper = {{StackNavigationWrapper}} { view_stack = {} } - // A wrapper view around the SpacesBar that lets us show/hide it via animation. SpacesBarWrapper = {{SpacesBarWrapper}} { width: Fill, height: (NAVIGATION_TAB_BAR_SIZE) @@ -30,11 +44,10 @@ live_design! { show_bg: true draw_bg: { color: (COLOR_PRIMARY_DARKER), - border_radius: 4.0, border_size: 0.0 shadow_color: #0005 shadow_radius: 15.0 - shadow_offset: vec2(1.0, 0.0), //5.0,5.0) + shadow_offset: vec2(1.0, 0.0) } { @@ -56,15 +69,8 @@ live_design! { } } - // The home screen widget contains the main content: - // rooms list, room screens, and the settings screen as an overlay. - // It adapts to both desktop and mobile layouts. pub HomeScreen = {{HomeScreen}} { { - // NOTE: within each of these sub views, we used `CachedWidget` wrappers - // to ensure that there is only a single global instance of each - // of those widgets, which means they maintain their state - // across transitions between the Desktop and Mobile variant. Desktop = { show_bg: true draw_bg: { @@ -76,13 +82,10 @@ live_design! { padding: 0, margin: 0, - // On the left, show the navigation tab bar vertically. { navigation_tab_bar = {} } - // To the right of that, we use the PageFlip widget to show either - // the main desktop UI or the settings screen. home_screen_page_flip = { width: Fill, height: Fill @@ -92,28 +95,6 @@ live_design! { home_page = { width: Fill, height: Fill flow: Down - - { - width: Fill, - height: 39, - flow: Right - padding: {top: 2, bottom: 2} - margin: {right: 2} - spacing: 2 - align: {y: 0.5} - - { - room_filter_input_bar = {} - } - - search_messages_button = { - // make this button match/align with the RoomFilterInputBar - height: 32.5, - margin: {right: 2} - } - } - - {} } settings_page = { @@ -128,6 +109,52 @@ live_design! { } } + kanban_page = { + width: Fill, height: Fill + flow: Right + show_bg: true, + draw_bg: { + color: #F4F5F7 + } + + kanban_lists_container = { + flow: Right, + padding: 20, + spacing: 16, + + kanban_list_todo = { + width: 280, height: Fill, + } + + kanban_list_doing = { + width: 280, height: Fill, + } + + kanban_list_done = { + width: 280, height: Fill, + } + } + + kanban_detail_panel = { + width: Fill, height: Fill + padding: 20 + flow: Down + show_bg: true + draw_bg: { color: #F4F5F7 } + + detail_empty =