From c4e64b5c0cf1511f17a1d122d7d06e968a924a8b Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Fri, 31 Jul 2026 15:30:45 +0200 Subject: [PATCH] Add milestone creation and editing --- TESTING.md | 15 +- crates/app/src/api.rs | 100 ++++++++- crates/app/src/domain.rs | 6 + crates/app/src/lib.rs | 51 +++++ crates/app/src/presentation.rs | 39 ++++ ios/Generated/gotcha_core.swift | 100 +++++++++ ios/Generated/gotcha_coreFFI.h | 22 ++ ios/Gotcha.xcodeproj/project.pbxproj | 4 + ios/Sources/ContentScreens.swift | 40 ++++ ios/Sources/DetailScreens.swift | 7 +- ios/Sources/IssueEditorViewController.swift | 13 -- .../MilestoneEditorViewController.swift | 198 ++++++++++++++++++ ios/Sources/Support.swift | 17 ++ 13 files changed, 591 insertions(+), 21 deletions(-) create mode 100644 ios/Sources/MilestoneEditorViewController.swift diff --git a/TESTING.md b/TESTING.md index cc6ab6a..9ee980f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -164,7 +164,7 @@ xcrun simctl launch booted de.rfc1437.gotcha - [ ] 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 +- [ ] 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. - [ ] Cancel both new and edited issues, including with the keyboard and date @@ -179,8 +179,8 @@ xcrun simctl launch booted de.rfc1437.gotcha Markdown, **Preview** renders it, and switching modes preserves the exact source. Save and verify the rendered comment appears after the detail refresh; Cancel leaves the issue unchanged. -- [ ] Each comment authored by the signed-in account has its own **Edit** - button and comments by other accounts do not. Edit an owned comment in +- [ ] Each comment authored by the signed-in account has its own pencil button + and comments by other accounts do not. Edit an owned comment in Write and Preview modes, save it, refresh and relaunch, and verify the Markdown change persists. Check Add/Edit/Cancel/Save with Dynamic Type and VoiceOver, including the empty-comment Save state. @@ -250,6 +250,15 @@ xcrun simctl launch booted de.rfc1437.gotcha counts, and a green/amber closed/open progress bar. - [ ] Open a milestone and verify every assigned issue and pull request is listed; tapping either opens its normal detail. +- [ ] Tap the add button. **New Milestone** has native Cancel and Save controls, + title and multiline description fields, and an optional inline date + picker. Save is disabled for an empty or whitespace-only title. Create + milestones both without and with a due date and verify each appears. +- [ ] Tap the pencil button on a milestone. Change its title and description, add and + change its due date, then turn Due Date off and save to clear it. Refresh + and relaunch after each save to verify persistence; Cancel leaves every + value unchanged. Exercise the form with the keyboard, Dynamic Type, and + VoiceOver labels. - [ ] Repository issue lists and issue details show the assigned milestone with a flag icon instead of a text label. - [ ] Repositories without milestones show the native empty state. diff --git a/crates/app/src/api.rs b/crates/app/src/api.rs index a8ec6f1..79238e4 100644 --- a/crates/app/src/api.rs +++ b/crates/app/src/api.rs @@ -3,14 +3,15 @@ use std::{ collections::{BTreeSet, BinaryHeap, HashMap}, }; -use gotcha_gitea::{Client, apis, models}; +use gotcha_gitea::{Client, Method, apis, models}; +use serde::Serialize; use tokio::task::JoinSet; use crate::{ diff, domain::{ HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails, - PAGE_SIZE, Page, PullDetails, RepositoryData, Server, api_date, + MilestoneDraft, PAGE_SIZE, Page, PullDetails, RepositoryData, Server, api_date, }, presentation::compact_date, }; @@ -229,6 +230,80 @@ pub async fn load_milestone( }) } +pub async fn load_milestone_editor( + server: &Server, + owner: &str, + repository: &str, + id: i64, +) -> Result { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + apis::issue_api::issue_get_milestone( + &client.configuration(), + owner, + repository, + &id.to_string(), + ) + .await + .map_err(|error| error.to_string()) +} + +#[derive(Serialize)] +struct MilestoneRequest { + title: String, + description: String, + due_on: Option, +} + +fn milestone_request(draft: MilestoneDraft) -> MilestoneRequest { + MilestoneRequest { + title: draft.title, + description: draft.description, + due_on: draft.due_date.map(api_date), + } +} + +pub async fn save_milestone( + server: &Server, + owner: &str, + repository: &str, + id: Option, + draft: MilestoneDraft, +) -> Result { + let client = + Client::new(&server.url, Some(&server.token)).map_err(|error| error.to_string())?; + let endpoint = match id { + Some(id) => format!( + "repos/{}/{}/milestones/{id}", + apis::urlencode(owner), + apis::urlencode(repository) + ), + None => format!( + "repos/{}/{}/milestones", + apis::urlencode(owner), + apis::urlencode(repository) + ), + }; + let request = client + .request( + if id.is_some() { + Method::PATCH + } else { + Method::POST + }, + &endpoint, + ) + .map_err(|error| error.to_string())? + .json(&milestone_request(draft)); + client + .execute(request) + .await + .map_err(|error| error.to_string())? + .json() + .await + .map_err(|error| error.to_string()) +} + pub async fn load_pulls( server: &Server, status: &str, @@ -1115,4 +1190,25 @@ mod tests { assert!(!comment_belongs_to_issue(&comment, 5)); assert!(!comment_belongs_to_issue(&models::Comment::default(), 4)); } + + #[test] + fn milestone_requests_can_set_and_clear_due_dates() { + let dated = serde_json::to_value(milestone_request(MilestoneDraft { + title: "Version 1".into(), + description: "Description".into(), + due_date: Some(1_709_164_800), + })) + .unwrap(); + assert_eq!(dated["title"], "Version 1"); + assert_eq!(dated["description"], "Description"); + assert_eq!(dated["due_on"], "2024-02-29T00:00:00Z"); + + let cleared = serde_json::to_value(milestone_request(MilestoneDraft { + title: "Version 1".into(), + description: String::new(), + due_date: None, + })) + .unwrap(); + assert!(cleared["due_on"].is_null()); + } } diff --git a/crates/app/src/domain.rs b/crates/app/src/domain.rs index 8f184e7..142fd23 100644 --- a/crates/app/src/domain.rs +++ b/crates/app/src/domain.rs @@ -179,6 +179,12 @@ pub struct MilestoneDetails { pub has_more: bool, } +pub struct MilestoneDraft { + pub title: String, + pub description: String, + pub due_date: Option, +} + pub struct PullDetails { pub pull: models::PullRequest, pub comments: Vec, diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs index 5cdeb97..22eac60 100644 --- a/crates/app/src/lib.rs +++ b/crates/app/src/lib.rs @@ -458,6 +458,57 @@ impl GotchaCore { )) } + pub async fn milestone_editor( + &self, + owner: String, + repository: String, + id: Option, + ) -> Result { + let (owner, repository) = validate_repository(&owner, &repository)?; + if id.is_some_and(|id| id <= 0) { + return Err("Invalid milestone selection.".into()); + } + let milestone = match id { + Some(id) => Some(load_milestone_editor(&self.server()?, owner, repository, id).await?), + None => None, + }; + Ok(milestone_editor_page(milestone)) + } + + pub async fn save_milestone( + &self, + owner: String, + repository: String, + id: Option, + title: String, + description: String, + due_date: Option, + ) -> Result { + let (owner, repository) = validate_repository(&owner, &repository)?; + let title = title.trim(); + if title.is_empty() { + return Err("Enter a milestone title.".into()); + } + if id.is_some_and(|id| id <= 0) { + return Err("Invalid milestone selection.".into()); + } + let milestone = save_milestone( + &self.server()?, + owner, + repository, + id, + MilestoneDraft { + title: title.into(), + description, + due_date, + }, + ) + .await?; + milestone + .id + .ok_or_else(|| "The saved milestone has no ID.".into()) + } + pub async fn pull_filters(&self) -> Result { let server = self.server()?; let filter = self diff --git a/crates/app/src/presentation.rs b/crates/app/src/presentation.rs index 169019e..7e93573 100644 --- a/crates/app/src/presentation.rs +++ b/crates/app/src/presentation.rs @@ -139,6 +139,13 @@ pub struct MilestoneListPage { pub has_more: bool, } +#[derive(Clone, uniffi::Record)] +pub struct MilestoneEditorPage { + pub title: String, + pub description: String, + pub due_date: Option, +} + #[derive(Clone, uniffi::Record)] pub struct PullFilterOptions { pub milestones: Vec, @@ -364,6 +371,23 @@ pub fn milestone_page(details: MilestoneDetails) -> MilestonePage { } } +pub fn milestone_editor_page(milestone: Option) -> MilestoneEditorPage { + MilestoneEditorPage { + title: milestone + .as_ref() + .and_then(|milestone| milestone.title.clone()) + .unwrap_or_default(), + description: milestone + .as_ref() + .and_then(|milestone| milestone.description.clone()) + .unwrap_or_default(), + due_date: milestone + .as_ref() + .and_then(|milestone| milestone.due_on.as_deref()) + .and_then(parse_api_date), + } +} + pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFilterOptions { let mut milestones = milestones.to_vec(); if !filter.milestone.is_empty() { @@ -1425,6 +1449,21 @@ mod tests { assert_eq!(page.pulls[0].number, 7); } + #[test] + fn milestone_editor_preserves_description_and_optional_due_date() { + let page = milestone_editor_page(Some(models::Milestone { + title: Some("Version 1".into()), + description: Some("Ship it".into()), + due_on: Some("2024-02-29T12:34:56Z".into()), + ..Default::default() + })); + assert_eq!(page.title, "Version 1"); + assert_eq!(page.description, "Ship it"); + assert_eq!(page.due_date, Some(1_709_164_800)); + + assert_eq!(milestone_editor_page(None).due_date, None); + } + #[test] fn classifies_repository_files_in_rust() { let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec()); diff --git a/ios/Generated/gotcha_core.swift b/ios/Generated/gotcha_core.swift index 1a76507..4aa60de 100644 --- a/ios/Generated/gotcha_core.swift +++ b/ios/Generated/gotcha_core.swift @@ -629,6 +629,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func milestone(owner: String, repository: String, id: Int64, page: UInt32) async throws -> MilestonePage + func milestoneEditor(owner: String, repository: String, id: Int64?) async throws -> MilestoneEditorPage + func milestones(owner: String, repository: String, page: UInt32) async throws -> MilestoneListPage func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage @@ -651,6 +653,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable { func saveIssueComment(owner: String, repository: String, number: Int64, commentId: Int64?, body: String) async throws + func saveMilestone(owner: String, repository: String, id: Int64?, title: String, description: String, dueDate: Int64?) async throws -> Int64 + func selectServer(index: UInt32) throws func servers() -> [ServerRow] @@ -922,6 +926,22 @@ open func milestone(owner: String, repository: String, id: Int64, page: UInt32)a ) } +open func milestoneEditor(owner: String, repository: String, id: Int64?)async throws -> MilestoneEditorPage { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_milestone_editor( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id) + ) + }, + 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: FfiConverterTypeMilestoneEditorPage_lift, + errorHandler: FfiConverterTypeGotchaError_lift + ) +} + open func milestones(owner: String, repository: String, page: UInt32)async throws -> MilestoneListPage { return try await uniffiRustCallAsync( @@ -1091,6 +1111,22 @@ open func saveIssueComment(owner: String, repository: String, number: Int64, com ) } +open func saveMilestone(owner: String, repository: String, id: Int64?, title: String, description: String, dueDate: Int64?)async throws -> Int64 { + return + try await uniffiRustCallAsync( + rustFutureFunc: { + uniffi_gotcha_core_fn_method_gotchacore_save_milestone( + self.uniffiCloneHandle(),FfiConverterString.lower(owner),FfiConverterString.lower(repository),FfiConverterOptionInt64.lower(id),FfiConverterString.lower(title),FfiConverterString.lower(description),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( @@ -2378,6 +2414,64 @@ public func FfiConverterTypeLabelRow_lower(_ value: LabelRow) -> RustBuffer { } +public struct MilestoneEditorPage: Equatable, Hashable { + public var title: String + public var description: String + public var dueDate: Int64? + + // Default memberwise initializers are never public by default, so we + // declare one manually. + public init(title: String, description: String, dueDate: Int64?) { + self.title = title + self.description = description + self.dueDate = dueDate + } + + + + +} + +#if compiler(>=6) +extension MilestoneEditorPage: Sendable {} +#endif + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public struct FfiConverterTypeMilestoneEditorPage: FfiConverterRustBuffer { + public static func read(from buf: inout (data: Data, offset: Data.Index)) throws -> MilestoneEditorPage { + return + try MilestoneEditorPage( + title: FfiConverterString.read(from: &buf), + description: FfiConverterString.read(from: &buf), + dueDate: FfiConverterOptionInt64.read(from: &buf) + ) + } + + public static func write(_ value: MilestoneEditorPage, into buf: inout [UInt8]) { + FfiConverterString.write(value.title, into: &buf) + FfiConverterString.write(value.description, into: &buf) + FfiConverterOptionInt64.write(value.dueDate, into: &buf) + } +} + + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMilestoneEditorPage_lift(_ buf: RustBuffer) throws -> MilestoneEditorPage { + return try FfiConverterTypeMilestoneEditorPage.lift(buf) +} + +#if swift(>=5.8) +@_documentation(visibility: private) +#endif +public func FfiConverterTypeMilestoneEditorPage_lower(_ value: MilestoneEditorPage) -> RustBuffer { + return FfiConverterTypeMilestoneEditorPage.lower(value) +} + + public struct MilestoneListPage: Equatable, Hashable { public var rows: [MilestoneRow] public var hasMore: Bool @@ -3878,6 +3972,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_milestone() != 57186) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_milestone_editor() != 52261) { + return InitializationResult.apiChecksumMismatch + } if (uniffi_gotcha_core_checksum_method_gotchacore_milestones() != 50431) { return InitializationResult.apiChecksumMismatch } @@ -3911,6 +4008,9 @@ private let initializationResult: InitializationResult = { if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) { return InitializationResult.apiChecksumMismatch } + if (uniffi_gotcha_core_checksum_method_gotchacore_save_milestone() != 33075) { + 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 88b8466..aeea108 100644 --- a/ios/Generated/gotcha_coreFFI.h +++ b/ios/Generated/gotcha_coreFFI.h @@ -324,6 +324,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_issues(uint64_t ptr, RustBuffer uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, uint32_t page ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONE_EDITOR +uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone_editor(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONES #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONES uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestones(uint64_t ptr, RustBuffer owner, RustBuffer repository, uint32_t page @@ -379,6 +384,11 @@ uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue(uint64_t ptr, RustBu uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_issue_comment(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t number, RustBuffer comment_id, RustBuffer body ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SAVE_MILESTONE +uint64_t uniffi_gotcha_core_fn_method_gotchacore_save_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, RustBuffer id, RustBuffer title, RustBuffer description, 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 @@ -765,6 +775,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_issues(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE uint16_t uniffi_gotcha_core_checksum_method_gotchacore_milestone(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE_EDITOR +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE_EDITOR +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_milestone_editor(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONES @@ -831,6 +847,12 @@ uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue(void #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE +#define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_MILESTONE +uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_milestone(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 7e1b063..14fa9b9 100644 --- a/ios/Gotcha.xcodeproj/project.pbxproj +++ b/ios/Gotcha.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; }; + 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */; }; 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; }; 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; }; 381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */; }; @@ -30,6 +31,7 @@ 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.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 = ""; }; + CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = ""; }; CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = ""; }; DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = ""; }; @@ -60,6 +62,7 @@ 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */, 722405F999984C7CBE838711 /* IssueEditorViewController.swift */, AC828923A1E11A58AE348A74 /* ListScreens.swift */, + CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */, 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */, F75B3E4FFB9C9992517C4D69 /* Support.swift */, ); @@ -201,6 +204,7 @@ 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */, D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */, 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */, + 1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */, 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */, F55A89489B2758D694F3B27D /* Support.swift in Sources */, D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */, diff --git a/ios/Sources/ContentScreens.swift b/ios/Sources/ContentScreens.swift index 8c5f66a..03435b6 100644 --- a/ios/Sources/ContentScreens.swift +++ b/ios/Sources/ContentScreens.swift @@ -375,9 +375,28 @@ final class MilestonesViewController: RefreshingTableViewController { tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 118 + let addButton = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(createMilestone) + ) + addButton.accessibilityLabel = "New milestone" + navigationItem.rightBarButtonItem = addButton loadContent(refreshing: false) } + @objc private func createMilestone() { + let editor = MilestoneEditorViewController( + context: context, + owner: owner, + repository: repository + ) { [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) { loadPage(1, refreshing: refreshing) } @@ -476,9 +495,30 @@ final class MilestoneViewController: RefreshingTableViewController { tableView.register(IssueCell.self, forCellReuseIdentifier: "issue") tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 118 + let editButton = UIBarButtonItem( + image: context.symbol("pencil"), + style: .plain, + target: self, + action: #selector(editMilestone) + ) + editButton.accessibilityLabel = "Edit milestone" + navigationItem.rightBarButtonItem = editButton loadContent(refreshing: false) } + @objc private func editMilestone() { + let editor = MilestoneEditorViewController( + context: context, + owner: owner, + repository: repository, + id: id + ) { [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) { loadPage(1, refreshing: refreshing) } diff --git a/ios/Sources/DetailScreens.swift b/ios/Sources/DetailScreens.swift index c5b227b..42494ad 100644 --- a/ios/Sources/DetailScreens.swift +++ b/ios/Sources/DetailScreens.swift @@ -154,11 +154,12 @@ final class IssueViewController: MarkdownPageViewController { ) addComment.accessibilityLabel = "Add comment" let editIssue = UIBarButtonItem( - title: "Edit", + image: context.symbol("pencil"), style: .plain, target: self, action: #selector(editIssue) ) + editIssue.accessibilityLabel = "Edit issue" navigationItem.rightBarButtonItems = [addComment, editIssue] loadContent(refreshing: false) } @@ -884,15 +885,15 @@ private func commentViews( var headerViews: [UIView] = [author, UIView()] if comment.canEdit, let editComment { var configuration = UIButton.Configuration.plain() - configuration.title = "Edit" configuration.image = UIImage(systemName: "pencil") - configuration.imagePadding = 5 configuration.contentInsets = .zero let button = UIButton( configuration: configuration, primaryAction: UIAction { _ in editComment(comment) } ) button.accessibilityLabel = "Edit comment by \(comment.author)" + button.widthAnchor.constraint(equalToConstant: 44).isActive = true + button.heightAnchor.constraint(equalToConstant: 44).isActive = true headerViews.append(button) } let header = UIStackView(arrangedSubviews: headerViews) diff --git a/ios/Sources/IssueEditorViewController.swift b/ios/Sources/IssueEditorViewController.swift index d8b5f8a..55c1e4b 100644 --- a/ios/Sources/IssueEditorViewController.swift +++ b/ios/Sources/IssueEditorViewController.swift @@ -337,17 +337,4 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI @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) - } } diff --git a/ios/Sources/MilestoneEditorViewController.swift b/ios/Sources/MilestoneEditorViewController.swift new file mode 100644 index 0000000..efda398 --- /dev/null +++ b/ios/Sources/MilestoneEditorViewController.swift @@ -0,0 +1,198 @@ +import UIKit + +@MainActor +final class MilestoneEditorViewController: UIViewController, UITextFieldDelegate { + private let context: AppContext + private let owner: String + private let repository: String + private let id: Int64? + private let saved: (Int64) -> Void + private let scrollView = UIScrollView() + private let stack = UIStackView() + private let titleField = UITextField() + private let descriptionView = UITextView() + private let dueSwitch = UISwitch() + private let duePicker = UIDatePicker() + private let spinner = UIActivityIndicatorView(style: .medium) + private var task: Task? + + init( + context: AppContext, + owner: String, + repository: String, + id: Int64? = nil, + saved: @escaping (Int64) -> Void + ) { + self.context = context + self.owner = owner + self.repository = repository + self.id = id + self.saved = saved + super.init(nibName: nil, bundle: nil) + title = id == nil ? "New Milestone" : "Edit Milestone" + } + + @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(customView: spinner) + spinner.startAnimating() + configureForm() + task = Task { await load() } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if id == nil { titleField.becomeFirstResponder() } + } + + 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 = "Milestone title" + titleField.addTarget(self, action: #selector(titleChanged), for: .editingChanged) + + let descriptionLabel = UILabel() + descriptionLabel.text = "Description" + descriptionLabel.font = .preferredFont(forTextStyle: .headline) + descriptionLabel.adjustsFontForContentSizeCategory = true + descriptionView.font = .preferredFont(forTextStyle: .body) + descriptionView.adjustsFontForContentSizeCategory = true + descriptionView.backgroundColor = .secondarySystemGroupedBackground + descriptionView.layer.cornerRadius = 10 + descriptionView.textContainerInset = UIEdgeInsets(top: 12, left: 8, bottom: 12, right: 8) + descriptionView.autocapitalizationType = .sentences + descriptionView.accessibilityLabel = "Milestone description" + + 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 + + stack.addArrangedSubview(titleField) + stack.addArrangedSubview(descriptionLabel) + stack.addArrangedSubview(descriptionView) + stack.addArrangedSubview(dueRow) + stack.addArrangedSubview(duePicker) + 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), + descriptionView.heightAnchor.constraint(greaterThanOrEqualToConstant: 180), + ]) + } + + private func load() async { + do { + let page = try await context.core.milestoneEditor( + owner: owner, + repository: repository, + id: id + ) + guard !Task.isCancelled else { return } + titleField.text = page.title + descriptionView.text = page.description + if let timestamp = page.dueDate { + dueSwitch.isOn = true + duePicker.date = localDate(forUTCTimestamp: timestamp) + duePicker.isHidden = false + } + stack.isHidden = false + } catch { + if !Task.isCancelled { show(error: error) } + } + restoreSaveButton() + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + descriptionView.becomeFirstResponder() + return true + } + + @objc private func dueChanged() { + duePicker.isHidden = !dueSwitch.isOn + } + + @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 id = try await context.core.saveMilestone( + owner: owner, + repository: repository, + id: id, + title: titleField.text ?? "", + description: descriptionView.text ?? "", + dueDate: dueSwitch.isOn ? utcTimestamp(forLocalDate: duePicker.date) : nil + ) + guard !Task.isCancelled else { return } + saved(id) + } 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) } +} diff --git a/ios/Sources/Support.swift b/ios/Sources/Support.swift index fa63cfb..6828476 100644 --- a/ios/Sources/Support.swift +++ b/ios/Sources/Support.swift @@ -1,5 +1,22 @@ import UIKit +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() +} + +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) +} + func errorAlert(_ message: String) -> UIAlertController { let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .default))