Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,5 @@ docs-private/

# Local API endpoint for Debug builds (see API.xcconfig)
API.local.xcconfig
# Local App Store ID (see AppStore.xcconfig)
AppStore.local.xcconfig
24 changes: 24 additions & 0 deletions AppStore.xcconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//
// AppStore.xcconfig
//
// Your app's App Store page, which the forced-update screen (NeedAppUpdatesView)
// opens from its Update App button. APP_STORE_ID is your app's Apple ID, the
// number at the end of its App Store URL (https://apps.apple.com/app/id<Apple ID>;
// App Store Connect > your app > App Information > Apple ID). It is built into
// Info.plist (AppStoreID) and read at launch.
//
// Empty, as committed, hides the Update App button: the screen still asks users
// to install the new version, which they get from the App Store app. A value
// that is not all digits also hides it, and InfoPlistTest fails on it.
//
// Set it here in your own app's repository, or in AppStore.local.xcconfig
// (gitignored), e.g.
//
// APP_STORE_ID = 1234567890
//
// Applies to both Debug and Release (the project-level base configuration,
// through Project.xcconfig).

APP_STORE_ID =

#include? "AppStore.local.xcconfig"
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Error messages are localized (English and Japanese). A request that fails without the API's `error_message` shows "Something went wrong while communicating with the server. Please try again later." (`NATIVEAPPTEMPLATE-2001`); developer-only errors (`NATIVEAPPTEMPLATE-1001`, `2002`–`2008`, `4001`–`4004`) show "An unexpected error occurred." The API's `error_message` is shown without the " [Status: nnn]" suffix. Error codes are unchanged; status codes, field names and underlying errors go to the log (`CodedError.debugDescription`)

### Fixed
- The update screen shown when the API requires a newer version opened MyTurnTag Creator's App Store page from its **Update App** button (hardcoded in `Strings.appStoreUrl`). It now opens `https://apps.apple.com/app/id<APP_STORE_ID>`, where `APP_STORE_ID` is a build setting in the new `AppStore.xcconfig` (included by the new `Project.xcconfig`, the project-level base configuration of Debug and Release; override it in the gitignored `AppStore.local.xcconfig`), built into `Info.plist` as `AppStoreID`. It is empty as committed, which hides the button; the screen still asks users to install the new version. See README "App Store page"
- The Local Network permission prompt (`NSLocalNetworkUsageDescription`) had an article before the product name, which reads wrong after a rename. It now reads "Connects to the app's API server running on your local network during development."; the Japanese is unchanged. `LocalizationTest` now fails on "a" or "an" right before a word the agent renames in the English strings
- Rename safety in tests and comments: `JSONAPIDocumentTest` no longer uses `itemTag` as a type the app does not know (the agent renames `item_tag` and `itemTag` to the same word, so it became a known type in generated apps), and comments drop the article before domain words and say "the agent" instead of the agent's name
- The alert after resending confirmation instructions read "An email has been sent the email containing instructions for confirming your email address." It now reads "An email with instructions for confirming your email address has been sent.", as in NativeAppTemplate-Free-Android; the Japanese is unchanged
- The message bar no longer covers the tab bar and blocks tab taps until it is closed: with the tabs showing, it sits above the tab bar. Its close button is a labeled "Close" button (new string, Japanese 閉じる) with a 32 pt target (the symbol stays 16 pt); the tabs' accessibility label no longer replaces the message's labels
- VoiceOver read validation messages that weren't shown, e.g. "Name is required." under a filled-in name: a valid form keeps the message's space with a clear color, which still left it an accessibility element. The message is now also hidden from accessibility until it is shown (`validationMessage(isShown:)`, used by every form that hid its message this way: sign-up, profile, password, shop create/basic settings, item tag create/edit)
- A failed sign-in's error message stayed on screen after the next attempt signed in; a new attempt now dismisses it
- Debug builds lost the local API when launched other than from Xcode, e.g. from the Home Screen or when iOS relaunched the app after its language was changed in Settings: the endpoint came only from the scheme's environment variables, which only Xcode passes. It now comes from `API.xcconfig` (override it in the gitignored `API.local.xcconfig`) and is built into `Info.plist`; `NATIVEAPPTEMPLATE_API_*` environment variables still win. The scheme no longer sets them

### Added
- Japanese localization, ported from NativeAppTemplate-iOS #102 with the same Japanese for every shared string. Every user-facing string goes through `Localizable.xcstrings` (`String(localized:)` in `Constants.swift`), with Japanese for all keys using the API's terms (店舗, アイテムタグ). `InfoPlist.xcstrings` keeps the app name as is. `LocalizationTest` fails on a key without Japanese, a translation with different format specifiers, ショップ, or an ItemTag not written as アイテムタグ in full
- Japanese localization, ported from NativeAppTemplate-iOS #102 with the same Japanese for every shared string. Every user-facing string goes through `Localizable.xcstrings` (`String(localized:)` in `Constants.swift`), with Japanese for all keys using the API's terms (店舗, アイテムタグ). `InfoPlist.xcstrings` keeps the app name as is. `LocalizationTest` fails on a key without Japanese, a translation with different format specifiers, ショップ, or ItemTag not written as アイテムタグ in full
- "App Language" on Edit Profile shows the language the app is shown in, with a button to change it in iOS Settings; emails follow it. Sign-up sends that language as `locale`, and after permissions are fetched (every launch and sign-in) the app sends `PATCH /shopkeeper_auth {locale}` when `shopkeepers.locale` differs (nativeapptemplateapi #150)
- `Shopkeeper.locale`, read from the sign-in, sign-up and profile responses and kept with the saved session. A session saved by an earlier version reads as `en`, the API's default

Expand Down
6 changes: 6 additions & 0 deletions NativeAppTemplate.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@
01A133A42E08BB1B000AD24A /* SignInEmailAndPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInEmailAndPasswordViewModel.swift; sourceTree = "<group>"; };
01A133A62E08BB66000AD24A /* SignUpViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpViewModel.swift; sourceTree = "<group>"; };
01A9E1F02EA0000100000001 /* API.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = API.xcconfig; sourceTree = "<group>"; };
A99570000000000000000001 /* AppStore.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppStore.xcconfig; sourceTree = "<group>"; };
5E7A10000000000000000105 /* Project.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Project.xcconfig; sourceTree = "<group>"; };
01B37C7529B0960700BF5B2D /* ForgotPasswordView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ForgotPasswordView.swift; sourceTree = "<group>"; };
01B526532AF4E36400655131 /* MainTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = "<group>"; };
01B526552AF4E82A00655131 /* ScrollToTopID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollToTopID.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -408,6 +410,8 @@
isa = PBXGroup;
children = (
01A9E1F02EA0000100000001 /* API.xcconfig */,
A99570000000000000000001 /* AppStore.xcconfig */,
5E7A10000000000000000105 /* Project.xcconfig */,
0135E8E22D7E4478004AD8FA /* SampleCode.xcconfig */,
);
name = Configuration;
Expand Down Expand Up @@ -1044,6 +1048,7 @@
/* Begin XCBuildConfiguration section */
011F6E10259EF16600BED22E /* Debug configuration for PBXProject "NativeAppTemplate" */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5E7A10000000000000000105 /* Project.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
Expand Down Expand Up @@ -1114,6 +1119,7 @@
};
011F6E11259EF16600BED22E /* Release configuration for PBXProject "NativeAppTemplate" */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 5E7A10000000000000000105 /* Project.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
Expand Down
17 changes: 15 additions & 2 deletions NativeAppTemplate/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,21 @@ enum Strings {

static let appName: String = "Native App Template"

/// This is for MyTurnTag Creator. Replace this.
static let appStoreUrl: String = "https://apps.apple.com/app/myturntag-creator/id1516198303"
// MARK: App Store page

/// APP_STORE_ID from AppStore.xcconfig (built into Info.plist): the app's Apple ID, the
/// number in its App Store URL. Empty, as committed, hides NeedAppUpdatesView's Update App
/// button. See README "App Store page".
static let appStoreId: String = Bundle.main.object(forInfoDictionaryKey: "AppStoreID") as? String ?? ""
/// The page NeedAppUpdatesView's Update App button opens; nil while APP_STORE_ID is empty
static let appStoreUrl: URL? = appStorePage(appStoreId: appStoreId)

/// https://apps.apple.com/app/id<Apple ID>, or nil unless `appStoreId` is all digits
/// (an Apple ID is a number; anything else, e.g. a pasted URL, would open a wrong page)
static func appStorePage(appStoreId: String) -> URL? {
guard !appStoreId.isEmpty, appStoreId.allSatisfy({ ("0"..."9").contains($0) }) else { return nil }
return URL(string: "https://apps.apple.com/app/id\(appStoreId)")
}

static let placeholderFullName: String = .init(localized: "John Smith")
static let placeholderEmail: String = "you@example.com"
Expand Down
4 changes: 3 additions & 1 deletion NativeAppTemplate/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>AppStoreID</key>
<string>$(APP_STORE_ID)</string>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
Expand All @@ -25,7 +27,7 @@
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>Connects to a NativeAppTemplate API server running on your local network during development.</string>
<string>Connects to the app's API server running on your local network during development.</string>
<key>NativeAppTemplateAPIDomain</key>
<string>$(NATIVEAPPTEMPLATE_API_DOMAIN)</string>
<key>NativeAppTemplateAPIPort</key>
Expand Down
2 changes: 1 addition & 1 deletion NativeAppTemplate/InfoPlist.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"en" : {
"stringUnit" : {
"state" : "new",
"value" : "Connects to a NativeAppTemplate API server running on your local network during development."
"value" : "Connects to the app's API server running on your local network during development."
}
},
"ja" : {
Expand Down
4 changes: 2 additions & 2 deletions NativeAppTemplate/UI/App Root/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ private extension MainView {
.environment(tabViewModel)
case .needsAppUpdate:
AppTabView(
shopListView: NeedAppUpdatesView.init,
settingsView: NeedAppUpdatesView.init
shopListView: { NeedAppUpdatesView() },
settingsView: { NeedAppUpdatesView() }
)
.environment(tabViewModel)
case .tabs:
Expand Down
20 changes: 15 additions & 5 deletions NativeAppTemplate/UI/Empty States/NeedAppUpdatesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import SwiftUI
struct NeedAppUpdatesView: View {
@Environment(\.openURL) var openURL

/// The app's App Store page (APP_STORE_ID in AppStore.xcconfig). nil, as committed, hides
/// the Update App button; the text still asks users to install the new version.
private let appStoreUrl: URL?

init(appStoreUrl: URL? = Strings.appStoreUrl) {
self.appStoreUrl = appStoreUrl
}

var body: some View {
VStack {
Spacer()
Expand All @@ -27,12 +35,14 @@ struct NeedAppUpdatesView: View {
Text(Strings.installNewVersionApp)
.foregroundStyle(.contentText)
.padding(.top, NativeAppTemplateConstants.Spacing.xxxs)
Button {
openURL(URL(string: Strings.appStoreUrl)!)
} label: {
Text(Strings.updateApp)
if let appStoreUrl {
Button {
openURL(appStoreUrl)
} label: {
Text(Strings.updateApp)
}
.padding(.top)
}
.padding(.top)
}
}
.padding(.horizontal, NativeAppTemplateConstants.Spacing.lg)
Expand Down
20 changes: 20 additions & 0 deletions NativeAppTemplateTests/App/InfoPlistTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ struct InfoPlistTest {
#expect(info["UIUserInterfaceStyle"] as? String == "Dark")
}

/// AppStore.xcconfig's APP_STORE_ID reaches the app through Info.plist; an empty value (as
/// committed) hides the forced-update screen's Update App button. A missing entry would hide
/// it even with an ID.
@Test
func declaresTheAppStoreID() throws {
let info = try #require(Bundle.main.infoDictionary)

#expect(info["AppStoreID"] is String)
}

/// Empty (as committed) or an Apple ID (digits only), which makes an App Store page. Anything
/// else, e.g. the whole App Store URL, would hide the Update App button without a word.
@Test
func theAppStoreIDIsEmptyOrAnAppleID() {
#expect(
Strings.appStoreId.isEmpty || Strings.appStoreUrl != nil,
"APP_STORE_ID is \"\(Strings.appStoreId)\"; see AppStore.xcconfig"
)
}

/// iOS local network privacy identifies an app by its main executable's
/// LC_UUID (Apple TN3179). With ENABLE_DEBUG_DYLIB the Debug main executable
/// is a tiny stub that depends only on the product name, so this app and the
Expand Down
38 changes: 34 additions & 4 deletions NativeAppTemplateTests/App/LocalizationTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ struct LocalizationTest {
}
}

/// Shop is 店舗 throughout, as in the API's ja.yml (店舗名). nativeapptemplate-agent
/// renames 店舗 as a substring, so ショップ, 店 or お店 would survive a rename.
/// Shop is 店舗 throughout, as in the API's ja.yml (店舗名). The agent renames
/// 店舗 as a substring, so ショップ, 店 or お店 would survive a rename.
@Test(.enabled(if: canReadSourceCatalogs, "Reads the .xcstrings sources on the Mac"))
func shopIsAlways店舗() throws {
let catalog = try StringCatalog(named: "Localizable.xcstrings")
Expand All @@ -74,8 +74,8 @@ struct LocalizationTest {
#expect(offenders.isEmpty, "Use 店舗: \(offenders)")
}

/// An ItemTag is アイテムタグ in full: nativeapptemplate-agent renames アイテムタグ
/// as a substring, so a shortened タグ or アイテム would survive a rename.
/// ItemTag is アイテムタグ in full: the agent renames アイテムタグ as a substring,
/// so a shortened タグ or アイテム would survive a rename.
@Test(.enabled(if: canReadSourceCatalogs, "Reads the .xcstrings sources on the Mac"))
func itemTagIsAlwaysアイテムタグInFull() throws {
let catalog = try StringCatalog(named: "Localizable.xcstrings")
Expand All @@ -86,6 +86,28 @@ struct LocalizationTest {
#expect(offenders.isEmpty, "Use アイテムタグ: \(offenders)")
}

// MARK: - Rename safety

/// No "a" or "an" right before a word the agent renames (SUBSTRATE-CONTRACT rule 1), which
/// would read "a OrangeBaton" or "an patient" after a rename. The Local Network prompt
/// (NSLocalNetworkUsageDescription) had one before the product name.
@Test(.enabled(if: canReadSourceCatalogs, "Reads the .xcstrings sources on the Mac"))
func noArticleBeforeARenamedWord() throws {
let localizable = try StringCatalog(named: "Localizable.xcstrings")
let infoPlist = try StringCatalog(named: "InfoPlist.xcstrings")
let info = try #require(Bundle.main.infoDictionary)
let usageDescriptions = info.filter { $0.key.hasSuffix("UsageDescription") }.compactMap { $0.value as? String }
let english = Array(localizable.strings.keys) + localizable.englishValues + infoPlist.englishValues
+ usageDescriptions
#expect(english.count > 100) // guards against reading nothing

let articleBeforeRenamedWord =
/\b(?:a|an) (?:shops?|shopkeepers?|item tags?|itemtags?|nativeapptemplate|native app template)\b/
.ignoresCase()
let offenders = english.filter { $0.contains(articleBeforeRenamedWord) }
#expect(offenders.isEmpty, "Drop the article: \(offenders)")
}

// MARK: - The built app carries Japanese

@Test
Expand Down Expand Up @@ -192,6 +214,14 @@ private struct StringCatalog {
}
}

/// English values, where the catalog has them (InfoPlist.xcstrings's keys are Info.plist keys)
var englishValues: [String] {
strings.values.compactMap { entry in
let english = (entry["localizations"] as? [String: Any])?["en"] as? [String: Any]
return (english?["stringUnit"] as? [String: Any])?["value"] as? String
}
}

var keysMissingJapanese: [String] {
strings.compactMap { key, entry in
if entry["shouldTranslate"] as? Bool == false { return nil }
Expand Down
2 changes: 1 addition & 1 deletion NativeAppTemplateTests/Data/DataStateListContentTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import Testing
/// List screens switch on `listContent(isEmpty:)`. A reload of a list already on screen
/// must stay `.list`, the same case as `.hasData`: as a branch of its own, SwiftUI rebuilds
/// the list on every reload and re-runs its `.task`. In MyTurnTagCreator 2.1.0 that made the
/// Shops tab reload about twice a second while a shop was open.
/// Shops tab reload about twice a second while shop detail was open.
@Suite
struct DataStateListContentTest {
@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ struct ItemTagRepositoryTest {
#expect(repository.state == .hasData)
}

/// An item tag resource as the API's ItemTagSerializer writes it (ItemTagAdapter's attributes)
/// Item tag resource as the API's ItemTagSerializer writes it (ItemTagAdapter's attributes)
private nonisolated static func itemTagResource(id: String, shopId: String = "A") -> [String: Any] {
[
"id": id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ struct ShopRepositoryTest {
#expect(repository.shops.isEmpty)
}

/// fetchDetail upserts: appends a shop it does not have, replaces one it has
/// fetchDetail upserts: appends what it does not have, replaces what it has
@Test
func fetchDetailAppendsOrReplaces() async throws {
let server = try await TestHTTPServer.start { request in
Expand Down
2 changes: 1 addition & 1 deletion NativeAppTemplateTests/Networking/APIEndpointTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct APIEndpointTest {
#expect(endpoint == APIEndpoint(scheme: "http", domain: "192.168.1.21", port: "3000"))
}

/// nativeapptemplate-agent passes the endpoint through `simctl launch`
/// The agent passes the endpoint through `simctl launch`
/// (SIMCTL_CHILD_*), which must keep working
@Test
func environmentVariablesWinOverInfoPlist() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ struct JSONAPIDocumentTest {
#expect(resource.id == "7")
}

/// Only those exact names: camelCase, capitalized, or a type EntityType does not
/// list (the paid app's account) has none
@Test(arguments: ["itemTag", "Shop", "account"])
/// Only those exact names: a type EntityType does not list (the paid app's account),
/// camelCase, or capitalized has none. The camelCase one keeps a part the agent does
/// not rename: it renames item_tag and itemTag to the same word, so a camelCase
/// item tag type would become a known one in the generated app.
@Test(arguments: ["account", "shopkeeperSignIn", "Shop"])
func otherTypeNamesHaveNoEntityType(type: String) throws {
let doc = try document(#"{"data": {"id": "1", "type": "\#(type)"}}"#)

Expand Down
Loading
Loading