Add milestone creation and editing
This commit is contained in:
15
TESTING.md
15
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.
|
||||
|
||||
@@ -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<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(
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,12 @@ pub struct MilestoneDetails {
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
pub struct MilestoneDraft {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub due_date: Option<i64>,
|
||||
}
|
||||
|
||||
pub struct PullDetails {
|
||||
pub pull: models::PullRequest,
|
||||
pub comments: Vec<models::Comment>,
|
||||
|
||||
@@ -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> {
|
||||
let server = self.server()?;
|
||||
let filter = self
|
||||
|
||||
@@ -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<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, uniffi::Record)]
|
||||
pub struct PullFilterOptions {
|
||||
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 {
|
||||
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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
198
ios/Sources/MilestoneEditorViewController.swift
Normal file
198
ios/Sources/MilestoneEditorViewController.swift
Normal 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) }
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user