Add milestone creation and editing

This commit is contained in:
Georg Bauer
2026-07-31 15:30:45 +02:00
parent da0a3aecb4
commit c4e64b5c0c
13 changed files with 591 additions and 21 deletions

View File

@@ -164,7 +164,7 @@ xcrun simctl launch booted de.rfc1437.gotcha
- [ ] Save is disabled for an empty or whitespace-only title. Create issues with - [ ] 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 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. 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 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. Save, refresh, and relaunch to verify every change persists.
- [ ] Cancel both new and edited issues, including with the keyboard and date - [ ] 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 Markdown, **Preview** renders it, and switching modes preserves the exact
source. Save and verify the rendered comment appears after the detail source. Save and verify the rendered comment appears after the detail
refresh; Cancel leaves the issue unchanged. refresh; Cancel leaves the issue unchanged.
- [ ] Each comment authored by the signed-in account has its own **Edit** - [ ] Each comment authored by the signed-in account has its own pencil button
button and comments by other accounts do not. Edit an owned comment in and comments by other accounts do not. Edit an owned comment in
Write and Preview modes, save it, refresh and relaunch, and verify the Write and Preview modes, save it, refresh and relaunch, and verify the
Markdown change persists. Check Add/Edit/Cancel/Save with Dynamic Type Markdown change persists. Check Add/Edit/Cancel/Save with Dynamic Type
and VoiceOver, including the empty-comment Save state. 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. counts, and a green/amber closed/open progress bar.
- [ ] Open a milestone and verify every assigned issue and pull request is - [ ] Open a milestone and verify every assigned issue and pull request is
listed; tapping either opens its normal detail. 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 - [ ] Repository issue lists and issue details show the assigned milestone with
a flag icon instead of a text label. a flag icon instead of a text label.
- [ ] Repositories without milestones show the native empty state. - [ ] Repositories without milestones show the native empty state.

View File

@@ -3,14 +3,15 @@ use std::{
collections::{BTreeSet, BinaryHeap, HashMap}, 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 tokio::task::JoinSet;
use crate::{ use crate::{
diff, diff,
domain::{ domain::{
HistoryCommit, HomeData, IssueDetails, IssueDraft, IssueEditorData, MilestoneDetails, 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, 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<models::Milestone, String> {
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<String>,
}
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<i64>,
draft: MilestoneDraft,
) -> Result<models::Milestone, String> {
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( pub async fn load_pulls(
server: &Server, server: &Server,
status: &str, status: &str,
@@ -1115,4 +1190,25 @@ mod tests {
assert!(!comment_belongs_to_issue(&comment, 5)); assert!(!comment_belongs_to_issue(&comment, 5));
assert!(!comment_belongs_to_issue(&models::Comment::default(), 4)); 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());
}
} }

View File

@@ -179,6 +179,12 @@ pub struct MilestoneDetails {
pub has_more: bool, pub has_more: bool,
} }
pub struct MilestoneDraft {
pub title: String,
pub description: String,
pub due_date: Option<i64>,
}
pub struct PullDetails { pub struct PullDetails {
pub pull: models::PullRequest, pub pull: models::PullRequest,
pub comments: Vec<models::Comment>, pub comments: Vec<models::Comment>,

View File

@@ -458,6 +458,57 @@ impl GotchaCore {
)) ))
} }
pub async fn milestone_editor(
&self,
owner: String,
repository: String,
id: Option<i64>,
) -> Result<MilestoneEditorPage, GotchaError> {
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<i64>,
title: String,
description: String,
due_date: Option<i64>,
) -> Result<i64, GotchaError> {
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<PullFilterOptions, GotchaError> { pub async fn pull_filters(&self) -> Result<PullFilterOptions, GotchaError> {
let server = self.server()?; let server = self.server()?;
let filter = self let filter = self

View File

@@ -139,6 +139,13 @@ pub struct MilestoneListPage {
pub has_more: bool, pub has_more: bool,
} }
#[derive(Clone, uniffi::Record)]
pub struct MilestoneEditorPage {
pub title: String,
pub description: String,
pub due_date: Option<i64>,
}
#[derive(Clone, uniffi::Record)] #[derive(Clone, uniffi::Record)]
pub struct PullFilterOptions { pub struct PullFilterOptions {
pub milestones: Vec<String>, pub milestones: Vec<String>,
@@ -364,6 +371,23 @@ pub fn milestone_page(details: MilestoneDetails) -> MilestonePage {
} }
} }
pub fn milestone_editor_page(milestone: Option<models::Milestone>) -> 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 { pub fn pull_filter_options(milestones: &[String], filter: &PullFilter) -> PullFilterOptions {
let mut milestones = milestones.to_vec(); let mut milestones = milestones.to_vec();
if !filter.milestone.is_empty() { if !filter.milestone.is_empty() {
@@ -1425,6 +1449,21 @@ mod tests {
assert_eq!(page.pulls[0].number, 7); 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] #[test]
fn classifies_repository_files_in_rust() { fn classifies_repository_files_in_rust() {
let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec()); let markdown = repository_file_page("docs/README.md", b"# Hello".to_vec());

View File

@@ -629,6 +629,8 @@ public protocol GotchaCoreProtocol: AnyObject, Sendable {
func milestone(owner: String, repository: String, id: Int64, page: UInt32) async throws -> MilestonePage 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 milestones(owner: String, repository: String, page: UInt32) async throws -> MilestoneListPage
func pull(owner: String, repository: String, number: Int64, page: UInt32) async throws -> PullPage 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 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 selectServer(index: UInt32) throws
func servers() -> [ServerRow] 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 { open func milestones(owner: String, repository: String, page: UInt32)async throws -> MilestoneListPage {
return return
try await uniffiRustCallAsync( 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) { open func selectServer(index: UInt32)throws {try rustCallWithError(FfiConverterTypeGotchaError_lift) {
uniffiCallStatus in uniffiCallStatus in
uniffi_gotcha_core_fn_method_gotchacore_select_server( 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 struct MilestoneListPage: Equatable, Hashable {
public var rows: [MilestoneRow] public var rows: [MilestoneRow]
public var hasMore: Bool public var hasMore: Bool
@@ -3878,6 +3972,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_milestone() != 57186) { if (uniffi_gotcha_core_checksum_method_gotchacore_milestone() != 57186) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_milestones() != 50431) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }
@@ -3911,6 +4008,9 @@ private let initializationResult: InitializationResult = {
if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) { if (uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment() != 58596) {
return InitializationResult.apiChecksumMismatch 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) { if (uniffi_gotcha_core_checksum_method_gotchacore_select_server() != 22721) {
return InitializationResult.apiChecksumMismatch return InitializationResult.apiChecksumMismatch
} }

View File

@@ -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 uint64_t uniffi_gotcha_core_fn_method_gotchacore_milestone(uint64_t ptr, RustBuffer owner, RustBuffer repository, int64_t id, uint32_t page
); );
#endif #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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_MILESTONES
#define 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 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 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 #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 #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_FN_METHOD_GOTCHACORE_SELECT_SERVER
#define 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 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 #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONE
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_milestone(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_MILESTONES #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 #define UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SAVE_ISSUE_COMMENT
uint16_t uniffi_gotcha_core_checksum_method_gotchacore_save_issue_comment(void 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 #endif
#ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER #ifndef UNIFFI_FFIDEF_UNIFFI_GOTCHA_CORE_CHECKSUM_METHOD_GOTCHACORE_SELECT_SERVER

View File

@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
1AED04075E363FBC0FF55773 /* ContentScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE5E10787062D1175125BB4C /* ContentScreens.swift */; }; 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 */; }; 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */; };
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; }; 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC828923A1E11A58AE348A74 /* ListScreens.swift */; };
381A27D70EA30C1A3BA1BBC1 /* CommentEditorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.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 = "<group>"; }; 8F9FFCC06E0313760524EBD2 /* CommentEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentEditorViewController.swift; sourceTree = "<group>"; };
9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 9C0921C76676A14A024BA417 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; }; AC828923A1E11A58AE348A74 /* ListScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListScreens.swift; sourceTree = "<group>"; };
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MilestoneEditorViewController.swift; sourceTree = "<group>"; };
CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = "<group>"; }; CE5E10787062D1175125BB4C /* ContentScreens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentScreens.swift; sourceTree = "<group>"; };
DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; DDAABE6B13ADC6D08D9438AF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; }; F61515849F6AACD721FE915C /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = "<group>"; };
@@ -60,6 +62,7 @@
13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */, 13EFB48F40AEB3016D3CF643 /* DetailScreens.swift */,
722405F999984C7CBE838711 /* IssueEditorViewController.swift */, 722405F999984C7CBE838711 /* IssueEditorViewController.swift */,
AC828923A1E11A58AE348A74 /* ListScreens.swift */, AC828923A1E11A58AE348A74 /* ListScreens.swift */,
CA582099DD57D35C748EFB02 /* MilestoneEditorViewController.swift */,
8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */, 8F145F13B8ED83A5AB0009A4 /* SettingsViewController.swift */,
F75B3E4FFB9C9992517C4D69 /* Support.swift */, F75B3E4FFB9C9992517C4D69 /* Support.swift */,
); );
@@ -201,6 +204,7 @@
25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */, 25BDBDED14B3B886E19F511C /* DetailScreens.swift in Sources */,
D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */, D2B9B033E92BF8E7C0BDCDB0 /* IssueEditorViewController.swift in Sources */,
374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */, 374320EB00185A0C8E40D985 /* ListScreens.swift in Sources */,
1EDCCB5DE286C1DA407F00F1 /* MilestoneEditorViewController.swift in Sources */,
96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */, 96C4AFC206A1DBAE3D3E00CE /* SettingsViewController.swift in Sources */,
F55A89489B2758D694F3B27D /* Support.swift in Sources */, F55A89489B2758D694F3B27D /* Support.swift in Sources */,
D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */, D59D3ED36ABCC1D8690A9088 /* gotcha_core.swift in Sources */,

View File

@@ -375,9 +375,28 @@ final class MilestonesViewController: RefreshingTableViewController {
tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone") tableView.register(MilestoneCell.self, forCellReuseIdentifier: "milestone")
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 118 tableView.estimatedRowHeight = 118
let addButton = UIBarButtonItem(
barButtonSystemItem: .add,
target: self,
action: #selector(createMilestone)
)
addButton.accessibilityLabel = "New milestone"
navigationItem.rightBarButtonItem = addButton
loadContent(refreshing: false) 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) { override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing) loadPage(1, refreshing: refreshing)
} }
@@ -476,9 +495,30 @@ final class MilestoneViewController: RefreshingTableViewController {
tableView.register(IssueCell.self, forCellReuseIdentifier: "issue") tableView.register(IssueCell.self, forCellReuseIdentifier: "issue")
tableView.rowHeight = UITableView.automaticDimension tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 118 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) 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) { override func loadContent(refreshing: Bool) {
loadPage(1, refreshing: refreshing) loadPage(1, refreshing: refreshing)
} }

View File

@@ -154,11 +154,12 @@ final class IssueViewController: MarkdownPageViewController {
) )
addComment.accessibilityLabel = "Add comment" addComment.accessibilityLabel = "Add comment"
let editIssue = UIBarButtonItem( let editIssue = UIBarButtonItem(
title: "Edit", image: context.symbol("pencil"),
style: .plain, style: .plain,
target: self, target: self,
action: #selector(editIssue) action: #selector(editIssue)
) )
editIssue.accessibilityLabel = "Edit issue"
navigationItem.rightBarButtonItems = [addComment, editIssue] navigationItem.rightBarButtonItems = [addComment, editIssue]
loadContent(refreshing: false) loadContent(refreshing: false)
} }
@@ -884,15 +885,15 @@ private func commentViews(
var headerViews: [UIView] = [author, UIView()] var headerViews: [UIView] = [author, UIView()]
if comment.canEdit, let editComment { if comment.canEdit, let editComment {
var configuration = UIButton.Configuration.plain() var configuration = UIButton.Configuration.plain()
configuration.title = "Edit"
configuration.image = UIImage(systemName: "pencil") configuration.image = UIImage(systemName: "pencil")
configuration.imagePadding = 5
configuration.contentInsets = .zero configuration.contentInsets = .zero
let button = UIButton( let button = UIButton(
configuration: configuration, configuration: configuration,
primaryAction: UIAction { _ in editComment(comment) } primaryAction: UIAction { _ in editComment(comment) }
) )
button.accessibilityLabel = "Edit comment by \(comment.author)" button.accessibilityLabel = "Edit comment by \(comment.author)"
button.widthAnchor.constraint(equalToConstant: 44).isActive = true
button.heightAnchor.constraint(equalToConstant: 44).isActive = true
headerViews.append(button) headerViews.append(button)
} }
let header = UIStackView(arrangedSubviews: headerViews) let header = UIStackView(arrangedSubviews: headerViews)

View File

@@ -337,17 +337,4 @@ final class IssueEditorViewController: UIViewController, UITextFieldDelegate, UI
@objc private func cancel() { dismiss(animated: true) } @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)
}
} }

View File

@@ -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<Void, Never>?
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) }
}

View File

@@ -1,5 +1,22 @@
import UIKit 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 { func errorAlert(_ message: String) -> UIAlertController {
let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert) let alert = UIAlertController(title: "Something went wrong", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default)) alert.addAction(UIAlertAction(title: "OK", style: .default))