swift-code-reviewer-skill 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +76 -444
- package/SKILL.md +97 -7
- package/package.json +1 -1
- package/references/architecture-patterns.md +275 -0
- package/references/performance-review.md +193 -0
- package/references/review-workflow.md +121 -0
- package/references/swiftui-review-checklist.md +738 -0
|
@@ -874,6 +874,734 @@ struct LoginView_Previews: PreviewProvider { // ❌ Old pattern
|
|
|
874
874
|
|
|
875
875
|
---
|
|
876
876
|
|
|
877
|
+
## 8. Navigation Architecture
|
|
878
|
+
|
|
879
|
+
### 8.1 Route Enum Enforcement
|
|
880
|
+
|
|
881
|
+
**Check for:**
|
|
882
|
+
- [ ] Navigation destinations defined as typed `Hashable` enums (not String/Int/raw values)
|
|
883
|
+
- [ ] Route enum covers all destinations in the stack
|
|
884
|
+
- [ ] Associated values carry only the necessary data (IDs, not full models when possible)
|
|
885
|
+
|
|
886
|
+
**Examples:**
|
|
887
|
+
|
|
888
|
+
❌ **Bad: String-based navigation**
|
|
889
|
+
```swift
|
|
890
|
+
struct ContentView: View {
|
|
891
|
+
@State private var path: [String] = [] // ❌ Stringly-typed route
|
|
892
|
+
|
|
893
|
+
var body: some View {
|
|
894
|
+
NavigationStack(path: $path) {
|
|
895
|
+
List(items) { item in
|
|
896
|
+
NavigationLink(value: "detail-\(item.id)") { // ❌ Magic string
|
|
897
|
+
Text(item.name)
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
✅ **Good: Typed route enum**
|
|
906
|
+
```swift
|
|
907
|
+
enum AppRoute: Hashable {
|
|
908
|
+
case userDetail(userID: UUID)
|
|
909
|
+
case userEdit(userID: UUID)
|
|
910
|
+
case settings
|
|
911
|
+
case notifications
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
struct ContentView: View {
|
|
915
|
+
@State private var router = RouterPath()
|
|
916
|
+
|
|
917
|
+
var body: some View {
|
|
918
|
+
NavigationStack(path: $router.path) {
|
|
919
|
+
List(users) { user in
|
|
920
|
+
NavigationLink(value: AppRoute.userDetail(userID: user.id)) {
|
|
921
|
+
Text(user.name)
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
.navigationDestination(for: AppRoute.self) { route in
|
|
925
|
+
routeDestination(route)
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
**Reference**: `~/.claude/skills/swiftui-ui-patterns/references/navigation.md`
|
|
933
|
+
|
|
934
|
+
### 8.2 RouterPath Pattern
|
|
935
|
+
|
|
936
|
+
**Check for:**
|
|
937
|
+
- [ ] Navigation path owned by `RouterPath` `@Observable` class, not ad-hoc `@State var path`
|
|
938
|
+
- [ ] RouterPath exposed as `@Observable` so views can bind to it
|
|
939
|
+
- [ ] Path manipulation methods (`navigate(to:)`, `pop()`, `popToRoot()`) on RouterPath
|
|
940
|
+
|
|
941
|
+
**Examples:**
|
|
942
|
+
|
|
943
|
+
❌ **Bad: Ad-hoc @State path**
|
|
944
|
+
```swift
|
|
945
|
+
struct RootView: View {
|
|
946
|
+
@State private var path = NavigationPath() // ❌ Ad-hoc, not reusable
|
|
947
|
+
@State private var showProfile = false
|
|
948
|
+
|
|
949
|
+
var body: some View {
|
|
950
|
+
NavigationStack(path: $path) {
|
|
951
|
+
// ...
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
```
|
|
956
|
+
|
|
957
|
+
✅ **Good: RouterPath @Observable**
|
|
958
|
+
```swift
|
|
959
|
+
@Observable
|
|
960
|
+
final class RouterPath {
|
|
961
|
+
var path: [AppRoute] = []
|
|
962
|
+
|
|
963
|
+
func navigate(to route: AppRoute) {
|
|
964
|
+
path.append(route)
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
func pop() {
|
|
968
|
+
guard !path.isEmpty else { return }
|
|
969
|
+
path.removeLast()
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
func popToRoot() {
|
|
973
|
+
path.removeAll()
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
func navigate(to routes: [AppRoute]) {
|
|
977
|
+
path.append(contentsOf: routes)
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
struct RootView: View {
|
|
982
|
+
@State private var router = RouterPath()
|
|
983
|
+
|
|
984
|
+
var body: some View {
|
|
985
|
+
NavigationStack(path: $router.path) {
|
|
986
|
+
HomeView(router: router)
|
|
987
|
+
.navigationDestination(for: AppRoute.self) { route in
|
|
988
|
+
destinationView(for: route, router: router)
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
### 8.3 Centralized navigationDestination
|
|
996
|
+
|
|
997
|
+
**Check for:**
|
|
998
|
+
- [ ] Single `.navigationDestination(for: Route.self)` per NavigationStack (not scattered in child views)
|
|
999
|
+
- [ ] Destination mapping in root or coordinator view
|
|
1000
|
+
- [ ] No `.navigationDestination` in deeply nested views for top-level routes
|
|
1001
|
+
|
|
1002
|
+
**Examples:**
|
|
1003
|
+
|
|
1004
|
+
❌ **Bad: Scattered navigationDestination**
|
|
1005
|
+
```swift
|
|
1006
|
+
struct HomeView: View {
|
|
1007
|
+
var body: some View {
|
|
1008
|
+
List(items) { item in
|
|
1009
|
+
NavigationLink(value: AppRoute.userDetail(userID: item.id)) {
|
|
1010
|
+
Text(item.name)
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
.navigationDestination(for: AppRoute.self) { route in // ❌ Defined in child
|
|
1014
|
+
// Only handles some routes
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
✅ **Good: Centralized in root**
|
|
1021
|
+
```swift
|
|
1022
|
+
struct RootView: View {
|
|
1023
|
+
@State private var router = RouterPath()
|
|
1024
|
+
|
|
1025
|
+
var body: some View {
|
|
1026
|
+
NavigationStack(path: $router.path) {
|
|
1027
|
+
HomeView(router: router)
|
|
1028
|
+
.navigationDestination(for: AppRoute.self) { route in // ✅ Single, centralized
|
|
1029
|
+
switch route {
|
|
1030
|
+
case .userDetail(let id): UserDetailView(userID: id, router: router)
|
|
1031
|
+
case .userEdit(let id): UserEditView(userID: id, router: router)
|
|
1032
|
+
case .settings: SettingsView(router: router)
|
|
1033
|
+
case .notifications: NotificationsView(router: router)
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
.environment(router)
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
1044
|
+
## 9. Sheet / Modal Routing
|
|
1045
|
+
|
|
1046
|
+
### 9.1 Item-Driven Sheet
|
|
1047
|
+
|
|
1048
|
+
**Check for:**
|
|
1049
|
+
- [ ] `.sheet(item:)` preferred over `.sheet(isPresented:)` when a model is being selected/shown
|
|
1050
|
+
- [ ] Sheet dismissed by setting item to `nil` (not manual boolean reset)
|
|
1051
|
+
- [ ] No manual boolean reset after sheet dismiss
|
|
1052
|
+
|
|
1053
|
+
**Examples:**
|
|
1054
|
+
|
|
1055
|
+
❌ **Bad: Boolean-driven sheet with manual item**
|
|
1056
|
+
```swift
|
|
1057
|
+
struct UserListView: View {
|
|
1058
|
+
@State private var showDetail = false // ❌ Manual boolean
|
|
1059
|
+
@State private var selectedUser: User? // ❌ Two states to keep in sync
|
|
1060
|
+
|
|
1061
|
+
var body: some View {
|
|
1062
|
+
List(users) { user in
|
|
1063
|
+
Button { selectedUser = user; showDetail = true } label: { Text(user.name) }
|
|
1064
|
+
}
|
|
1065
|
+
.sheet(isPresented: $showDetail) {
|
|
1066
|
+
if let user = selectedUser { // ❌ Forced optional unwrap in sheet
|
|
1067
|
+
UserDetailSheet(user: user)
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
```
|
|
1073
|
+
|
|
1074
|
+
✅ **Good: Item-driven sheet**
|
|
1075
|
+
```swift
|
|
1076
|
+
struct UserListView: View {
|
|
1077
|
+
@State private var selectedUser: User? // ✅ Single source of truth
|
|
1078
|
+
|
|
1079
|
+
var body: some View {
|
|
1080
|
+
List(users) { user in
|
|
1081
|
+
Button { selectedUser = user } label: { Text(user.name) }
|
|
1082
|
+
}
|
|
1083
|
+
.sheet(item: $selectedUser) { user in // ✅ Item-driven, auto-dismisses on nil
|
|
1084
|
+
UserDetailSheet(user: user)
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
### 9.2 SheetDestination Enum
|
|
1091
|
+
|
|
1092
|
+
**Check for:**
|
|
1093
|
+
- [ ] Multiple sheets represented as a single `Identifiable` enum, not multiple `@State` booleans
|
|
1094
|
+
- [ ] `SheetDestination` enum covers all possible modals in the view
|
|
1095
|
+
- [ ] Only one `.sheet(item:)` call per view
|
|
1096
|
+
|
|
1097
|
+
**Examples:**
|
|
1098
|
+
|
|
1099
|
+
❌ **Bad: Multiple boolean states for sheets**
|
|
1100
|
+
```swift
|
|
1101
|
+
struct HomeView: View {
|
|
1102
|
+
@State private var showCompose = false // ❌ Multiple booleans
|
|
1103
|
+
@State private var showProfile = false // ❌ Multiple booleans
|
|
1104
|
+
@State private var showSettings = false // ❌ Multiple booleans
|
|
1105
|
+
|
|
1106
|
+
var body: some View {
|
|
1107
|
+
// ...
|
|
1108
|
+
.sheet(isPresented: $showCompose) { ComposeView() }
|
|
1109
|
+
.sheet(isPresented: $showProfile) { ProfileView() } // ❌ Multiple .sheet on same view
|
|
1110
|
+
.sheet(isPresented: $showSettings) { SettingsView() }
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
```
|
|
1114
|
+
|
|
1115
|
+
✅ **Good: SheetDestination enum**
|
|
1116
|
+
```swift
|
|
1117
|
+
enum SheetDestination: Identifiable {
|
|
1118
|
+
case compose
|
|
1119
|
+
case profile(userID: UUID)
|
|
1120
|
+
case settings
|
|
1121
|
+
|
|
1122
|
+
var id: String {
|
|
1123
|
+
switch self {
|
|
1124
|
+
case .compose: return "compose"
|
|
1125
|
+
case .profile(let id): return "profile-\(id)"
|
|
1126
|
+
case .settings: return "settings"
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
struct HomeView: View {
|
|
1132
|
+
@State private var sheetDestination: SheetDestination? // ✅ Single state
|
|
1133
|
+
|
|
1134
|
+
var body: some View {
|
|
1135
|
+
// ...
|
|
1136
|
+
.sheet(item: $sheetDestination) { destination in // ✅ Single .sheet
|
|
1137
|
+
switch destination {
|
|
1138
|
+
case .compose: ComposeView()
|
|
1139
|
+
case .profile(let id): ProfileView(userID: id)
|
|
1140
|
+
case .settings: SettingsView()
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
```
|
|
1146
|
+
|
|
1147
|
+
---
|
|
1148
|
+
|
|
1149
|
+
## 10. Deep Link Handling
|
|
1150
|
+
|
|
1151
|
+
### 10.1 Centralized Deep Link Routing
|
|
1152
|
+
|
|
1153
|
+
**Check for:**
|
|
1154
|
+
- [ ] `.onOpenURL` applied at the app root (not in feature views)
|
|
1155
|
+
- [ ] URL parsing and validation happens in a dedicated router/coordinator
|
|
1156
|
+
- [ ] Feature views do not contain URL parsing logic
|
|
1157
|
+
|
|
1158
|
+
**Examples:**
|
|
1159
|
+
|
|
1160
|
+
❌ **Bad: onOpenURL scattered in feature views**
|
|
1161
|
+
```swift
|
|
1162
|
+
struct HomeView: View {
|
|
1163
|
+
var body: some View {
|
|
1164
|
+
// ...
|
|
1165
|
+
.onOpenURL { url in // ❌ URL handling in feature view
|
|
1166
|
+
if url.pathComponents.contains("profile") {
|
|
1167
|
+
// Handle profile deep link
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
struct SettingsView: View {
|
|
1174
|
+
var body: some View {
|
|
1175
|
+
// ...
|
|
1176
|
+
.onOpenURL { url in // ❌ Another scattered handler
|
|
1177
|
+
if url.pathComponents.contains("settings") {
|
|
1178
|
+
// Handle settings deep link
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
✅ **Good: Centralized at root, router handles routing**
|
|
1186
|
+
```swift
|
|
1187
|
+
@Observable
|
|
1188
|
+
final class RouterPath {
|
|
1189
|
+
var path: [AppRoute] = []
|
|
1190
|
+
|
|
1191
|
+
func handle(url: URL) -> Bool {
|
|
1192
|
+
guard let route = AppRoute(url: url) else { return false } // ✅ URL → Route
|
|
1193
|
+
navigate(to: route)
|
|
1194
|
+
return true
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// In App root
|
|
1199
|
+
struct RootView: View {
|
|
1200
|
+
@State private var router = RouterPath()
|
|
1201
|
+
|
|
1202
|
+
var body: some View {
|
|
1203
|
+
NavigationStack(path: $router.path) {
|
|
1204
|
+
HomeView()
|
|
1205
|
+
.navigationDestination(for: AppRoute.self) { route in
|
|
1206
|
+
destinationView(for: route)
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
.onOpenURL { url in // ✅ Single handler at root
|
|
1210
|
+
_ = router.handle(url: url)
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
```
|
|
1215
|
+
|
|
1216
|
+
---
|
|
1217
|
+
|
|
1218
|
+
## 11. TabView Architecture
|
|
1219
|
+
|
|
1220
|
+
### 11.1 Independent Navigation History Per Tab
|
|
1221
|
+
|
|
1222
|
+
**Check for:**
|
|
1223
|
+
- [ ] Each tab has its own `RouterPath` (not a shared global path)
|
|
1224
|
+
- [ ] Switching tabs preserves the navigation stack for each tab
|
|
1225
|
+
- [ ] Tab routers are independent `@Observable` instances
|
|
1226
|
+
|
|
1227
|
+
**Examples:**
|
|
1228
|
+
|
|
1229
|
+
❌ **Bad: Shared navigation path across tabs**
|
|
1230
|
+
```swift
|
|
1231
|
+
struct MainTabView: View {
|
|
1232
|
+
@State private var path = NavigationPath() // ❌ Shared across all tabs
|
|
1233
|
+
|
|
1234
|
+
var body: some View {
|
|
1235
|
+
TabView {
|
|
1236
|
+
NavigationStack(path: $path) { HomeView() }.tabItem { Label("Home", systemImage: "house") }
|
|
1237
|
+
NavigationStack(path: $path) { SearchView() }.tabItem { Label("Search", systemImage: "magnifyingglass") }
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
```
|
|
1242
|
+
|
|
1243
|
+
✅ **Good: Independent RouterPath per tab**
|
|
1244
|
+
```swift
|
|
1245
|
+
enum AppTab: Int, CaseIterable {
|
|
1246
|
+
case home, search, notifications, profile
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
@Observable
|
|
1250
|
+
final class AppTabRouter {
|
|
1251
|
+
var selectedTab: AppTab = .home
|
|
1252
|
+
|
|
1253
|
+
// Independent path per tab
|
|
1254
|
+
var homeRouter = RouterPath()
|
|
1255
|
+
var searchRouter = RouterPath()
|
|
1256
|
+
var notificationsRouter = RouterPath()
|
|
1257
|
+
var profileRouter = RouterPath()
|
|
1258
|
+
|
|
1259
|
+
func router(for tab: AppTab) -> RouterPath {
|
|
1260
|
+
switch tab {
|
|
1261
|
+
case .home: return homeRouter
|
|
1262
|
+
case .search: return searchRouter
|
|
1263
|
+
case .notifications: return notificationsRouter
|
|
1264
|
+
case .profile: return profileRouter
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
struct MainTabView: View {
|
|
1270
|
+
@State private var tabRouter = AppTabRouter()
|
|
1271
|
+
|
|
1272
|
+
var body: some View {
|
|
1273
|
+
TabView(selection: $tabRouter.selectedTab) {
|
|
1274
|
+
Tab("Home", systemImage: "house", value: .home) {
|
|
1275
|
+
NavigationStack(path: $tabRouter.homeRouter.path) { // ✅ Per-tab router
|
|
1276
|
+
HomeView(router: tabRouter.homeRouter)
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
Tab("Search", systemImage: "magnifyingglass", value: .search) {
|
|
1280
|
+
NavigationStack(path: $tabRouter.searchRouter.path) { // ✅ Per-tab router
|
|
1281
|
+
SearchView(router: tabRouter.searchRouter)
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
### 11.2 Custom Tab Binding with Side Effects
|
|
1290
|
+
|
|
1291
|
+
**Check for:**
|
|
1292
|
+
- [ ] Action tabs (e.g., compose, post) handled as side effects, not actual tab destinations
|
|
1293
|
+
- [ ] `AppTab` enum distinguishes navigable tabs from action tabs
|
|
1294
|
+
- [ ] Tab selection goes through `updateTab(_:)` or equivalent to handle action tabs
|
|
1295
|
+
|
|
1296
|
+
**Examples:**
|
|
1297
|
+
|
|
1298
|
+
❌ **Bad: Action tab treated as normal tab**
|
|
1299
|
+
```swift
|
|
1300
|
+
struct MainTabView: View {
|
|
1301
|
+
@State private var selectedTab = 0
|
|
1302
|
+
|
|
1303
|
+
var body: some View {
|
|
1304
|
+
TabView(selection: $selectedTab) {
|
|
1305
|
+
HomeView().tabItem { Label("Home", systemImage: "house") }.tag(0)
|
|
1306
|
+
EmptyView().tabItem { Label("Compose", systemImage: "plus") }.tag(1) // ❌ No action
|
|
1307
|
+
ProfileView().tabItem { Label("Profile", systemImage: "person") }.tag(2)
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
✅ **Good: Action tabs trigger side effects**
|
|
1314
|
+
```swift
|
|
1315
|
+
enum AppTab: Int {
|
|
1316
|
+
case home, compose, profile
|
|
1317
|
+
|
|
1318
|
+
var isAction: Bool { self == .compose }
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
@Observable
|
|
1322
|
+
final class AppTabRouter {
|
|
1323
|
+
var selectedTab: AppTab = .home
|
|
1324
|
+
var showCompose = false
|
|
1325
|
+
|
|
1326
|
+
func updateTab(_ tab: AppTab) {
|
|
1327
|
+
if tab.isAction {
|
|
1328
|
+
showCompose = true // ✅ Action tab triggers modal, not navigation
|
|
1329
|
+
} else {
|
|
1330
|
+
selectedTab = tab
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
struct MainTabView: View {
|
|
1336
|
+
@State private var tabRouter = AppTabRouter()
|
|
1337
|
+
|
|
1338
|
+
var body: some View {
|
|
1339
|
+
TabView(selection: Binding(
|
|
1340
|
+
get: { tabRouter.selectedTab },
|
|
1341
|
+
set: { tabRouter.updateTab($0) } // ✅ Route through updateTab
|
|
1342
|
+
)) {
|
|
1343
|
+
HomeView().tabItem { Label("Home", systemImage: "house") }.tag(AppTab.home)
|
|
1344
|
+
Color.clear.tabItem { Label("Compose", systemImage: "plus") }.tag(AppTab.compose)
|
|
1345
|
+
ProfileView().tabItem { Label("Profile", systemImage: "person") }.tag(AppTab.profile)
|
|
1346
|
+
}
|
|
1347
|
+
.sheet(isPresented: $tabRouter.showCompose) {
|
|
1348
|
+
ComposeView()
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
---
|
|
1355
|
+
|
|
1356
|
+
## 12. Theming Enforcement
|
|
1357
|
+
|
|
1358
|
+
### 12.1 Semantic Colors via Theme Object
|
|
1359
|
+
|
|
1360
|
+
**Check for:**
|
|
1361
|
+
- [ ] No raw color values (`Color.blue`, `Color.white`, `Color(hex:)`) when a `Theme` object exists
|
|
1362
|
+
- [ ] Colors accessed via `@Environment(Theme.self)` or equivalent design token
|
|
1363
|
+
- [ ] Theme propagated at app root via `.environment(theme)`
|
|
1364
|
+
|
|
1365
|
+
**Examples:**
|
|
1366
|
+
|
|
1367
|
+
❌ **Bad: Raw color values**
|
|
1368
|
+
```swift
|
|
1369
|
+
struct PostRowView: View {
|
|
1370
|
+
let post: Post
|
|
1371
|
+
|
|
1372
|
+
var body: some View {
|
|
1373
|
+
HStack {
|
|
1374
|
+
Text(post.author)
|
|
1375
|
+
.foregroundStyle(Color.gray) // ❌ Raw color
|
|
1376
|
+
Text(post.content)
|
|
1377
|
+
.foregroundStyle(Color.black) // ❌ Raw color
|
|
1378
|
+
Spacer()
|
|
1379
|
+
Image(systemName: "heart")
|
|
1380
|
+
.foregroundStyle(Color.red) // ❌ Raw color
|
|
1381
|
+
}
|
|
1382
|
+
.background(Color.white) // ❌ Raw color
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
✅ **Good: Semantic colors via Theme**
|
|
1388
|
+
```swift
|
|
1389
|
+
struct PostRowView: View {
|
|
1390
|
+
let post: Post
|
|
1391
|
+
@Environment(Theme.self) private var theme // ✅ Theme from environment
|
|
1392
|
+
|
|
1393
|
+
var body: some View {
|
|
1394
|
+
HStack {
|
|
1395
|
+
Text(post.author)
|
|
1396
|
+
.foregroundStyle(theme.labelSecondary) // ✅ Semantic color
|
|
1397
|
+
Text(post.content)
|
|
1398
|
+
.foregroundStyle(theme.labelPrimary) // ✅ Semantic color
|
|
1399
|
+
Spacer()
|
|
1400
|
+
Image(systemName: "heart")
|
|
1401
|
+
.foregroundStyle(theme.tintColor) // ✅ Semantic color
|
|
1402
|
+
}
|
|
1403
|
+
.background(theme.primaryBackground) // ✅ Semantic color
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
// Provide at app root
|
|
1408
|
+
@main
|
|
1409
|
+
struct MyApp: App {
|
|
1410
|
+
@State private var theme = Theme.default
|
|
1411
|
+
|
|
1412
|
+
var body: some Scene {
|
|
1413
|
+
WindowGroup {
|
|
1414
|
+
ContentView()
|
|
1415
|
+
.environment(theme) // ✅ Theme propagated to all views
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
```
|
|
1420
|
+
|
|
1421
|
+
---
|
|
1422
|
+
|
|
1423
|
+
## 13. Async State Patterns
|
|
1424
|
+
|
|
1425
|
+
### 13.1 .task(id:) for Input-Driven Work
|
|
1426
|
+
|
|
1427
|
+
**Check for:**
|
|
1428
|
+
- [ ] `.task(id: someValue)` used instead of `.onChange + Task { }` for input-driven async work
|
|
1429
|
+
- [ ] `CancellationError` silenced (not re-thrown or shown as error to user)
|
|
1430
|
+
- [ ] Debounce implemented inside `.task(id:)` using `Task.sleep` before the actual work
|
|
1431
|
+
|
|
1432
|
+
**Examples:**
|
|
1433
|
+
|
|
1434
|
+
❌ **Bad: .onChange + manual Task**
|
|
1435
|
+
```swift
|
|
1436
|
+
struct SearchView: View {
|
|
1437
|
+
@State private var query = ""
|
|
1438
|
+
@State private var results: [Result] = []
|
|
1439
|
+
|
|
1440
|
+
var body: some View {
|
|
1441
|
+
TextField("Search", text: $query)
|
|
1442
|
+
List(results) { result in ResultRow(result: result) }
|
|
1443
|
+
.onChange(of: query) { _, newValue in
|
|
1444
|
+
Task { // ❌ Manual Task, previous not cancelled properly
|
|
1445
|
+
results = await search(query: newValue)
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
✅ **Good: .task(id:) with built-in cancellation and debounce**
|
|
1453
|
+
```swift
|
|
1454
|
+
struct SearchView: View {
|
|
1455
|
+
@State private var query = ""
|
|
1456
|
+
@State private var results: [SearchResult] = []
|
|
1457
|
+
|
|
1458
|
+
var body: some View {
|
|
1459
|
+
TextField("Search", text: $query)
|
|
1460
|
+
List(results) { result in ResultRow(result: result) }
|
|
1461
|
+
.task(id: query) { // ✅ Auto-cancels previous task when query changes
|
|
1462
|
+
do {
|
|
1463
|
+
try await Task.sleep(for: .milliseconds(300)) // ✅ Debounce inside task
|
|
1464
|
+
results = try await search(query: query)
|
|
1465
|
+
} catch is CancellationError {
|
|
1466
|
+
// ✅ Silenced — expected when task is superseded
|
|
1467
|
+
} catch {
|
|
1468
|
+
// Handle real errors
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
```
|
|
1474
|
+
|
|
1475
|
+
### 13.2 Explicit Loading/Error States
|
|
1476
|
+
|
|
1477
|
+
**Check for:**
|
|
1478
|
+
- [ ] `LoadState<T>` enum (or equivalent) used instead of multiple booleans (`isLoading`, `hasError`, `isEmpty`)
|
|
1479
|
+
- [ ] All states represented: `.idle`, `.loading`, `.loaded(T)`, `.error(Error)`
|
|
1480
|
+
- [ ] View switches on `LoadState` to render appropriate UI
|
|
1481
|
+
|
|
1482
|
+
**Examples:**
|
|
1483
|
+
|
|
1484
|
+
❌ **Bad: Multiple boolean flags**
|
|
1485
|
+
```swift
|
|
1486
|
+
@Observable
|
|
1487
|
+
final class UserListViewModel {
|
|
1488
|
+
var users: [User] = []
|
|
1489
|
+
var isLoading: Bool = false // ❌ Multiple flags
|
|
1490
|
+
var hasError: Bool = false // ❌ Multiple flags
|
|
1491
|
+
var errorMessage: String = "" // ❌ Multiple flags
|
|
1492
|
+
var isEmpty: Bool = false // ❌ Derived, should be computed
|
|
1493
|
+
}
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
✅ **Good: LoadState enum**
|
|
1497
|
+
```swift
|
|
1498
|
+
enum LoadState<T> {
|
|
1499
|
+
case idle
|
|
1500
|
+
case loading
|
|
1501
|
+
case loaded(T)
|
|
1502
|
+
case error(Error)
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
@Observable
|
|
1506
|
+
final class UserListViewModel {
|
|
1507
|
+
var loadState: LoadState<[User]> = .idle // ✅ Single source of truth
|
|
1508
|
+
|
|
1509
|
+
func loadUsers() async {
|
|
1510
|
+
loadState = .loading
|
|
1511
|
+
do {
|
|
1512
|
+
let users = try await userRepository.fetchUsers()
|
|
1513
|
+
loadState = .loaded(users)
|
|
1514
|
+
} catch {
|
|
1515
|
+
loadState = .error(error)
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
struct UserListView: View {
|
|
1521
|
+
let viewModel: UserListViewModel
|
|
1522
|
+
|
|
1523
|
+
var body: some View {
|
|
1524
|
+
Group {
|
|
1525
|
+
switch viewModel.loadState {
|
|
1526
|
+
case .idle: EmptyView()
|
|
1527
|
+
case .loading: ProgressView()
|
|
1528
|
+
case .loaded(let users): UserListContent(users: users)
|
|
1529
|
+
case .error(let error): ErrorView(error: error, retry: { Task { await viewModel.loadUsers() } })
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
.task { await viewModel.loadUsers() }
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
```
|
|
1536
|
+
|
|
1537
|
+
---
|
|
1538
|
+
|
|
1539
|
+
## 14. Focus and Input Patterns
|
|
1540
|
+
|
|
1541
|
+
### 14.1 Focus State Chaining
|
|
1542
|
+
|
|
1543
|
+
**Check for:**
|
|
1544
|
+
- [ ] `FocusField` enum used with `@FocusState` instead of multiple boolean focus states
|
|
1545
|
+
- [ ] `.onSubmit` advances focus to the next field in the sequence
|
|
1546
|
+
- [ ] Last field in chain submits the form or triggers the primary action
|
|
1547
|
+
|
|
1548
|
+
**Examples:**
|
|
1549
|
+
|
|
1550
|
+
❌ **Bad: Multiple boolean focus states**
|
|
1551
|
+
```swift
|
|
1552
|
+
struct LoginFormView: View {
|
|
1553
|
+
@State private var email = ""
|
|
1554
|
+
@State private var password = ""
|
|
1555
|
+
@FocusState private var emailFocused: Bool // ❌ Separate boolean per field
|
|
1556
|
+
@FocusState private var passwordFocused: Bool // ❌ Separate boolean per field
|
|
1557
|
+
|
|
1558
|
+
var body: some View {
|
|
1559
|
+
TextField("Email", text: $email).focused($emailFocused)
|
|
1560
|
+
SecureField("Password", text: $password).focused($passwordFocused)
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
```
|
|
1564
|
+
|
|
1565
|
+
✅ **Good: FocusField enum with chaining**
|
|
1566
|
+
```swift
|
|
1567
|
+
struct LoginFormView: View {
|
|
1568
|
+
@State private var email = ""
|
|
1569
|
+
@State private var password = ""
|
|
1570
|
+
@FocusState private var focusedField: FocusField? // ✅ Single enum for all fields
|
|
1571
|
+
|
|
1572
|
+
enum FocusField {
|
|
1573
|
+
case email, password
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
var body: some View {
|
|
1577
|
+
VStack(spacing: 16) {
|
|
1578
|
+
TextField("Email", text: $email)
|
|
1579
|
+
.focused($focusedField, equals: .email)
|
|
1580
|
+
.textContentType(.emailAddress)
|
|
1581
|
+
.keyboardType(.emailAddress)
|
|
1582
|
+
.submitLabel(.next)
|
|
1583
|
+
.onSubmit { focusedField = .password } // ✅ Chain to next field
|
|
1584
|
+
|
|
1585
|
+
SecureField("Password", text: $password)
|
|
1586
|
+
.focused($focusedField, equals: .password)
|
|
1587
|
+
.textContentType(.password)
|
|
1588
|
+
.submitLabel(.done)
|
|
1589
|
+
.onSubmit { login() } // ✅ Last field submits form
|
|
1590
|
+
|
|
1591
|
+
Button("Log In", action: login)
|
|
1592
|
+
}
|
|
1593
|
+
.onAppear { focusedField = .email } // ✅ Auto-focus first field
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
private func login() {
|
|
1597
|
+
focusedField = nil // ✅ Dismiss keyboard on submit
|
|
1598
|
+
Task { await viewModel.login() }
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
```
|
|
1602
|
+
|
|
1603
|
+
---
|
|
1604
|
+
|
|
877
1605
|
## Quick Reference Checklist
|
|
878
1606
|
|
|
879
1607
|
### Critical Issues
|
|
@@ -888,6 +1616,12 @@ struct LoginView_Previews: PreviewProvider { // ❌ Old pattern
|
|
|
888
1616
|
- [ ] .task instead of .onAppear for async work (iOS 15+)
|
|
889
1617
|
- [ ] Proper property wrapper selection
|
|
890
1618
|
- [ ] View extraction for complex views
|
|
1619
|
+
- [ ] Route destinations as typed Hashable enum (not String/Int raw values)
|
|
1620
|
+
- [ ] RouterPath @Observable owns navigation path (not ad-hoc @State)
|
|
1621
|
+
- [ ] `.sheet(item:)` preferred when model is selected
|
|
1622
|
+
- [ ] Multiple sheets use SheetDestination enum (not multiple booleans)
|
|
1623
|
+
- [ ] Independent RouterPath per tab (not shared path)
|
|
1624
|
+
- [ ] `.task(id:)` for input-driven async work with CancellationError silenced
|
|
891
1625
|
|
|
892
1626
|
### Medium Priority
|
|
893
1627
|
- [ ] Modern .onChange syntax (iOS 17+)
|
|
@@ -895,6 +1629,10 @@ struct LoginView_Previews: PreviewProvider { // ❌ Old pattern
|
|
|
895
1629
|
- [ ] Dynamic Type support
|
|
896
1630
|
- [ ] Equatable conformance for view models
|
|
897
1631
|
- [ ] #Preview macro (iOS 17+)
|
|
1632
|
+
- [ ] Semantic colors via Theme @Environment (no raw Color.blue/Color.white)
|
|
1633
|
+
- [ ] FocusField enum with @FocusState for multi-field forms
|
|
1634
|
+
- [ ] `.onOpenURL` at app root (not in feature views)
|
|
1635
|
+
- [ ] LoadState<T> enum instead of multiple isLoading/hasError booleans
|
|
898
1636
|
|
|
899
1637
|
### Low Priority
|
|
900
1638
|
- [ ] View body < 50 lines
|