From 228879da15b727ac4bb9f92d6cb9b466fa9d1fe0 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 14:39:17 +0200 Subject: [PATCH] Add issue creation and editing --- TESTING.md | 15 + crates/app/src/api.rs | 141 +++++++- crates/app/src/domain.rs | 65 ++++ crates/app/src/lib.rs | 72 ++++ crates/app/src/presentation.rs | 152 +++++++-- ios/Generated/gotcha_core.swift | 323 ++++++++++++++++++ ios/Generated/gotcha_coreFFI.h | 22 ++ ios/Gotcha.xcodeproj/project.pbxproj | 4 + ios/Sources/ContentScreens.swift | 47 ++- ios/Sources/DetailScreens.swift | 21 +- ios/Sources/IssueEditorViewController.swift | 353 ++++++++++++++++++++ 11 files changed, 1175 insertions(+), 40 deletions(-) create mode 100644 ios/Sources/IssueEditorViewController.swift diff --git a/TESTING.md b/TESTING.md index 6b0fdc2..da4ee2c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -141,6 +141,21 @@ xcrun simctl launch booted de.rfc1437.gotcha selections persist independently per repository and status persists. - [ ] The issue-filter icon uses a neutral color for Open + All Milestones + no labels, and the app accent color whenever any non-default filter is set. +- [ ] Tap the add button. **New Issue** appears as a native modal with Cancel + and Save, title and Markdown body fields, multi-select labels, a + single-select optional milestone, and an optional inline due-date picker. +- [ ] In the new-issue body, enter headings, emphasis, a list, a link, and a + fenced code block. **Write** syntax-highlights the Markdown and **Preview** + renders it; switching repeatedly preserves the exact source text. +- [ ] Save is disabled for an empty or whitespace-only title. Create issues with + no labels/milestone/due date and with multiple labels, a milestone, and a + due date; each new issue appears in the list and opens its detail. +- [ ] Tap **Edit** on an issue. Change its title and Markdown body, replace and + clear labels, select and clear its milestone, and add, change, and remove + its due date. Save, refresh, and relaunch to verify every change persists. +- [ ] Cancel both new and edited issues, including with the keyboard and date + picker visible. Nothing is saved, the modal dismisses normally, and the + issue list/detail remains usable with Dynamic Type and VoiceOver labels. - [ ] Open both an open and a closed issue. A green open or purple closed icon appears beside the title and VoiceOver announces the state; author metadata, Markdown body, and comments remain correct. Links and diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index 022d754..0f509b4 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -9,8 +9,8 @@ use tokio::task::JoinSet; use crate::{ diff, domain::{ - HistoryCommit, HomeData, IssueDetails, MilestoneDetails, PullDetails, RepositoryData, - Server, + HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails, + PullDetails, RepositoryData, Server, api_date, }, presentation::compact_date, }; @@ -430,6 +430,115 @@ pub async fn load_issue( }) } +pub async fn load_issue_editor( + server: &Server, + owner: &str, + repository: &str, + number: Option, +) -> Result { + let issue = async { + match number { + Some(number) => { + let client = Client::new(&server.url, Some(&server.token)) + .map_err(|error| error.to_string())?; + apis::issue_api::issue_get_issue(&client.configuration(), owner, repository, number) + .await + .map(Some) + .map_err(|error| error.to_string()) + } + None => Ok(None), + } + }; + let (issue, labels, milestones) = tokio::try_join!( + issue, + load_labels(server, owner, repository), + load_milestones(server, owner, repository) + )?; + Ok(IssueEditorData { + issue, + labels, + milestones, + }) +} + +fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption { + models::CreateIssueOption { + body: Some(draft.body), + due_date: draft.due_date.map(api_date), + labels: Some(draft.label_ids), + milestone: draft.milestone_id, + ..models::CreateIssueOption::new(draft.title) + } +} + +fn edit_issue_option(draft: &IssueDraft) -> models::EditIssueOption { + models::EditIssueOption { + body: Some(draft.body.clone()), + due_date: draft.due_date.map(api_date), + milestone: Some(draft.milestone_id.unwrap_or_default()), + title: Some(draft.title.clone()), + unset_due_date: draft.due_date.is_none().then_some(true), + ..models::EditIssueOption::new() + } +} + +pub async fn create_issue( + server: &Server, + owner: &str, + repository: &str, + draft: IssueDraft, +) -> Result { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::issue_api::issue_create_issue( + &client.configuration(), + owner, + repository, + Some(create_issue_option(draft)), + ) + .await + .map_err(|error| error.to_string()) +} + +pub async fn edit_issue( + server: &Server, + owner: &str, + repository: &str, + number: i64, + draft: IssueDraft, +) -> Result { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let configuration = client.configuration(); + let issue = apis::issue_api::issue_edit_issue( + &configuration, + owner, + repository, + number, + Some(edit_issue_option(&draft)), + ) + .await + .map_err(|error| error.to_string())?; + apis::issue_api::issue_replace_labels( + &configuration, + owner, + repository, + number, + Some(models::IssueLabelsOption { + labels: Some( + draft + .label_ids + .into_iter() + .map(serde_json::Value::from) + .collect(), + ), + }), + ) + .await + .map_err(|error| error.to_string())?; + Ok(issue) +} + pub async fn load_pull( server: &Server, owner: &str, @@ -845,4 +954,32 @@ mod tests { assert!(commits[4].bottom_lanes.is_empty()); assert_eq!(commits[4].node_lane, Some(0)); } + + #[test] + fn builds_complete_create_and_edit_issue_requests() { + let create = create_issue_option(IssueDraft { + title: "Title".into(), + body: "**Body**".into(), + label_ids: vec![2, 3], + milestone_id: Some(5), + due_date: Some(1_709_164_800), + }); + assert_eq!(create.title, "Title"); + assert_eq!(create.body.as_deref(), Some("**Body**")); + assert_eq!(create.labels, Some(vec![2, 3])); + assert_eq!(create.milestone, Some(5)); + assert_eq!(create.due_date.as_deref(), Some("2024-02-29T00:00:00Z")); + + let edit = edit_issue_option(&IssueDraft { + title: "New".into(), + body: String::new(), + label_ids: Vec::new(), + milestone_id: None, + due_date: None, + }); + assert_eq!(edit.title.as_deref(), Some("New")); + assert_eq!(edit.body.as_deref(), Some("")); + assert_eq!(edit.milestone, Some(0)); + assert_eq!(edit.unset_due_date, Some(true)); + } } diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index d5f4e48..a755032 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -139,6 +139,20 @@ pub struct IssueDetails { pub comments: Vec, } +pub struct IssueEditorData { + pub issue: Option, + pub labels: Vec, + pub milestones: Vec, +} + +pub struct IssueDraft { + pub title: String, + pub body: String, + pub label_ids: Vec, + pub milestone_id: Option, + pub due_date: Option, +} + pub struct MilestoneDetails { pub milestone: models::Milestone, pub issues: Vec, @@ -155,6 +169,49 @@ pub fn open_status() -> String { "open".into() } +pub fn civil_from_days(days: i64) -> (i64, i64, i64) { + let days = days + 719_468; + let era = days.div_euclid(146_097); + let day_of_era = days - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_prime = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_prime + 2) / 5 + 1; + let month = month_prime + if month_prime < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +pub fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 { + year -= i64::from(month <= 2); + let era = year.div_euclid(400); + let year_of_era = year - era * 400; + let month_prime = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * month_prime + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era - 719_468 +} + +pub fn api_date(timestamp: i64) -> String { + let (year, month, day) = civil_from_days(timestamp.div_euclid(86_400)); + format!("{year:04}-{month:02}-{day:02}T00:00:00Z") +} + +pub fn parse_api_date(value: &str) -> Option { + let date = value.get(..10)?; + let mut parts = date.split('-'); + let year = parts.next()?.parse().ok()?; + let month = parts.next()?.parse().ok()?; + let day = parts.next()?.parse().ok()?; + if parts.next().is_some() || !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + let days = days_from_civil(year, month, day); + (civil_from_days(days) == (year, month, day)).then_some(days * 86_400) +} + #[cfg(test)] mod tests { use super::*; @@ -179,4 +236,12 @@ mod tests { .is_active("open") ); } + + #[test] + fn issue_dates_round_trip_and_reject_invalid_dates() { + let leap_day = parse_api_date("2024-02-29T12:34:56Z").unwrap(); + assert_eq!(api_date(leap_day), "2024-02-29T00:00:00Z"); + assert_eq!(parse_api_date("2023-02-29T00:00:00Z"), None); + assert_eq!(parse_api_date("not-a-date"), None); + } } diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index ba3e2aa..9bfd006 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -323,6 +323,66 @@ impl GotchaCore { )) } + pub async fn issue_editor( + &self, + owner: String, + repository: String, + number: Option, + ) -> Result { + let (owner, repository) = validate_repository(&owner, &repository)?; + if number.is_some_and(|number| number <= 0) { + return Err("Invalid issue number.".into()); + } + Ok(issue_editor_page( + load_issue_editor(&self.server()?, owner, repository, number).await?, + )) + } + + #[allow(clippy::too_many_arguments)] + pub async fn save_issue( + &self, + owner: String, + repository: String, + number: Option, + title: String, + body: String, + label_ids: Vec, + milestone_id: Option, + due_date: Option, + ) -> Result { + let (owner, repository) = validate_repository(&owner, &repository)?; + let title = title.trim(); + if title.is_empty() { + return Err("Enter an issue title.".into()); + } + if number.is_some_and(|number| number <= 0) + || milestone_id.is_some_and(|id| id <= 0) + || label_ids.iter().any(|id| *id <= 0) + { + return Err("Invalid issue editor selection.".into()); + } + let label_ids: Vec<_> = label_ids + .into_iter() + .collect::>() + .into_iter() + .collect(); + let draft = IssueDraft { + title: title.into(), + body, + label_ids, + milestone_id, + due_date, + }; + let server = self.server()?; + let issue = match number { + Some(number) => edit_issue(&server, owner, repository, number, draft).await?, + None => create_issue(&server, owner, repository, draft).await?, + }; + issue + .number + .ok_or_else(|| "The saved issue has no number.".into()) + } + pub async fn milestones( &self, owner: String, @@ -502,6 +562,18 @@ impl GotchaCore { } } +fn validate_repository<'a>( + owner: &'a str, + repository: &'a str, +) -> Result<(&'a str, &'a str), GotchaError> { + let owner = owner.trim(); + let repository = repository.trim(); + if owner.is_empty() || repository.is_empty() { + return Err("Select a repository first.".into()); + } + Ok((owner, repository)) +} + impl GotchaCore { fn server(&self) -> Result { let state = self.state.lock().unwrap(); diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index ca560e1..f70bd1b 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -3,8 +3,8 @@ use gotcha_gitea::models; use crate::{ activity, domain::{ - HistoryCommit, HomeData, IssueDetails, IssueFilter, MilestoneDetails, PullDetails, - PullFilter, RepositoryData, + HistoryCommit, HomeData, IssueDetails, IssueEditorData, IssueFilter, MilestoneDetails, + PullDetails, PullFilter, RepositoryData, civil_from_days, days_from_civil, parse_api_date, }, }; @@ -67,6 +67,29 @@ pub struct IssuePage { pub comments: Vec, } +#[derive(Clone, uniffi::Record)] +pub struct IssueEditorLabel { + pub id: i64, + pub name: String, + pub selected: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct IssueEditorMilestone { + pub id: i64, + pub title: String, + pub selected: bool, +} + +#[derive(Clone, uniffi::Record)] +pub struct IssueEditorPage { + pub title: String, + pub body: String, + pub due_date: Option, + pub labels: Vec, + pub milestones: Vec, +} + #[derive(Clone, uniffi::Record)] pub struct IssueFilterOptions { pub milestones: Vec, @@ -457,6 +480,70 @@ pub fn issue_page(details: IssueDetails) -> IssuePage { } } +pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage { + let selected_labels: std::collections::BTreeSet<_> = data + .issue + .as_ref() + .and_then(|issue| issue.labels.as_deref()) + .unwrap_or_default() + .iter() + .filter_map(|label| label.id) + .collect(); + let selected_milestone = data + .issue + .as_ref() + .and_then(|issue| issue.milestone.as_ref()) + .and_then(|milestone| milestone.id); + let mut labels: Vec<_> = data + .labels + .into_iter() + .filter_map(|label| { + let id = label.id?; + Some(IssueEditorLabel { + id, + name: label.name?, + selected: selected_labels.contains(&id), + }) + }) + .collect(); + labels.sort_by_key(|label| label.name.to_lowercase()); + let mut milestones: Vec<_> = data + .milestones + .into_iter() + .filter(|milestone| { + milestone.state.as_deref() != Some("closed") || milestone.id == selected_milestone + }) + .filter_map(|milestone| { + let id = milestone.id?; + Some(IssueEditorMilestone { + id, + title: milestone.title?, + selected: Some(id) == selected_milestone, + }) + }) + .collect(); + milestones.sort_by_key(|milestone| milestone.title.to_lowercase()); + IssueEditorPage { + title: data + .issue + .as_ref() + .and_then(|issue| issue.title.clone()) + .unwrap_or_default(), + body: data + .issue + .as_ref() + .and_then(|issue| issue.body.clone()) + .unwrap_or_default(), + due_date: data + .issue + .as_ref() + .and_then(|issue| issue.due_date.as_deref()) + .and_then(parse_api_date), + labels, + milestones, + } +} + pub fn pull_page(details: PullDetails) -> PullPage { let pull = &details.pull; let author = pull @@ -840,31 +927,6 @@ fn heat_cells(data: &[models::UserHeatmapData]) -> (Vec, i64) { (cells, contribution_count) } -fn civil_from_days(days: i64) -> (i64, i64, i64) { - let days = days + 719_468; - let era = days.div_euclid(146_097); - let day_of_era = days - era * 146_097; - let year_of_era = - (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; - let mut year = year_of_era + era * 400; - let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); - let month_prime = (5 * day_of_year + 2) / 153; - let day = day_of_year - (153 * month_prime + 2) / 5 + 1; - let month = month_prime + if month_prime < 10 { 3 } else { -9 }; - year += i64::from(month <= 2); - (year, month, day) -} - -fn days_from_civil(mut year: i64, month: i64, day: i64) -> i64 { - year -= i64::from(month <= 2); - let era = year.div_euclid(400); - let year_of_era = year - era * 400; - let month_prime = month + if month > 2 { -3 } else { 9 }; - let day_of_year = (153 * month_prime + 2) / 5 + day - 1; - let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; - era * 146_097 + day_of_era - 719_468 -} - fn label_row(label: &models::Label) -> Option { let name = label.name.as_deref()?.trim(); let color = label.color.as_deref()?.trim().trim_start_matches('#'); @@ -1172,6 +1234,42 @@ mod tests { assert_eq!(page.state, "closed"); } + #[test] + fn issue_editor_exposes_selected_metadata_and_due_date() { + let selected_label = models::Label { + id: Some(2), + name: Some("bug".into()), + ..Default::default() + }; + let page = issue_editor_page(IssueEditorData { + issue: Some(models::Issue { + title: Some("Title".into()), + body: Some("Body".into()), + due_date: Some("2024-02-29T18:00:00Z".into()), + labels: Some(vec![selected_label.clone()]), + milestone: Some(Box::new(models::Milestone { + id: Some(4), + title: Some("Current".into()), + state: Some("closed".into()), + ..Default::default() + })), + ..Default::default() + }), + labels: vec![selected_label], + milestones: vec![models::Milestone { + id: Some(4), + title: Some("Current".into()), + state: Some("closed".into()), + ..Default::default() + }], + }); + + assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body")); + assert_eq!(page.due_date, Some(1_709_164_800)); + assert!(page.labels[0].selected); + assert!(page.milestones[0].selected); + } + #[test] fn builds_sorted_issue_filter_options() { let filter = IssueFilter { diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index fecc56d..5e473b6 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -619,6 +619,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func issue(owner: String, repository: String, number: Int64) async throws -> IssuePage + func issueEditor(owner: String, repository: String, number: Int64?) async throws -> IssueEditorPage + func issueFilters(owner: String, repository: String) async throws -> IssueFilterOptions func issueFiltersActive(owner: String, repository: String) throws -> Bool @@ -645,6 +647,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func repositoryFile(owner: String, repository: String, path: String) async throws -> RepositoryFilePage + func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?) async throws -> Int64 + func selectServer(index: UInt32) throws func servers() -> [ServerRow] @@ -841,6 +845,22 @@ open func issue(owner: String, repository: String, number: Int64)async throws - ) } +open func issueEditor(owner: String, repository: String, number: Int64?)async throws -> IssueEditorPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_issue_editor( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_rust_buffer, + completeFunc: ffi_gotcha_core_rust_future_complete_rust_buffer, + freeFunc: ffi_gotcha_core_rust_future_free_rust_buffer, + liftFunc: FfiConverterTypeIssueEditorPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func issueFilters(owner: String, repository: String)async throws -> IssueFilterOptions { return try await uniffiRustCallAsync( @@ -1037,6 +1057,22 @@ open func repositoryFile(owner: String, repository: String, path: String)async t ) } +open func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?)async throws -> Int64 { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_save_issue( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(number),FfiConverterString.lower(title),FfiConverterString.lower(body),FfiConverterSequenceInt64.lower(labelIds),FfiConverterOptionInt64.lower(milestoneId),FfiConverterOptionInt64.lower(dueDate) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_i64, + completeFunc: ffi_gotcha_core_rust_future_complete_i64, + freeFunc: ffi_gotcha_core_rust_future_free_i64, + liftFunc: FfiConverterInt64.lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_select_server( @@ -1750,6 +1786,188 @@ public func FfiConverterTypeHomePage_lower(_ value: HomePage) -> RustBuffer { } +public struct IssueEditorLabel: Equatable, Hashable { + public var id: Int64 + public var name: String + public var selected: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: Int64, name: String, selected: Bool) { + self.id = id + self.name = name + self.selected = selected + } + + + + +} + +#if compiler(>=6) +extension IssueEditorLabel: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeIssueEditorLabel: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorLabel { + return + try IssueEditorLabel( + id: FfiConverterInt64.read(from: &buf), + name: FfiConverterString.read(from: &buf), + selected: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: IssueEditorLabel, into buf: inout [UInt8]) { + FfiConverterInt64.write(value.id, into: &buf) + FfiConverterString.write(value.name, into: &buf) + FfiConverterBool.write(value.selected, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueEditorLabel_lift(_ buf: RustBuffer) throws -> IssueEditorLabel { + return try FfiConverterTypeIssueEditorLabel.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueEditorLabel_lower(_ value: IssueEditorLabel) -> RustBuffer { + return FfiConverterTypeIssueEditorLabel.lower(value) +} + + +public struct IssueEditorMilestone: Equatable, Hashable { + public var id: Int64 + public var title: String + public var selected: Bool + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(id: Int64, title: String, selected: Bool) { + self.id = id + self.title = title + self.selected = selected + } + + + + +} + +#if compiler(>=6) +extension IssueEditorMilestone: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeIssueEditorMilestone: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorMilestone { + return + try IssueEditorMilestone( + id: FfiConverterInt64.read(from: &buf), + title: FfiConverterString.read(from: &buf), + selected: FfiConverterBool.read(from: &buf) + ) + } + + public static func write(_ value: IssueEditorMilestone, into buf: inout [UInt8]) { + FfiConverterInt64.write(value.id, into: &buf) + FfiConverterString.write(value.title, into: &buf) + FfiConverterBool.write(value.selected, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueEditorMilestone_lift(_ buf: RustBuffer) throws -> IssueEditorMilestone { + return try FfiConverterTypeIssueEditorMilestone.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueEditorMilestone_lower(_ value: IssueEditorMilestone) -> RustBuffer { + return FfiConverterTypeIssueEditorMilestone.lower(value) +} + + +public struct IssueEditorPage: Equatable, Hashable { + public var title: String + public var body: String + public var dueDate: Int64? + public var labels: [IssueEditorLabel] + public var milestones: [IssueEditorMilestone] + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(title: String, body: String, dueDate: Int64?, labels: [IssueEditorLabel], milestones: [IssueEditorMilestone]) { + self.title = title + self.body = body + self.dueDate = dueDate + self.labels = labels + self.milestones = milestones + } + + + + +} + +#if compiler(>=6) +extension IssueEditorPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeIssueEditorPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> IssueEditorPage { + return + try IssueEditorPage( + title: FfiConverterString.read(from: &buf), + body: FfiConverterString.read(from: &buf), + dueDate: FfiConverterOptionInt64.read(from: &buf), + labels: FfiConverterSequenceTypeIssueEditorLabel.read(from: &buf), + milestones: FfiConverterSequenceTypeIssueEditorMilestone.read(from: &buf) + ) + } + + public static func write(_ value: IssueEditorPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.body, into: &buf) + FfiConverterOptionInt64.write(value.dueDate, into: &buf) + FfiConverterSequenceTypeIssueEditorLabel.write(value.labels, into: &buf) + FfiConverterSequenceTypeIssueEditorMilestone.write(value.milestones, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueEditorPage_lift(_ buf: RustBuffer) throws -> IssueEditorPage { + return try FfiConverterTypeIssueEditorPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeIssueEditorPage_lower(_ value: IssueEditorPage) -> RustBuffer { + return FfiConverterTypeIssueEditorPage.lower(value) +} + + public struct IssueFilterOptions: Equatable, Hashable { public var milestones: [String] public var labels: [String] @@ -2744,6 +2962,30 @@ fileprivate struct FfiConverterOptionUInt32: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterOptionInt64: FfiConverterRustBuffer { + typealias SwiftType = Int64? + + public static func write(_ value: SwiftType, into buf: inout [UInt8]) { + guard let value = value else { + writeInt(&buf, Int8(0)) + return + } + writeInt(&buf, Int8(1)) + FfiConverterInt64.write(value, into: &buf) + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> SwiftType { + switch try readInt(&buf) as Int8 { + case 0: return nil + case 1: return try FfiConverterInt64.read(from: &buf) + default: throw UniffiInternalError.unexpectedOptionalTag + } + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2793,6 +3035,31 @@ fileprivate struct FfiConverterSequenceUInt32: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceInt64: FfiConverterRustBuffer { + typealias SwiftType = [Int64] + + public static func write(_ value: [Int64], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterInt64.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [Int64] { + let len: Int32 = try readInt(&buf) + var seq = [Int64]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterInt64.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -2968,6 +3235,56 @@ fileprivate struct FfiConverterSequenceTypeHeatCell: FfiConverterRustBuffer { } } +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeIssueEditorLabel: FfiConverterRustBuffer { + typealias SwiftType = [IssueEditorLabel] + + public static func write(_ value: [IssueEditorLabel], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeIssueEditorLabel.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [IssueEditorLabel] { + let len: Int32 = try readInt(&buf) + var seq = [IssueEditorLabel]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeIssueEditorLabel.read(from: &buf)) + } + return seq + } +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +fileprivate struct FfiConverterSequenceTypeIssueEditorMilestone: FfiConverterRustBuffer { + typealias SwiftType = [IssueEditorMilestone] + + public static func write(_ value: [IssueEditorMilestone], into buf: inout [UInt8]) { + let len = Int32(value.count) + writeInt(&buf, len) + for item in value { + FfiConverterTypeIssueEditorMilestone.write(item, into: &buf) + } + } + + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> [IssueEditorMilestone] { + let len: Int32 = try readInt(&buf) + var seq = [IssueEditorMilestone]() + seq.reserveCapacity(Int(len)) + for _ in 0 ..< len { + seq.append(try FfiConverterTypeIssueEditorMilestone.read(from: &buf)) + } + return seq + } +} + #if swift(>=5.8) @_documentation(visibility: private) #endif @@ -3230,6 +3547,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_issue() != 56735) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_issue_editor() != 10078) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_issue_filters() != 24054) { return InitializationResult.apiChecksumMismatch } @@ -3269,6 +3589,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_repository_file() != 44948) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 55053) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index 474b545..9bc3d49 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -299,6 +299,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_EDITOR +uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_editor(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_ISSUE_FILTERS uint64_t uniffi_gotcha_core_fn_method_gotchacore_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository @@ -364,6 +369,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_contents(uint64_t pt uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer path ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer number, RustBuffer title, RustBuffer body, RustBuffer label_ids, RustBuffer milestone_id, RustBuffer due_date +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER void uniffi_gotcha_core_fn_method_gotchacore_select_server(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status @@ -720,6 +730,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_home(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_EDITOR +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issue_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_ISSUE_FILTERS @@ -798,6 +814,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_contents(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_REPOSITORY_FILE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_repository_file(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER diff --git a/ios/Gotcha.xcodeproj/project.pbxproj b/ios/Gotcha.xcodeproj/project.pbxproj index 0130e54..a9e5b65 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 7DD332583B169B3CA1CA46E0 /* Highlighter in Frameworks */ = {isa = PBXBuildFile; productRef = EC5F999F50905E3801E8A71A /* Highlighter */; }; 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C0921C76676A14A024BA417 /* AppDelegate.swift */; }; 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */; }; + D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722405F999984C7CBE838711 /* IssueEditorViewController.swift */; }; D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE12480725C13293FAFDDA52 /* gotcha_core.swift */; }; D78A121E9ADA0B72E2CF94DC /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 8AD463F3AC817A44306C2297 /* MarkdownUI */; }; E7AC0B140F5CFC5EF226D174 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */; }; @@ -23,6 +24,7 @@ /* Begin PBXFileReference section */ 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailScreens.swift; sourceTree = ""; }; 5FB3250A93766966A60A685E /* Gotcha.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Gotcha.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 722405F999984C7CBE838711 /* IssueEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueEditorViewController.swift; sourceTree = ""; }; 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewController.swift; sourceTree = ""; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = ""; }; @@ -53,6 +55,7 @@ 9C0921C76676A14A024BA417 /* AppDelegate.swift */, CE5E10787062D1175125BB4C /* ContentScreens.swift */, 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */, + 722405F999984C7CBE838711 /* IssueEditorViewController.swift */, AC828923A1E11A58AE348A74 /* ListScreens.swift */, 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */, F75B3E4FFB9C9992517C4D69 /* Support.swift */, @@ -192,6 +195,7 @@ 950C584D58E80106DF350A22 /* AppDelegate.swift in Sources */, 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */, 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */, + D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */, 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */, 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */, F55A89489B2758D694F3B27D /* Support.swift in Sources */, diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index fab6984..eb84100 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -8,6 +8,9 @@ final class IssuesViewController: RefreshingTableViewController { private var rows: [IssueRow] = [] private var filterOptions: IssueFilterOptions? private var filterTask: Task? + private lazy var filterButton = UIBarButtonItem( + image: context.symbol("line.3.horizontal.decrease.circle") + ) init(context: AppContext, owner: String, repository: String) { self.context = context @@ -25,6 +28,13 @@ final class IssuesViewController: RefreshingTableViewController { override func viewDidLoad() { super.viewDidLoad() tableView.register(IssueCell.self, forCellReuseIdentifier: "issue") + let addButton = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(createIssue) + ) + addButton.accessibilityLabel = "New issue" + navigationItem.rightBarButtonItems = [addButton, filterButton] updateFilterMenu() loadContent(refreshing: false) } @@ -122,14 +132,8 @@ final class IssuesViewController: RefreshingTableViewController { } else { children.append(UIAction(title: "Loading filters…", attributes: .disabled) { _ in }) } - let item = navigationItem.rightBarButtonItem ?? UIBarButtonItem( - image: context.symbol("line.3.horizontal.decrease.circle") - ) - item.menu = UIMenu(children: children) - item.accessibilityLabel = "Filter issues" - if navigationItem.rightBarButtonItem == nil { - navigationItem.rightBarButtonItem = item - } + filterButton.menu = UIMenu(children: children) + filterButton.accessibilityLabel = "Filter issues" updateFilterTint() } @@ -211,12 +215,35 @@ final class IssuesViewController: RefreshingTableViewController { } private func updateFilterTint() { - navigationItem.rightBarButtonItem?.tintColor = filtersActive ? .tintColor : .secondaryLabel - navigationItem.rightBarButtonItem?.accessibilityValue = filtersActive + filterButton.tintColor = filtersActive ? .tintColor : .secondaryLabel + filterButton.accessibilityValue = filtersActive ? "Filters active" : "Default filters" } + @objc private func createIssue() { + let editor = IssueEditorViewController( + context: context, + owner: owner, + repository: repository + ) { [weak self] number in + guard let self else { return } + self.dismiss(animated: true) { + self.loadContent(refreshing: false) + self.navigationController?.pushViewController( + IssueViewController( + context: self.context, + owner: self.owner, + repository: self.repository, + number: number + ), + animated: true + ) + } + } + present(UINavigationController(rootViewController: editor), animated: true) + } + private func updateEmptyState() { guard rows.isEmpty else { tableView.backgroundView = nil diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index 97c4def..a222cb1 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -87,9 +87,28 @@ final class IssueViewController: MarkdownPageViewController { override func viewDidLoad() { super.viewDidLoad() + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Edit", + style: .plain, + target: self, + action: #selector(editIssue) + ) loadContent(refreshing: false) } + @objc private func editIssue() { + let editor = IssueEditorViewController( + context: context, + owner: owner, + repository: repository, + number: number + ) { [weak self] _ in + guard let self else { return } + self.dismiss(animated: true) { self.loadContent(refreshing: false) } + } + present(UINavigationController(rootViewController: editor), animated: true) + } + override func loadContent(refreshing: Bool) { beginLoading(refreshing: refreshing) loadingTask?.cancel() @@ -538,7 +557,7 @@ final class RepositoryFileViewController: UIViewController, QLPreviewControllerD } } -private struct RepositoryMarkdownPreview: View { +struct RepositoryMarkdownPreview: View { let source: String var body: some View { diff --git a/ios/Sources/IssueEditorViewController.swift b/ios/Sources/IssueEditorViewController.swift new file mode 100644 index 0000000..d8b5f8a --- /dev/null +++ b/ios/Sources/IssueEditorViewController.swift @@ -0,0 +1,353 @@ +import Highlighter +import SwiftUI +import UIKit + +@MainActor +final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UITextViewDelegate { + private let context: AppContext + private let owner: String + private let repository: String + private let number: Int64? + private let saved: (Int64) -> Void + private let scrollView = UIScrollView() + private let stack = UIStackView() + private let titleField = UITextField() + private let modeControl = UISegmentedControl(items: ["Write", "Preview"]) + private let bodyView = UITextView() + private let previewView = UIView() + private let labelsButton = UIButton(type: .system) + private let milestoneButton = UIButton(type: .system) + private let dueSwitch = UISwitch() + private let duePicker = UIDatePicker() + private let spinner = UIActivityIndicatorView(style: .medium) + private var page: IssueEditorPage? + private var selectedLabels = Set() + private var selectedMilestone: Int64? + private var task: Task? + private var previewController: UIViewController? + private var editorFont: UIFont { + UIFontMetrics(forTextStyle: .body).scaledFont( + for: .monospacedSystemFont(ofSize: 15, weight: .regular) + ) + } + + init( + context: AppContext, + owner: String, + repository: String, + number: Int64? = nil, + saved: @escaping (Int64) -> Void + ) { + self.context = context + self.owner = owner + self.repository = repository + self.number = number + self.saved = saved + super.init(nibName: nil, bundle: nil) + title = number == nil ? "New Issue" : "Edit Issue" + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + deinit { task?.cancel() } + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .systemGroupedBackground + navigationItem.leftBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .cancel, + target: self, + action: #selector(cancel) + ) + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Save", + style: .done, + target: self, + action: #selector(save) + ) + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) + spinner.startAnimating() + configureForm() + task = Task { await load() } + } + + private func configureForm() { + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.keyboardDismissMode = .interactive + view.addSubview(scrollView) + stack.axis = .vertical + stack.spacing = 12 + stack.translatesAutoresizingMaskIntoConstraints = false + stack.isHidden = true + scrollView.addSubview(stack) + + titleField.placeholder = "Title" + titleField.delegate = self + titleField.font = .preferredFont(forTextStyle: .headline) + titleField.adjustsFontForContentSizeCategory = true + titleField.borderStyle = .roundedRect + titleField.autocapitalizationType = .sentences + titleField.returnKeyType = .next + titleField.accessibilityLabel = "Issue title" + titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged) + + [labelsButton, milestoneButton].forEach { button in + var configuration = UIButton.Configuration.tinted() + configuration.imagePlacement = .leading + configuration.imagePadding = 8 + button.configuration = configuration + button.showsMenuAsPrimaryAction = true + button.contentHorizontalAlignment = .leading + } + labelsButton.configuration?.image = context.symbol("tag") + milestoneButton.configuration?.image = context.symbol("flag") + + let dueLabel = UILabel() + dueLabel.text = "Due Date" + dueLabel.font = .preferredFont(forTextStyle: .body) + dueLabel.adjustsFontForContentSizeCategory = true + let dueRow = UIStackView(arrangedSubviews: [dueLabel, dueSwitch]) + dueRow.alignment = .center + dueSwitch.accessibilityLabel = "Set due date" + dueSwitch.addTarget(self, action: #selector(dueChanged), for: .valueChanged) + duePicker.datePickerMode = .date + duePicker.preferredDatePickerStyle = .inline + duePicker.isHidden = true + + modeControl.selectedSegmentIndex = 0 + modeControl.addTarget(self, action: #selector(modeChanged), for: .valueChanged) + bodyView.delegate = self + bodyView.font = editorFont + bodyView.adjustsFontForContentSizeCategory = true + bodyView.backgroundColor = .secondarySystemGroupedBackground + bodyView.layer.cornerRadius = 10 + bodyView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) + bodyView.autocapitalizationType = .sentences + bodyView.accessibilityLabel = "Issue description in Markdown" + previewView.backgroundColor = .secondarySystemGroupedBackground + previewView.layer.cornerRadius = 10 + previewView.accessibilityLabel = "Issue description preview" + previewView.isHidden = true + + stack.addArrangedSubview(titleField) + stack.addArrangedSubview(labelsButton) + stack.addArrangedSubview(milestoneButton) + stack.addArrangedSubview(dueRow) + stack.addArrangedSubview(duePicker) + stack.addArrangedSubview(modeControl) + stack.addArrangedSubview(bodyView) + stack.addArrangedSubview(previewView) + NSLayoutConstraint.activate([ + scrollView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), + stack.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16), + stack.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16), + stack.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16), + stack.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16), + stack.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32), + bodyView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280), + previewView.heightAnchor.constraint(greaterThanOrEqualToConstant: 280), + ]) + } + + private func load() async { + do { + let page = try await context.core.issueEditor( + owner: owner, + repository: repository, + number: number + ) + guard !Task.isCancelled else { return } + self.page = page + titleField.text = page.title + bodyView.text = page.body + selectedLabels = Set(page.labels.filter(\.selected).map(\.id)) + selectedMilestone = page.milestones.first(where: \.selected)?.id + if let timestamp = page.dueDate { + dueSwitch.isOn = true + duePicker.date = localDate(forUTCTimestamp: timestamp) + duePicker.isHidden = false + } + updateLabelsMenu() + updateMilestoneMenu() + highlightBody() + stack.isHidden = false + navigationItem.rightBarButtonItem?.isEnabled = !page.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } catch { + if !Task.isCancelled { show(error: error) } + } + restoreSaveButton() + } + + private func updateLabelsMenu() { + guard let page else { return } + labelsButton.configuration?.title = selectedLabels.isEmpty + ? "Labels" + : "Labels (\(selectedLabels.count))" + labelsButton.accessibilityValue = selectedLabels.isEmpty + ? "None" + : page.labels.filter { selectedLabels.contains($0.id) }.map(\.name).joined(separator: ", ") + let actions = page.labels.map { label in + UIAction( + title: label.name, + attributes: .keepsMenuPresented, + state: selectedLabels.contains(label.id) ? .on : .off + ) { [weak self] action in + guard let self else { return } + if self.selectedLabels.remove(label.id) == nil { self.selectedLabels.insert(label.id) } + action.state = self.selectedLabels.contains(label.id) ? .on : .off + self.updateLabelsMenu() + } + } + labelsButton.menu = UIMenu(children: actions.isEmpty + ? [UIAction(title: "No labels", attributes: .disabled) { _ in }] + : actions) + } + + private func updateMilestoneMenu() { + guard let page else { return } + milestoneButton.configuration?.title = page.milestones + .first(where: { $0.id == selectedMilestone })?.title ?? "Milestone" + milestoneButton.accessibilityValue = page.milestones + .first(where: { $0.id == selectedMilestone })?.title ?? "None" + let none = UIAction(title: "No Milestone", state: selectedMilestone == nil ? .on : .off) { + [weak self] _ in + self?.selectedMilestone = nil + self?.updateMilestoneMenu() + } + milestoneButton.menu = UIMenu(options: .singleSelection, children: [none] + page.milestones.map { milestone in + UIAction( + title: milestone.title, + state: milestone.id == selectedMilestone ? .on : .off + ) { [weak self] _ in + self?.selectedMilestone = milestone.id + self?.updateMilestoneMenu() + } + }) + } + + func textViewDidChange(_ textView: UITextView) { + highlightBody() + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + bodyView.becomeFirstResponder() + return true + } + + private func highlightBody() { + let source = bodyView.text ?? "" + let selection = bodyView.selectedRange + let highlighter = Highlighter() + highlighter?.setTheme(traitCollection.userInterfaceStyle == .dark ? "atom-one-dark" : "atom-one-light") + highlighter?.theme.setCodeFont(editorFont) + bodyView.attributedText = highlighter?.highlight(source, as: "markdown") + ?? NSAttributedString(string: source, attributes: [ + .font: editorFont, + .foregroundColor: UIColor.label, + ]) + bodyView.selectedRange = selection + bodyView.typingAttributes = [ + .font: editorFont, + .foregroundColor: UIColor.label, + ] + } + + @objc private func modeChanged() { + let preview = modeControl.selectedSegmentIndex == 1 + if preview { + previewController?.willMove(toParent: nil) + previewController?.view.removeFromSuperview() + previewController?.removeFromParent() + let controller = UIHostingController( + rootView: RepositoryMarkdownPreview(source: bodyView.text ?? "") + ) + addChild(controller) + controller.view.translatesAutoresizingMaskIntoConstraints = false + controller.view.layer.cornerRadius = 10 + controller.view.clipsToBounds = true + previewView.addSubview(controller.view) + NSLayoutConstraint.activate([ + controller.view.leadingAnchor.constraint(equalTo: previewView.leadingAnchor), + controller.view.trailingAnchor.constraint(equalTo: previewView.trailingAnchor), + controller.view.topAnchor.constraint(equalTo: previewView.topAnchor), + controller.view.bottomAnchor.constraint(equalTo: previewView.bottomAnchor), + ]) + controller.didMove(toParent: self) + previewController = controller + } + bodyView.isHidden = preview + previewView.isHidden = !preview + view.endEditing(true) + } + + @objc private func dueChanged() { + duePicker.isHidden = !dueSwitch.isOn + if dueSwitch.isOn, page?.dueDate == nil { duePicker.date = Date() } + } + + @objc private func titleChanged() { + navigationItem.rightBarButtonItem?.isEnabled = !(titleField.text ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + @objc private func save() { + view.endEditing(true) + navigationItem.leftBarButtonItem?.isEnabled = false + navigationItem.rightBarButtonItem = UIBarButtonItem(customView: spinner) + spinner.startAnimating() + task?.cancel() + task = Task { + do { + let number = try await context.core.saveIssue( + owner: owner, + repository: repository, + number: number, + title: titleField.text ?? "", + body: bodyView.text ?? "", + labelIds: Array(selectedLabels), + milestoneId: selectedMilestone, + dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil + ) + guard !Task.isCancelled else { return } + saved(number) + } catch { + if !Task.isCancelled { + show(error: error) + restoreSaveButton() + } + } + } + } + + private func restoreSaveButton() { + spinner.stopAnimating() + navigationItem.leftBarButtonItem?.isEnabled = true + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Save", + style: .done, + target: self, + action: #selector(save) + ) + titleChanged() + } + + @objc private func cancel() { dismiss(animated: true) } + + private func localDate(forUTCTimestamp timestamp: Int64) -> Date { + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(secondsFromGMT: 0)! + let components = utc.dateComponents([.year, .month, .day], from: Date(timeIntervalSince1970: TimeInterval(timestamp))) + return Calendar.current.date(from: components) ?? Date() + } + + private func utcTimestamp(forLocalDate date: Date) -> Int64 { + let components = Calendar.current.dateComponents([.year, .month, .day], from: date) + var utc = Calendar(identifier: .gregorian) + utc.timeZone = TimeZone(secondsFromGMT: 0)! + return Int64((utc.date(from: components) ?? date).timeIntervalSince1970) + } +}