Update README.md #2

Closed
atridad wants to merge 0 commits from test into main
7 changed files with 262 additions and 321 deletions

View File

@ -15,4 +15,6 @@ A native SwiftUI application for interacting with any Gitea or Forgejo instance.
## Upcoming Features ## Upcoming Features
TBA TBA
TESTING

View File

@ -140,9 +140,7 @@ class AuthenticationManager: ObservableObject {
print("DEBUG: refreshUser calling getCurrentUser") print("DEBUG: refreshUser calling getCurrentUser")
let user = try await apiService.getCurrentUser() let user = try await apiService.getCurrentUser()
print("DEBUG: refreshUser got user: \(user.login)") print("DEBUG: refreshUser got user: \(user.login)")
await MainActor.run { currentUser = user
currentUser = user
}
} catch { } catch {
print("DEBUG: refreshUser failed with error: \(error)") print("DEBUG: refreshUser failed with error: \(error)")
// Handle cancellation and other errors gracefully // Handle cancellation and other errors gracefully
@ -151,9 +149,7 @@ class AuthenticationManager: ObservableObject {
// Don't show error message for cancellation // Don't show error message for cancellation
return return
} }
await MainActor.run { errorMessage = error.localizedDescription
errorMessage = error.localizedDescription
}
} }
} }
@ -199,21 +195,15 @@ class AuthenticationManager: ObservableObject {
do { do {
let updatedUser = try await apiService.updateUserSettings(settings: settings) let updatedUser = try await apiService.updateUserSettings(settings: settings)
await MainActor.run { currentUser = updatedUser
currentUser = updatedUser
}
} catch { } catch {
await MainActor.run { errorMessage = error.localizedDescription
errorMessage = error.localizedDescription
}
throw error throw error
} }
} }
func updateCurrentUser(_ user: User) { func updateCurrentUser(_ user: User) {
Task { @MainActor in currentUser = user
currentUser = user
}
} }
func getAPIService() -> GiteaAPIService? { func getAPIService() -> GiteaAPIService? {

View File

@ -180,47 +180,15 @@ class GiteaAPIService: ObservableObject {
if let state = state { if let state = state {
queryParams += "&state=\(state.rawValue)" queryParams += "&state=\(state.rawValue)"
} }
// Add type parameter to filter only issues (not pull requests)
queryParams += "&type=issues"
guard let request = createRequest(endpoint: "/repos/\(owner)/\(repo)/issues" + queryParams) guard let request = createRequest(endpoint: "/repos/\(owner)/\(repo)/issues" + queryParams)
else { else {
throw APIError.invalidURL throw APIError.invalidURL
} }
// Use manual JSON parsing to handle mixed issue/PR responses and null assignees return try await performRequest(request, responseType: [Issue].self)
let (data, _) = try await URLSession.shared.data(for: request)
guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
throw APIError.invalidResponse
}
var issues: [Issue] = []
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
for itemDict in jsonArray {
// Skip pull requests - they have base/head fields or URL contains /pulls/
if itemDict["base"] != nil || itemDict["head"] != nil {
continue
}
if let urlString = itemDict["url"] as? String, urlString.contains("/pulls/") {
continue
}
// Try to decode as Issue
do {
let itemData = try JSONSerialization.data(withJSONObject: itemDict)
let issue = try decoder.decode(Issue.self, from: itemData)
issues.append(issue)
} catch {
// Log but don't fail - just skip this item
print("Failed to decode issue: \(error)")
continue
}
}
return issues
} }
func getRepositoryPullRequests( func getRepositoryPullRequests(
@ -571,52 +539,73 @@ class GiteaAPIService: ObservableObject {
print("DEBUG: Request JSON: \(jsonString)") print("DEBUG: Request JSON: \(jsonString)")
} }
// Try the user settings endpoint first (most likely to work) // Try different endpoints and methods as fallbacks
guard // Order by most likely to succeed first
let request = createRequest( let endpoints = [
endpoint: "/user/settings", method: .PATCH, body: settingsData) ("/user", HTTPMethod.PATCH),
else { ("/user", HTTPMethod.PUT),
print("DEBUG: Failed to create request for /user/settings") ("/user/settings", HTTPMethod.PATCH),
throw APIError.invalidRequest ("/user/settings", HTTPMethod.PUT),
} ]
print("DEBUG: Request headers: \(request.allHTTPHeaderFields ?? [:])") var lastError: Error?
do { for (endpoint, method) in endpoints {
// Try to update settings - this returns UserSettingsResponse, not User print("DEBUG: Trying \(method.rawValue) request to: \(baseURL)/api/v1\(endpoint)")
let _ = try await performRequest(request, responseType: UserSettingsResponse.self)
print("DEBUG: updateUserSettings succeeded with PATCH /user/settings")
// Now fetch the updated user data
print("DEBUG: Fetching updated user data")
let updatedUser = try await getCurrentUser()
print("DEBUG: Successfully fetched updated user data")
return updatedUser
} catch let error as APIError {
print("DEBUG: PATCH to /user/settings failed: \(error)")
// If settings update failed, try the user endpoint as fallback
guard guard
let userRequest = createRequest( let request = createRequest(endpoint: endpoint, method: method, body: settingsData)
endpoint: "/user", method: .PATCH, body: settingsData)
else { else {
print("DEBUG: Failed to create request for /user") print("DEBUG: Failed to create request for \(endpoint)")
throw error continue
} }
print("DEBUG: Request headers: \(request.allHTTPHeaderFields ?? [:])")
do { do {
let result = try await performRequest(userRequest, responseType: User.self) let result = try await performRequest(request, responseType: User.self)
print("DEBUG: updateUserSettings succeeded with PATCH /user") print("DEBUG: updateUserSettings succeeded with \(method.rawValue) \(endpoint)")
return result return result
} catch let error as APIError {
print("DEBUG: \(method.rawValue) to \(endpoint) failed: \(error)")
lastError = error
// If it's a 405 Method Not Allowed, try the next endpoint/method
if case .httpError(405, _) = error {
continue
}
// If it's a 404 Not Found, try the next endpoint
if case .httpError(404, _) = error {
continue
}
// For authentication errors, don't retry
if case .httpError(401, _) = error {
throw error
}
if case .httpError(403, _) = error {
throw error
}
// For other client errors (4xx), still try other endpoints
if case .httpError(let code, _) = error, code >= 400 && code < 500 {
continue
}
// For server errors (5xx), don't retry
if case .httpError(let code, _) = error, code >= 500 {
throw error
}
// For other errors, still continue but this might be the final error
continue
} catch { } catch {
print("DEBUG: PATCH to /user also failed: \(error)") print("DEBUG: \(method.rawValue) to \(endpoint) failed: \(error)")
throw error lastError = error
continue
} }
} catch {
print("DEBUG: updateUserSettings failed with unexpected error: \(error)")
throw error
} }
print(
"DEBUG: All update methods failed, final error: \(lastError?.localizedDescription ?? "Unknown error")"
)
throw lastError ?? APIError.invalidResponse
} }
} }
@ -677,9 +666,6 @@ struct UserSettingsOptions: Codable {
} }
} }
// UserSettingsResponse has the same structure as UserSettingsOptions for the API response
typealias UserSettingsResponse = UserSettingsOptions
// MARK: - API Errors // MARK: - API Errors
enum APIError: LocalizedError { enum APIError: LocalizedError {

View File

@ -177,24 +177,6 @@ struct ExternalWiki: Codable, Equatable {
} }
} }
// MARK: - Asset
struct Asset: Codable, Identifiable {
let id: Int
let name: String
let size: Int
let downloadCount: Int
let createdAt: Date
let browserDownloadUrl: String
let uuid: String
enum CodingKeys: String, CodingKey {
case id, name, size, uuid
case downloadCount = "download_count"
case createdAt = "created_at"
case browserDownloadUrl = "browser_download_url"
}
}
// MARK: - Issue // MARK: - Issue
// MARK: - Issue Repository (simplified for issue responses) // MARK: - Issue Repository (simplified for issue responses)
struct IssueRepository: Codable { struct IssueRepository: Codable {
@ -218,7 +200,7 @@ struct Issue: Codable, Identifiable {
let labels: [Label] let labels: [Label]
let milestone: Milestone? let milestone: Milestone?
let assignee: User? let assignee: User?
let assignees: [User]? let assignees: [User]
let state: IssueState let state: IssueState
let isLocked: Bool let isLocked: Bool
let comments: Int let comments: Int
@ -230,15 +212,10 @@ struct Issue: Codable, Identifiable {
let repository: IssueRepository? let repository: IssueRepository?
let htmlUrl: String let htmlUrl: String
let url: String let url: String
let assets: [Asset]?
let originalAuthor: String?
let originalAuthorId: Int?
let pinOrder: Int?
let ref: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, number, title, body, user, labels, milestone, assignee, assignees case id, number, title, body, user, labels, milestone, assignee, assignees
case state, comments, repository, url, assets, ref case state, comments, repository, url
case isLocked = "is_locked" case isLocked = "is_locked"
case createdAt = "created_at" case createdAt = "created_at"
case updatedAt = "updated_at" case updatedAt = "updated_at"
@ -246,42 +223,6 @@ struct Issue: Codable, Identifiable {
case dueDate = "due_date" case dueDate = "due_date"
case pullRequest = "pull_request" case pullRequest = "pull_request"
case htmlUrl = "html_url" case htmlUrl = "html_url"
case originalAuthor = "original_author"
case originalAuthorId = "original_author_id"
case pinOrder = "pin_order"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
number = try container.decode(Int.self, forKey: .number)
title = try container.decode(String.self, forKey: .title)
body = try container.decodeIfPresent(String.self, forKey: .body)
user = try container.decode(User.self, forKey: .user)
labels = try container.decode([Label].self, forKey: .labels)
milestone = try container.decodeIfPresent(Milestone.self, forKey: .milestone)
assignee = try container.decodeIfPresent(User.self, forKey: .assignee)
assignees = try container.decodeIfPresent([User].self, forKey: .assignees)
// Handle state as string and convert to enum
let stateString = try container.decode(String.self, forKey: .state)
state = IssueState(rawValue: stateString) ?? .open
isLocked = try container.decode(Bool.self, forKey: .isLocked)
comments = try container.decode(Int.self, forKey: .comments)
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
closedAt = try container.decodeIfPresent(Date.self, forKey: .closedAt)
dueDate = try container.decodeIfPresent(Date.self, forKey: .dueDate)
pullRequest = try container.decodeIfPresent(PullRequestMeta.self, forKey: .pullRequest)
repository = try container.decodeIfPresent(IssueRepository.self, forKey: .repository)
htmlUrl = try container.decode(String.self, forKey: .htmlUrl)
url = try container.decode(String.self, forKey: .url)
assets = try container.decodeIfPresent([Asset].self, forKey: .assets)
originalAuthor = try container.decodeIfPresent(String.self, forKey: .originalAuthor)
originalAuthorId = try container.decodeIfPresent(Int.self, forKey: .originalAuthorId)
pinOrder = try container.decodeIfPresent(Int.self, forKey: .pinOrder)
ref = try container.decodeIfPresent(String.self, forKey: .ref)
} }
} }

View File

@ -189,9 +189,12 @@ struct EditProfileView: View {
return return
} }
await MainActor.run { isLoading = true
isLoading = true errorMessage = nil
errorMessage = nil
defer {
isLoading = false
print("DEBUG: saveProfile - isLoading set to false")
} }
do { do {
@ -212,22 +215,17 @@ struct EditProfileView: View {
) )
print("DEBUG: saveProfile - update successful, dismissing sheet") print("DEBUG: saveProfile - update successful, dismissing sheet")
// Success - dismiss the sheet
await MainActor.run { dismiss()
isLoading = false
dismiss()
}
} catch { } catch {
print("DEBUG: saveProfile - error occurred: \(error)") print("DEBUG: saveProfile - error occurred: \(error)")
// Set error message but still dismiss for certain errors
errorMessage = error.localizedDescription
await MainActor.run { // Always dismiss the sheet after a few seconds to prevent getting stuck
isLoading = false DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
errorMessage = error.localizedDescription print("DEBUG: saveProfile - dismissing sheet after error with delay")
dismiss()
// Dismiss after showing error briefly
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
dismiss()
}
} }
} }
} }

View File

@ -14,7 +14,6 @@ struct RepositoriesView: View {
@State private var showPrivateOnly = false @State private var showPrivateOnly = false
@State private var showPublicOnly = false @State private var showPublicOnly = false
@Namespace private var glassNamespace @Namespace private var glassNamespace
@State private var isFetching = false
enum SortOption: String, CaseIterable { enum SortOption: String, CaseIterable {
case name = "Name" case name = "Name"
@ -90,8 +89,9 @@ struct RepositoriesView: View {
destination: RepositoryDetailView( destination: RepositoryDetailView(
repository: repository, repository: repository,
onRepositoryUpdated: { onRepositoryUpdated: {
// Don't automatically refresh - let user manually refresh if needed Task {
// This prevents infinite loops during navigation await fetchRepositories()
}
}) })
) { ) {
CompactRepositoryRow(repository: repository) CompactRepositoryRow(repository: repository)
@ -137,10 +137,8 @@ struct RepositoriesView: View {
isPresented: $showingCreateRepository, isPresented: $showingCreateRepository,
onDismiss: { onDismiss: {
// Refresh repositories when create sheet is dismissed // Refresh repositories when create sheet is dismissed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { Task {
Task { await fetchRepositories()
await fetchRepositories()
}
} }
} }
) { ) {
@ -212,58 +210,22 @@ struct RepositoriesView: View {
} }
private func fetchRepositories() async { private func fetchRepositories() async {
// Prevent multiple simultaneous fetches
if isFetching {
return
}
guard let apiService = authManager.getAPIService() else { guard let apiService = authManager.getAPIService() else {
await MainActor.run { errorMessage = "Not authenticated"
errorMessage = "Not authenticated"
}
return return
} }
await MainActor.run { isLoading = true
isFetching = true errorMessage = nil
isLoading = true
errorMessage = nil
}
do { do {
let fetchedRepositories = try await apiService.getUserRepositories() repositories = try await apiService.getUserRepositories()
await MainActor.run { applyFiltersAndSort()
repositories = fetchedRepositories
applyFiltersAndSort()
isLoading = false
isFetching = false
}
} catch { } catch {
await MainActor.run { errorMessage = error.localizedDescription
isLoading = false
isFetching = false
// Handle different types of cancellation errors gracefully - don't show error or trigger retry
if error is CancellationError {
return
}
// Check for URL session cancellation
if let nsError = error as NSError? {
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
return
}
}
// Check for string-based cancellation
if error.localizedDescription.lowercased().contains("cancelled") {
return
}
// Only show error for non-cancellation errors
errorMessage = error.localizedDescription
}
} }
isLoading = false
} }
} }

View File

@ -27,6 +27,8 @@ struct RepositoryDetailView: View {
@State private var branches: [Branch] = [] @State private var branches: [Branch] = []
@State private var releases: [Release] = [] @State private var releases: [Release] = []
@State private var issues: [Issue] = [] @State private var issues: [Issue] = []
@State private var pullRequests: [PullRequest] = []
@State private var commits: [Commit] = []
@State private var isStarred = false @State private var isStarred = false
@State private var isWatching = false @State private var isWatching = false
@State private var showingBranches = false @State private var showingBranches = false
@ -34,69 +36,71 @@ struct RepositoryDetailView: View {
@State private var repositoryDeleted = false @State private var repositoryDeleted = false
var body: some View { var body: some View {
ZStack { VStack(spacing: 0) {
VStack(spacing: 0) { CustomHeader(
CustomHeader( title: repository.name,
title: repository.name, showBackButton: true,
showBackButton: false, backAction: {
trailingContent: { // Handle back navigation
AnyView( dismiss()
Menu { },
Button(action: { toggleStar() }) { trailingContent: {
HStack { AnyView(
Image(systemName: isStarred ? "star.fill" : "star") Menu {
Text(isStarred ? "Unstar" : "Star") Button(action: { toggleStar() }) {
} HStack {
Image(systemName: isStarred ? "star.fill" : "star")
Text(isStarred ? "Unstar" : "Star")
} }
Button(action: { toggleWatch() }) {
HStack {
Image(systemName: isWatching ? "eye.fill" : "eye")
Text(isWatching ? "Unwatch" : "Watch")
}
}
Divider()
Button(action: { showingBranches = true }) {
HStack {
Image(systemName: "arrow.triangle.branch")
Text("Branches")
}
}
Divider()
Button(action: { showingRepositorySettings = true }) {
HStack {
Image(systemName: "gearshape")
Text("Settings")
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.primary)
.frame(width: 44, height: 44)
.modifier(GlassEffectModifier(shape: Circle()))
} }
.menuStyle(.borderlessButton)
)
}
)
ScrollView { Button(action: { toggleWatch() }) {
VStack(spacing: 0) { HStack {
repositoryHeader Image(systemName: isWatching ? "eye.fill" : "eye")
Text(isWatching ? "Unwatch" : "Watch")
}
}
Divider() Divider()
tabSelector Button(action: { showingBranches = true }) {
HStack {
Image(systemName: "arrow.triangle.branch")
Text("Branches")
}
}
Divider() Divider()
tabContent Button(action: { showingRepositorySettings = true }) {
} HStack {
Image(systemName: "gearshape")
Text("Settings")
}
}
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 18, weight: .medium))
.foregroundColor(.primary)
.frame(width: 44, height: 44)
.modifier(GlassEffectModifier(shape: Circle()))
}
.menuStyle(.borderlessButton)
)
}
)
ScrollView {
VStack(spacing: 0) {
repositoryHeader
Divider()
tabSelector
Divider()
tabContent
} }
} }
} }
@ -234,9 +238,16 @@ struct RepositoryDetailView: View {
TabButton(title: "Issues (\(issues.count))", isSelected: selectedTab == 0) { TabButton(title: "Issues (\(issues.count))", isSelected: selectedTab == 0) {
selectedTab = 0 selectedTab = 0
} }
TabButton(title: "Releases (\(releases.count))", isSelected: selectedTab == 1) {
TabButton(
title: "Pull Requests (\(pullRequests.count))", isSelected: selectedTab == 1
) {
selectedTab = 1 selectedTab = 1
} }
TabButton(title: "Releases (\(releases.count))", isSelected: selectedTab == 2) {
selectedTab = 2
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
} }
@ -249,6 +260,8 @@ struct RepositoryDetailView: View {
case 0: case 0:
issuesTab issuesTab
case 1: case 1:
pullRequestsTab
case 2:
releasesTab releasesTab
default: default:
EmptyView() EmptyView()
@ -343,6 +356,52 @@ struct RepositoryDetailView: View {
} }
} }
private var pullRequestsTab: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Recent Pull Requests")
.font(.headline)
Spacer()
if pullRequests.count > 5 {
Text("View All")
.font(.caption)
.foregroundColor(.accentColor)
}
}
if pullRequests.isEmpty {
VStack(spacing: 20) {
Image(systemName: "arrow.triangle.pull")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No Pull Requests")
.font(.title2)
.fontWeight(.semibold)
Text(
"Pull requests let you propose changes and collaborate on code with others."
)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(Array(pullRequests.prefix(5))) { pr in
PullRequestRowView(pullRequest: pr)
}
}
}
}
}
}
private var releasesTab: some View { private var releasesTab: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
@ -392,8 +451,15 @@ struct RepositoryDetailView: View {
owner: repository.owner.login, repo: repository.name) owner: repository.owner.login, repo: repository.name)
async let releasesTask = apiService.getRepositoryReleases( async let releasesTask = apiService.getRepositoryReleases(
owner: repository.owner.login, repo: repository.name, limit: 10) owner: repository.owner.login, repo: repository.name, limit: 10)
async let pullRequestsTask = apiService.getRepositoryPullRequests(
owner: repository.owner.login, repo: repository.name, limit: 10)
async let commitsTask = apiService.getRepositoryCommits(
owner: repository.owner.login, repo: repository.name, limit: 10)
branches = try await branchesTask branches = try await branchesTask
releases = try await releasesTask releases = try await releasesTask
pullRequests = try await pullRequestsTask
commits = try await commitsTask
// Load issues using the 'all' state to get both open and closed issues // Load issues using the 'all' state to get both open and closed issues
issues = try await apiService.getRepositoryIssues( issues = try await apiService.getRepositoryIssues(
@ -619,6 +685,41 @@ struct IssueRowView: View {
} }
} }
struct PullRequestRowView: View {
let pullRequest: PullRequest
var body: some View {
HStack {
Image(systemName: pullRequest.merged ? "arrow.triangle.merge" : "arrow.triangle.pull")
.foregroundColor(pullRequest.merged ? .purple : .green)
.font(.system(size: 12))
VStack(alignment: .leading, spacing: 2) {
Text(pullRequest.title)
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
HStack {
Text("#\(pullRequest.number)")
.font(.system(size: 12))
.foregroundColor(.secondary)
Text("")
.font(.system(size: 12))
.foregroundColor(.secondary)
Text(pullRequest.createdAt.timeAgoSince())
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.vertical, 2)
}
}
struct ReleaseRowView: View { struct ReleaseRowView: View {
let release: Release let release: Release
@ -839,47 +940,15 @@ struct RepositorySettingsView: View {
isSaving = true isSaving = true
errorMessage = nil errorMessage = nil
guard let apiService = authManager.getAPIService() else { guard authManager.getAPIService() != nil else {
errorMessage = "Authentication error" errorMessage = "Authentication error"
isSaving = false isSaving = false
return return
} }
do { // For now, just dismiss since updateRepository method may not exist
let updateData: [String: Any] = [ isSaving = false
"name": name, dismiss()
"description": description,
"website": website.isEmpty ? nil : website,
"private": isPrivate,
"has_issues": hasIssues,
"has_wiki": hasWiki,
"has_pull_requests": hasPullRequests,
"default_branch": defaultBranch,
"topics": topics.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty },
].compactMapValues { $0 }
let updatedRepository = try await apiService.updateRepository(
owner: repository.owner.login,
repo: repository.name,
updateData: updateData
)
await MainActor.run {
isSaving = false
onRepositoryUpdated?(updatedRepository)
// Add small delay to prevent race condition between callback and dismiss
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
dismiss()
}
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isSaving = false
}
}
} }
private func deleteRepository() async { private func deleteRepository() async {
@ -897,19 +966,12 @@ struct RepositorySettingsView: View {
owner: repository.owner.login, owner: repository.owner.login,
repo: repository.name repo: repository.name
) )
await MainActor.run { isSaving = false
isSaving = false dismiss()
dismiss() onRepositoryDeleted?()
// Add small delay to prevent race condition between dismiss and callback
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onRepositoryDeleted?()
}
}
} catch { } catch {
await MainActor.run { errorMessage = error.localizedDescription
errorMessage = error.localizedDescription isSaving = false
isSaving = false
}
} }
} }
} }