diff --git a/TESTING.md b/TESTING.md index ce443d9..de5276f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -173,7 +173,8 @@ xcrun simctl launch booted de.rfc1437.gotcha returns every filter icon to neutral. - [ ] 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. + single-select optional milestone, a **Closed** switch that defaults off, + 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. @@ -182,7 +183,13 @@ xcrun simctl launch booted de.rfc1437.gotcha due date; each new issue appears in the list and opens its detail. - [ ] Tap the pencil button 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. + its due date. Turn **Closed** on and save, then edit it again and turn + **Closed** off. Save, refresh, and relaunch to verify every change persists. +- [ ] In the issue list, partially swipe an open issue to reveal **Close** and + **Delete**. Cancel the destructive Delete confirmation and verify nothing + changes. Full-swipe the same row to close it, switch to Closed issues, and + full-swipe it again to reopen it; each row leaves the current filtered list + only after the server mutation succeeds. - [ ] 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. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index a0cbe94..e2605c0 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -304,6 +304,36 @@ pub async fn edit_issue( .map_err(message) } +pub async fn set_issue_closed( + server: &Server, + owner: &str, + repository: &str, + number: i64, + closed: bool, +) -> Result<(), String> { + client(server)? + .set_issue_state( + &scope(owner, repository)?, + &[number], + if closed { "closed" } else { "open" }, + ) + .await + .map(|_| ()) + .map_err(message) +} + +pub async fn delete_issue( + server: &Server, + owner: &str, + repository: &str, + number: i64, +) -> Result<(), String> { + client(server)? + .delete_issue(&scope(owner, repository)?, number) + .await + .map_err(message) +} + pub async fn load_pull( server: &Server, owner: &str, @@ -382,6 +412,7 @@ fn shared_issue_draft(draft: IssueDraft) -> gotcha_gitea::IssueDraft { label_ids: draft.label_ids, milestone_id: draft.milestone_id, due_date: draft.due_date.map(api_date), + closed: draft.closed, } } diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 003d7f8..3c2513c 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -132,6 +132,7 @@ pub struct IssueDraft { pub label_ids: Vec, pub milestone_id: Option, pub due_date: Option, + pub closed: bool, } pub struct MilestoneDraft { diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 138f178..f8ff428 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -445,6 +445,7 @@ impl GotchaCore { label_ids: Vec, milestone_id: Option, due_date: Option, + closed: bool, ) -> Result { let (owner, repository) = validate_repository(&owner, &repository)?; let title = title.trim(); @@ -468,6 +469,7 @@ impl GotchaCore { label_ids, milestone_id, due_date, + closed, }; let server = self.server()?; let issue = match number { @@ -479,6 +481,35 @@ impl GotchaCore { .ok_or_else(|| "The saved issue has no number.".into()) } + pub async fn set_issue_closed( + &self, + owner: String, + repository: String, + number: i64, + closed: bool, + ) -> Result<(), GotchaError> { + let (owner, repository) = validate_repository(&owner, &repository)?; + if number <= 0 { + return Err("Invalid issue number.".into()); + } + set_issue_closed(&self.server()?, owner, repository, number, closed).await?; + Ok(()) + } + + pub async fn delete_issue( + &self, + owner: String, + repository: String, + number: i64, + ) -> Result<(), GotchaError> { + let (owner, repository) = validate_repository(&owner, &repository)?; + if number <= 0 { + return Err("Invalid issue number.".into()); + } + delete_issue(&self.server()?, owner, repository, number).await?; + Ok(()) + } + pub async fn milestones( &self, owner: String, diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index d702054..a42afc7 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -103,6 +103,7 @@ pub struct IssueEditorPage { pub title: String, pub body: String, pub due_date: Option, + pub closed: bool, pub labels: Vec, pub milestones: Vec, } @@ -666,6 +667,7 @@ pub fn issue_editor_page(data: IssueEditorData) -> IssueEditorPage { .as_ref() .and_then(|issue| issue.due_date.as_deref()) .and_then(parse_api_date), + closed: data.issue.as_ref().and_then(|issue| issue.state.as_deref()) == Some("closed"), labels, milestones, } @@ -1513,6 +1515,7 @@ mod tests { title: Some("Title".into()), body: Some("Body".into()), due_date: Some("2024-02-29T18:00:00Z".into()), + state: Some("closed".into()), labels: Some(vec![selected_label.clone()]), milestone: Some(Box::new(models::Milestone { id: Some(4), @@ -1533,8 +1536,16 @@ mod tests { assert_eq!((page.title.as_str(), page.body.as_str()), ("Title", "Body")); assert_eq!(page.due_date, Some(1_709_164_800)); + assert!(page.closed); assert!(page.labels[0].selected); assert!(page.milestones[0].selected); + + let new = issue_editor_page(IssueEditorData { + issue: None, + labels: Vec::new(), + milestones: Vec::new(), + }); + assert!(!new.closed); } #[test] diff --git a/crates/gitea/src/domain.rs b/crates/gitea/src/domain.rs index dc07d3a..1d80025 100644 --- a/crates/gitea/src/domain.rs +++ b/crates/gitea/src/domain.rs @@ -108,6 +108,7 @@ pub struct IssueDraft { pub label_ids: Vec, pub milestone_id: Option, pub due_date: Option, + pub closed: bool, } #[derive(Clone, Debug)] diff --git a/crates/gitea/src/issues.rs b/crates/gitea/src/issues.rs index 887202f..8063c24 100644 --- a/crates/gitea/src/issues.rs +++ b/crates/gitea/src/issues.rs @@ -463,6 +463,7 @@ pub fn comment_can_edit(comment: &models::Comment, viewer_id: Option) -> bo fn create_issue_option(draft: IssueDraft) -> models::CreateIssueOption { models::CreateIssueOption { body: Some(draft.body), + closed: Some(draft.closed), due_date: draft.due_date, labels: Some(draft.label_ids), milestone: draft.milestone_id, @@ -476,6 +477,7 @@ fn edit_issue_draft(draft: IssueDraft) -> EditIssue { body: Some(draft.body), due_date: draft.due_date.clone(), milestone: Some(draft.milestone_id.unwrap_or_default()), + state: Some(if draft.closed { "closed" } else { "open" }.into()), title: Some(draft.title), unset_due_date: draft.due_date.is_none().then_some(true), ..models::EditIssueOption::new() @@ -552,13 +554,16 @@ mod tests { label_ids: vec![2, 4], milestone_id: None, due_date: None, + closed: true, }; let create = create_issue_option(draft.clone()); assert_eq!(create.title, "Title"); assert_eq!(create.body.as_deref(), Some("Body")); + assert_eq!(create.closed, Some(true)); assert_eq!(create.labels, Some(vec![2, 4])); let edit = edit_issue_draft(draft); assert_eq!(edit.option.milestone, Some(0)); + assert_eq!(edit.option.state.as_deref(), Some("closed")); assert_eq!(edit.option.unset_due_date, Some(true)); assert_eq!(edit.replace_labels, Some(vec![2, 4])); } diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 271f307..b95fb5a 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -619,6 +619,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func commits(owner: String, repository: String, branch: String?, path: String, pages: UInt32) async throws -> CommitPage + func deleteIssue(owner: String, repository: String, number: Int64) async throws + func home(page: UInt32, filter: HomeActivityFilter) async throws -> HomePage func issue(owner: String, repository: String, number: Int64, page: UInt32) async throws -> IssuePage @@ -653,7 +655,7 @@ 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 saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?, closed: Bool) async throws -> Int64 func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws @@ -665,6 +667,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func setAppearance(index: UInt32) throws + func setIssueClosed(owner: String, repository: String, number: Int64, closed: Bool) async throws + func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String) throws func setIssueStatus(status: String) throws @@ -841,6 +845,22 @@ open func commits(owner: String, repository: String, branch: String?, path: Stri ) } +open func deleteIssue(owner: String, repository: String, number: Int64)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_delete_issue( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func home(page: UInt32, filter: HomeActivityFilter)async throws -> HomePage { return try await uniffiRustCallAsync( @@ -1101,12 +1121,12 @@ 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 { +open func saveIssue(owner: String, repository: String, number: Int64?, title: String, body: String, labelIds: [Int64], milestoneId: Int64?, dueDate: Int64?, closed: Bool)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) + 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),FfiConverterBool.lower(closed) ) }, pollFunc: ffi_gotcha_core_rust_future_poll_i64, @@ -1176,6 +1196,22 @@ open func setAppearance(index: UInt32)throws {try rustCallWithError(FfiConvert } } +open func setIssueClosed(owner: String, repository: String, number: Int64, closed: Bool)async throws { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterInt64.lower(number),FfiConverterBool.lower(closed) + ) + }, + pollFunc: ffi_gotcha_core_rust_future_poll_void, + completeFunc: ffi_gotcha_core_rust_future_complete_void, + freeFunc: ffi_gotcha_core_rust_future_free_void, + liftFunc: { $0 }, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func setIssueFilters(owner: String, repository: String, milestone: String, labels: [String], searchText: String)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) { uniffiCallStatus in uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters( @@ -2171,15 +2207,17 @@ public struct IssueEditorPage: Equatable, Hashable { public var title: String public var body: String public var dueDate: Int64? + public var closed: Bool 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]) { + public init(title: String, body: String, dueDate: Int64?, closed: Bool, labels: [IssueEditorLabel], milestones: [IssueEditorMilestone]) { self.title = title self.body = body self.dueDate = dueDate + self.closed = closed self.labels = labels self.milestones = milestones } @@ -2203,6 +2241,7 @@ public struct FfiConverterTypeIssueEditorPage: FfiConverterRustBuffer { title: FfiConverterString.read(from: &buf), body: FfiConverterString.read(from: &buf), dueDate: FfiConverterOptionInt64.read(from: &buf), + closed: FfiConverterBool.read(from: &buf), labels: FfiConverterSequenceTypeIssueEditorLabel.read(from: &buf), milestones: FfiConverterSequenceTypeIssueEditorMilestone.read(from: &buf) ) @@ -2212,6 +2251,7 @@ public struct FfiConverterTypeIssueEditorPage: FfiConverterRustBuffer { FfiConverterString.write(value.title, into: &buf) FfiConverterString.write(value.body, into: &buf) FfiConverterOptionInt64.write(value.dueDate, into: &buf) + FfiConverterBool.write(value.closed, into: &buf) FfiConverterSequenceTypeIssueEditorLabel.write(value.labels, into: &buf) FfiConverterSequenceTypeIssueEditorMilestone.write(value.milestones, into: &buf) } @@ -4301,6 +4341,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_commits() != 60062) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_delete_issue() != 9524) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_home() != 2797) { return InitializationResult.apiChecksumMismatch } @@ -4352,7 +4395,7 @@ 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) { + if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue() != 3056) { return InitializationResult.apiChecksumMismatch } if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) { @@ -4370,6 +4413,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_set_appearance() != 61293) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed() != 38814) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_set_issue_filters() != 24891) { return InitializationResult.apiChecksumMismatch } diff --git a/ios/Generated/gotcha_coreFFI.h b/ios/Generated/gotcha_coreFFI.h index b115fbb..c7dc322 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_commit_diff(uint64_t ptr, RustB uint64_t uniffi_gotcha_core_fn_method_gotchacore_commits(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer branch, RustBuffer path, uint32_t pages ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_ISSUE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_DELETE_ISSUE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_delete_issue(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_HOME uint64_t uniffi_gotcha_core_fn_method_gotchacore_home(uint64_t ptr, uint32_t page, RustBuffer filter @@ -386,7 +391,7 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_repository_file(uint64_t ptr, R #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 +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, int8_t closed ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT @@ -414,6 +419,11 @@ RustBuffer uniffi_gotcha_core_fn_method_gotchacore_servers(uint64_t ptr, RustCal void uniffi_gotcha_core_fn_method_gotchacore_set_appearance(uint64_t ptr, uint32_t index, RustCallStatus *_Nonnull out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +uint64_t uniffi_gotcha_core_fn_method_gotchacore_set_issue_closed(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, int8_t closed +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SET_ISSUE_FILTERS void uniffi_gotcha_core_fn_method_gotchacore_set_issue_filters(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer milestone, RustBuffer labels, RustBuffer search_text, RustCallStatus *_Nonnull out_status @@ -755,6 +765,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commit_diff(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_COMMITS uint16_t uniffi_gotcha_core_checksum_method_gotchacore_commits(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_DELETE_ISSUE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_delete_issue(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_HOME @@ -893,6 +909,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_servers(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_APPEARANCE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_appearance(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_CLOSED +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_set_issue_closed(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SET_ISSUE_FILTERS diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index c63d3e1..5d36593 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -26,6 +26,7 @@ final class IssuesViewController: RefreshingTableViewController { private var rows: [IssueRow] = [] private var filterOptions: IssueFilterOptions? private var filterTask: Task? + private var mutationTask: Task? private var currentPage: UInt32 = 0 private lazy var filterButton = UIBarButtonItem( image: context.symbol("line.3.horizontal.decrease.circle") @@ -42,7 +43,10 @@ final class IssuesViewController: RefreshingTableViewController { @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } - deinit { filterTask?.cancel() } + deinit { + filterTask?.cancel() + mutationTask?.cancel() + } override func viewDidLoad() { super.viewDidLoad() @@ -143,6 +147,94 @@ final class IssuesViewController: RefreshingTableViewController { ) } + override func tableView( + _ tableView: UITableView, + trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath + ) -> UISwipeActionsConfiguration? { + let row = rows[indexPath.row] + let close = row.state != "closed" + let stateAction = UIContextualAction( + style: .normal, + title: close ? "Close" : "Open" + ) { [weak self] _, _, completion in + self?.setIssue(row, closed: close, completion: completion) ?? completion(false) + } + stateAction.image = context.symbol(close ? "checkmark.circle" : "arrow.uturn.left.circle") + stateAction.backgroundColor = close ? .systemPurple : .systemGreen + + let deleteAction = UIContextualAction(style: .destructive, title: "Delete") { + [weak self] _, _, completion in + self?.confirmDelete(row, completion: completion) ?? completion(false) + } + deleteAction.image = context.symbol("trash") + + let configuration = UISwipeActionsConfiguration(actions: [stateAction, deleteAction]) + configuration.performsFirstActionWithFullSwipe = true + return configuration + } + + private func setIssue( + _ row: IssueRow, + closed: Bool, + completion: @escaping (Bool) -> Void + ) { + mutationTask?.cancel() + mutationTask = Task { + do { + try await context.core.setIssueClosed( + owner: owner, + repository: repository, + number: row.number, + closed: closed + ) + guard !Task.isCancelled else { + completion(false) + return + } + completion(true) + loadIssues(refreshing: false) + } catch { + completion(false) + if !Task.isCancelled { show(error: error) } + } + } + } + + private func confirmDelete(_ row: IssueRow, completion: @escaping (Bool) -> Void) { + let alert = UIAlertController( + title: "Delete Issue #\(row.number)?", + message: "“\(row.title)” will be permanently deleted. This can’t be undone.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in completion(false) }) + alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self] _ in + guard let self else { + completion(false) + return + } + self.mutationTask?.cancel() + self.mutationTask = Task { + do { + try await self.context.core.deleteIssue( + owner: self.owner, + repository: self.repository, + number: row.number + ) + guard !Task.isCancelled else { + completion(false) + return + } + completion(true) + self.loadIssues(refreshing: false) + } catch { + completion(false) + if !Task.isCancelled { self.show(error: error) } + } + } + }) + present(alert, animated: true) + } + private func updateFilterMenu() { let current = context.core.settings().issueStatus let status = UIMenu( diff --git a/ios/Sources/IssueEditorViewController.swift b/ios/Sources/IssueEditorViewController.swift index 55c1e4b..822446d 100644 --- a/ios/Sources/IssueEditorViewController.swift +++ b/ios/Sources/IssueEditorViewController.swift @@ -17,6 +17,7 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI private let previewView = UIView() private let labelsButton = UIButton(type: .system) private let milestoneButton = UIButton(type: .system) + private let closedSwitch = UISwitch() private let dueSwitch = UISwitch() private let duePicker = UIDatePicker() private let spinner = UIActivityIndicatorView(style: .medium) @@ -103,6 +104,14 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI labelsButton.configuration?.image = context.symbol("tag") milestoneButton.configuration?.image = context.symbol("flag") + let closedLabel = UILabel() + closedLabel.text = "Closed" + closedLabel.font = .preferredFont(forTextStyle: .body) + closedLabel.adjustsFontForContentSizeCategory = true + let closedRow = UIStackView(arrangedSubviews: [closedLabel, closedSwitch]) + closedRow.alignment = .center + closedSwitch.accessibilityLabel = "Closed issue" + let dueLabel = UILabel() dueLabel.text = "Due Date" dueLabel.font = .preferredFont(forTextStyle: .body) @@ -133,6 +142,7 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI stack.addArrangedSubview(titleField) stack.addArrangedSubview(labelsButton) stack.addArrangedSubview(milestoneButton) + stack.addArrangedSubview(closedRow) stack.addArrangedSubview(dueRow) stack.addArrangedSubview(duePicker) stack.addArrangedSubview(modeControl) @@ -164,6 +174,7 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI self.page = page titleField.text = page.title bodyView.text = page.body + closedSwitch.isOn = page.closed selectedLabels = Set(page.labels.filter(\.selected).map(\.id)) selectedMilestone = page.milestones.first(where: \.selected)?.id if let timestamp = page.dueDate { @@ -310,7 +321,8 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI body: bodyView.text ?? "", labelIds: Array(selectedLabels), milestoneId: selectedMilestone, - dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil + dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil, + closed: closedSwitch.isOn ) guard !Task.isCancelled else { return } saved(number)