Compare commits

..

4 Commits

Author SHA1 Message Date
98e2a1d3f1 fix(ios): iptal edilen isteklerde hata gösterimini düzelt
- API uç noktasını yeni IP adresine güncelle
- Docker yapılandırmasında node_modules için adlandırılmış volume ekle
- Arama işlemlerinde iptal durumlarını yoksay ve hata mesajı gösterme
- NetworkErrorView'da wifi ikonunu kaldır
2026-02-11 22:22:20 +03:00
362b9b7d1b feat(ios): tab tabanlı navigasyon ve okuma durumu takibi ekle 2026-02-11 18:26:17 +03:00
52212f015b feat: ios mobil arayüz tasarımı 2026-02-11 18:07:02 +03:00
261b2f58cc feat: ios mobil arayüz tasarımı 2026-02-11 18:06:35 +03:00
48 changed files with 3067 additions and 1 deletions

View File

@@ -28,7 +28,8 @@ services:
condition: service_healthy
volumes:
- ./:/app
command: npm run dev
- api-node_modules:/app/node_modules
command: sh -c "npm install && npm run dev"
redis:
image: redis:7-alpine
@@ -63,3 +64,4 @@ services:
volumes:
postgres-data:
frontend-node_modules:
api-node_modules:

View File

@@ -0,0 +1,499 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
07D06DDC629661D80D4BB7A2 /* CategoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27AEC6ED0A12CE62E5E7B42F /* CategoryViewModel.swift */; };
0E57D2E3B73CC64C45A13E5A /* AuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86CC67809D9F26DFB7F53280 /* AuthViewModel.swift */; };
1569FBE19975206A58F8E694 /* ReadingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = C94DACFF2F104F68F5E72793 /* ReadingStatus.swift */; };
17B5B7FBC0025E80FA65036F /* ScrewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091D8A6C89ACBCDD9D022FB4 /* ScrewView.swift */; };
1B120941F76A7C9ACC9B82C2 /* CategoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D4C72C39F36045BF4D5ECAF /* CategoryListView.swift */; };
256166E4812B7DF17BD32FD4 /* VisionKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3DDA87F942C70D1FE96ED9C4 /* VisionKit.framework */; };
3DEF8AE33F0E47F9E942FAD6 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12C0E4FA7E8B3F62D198485B /* AuthView.swift */; };
4FC8807E8263C91ADA8FA591 /* ImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 479C2FCDB84B962FF4BF68AE /* ImageCache.swift */; };
546CB89AAC170EE1B541830D /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BC26B431A2E4633F2FC89CA /* HomeView.swift */; };
686D28482BE40BE453EFE0D3 /* mock_book_remote.json in Resources */ = {isa = PBXBuildFile; fileRef = 0E5A7ADF9962345CB78AC571 /* mock_book_remote.json */; };
69CAD3618DD77D79F462C76E /* SwiftData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 87EF451B4A1589BBCB00D7F8 /* SwiftData.framework */; };
6E060ACBDE0AD85FDB2F8010 /* AuthService.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAC488BF2DA3B6B94C2958E8 /* AuthService.swift */; };
6F398B380F29EF5C2C6E1653 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDA9B5DE02839213D93F6A9 /* APIClient.swift */; };
6F59713411606CDC297CF733 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5EABC698DDB8062029E5E9FA /* Foundation.framework */; };
72212EAE36C9151956F84262 /* ShelfSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0887E6542AB83E17C9490AF /* ShelfSectionView.swift */; };
76CDBAECDA13604B0A3EC338 /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 523E0F614C3E08527D265ADC /* BookDetailView.swift */; };
7FC47B691C19F4E6C1102606 /* BookCoverCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E0C3AF58827D6FBF93157B7 /* BookCoverCard.swift */; };
8287AA6A6111C8E14BA92E81 /* BarcodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BEE917DB0A13B5EA34D030 /* BarcodeScannerView.swift */; };
865A3EBCDA84FBA692A31939 /* EmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A850CCC78FC41E21559CE03A /* EmptyStateView.swift */; };
8707C5BBD8347B96B1E4DFB8 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0563C287DF8A317E7F6D4A22 /* HomeViewModel.swift */; };
919B2721944E5A9DF8F696AF /* KeychainStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF7DCABD0AFF317C4C25C1D3 /* KeychainStore.swift */; };
9296F2BCE6EB7FFAC4F6A5B1 /* NetworkErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 026D1C09514A01EB0D4C7641 /* NetworkErrorView.swift */; };
971C5D3C6576578B4B4B8CBB /* BooksService.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1F2AEEF3846E89EF58E8E2B /* BooksService.swift */; };
97FD0730276CEBBE6BDBCCA1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = B3CA8360A84C636945C83DD0 /* Localizable.strings */; };
9C71E8718C4820EFD2F50C17 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0A47804190A193B8EE3159EA /* Assets.xcassets */; };
A597F6A4E13688A21BD3FEAE /* LibraryBook.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFB981066E192B332E6BB5E2 /* LibraryBook.swift */; };
A6E600A345801C9AF1CB1F10 /* AddBooksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E960D3986FCFF358D1CC83 /* AddBooksView.swift */; };
AE16C52E1D5A765D0723F243 /* AppRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4AC4F542A93E316D86251043 /* AppRouter.swift */; };
B1E7E3F062B3B01ED5EB7019 /* BlurFogOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = B3448927BF4293DFAF113048 /* BlurFogOverlay.swift */; };
B37F57B2BB478E72702A551E /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9547DCE4663825EA1267F3C /* UserProfile.swift */; };
C59A615F50B6A23903E02280 /* BookCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 216DFB59A593E4AD349FCE0F /* BookCardView.swift */; };
CE6FC5E5744AA2A05C3B6049 /* BookRemote.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3D6631E1FD7160883FC31F4 /* BookRemote.swift */; };
D9D5AD82B32F16BD92E82238 /* PrimaryPillButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D60CE4B6FDA59B1114B8168 /* PrimaryPillButton.swift */; };
DC0602EC768B6265564FE8FD /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 395E4C35E6D3C362B550AF3B /* Localizable.strings */; };
E0FC3411B06A5726172021BE /* AddBooksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40AD40B111A582310571F11E /* AddBooksViewModel.swift */; };
E318F3175C8B01CEA69F50C9 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67C4E14D093F318ECAB9FA6A /* Theme.swift */; };
EB4366DC44468E12BCE5177D /* BookibraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8F25BA817C2459E1751520C /* BookibraApp.swift */; };
ECDD56903097A589E63C7D64 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09E9BD1CDD8189A9BD8F93C0 /* BookDetailViewModel.swift */; };
F3E86067A1062297B95CE2CA /* AVFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF373FA01D444E33C45D1B74 /* AVFoundation.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
026D1C09514A01EB0D4C7641 /* NetworkErrorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NetworkErrorView.swift; path = DesignSystem/Components/NetworkErrorView.swift; sourceTree = "<group>"; };
0563C287DF8A317E7F6D4A22 /* HomeViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HomeViewModel.swift; path = ViewModels/HomeViewModel.swift; sourceTree = "<group>"; };
06BEE917DB0A13B5EA34D030 /* BarcodeScannerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BarcodeScannerView.swift; path = Views/AddBooks/BarcodeScannerView.swift; sourceTree = "<group>"; };
091D8A6C89ACBCDD9D022FB4 /* ScrewView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ScrewView.swift; path = DesignSystem/Components/ScrewView.swift; sourceTree = "<group>"; };
09E9BD1CDD8189A9BD8F93C0 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BookDetailViewModel.swift; path = ViewModels/BookDetailViewModel.swift; sourceTree = "<group>"; };
0A47804190A193B8EE3159EA /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Resources/Assets.xcassets; sourceTree = "<group>"; };
0E5A7ADF9962345CB78AC571 /* mock_book_remote.json */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.json; name = mock_book_remote.json; path = Resources/mock_book_remote.json; sourceTree = "<group>"; };
12C0E4FA7E8B3F62D198485B /* AuthView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AuthView.swift; path = Views/Auth/AuthView.swift; sourceTree = "<group>"; };
216DFB59A593E4AD349FCE0F /* BookCardView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BookCardView.swift; path = DesignSystem/Components/BookCardView.swift; sourceTree = "<group>"; };
27AEC6ED0A12CE62E5E7B42F /* CategoryViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CategoryViewModel.swift; path = ViewModels/CategoryViewModel.swift; sourceTree = "<group>"; };
2D4C72C39F36045BF4D5ECAF /* CategoryListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CategoryListView.swift; path = Views/Category/CategoryListView.swift; sourceTree = "<group>"; };
395E4C35E6D3C362B550AF3B /* Localizable.strings */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = Resources/tr.lproj/Localizable.strings; sourceTree = "<group>"; };
3BC26B431A2E4633F2FC89CA /* HomeView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HomeView.swift; path = Views/Home/HomeView.swift; sourceTree = "<group>"; };
3D60CE4B6FDA59B1114B8168 /* PrimaryPillButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrimaryPillButton.swift; path = DesignSystem/Components/PrimaryPillButton.swift; sourceTree = "<group>"; };
3DDA87F942C70D1FE96ED9C4 /* VisionKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = VisionKit.framework; path = System/Library/Frameworks/VisionKit.framework; sourceTree = "<group>"; };
40AD40B111A582310571F11E /* AddBooksViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AddBooksViewModel.swift; path = ViewModels/AddBooksViewModel.swift; sourceTree = "<group>"; };
46E960D3986FCFF358D1CC83 /* AddBooksView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AddBooksView.swift; path = Views/AddBooks/AddBooksView.swift; sourceTree = "<group>"; };
479C2FCDB84B962FF4BF68AE /* ImageCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ImageCache.swift; path = Services/ImageCache.swift; sourceTree = "<group>"; };
4AC4F542A93E316D86251043 /* AppRouter.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppRouter.swift; path = App/AppRouter.swift; sourceTree = "<group>"; };
4E0C3AF58827D6FBF93157B7 /* BookCoverCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BookCoverCard.swift; path = DesignSystem/Components/BookCoverCard.swift; sourceTree = "<group>"; };
4EDA9B5DE02839213D93F6A9 /* APIClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = APIClient.swift; path = Services/APIClient.swift; sourceTree = "<group>"; };
523E0F614C3E08527D265ADC /* BookDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BookDetailView.swift; path = Views/Detail/BookDetailView.swift; sourceTree = "<group>"; };
5EABC698DDB8062029E5E9FA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
67C4E14D093F318ECAB9FA6A /* Theme.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Theme.swift; path = DesignSystem/Theme.swift; sourceTree = "<group>"; };
86CC67809D9F26DFB7F53280 /* AuthViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AuthViewModel.swift; path = ViewModels/AuthViewModel.swift; sourceTree = "<group>"; };
87EF451B4A1589BBCB00D7F8 /* SwiftData.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftData.framework; path = System/Library/Frameworks/SwiftData.framework; sourceTree = "<group>"; };
9D96BF3D1EBDBB9B18FE826D /* Bookibra.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Bookibra.app; sourceTree = BUILT_PRODUCTS_DIR; };
A850CCC78FC41E21559CE03A /* EmptyStateView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = EmptyStateView.swift; path = DesignSystem/Components/EmptyStateView.swift; sourceTree = "<group>"; };
A8F25BA817C2459E1751520C /* BookibraApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BookibraApp.swift; path = App/BookibraApp.swift; sourceTree = "<group>"; };
B3448927BF4293DFAF113048 /* BlurFogOverlay.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BlurFogOverlay.swift; path = DesignSystem/Components/BlurFogOverlay.swift; sourceTree = "<group>"; };
B3CA8360A84C636945C83DD0 /* Localizable.strings */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.strings; name = Localizable.strings; path = Resources/en.lproj/Localizable.strings; sourceTree = "<group>"; };
B44591FBA4BB2AEA612510DD /* Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Bookibra/Resources/Debug.xcconfig; sourceTree = "<group>"; };
C0887E6542AB83E17C9490AF /* ShelfSectionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ShelfSectionView.swift; path = DesignSystem/Components/ShelfSectionView.swift; sourceTree = "<group>"; };
C94DACFF2F104F68F5E72793 /* ReadingStatus.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ReadingStatus.swift; path = Models/ReadingStatus.swift; sourceTree = "<group>"; };
CAC488BF2DA3B6B94C2958E8 /* AuthService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AuthService.swift; path = Services/AuthService.swift; sourceTree = "<group>"; };
D1F2AEEF3846E89EF58E8E2B /* BooksService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BooksService.swift; path = Services/BooksService.swift; sourceTree = "<group>"; };
D9547DCE4663825EA1267F3C /* UserProfile.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = UserProfile.swift; path = Models/UserProfile.swift; sourceTree = "<group>"; };
DF373FA01D444E33C45D1B74 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = "<group>"; };
E3D6631E1FD7160883FC31F4 /* BookRemote.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = BookRemote.swift; path = Models/BookRemote.swift; sourceTree = "<group>"; };
FDC0CE87CC54E1384BD6557B /* Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Bookibra/Resources/Release.xcconfig; sourceTree = "<group>"; };
FF7DCABD0AFF317C4C25C1D3 /* KeychainStore.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = KeychainStore.swift; path = Services/KeychainStore.swift; sourceTree = "<group>"; };
FFB981066E192B332E6BB5E2 /* LibraryBook.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LibraryBook.swift; path = Models/LibraryBook.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
E88BA262CD3F12CEBF38184A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
6F59713411606CDC297CF733 /* Foundation.framework in Frameworks */,
F3E86067A1062297B95CE2CA /* AVFoundation.framework in Frameworks */,
256166E4812B7DF17BD32FD4 /* VisionKit.framework in Frameworks */,
69CAD3618DD77D79F462C76E /* SwiftData.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1F97BBDD9672559E121C4FFA /* Frameworks */ = {
isa = PBXGroup;
children = (
71736683D1BB2B9A0FF730B9 /* iOS */,
DF373FA01D444E33C45D1B74 /* AVFoundation.framework */,
3DDA87F942C70D1FE96ED9C4 /* VisionKit.framework */,
87EF451B4A1589BBCB00D7F8 /* SwiftData.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
3D7744C35B489BFB545D42AD /* Products */ = {
isa = PBXGroup;
children = (
9D96BF3D1EBDBB9B18FE826D /* Bookibra.app */,
);
name = Products;
sourceTree = "<group>";
};
41E096AA37EBB337950112DD /* Bookibra */ = {
isa = PBXGroup;
children = (
4AC4F542A93E316D86251043 /* AppRouter.swift */,
A8F25BA817C2459E1751520C /* BookibraApp.swift */,
B3448927BF4293DFAF113048 /* BlurFogOverlay.swift */,
216DFB59A593E4AD349FCE0F /* BookCardView.swift */,
4E0C3AF58827D6FBF93157B7 /* BookCoverCard.swift */,
A850CCC78FC41E21559CE03A /* EmptyStateView.swift */,
026D1C09514A01EB0D4C7641 /* NetworkErrorView.swift */,
3D60CE4B6FDA59B1114B8168 /* PrimaryPillButton.swift */,
091D8A6C89ACBCDD9D022FB4 /* ScrewView.swift */,
C0887E6542AB83E17C9490AF /* ShelfSectionView.swift */,
67C4E14D093F318ECAB9FA6A /* Theme.swift */,
E3D6631E1FD7160883FC31F4 /* BookRemote.swift */,
FFB981066E192B332E6BB5E2 /* LibraryBook.swift */,
C94DACFF2F104F68F5E72793 /* ReadingStatus.swift */,
D9547DCE4663825EA1267F3C /* UserProfile.swift */,
4EDA9B5DE02839213D93F6A9 /* APIClient.swift */,
CAC488BF2DA3B6B94C2958E8 /* AuthService.swift */,
D1F2AEEF3846E89EF58E8E2B /* BooksService.swift */,
479C2FCDB84B962FF4BF68AE /* ImageCache.swift */,
FF7DCABD0AFF317C4C25C1D3 /* KeychainStore.swift */,
40AD40B111A582310571F11E /* AddBooksViewModel.swift */,
86CC67809D9F26DFB7F53280 /* AuthViewModel.swift */,
09E9BD1CDD8189A9BD8F93C0 /* BookDetailViewModel.swift */,
27AEC6ED0A12CE62E5E7B42F /* CategoryViewModel.swift */,
0563C287DF8A317E7F6D4A22 /* HomeViewModel.swift */,
46E960D3986FCFF358D1CC83 /* AddBooksView.swift */,
06BEE917DB0A13B5EA34D030 /* BarcodeScannerView.swift */,
12C0E4FA7E8B3F62D198485B /* AuthView.swift */,
2D4C72C39F36045BF4D5ECAF /* CategoryListView.swift */,
523E0F614C3E08527D265ADC /* BookDetailView.swift */,
3BC26B431A2E4633F2FC89CA /* HomeView.swift */,
0A47804190A193B8EE3159EA /* Assets.xcassets */,
B3CA8360A84C636945C83DD0 /* Localizable.strings */,
395E4C35E6D3C362B550AF3B /* Localizable.strings */,
0E5A7ADF9962345CB78AC571 /* mock_book_remote.json */,
);
path = Bookibra;
sourceTree = "<group>";
};
71736683D1BB2B9A0FF730B9 /* iOS */ = {
isa = PBXGroup;
children = (
5EABC698DDB8062029E5E9FA /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
FC7D15935DED2E50DD94B7D5 = {
isa = PBXGroup;
children = (
3D7744C35B489BFB545D42AD /* Products */,
1F97BBDD9672559E121C4FFA /* Frameworks */,
B44591FBA4BB2AEA612510DD /* Debug.xcconfig */,
FDC0CE87CC54E1384BD6557B /* Release.xcconfig */,
41E096AA37EBB337950112DD /* Bookibra */,
);
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
0E74DD6C4F903703E3E4BCAD /* Bookibra */ = {
isa = PBXNativeTarget;
buildConfigurationList = 27C2348C30A81863879653CA /* Build configuration list for PBXNativeTarget "Bookibra" */;
buildPhases = (
D93C53F09F67A433B3053B5D /* Sources */,
E88BA262CD3F12CEBF38184A /* Frameworks */,
4ED22AFE6C5FD2EAFD12AE94 /* Resources */,
);
buildRules = (
);
dependencies = (
);
name = Bookibra;
productName = Bookibra;
productReference = 9D96BF3D1EBDBB9B18FE826D /* Bookibra.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
4A57E4BC2562874685F10390 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 2620;
};
buildConfigurationList = 66162088EAD405EAB915FA6C /* Build configuration list for PBXProject "Bookibra" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = FC7D15935DED2E50DD94B7D5;
productRefGroup = 3D7744C35B489BFB545D42AD /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
0E74DD6C4F903703E3E4BCAD /* Bookibra */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
4ED22AFE6C5FD2EAFD12AE94 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
9C71E8718C4820EFD2F50C17 /* Assets.xcassets in Resources */,
97FD0730276CEBBE6BDBCCA1 /* Localizable.strings in Resources */,
DC0602EC768B6265564FE8FD /* Localizable.strings in Resources */,
686D28482BE40BE453EFE0D3 /* mock_book_remote.json in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
D93C53F09F67A433B3053B5D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AE16C52E1D5A765D0723F243 /* AppRouter.swift in Sources */,
EB4366DC44468E12BCE5177D /* BookibraApp.swift in Sources */,
B1E7E3F062B3B01ED5EB7019 /* BlurFogOverlay.swift in Sources */,
C59A615F50B6A23903E02280 /* BookCardView.swift in Sources */,
7FC47B691C19F4E6C1102606 /* BookCoverCard.swift in Sources */,
865A3EBCDA84FBA692A31939 /* EmptyStateView.swift in Sources */,
9296F2BCE6EB7FFAC4F6A5B1 /* NetworkErrorView.swift in Sources */,
D9D5AD82B32F16BD92E82238 /* PrimaryPillButton.swift in Sources */,
17B5B7FBC0025E80FA65036F /* ScrewView.swift in Sources */,
72212EAE36C9151956F84262 /* ShelfSectionView.swift in Sources */,
E318F3175C8B01CEA69F50C9 /* Theme.swift in Sources */,
CE6FC5E5744AA2A05C3B6049 /* BookRemote.swift in Sources */,
A597F6A4E13688A21BD3FEAE /* LibraryBook.swift in Sources */,
1569FBE19975206A58F8E694 /* ReadingStatus.swift in Sources */,
B37F57B2BB478E72702A551E /* UserProfile.swift in Sources */,
6F398B380F29EF5C2C6E1653 /* APIClient.swift in Sources */,
6E060ACBDE0AD85FDB2F8010 /* AuthService.swift in Sources */,
971C5D3C6576578B4B4B8CBB /* BooksService.swift in Sources */,
4FC8807E8263C91ADA8FA591 /* ImageCache.swift in Sources */,
919B2721944E5A9DF8F696AF /* KeychainStore.swift in Sources */,
E0FC3411B06A5726172021BE /* AddBooksViewModel.swift in Sources */,
0E57D2E3B73CC64C45A13E5A /* AuthViewModel.swift in Sources */,
ECDD56903097A589E63C7D64 /* BookDetailViewModel.swift in Sources */,
07D06DDC629661D80D4BB7A2 /* CategoryViewModel.swift in Sources */,
8707C5BBD8347B96B1E4DFB8 /* HomeViewModel.swift in Sources */,
A6E600A345801C9AF1CB1F10 /* AddBooksView.swift in Sources */,
8287AA6A6111C8E14BA92E81 /* BarcodeScannerView.swift in Sources */,
3DEF8AE33F0E47F9E942FAD6 /* AuthView.swift in Sources */,
1B120941F76A7C9ACC9B82C2 /* CategoryListView.swift in Sources */,
76CDBAECDA13604B0A3EC338 /* BookDetailView.swift in Sources */,
546CB89AAC170EE1B541830D /* HomeView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
53E3CF14C3FC84D36F571AEE /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = FDC0CE87CC54E1384BD6557B /* Release.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.bookibra.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
883CA3D20F5C26D151A2C437 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = FDC0CE87CC54E1384BD6557B /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = NO;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Bookibra/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.bookibra.ios;
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
CDAF5161EEF6E3EE85E10530 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B44591FBA4BB2AEA612510DD /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CLANG_ENABLE_OBJC_WEAK = NO;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = Bookibra/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.bookibra.ios;
SDKROOT = iphoneos;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
F6310A668DF5F079EAD0B12C /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B44591FBA4BB2AEA612510DD /* Debug.xcconfig */;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
MARKETING_VERSION = 1.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_BUNDLE_IDENTIFIER = com.bookibra.ios;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.9;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
27C2348C30A81863879653CA /* Build configuration list for PBXNativeTarget "Bookibra" */ = {
isa = XCConfigurationList;
buildConfigurations = (
883CA3D20F5C26D151A2C437 /* Release */,
CDAF5161EEF6E3EE85E10530 /* Debug */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
66162088EAD405EAB915FA6C /* Build configuration list for PBXProject "Bookibra" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F6310A668DF5F079EAD0B12C /* Debug */,
53E3CF14C3FC84D36F571AEE /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 4A57E4BC2562874685F10390 /* Project object */;
}

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-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>SchemeUserState</key>
<dict>
<key>Bookibra.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-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>SchemeUserState</key>
<dict>
<key>Bookibra.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View File

@@ -0,0 +1,61 @@
import Foundation
import SwiftUI
@MainActor
final class AppRouter: ObservableObject {
enum Tab: Hashable {
case library
case discover
case stats
case profile
}
enum Route: Hashable {
case category(name: String)
case detail(BookRemote)
}
@Published var isAuthenticated = false
@Published var selectedTab: Tab = .library
@Published var path: [Route] = []
func resetToHome() {
path.removeAll()
selectedTab = .library
}
}
struct AppDependencies {
let apiClient: APIClientProtocol
let authService: AuthServiceProtocol
let booksService: BooksServiceProtocol
let keychain: KeychainStoreProtocol
let imageCache: ImageCacheProtocol
static func live() -> AppDependencies {
let config = URLSessionConfiguration.default
config.waitsForConnectivity = false
config.timeoutIntervalForRequest = 8
config.timeoutIntervalForResource = 15
let session = URLSession(configuration: config)
let client = APIClient(baseURL: Bundle.main.apiBaseURL, session: session)
return AppDependencies(
apiClient: client,
authService: AuthService(client: client),
booksService: BooksService(client: client),
keychain: KeychainStore(),
imageCache: ImageCache.shared
)
}
}
private struct DependenciesKey: EnvironmentKey {
static let defaultValue = AppDependencies.live()
}
extension EnvironmentValues {
var dependencies: AppDependencies {
get { self[DependenciesKey.self] }
set { self[DependenciesKey.self] = newValue }
}
}

View File

@@ -0,0 +1,133 @@
import SwiftUI
import SwiftData
@main
struct BookibraApp: App {
@StateObject private var router = AppRouter()
private let dependencies = AppDependencies.live()
private let container: ModelContainer
init() {
do {
container = try ModelContainer(for: LibraryBook.self)
} catch {
fatalError("SwiftData container oluşturulamadı: \(error)")
}
}
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(router)
.environment(\.dependencies, dependencies)
.modelContainer(container)
}
}
}
private struct RootView: View {
@EnvironmentObject private var router: AppRouter
@Environment(\.dependencies) private var dependencies
var body: some View {
NavigationStack(path: $router.path) {
Group {
if router.isAuthenticated {
MainTabView()
} else {
AuthView(viewModel: AuthViewModel(authService: dependencies.authService, keychain: dependencies.keychain))
}
}
.navigationDestination(for: AppRouter.Route.self) { route in
switch route {
case .category(let name):
CategoryListView(viewModel: CategoryViewModel(categoryName: name))
case .detail(let book):
BookDetailView(viewModel: BookDetailViewModel(book: book))
}
}
}
.task {
await bootstrapSession()
}
}
@MainActor
private func bootstrapSession() async {
let token = dependencies.keychain.read(for: AuthViewModel.tokenKey)
guard let token, !token.isEmpty else {
router.isAuthenticated = false
return
}
do {
_ = try await dependencies.authService.profile(token: token)
router.isAuthenticated = true
} catch {
router.isAuthenticated = false
}
}
}
private struct MainTabView: View {
@EnvironmentObject private var router: AppRouter
@Environment(\.dependencies) private var dependencies
var body: some View {
TabView(selection: $router.selectedTab) {
HomeView(viewModel: HomeViewModel())
.tabItem {
Label("Library", systemImage: "books.vertical")
}
.tag(AppRouter.Tab.library)
AddBooksView(viewModel: AddBooksViewModel(booksService: dependencies.booksService))
.tabItem {
Label("Discover", systemImage: "sparkle.magnifyingglass")
}
.tag(AppRouter.Tab.discover)
StatsPlaceholderView()
.tabItem {
Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
}
.tag(AppRouter.Tab.stats)
ProfilePlaceholderView()
.tabItem {
Label("Profile", systemImage: "person.crop.circle")
}
.tag(AppRouter.Tab.profile)
}
}
}
private struct StatsPlaceholderView: View {
var body: some View {
ContentUnavailableView {
Label("Stats", systemImage: "chart.bar.xaxis")
} description: {
Text("Reading analytics yakında eklenecek.")
}
}
}
private struct ProfilePlaceholderView: View {
@EnvironmentObject private var router: AppRouter
@Environment(\.dependencies) private var dependencies
var body: some View {
NavigationStack {
List {
Section("Session") {
Button("Logout", role: .destructive) {
_ = dependencies.keychain.delete(for: AuthViewModel.tokenKey)
router.isAuthenticated = false
router.resetToHome()
}
}
}
.navigationTitle("Profile")
}
}
}

View File

@@ -0,0 +1,17 @@
import SwiftUI
struct BlurFogOverlay: View {
var body: some View {
Rectangle()
.fill(
LinearGradient(
colors: [Color.clear, Theme.background.opacity(0.82), Theme.background],
startPoint: .top,
endPoint: .bottom
)
)
.background(.ultraThinMaterial)
.ignoresSafeArea(edges: .bottom)
.allowsHitTesting(false)
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
struct BookCardView: View {
let title: String
let author: String
let coverURL: URL?
let status: ReadingStatus
let progress: Double?
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.small) {
AsyncImage(url: coverURL) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFill()
default:
ZStack {
RoundedRectangle(cornerRadius: Theme.Radius.image)
.fill(Color.gray.opacity(0.22))
Image(systemName: "book.closed")
.font(.title2)
.foregroundStyle(.secondary)
}
}
}
.frame(height: 170)
.clipShape(RoundedRectangle(cornerRadius: Theme.Radius.image, style: .continuous))
Text(title)
.font(.headline)
.lineLimit(2)
.minimumScaleFactor(0.85)
Text(author.isEmpty ? "Unknown Author" : author)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: Theme.Spacing.xSmall) {
Image(systemName: status.symbol)
Text(status.title)
.lineLimit(1)
}
.font(.caption.weight(.semibold))
.padding(.horizontal, 8)
.padding(.vertical, 5)
.foregroundStyle(status.color)
.background(status.color.opacity(0.14), in: Capsule())
if let progress, status == .reading {
ProgressView(value: progress)
.tint(status.color)
.animation(.easeOut(duration: 0.35), value: progress)
.accessibilityLabel("Reading progress")
.accessibilityValue("\(Int(progress * 100)) percent")
}
}
.padding(Theme.Spacing.medium)
.background(.background, in: RoundedRectangle(cornerRadius: Theme.Radius.card, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: Theme.Radius.card, style: .continuous)
.stroke(Color.primary.opacity(0.06), lineWidth: 1)
}
.shadow(color: Theme.Shadow.card.color, radius: Theme.Shadow.card.radius, y: Theme.Shadow.card.y)
.accessibilityElement(children: .combine)
.accessibilityLabel("\(title), \(author), \(status.title)")
}
}

View File

@@ -0,0 +1,57 @@
import SwiftUI
struct BookCoverCard: View {
let book: BookRemote
let imageCache: ImageCacheProtocol
var body: some View {
VStack(spacing: 6) {
RemoteImageView(url: book.coverImageUrl, imageCache: imageCache)
.frame(width: 98, height: 145)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 10)
.stroke(Color.black.opacity(0.08), lineWidth: 1)
}
.shadow(color: .black.opacity(0.16), radius: 6, y: 4)
Text(book.title)
.font(.caption)
.lineLimit(1)
.foregroundStyle(.primary)
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(book.title), \(book.authors.joined(separator: ", "))")
}
}
private struct RemoteImageView: View {
let url: URL?
let imageCache: ImageCacheProtocol
@State private var image: UIImage?
var body: some View {
ZStack {
RoundedRectangle(cornerRadius: 10)
.fill(LinearGradient(colors: [Color.gray.opacity(0.22), Color.gray.opacity(0.35)], startPoint: .top, endPoint: .bottom))
if let image {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
Image(systemName: "book.closed")
.font(.title3)
.foregroundStyle(Color.black.opacity(0.35))
}
}
.task(id: url) {
guard let url else { return }
do {
image = try await imageCache.image(for: url)
} catch {
image = nil
}
}
}
}

View File

@@ -0,0 +1,34 @@
import SwiftUI
struct EmptyStateView: View {
let symbol: String
let title: String
let message: String
let buttonTitle: String?
let action: (() -> Void)?
var body: some View {
VStack(spacing: Theme.Spacing.medium) {
Image(systemName: symbol)
.font(.system(size: 44, weight: .semibold))
.foregroundStyle(.secondary)
Text(title)
.font(.title3.weight(.semibold))
Text(message)
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if let buttonTitle, let action {
Button(buttonTitle, action: action)
.buttonStyle(.borderedProminent)
}
}
.padding(24)
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
.padding(.horizontal, 20)
}
}

View File

@@ -0,0 +1,19 @@
import SwiftUI
struct NetworkErrorView: View {
let message: String
let retryAction: () -> Void
var body: some View {
VStack(spacing: 12) {
Text(message)
.font(.subheadline)
.multilineTextAlignment(.center)
Button(String(localized: "common.retry"), action: retryAction)
.buttonStyle(.borderedProminent)
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
}
}

View File

@@ -0,0 +1,21 @@
import SwiftUI
struct PrimaryPillButton: View {
let title: String
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.contentShape(Rectangle())
}
.background(Color.black)
.clipShape(Capsule())
.shadow(color: .black.opacity(0.28), radius: 18, y: 8)
.accessibilityLabel(title)
}
}

View File

@@ -0,0 +1,14 @@
import SwiftUI
struct ScrewView: View {
var body: some View {
ZStack {
Circle()
.fill(Color.black.opacity(0.25))
Circle()
.stroke(Color.white.opacity(0.5), lineWidth: 1)
.padding(1)
}
.frame(width: 9, height: 9)
}
}

View File

@@ -0,0 +1,85 @@
import SwiftUI
struct ShelfSectionView: View {
let title: String
let books: [BookRemote]
let gradient: LinearGradient
let imageCache: ImageCacheProtocol
let onTapCategory: () -> Void
let onTapBook: (BookRemote) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Button(action: onTapCategory) {
Text(title)
.font(.title3.weight(.bold))
.foregroundStyle(Theme.textPrimary)
}
Spacer()
Text("\(books.count) books")
.font(.caption)
.foregroundStyle(Theme.textSecondary)
Image(systemName: "chevron.left")
.font(.caption2)
.foregroundStyle(.gray)
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.gray)
}
ZStack(alignment: .bottom) {
RoundedRectangle(cornerRadius: 16)
.fill(gradient)
.frame(height: 74)
.shadow(color: .black.opacity(0.12), radius: 10, y: 8)
.overlay {
HStack {
ScrewView()
Spacer()
ScrewView()
}
.padding(.horizontal, 12)
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
if books.isEmpty {
ghostCovers
} else {
ForEach(books) { book in
Button {
onTapBook(book)
} label: {
BookCoverCard(book: book, imageCache: imageCache)
}
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 18)
}
.frame(height: 190)
}
}
.padding(.horizontal, 20)
}
private var ghostCovers: some View {
HStack(spacing: 10) {
ForEach(0..<3, id: \.self) { _ in
RoundedRectangle(cornerRadius: 10)
.fill(Color.white.opacity(0.5))
.frame(width: 92, height: 140)
.overlay {
Image(systemName: "book")
.foregroundStyle(.white.opacity(0.8))
}
}
Text(String(localized: "home.noBooks"))
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 130, alignment: .leading)
}
}
}

View File

@@ -0,0 +1,46 @@
import SwiftUI
enum Theme {
static let background = Color(red: 0.97, green: 0.96, blue: 0.94)
static let textPrimary = Color.black
static let textSecondary = Color.gray
static let designShelf = LinearGradient(
colors: [Color(red: 0.87, green: 0.66, blue: 0.45), Color(red: 0.79, green: 0.55, blue: 0.34)],
startPoint: .top,
endPoint: .bottom
)
static let psychologyShelf = LinearGradient(
colors: [Color(red: 0.58, green: 0.67, blue: 0.78), Color(red: 0.45, green: 0.55, blue: 0.66)],
startPoint: .top,
endPoint: .bottom
)
static let novelsShelf = LinearGradient(
colors: [Color.white, Color(red: 0.9, green: 0.9, blue: 0.9)],
startPoint: .top,
endPoint: .bottom
)
static func headerSerif(size: CGFloat) -> Font {
.custom("NewYork-Regular", size: size, relativeTo: .largeTitle)
}
enum Spacing {
static let xSmall: CGFloat = 6
static let small: CGFloat = 10
static let medium: CGFloat = 16
static let large: CGFloat = 24
}
enum Radius {
static let card: CGFloat = 14
static let image: CGFloat = 10
static let pill: CGFloat = 999
}
enum Shadow {
static let card = (color: Color.black.opacity(0.14), radius: CGFloat(8), y: CGFloat(4))
}
}

View File

@@ -0,0 +1,141 @@
import Foundation
struct BookRemote: Codable, Identifiable, Hashable {
var id: String { isbn13 ?? isbn10 ?? title + (authors.first ?? "") }
let remoteId: String?
let title: String
let authors: [String]
let publishedYear: Int?
let isbn10: String?
let isbn13: String?
let coverImageUrl: URL?
let language: String?
let description: String?
let pageCount: Int?
let categories: [String]
let publisher: String?
let sourceLocale: String?
let readingStatus: ReadingStatus?
let readingProgress: Double?
init(
remoteId: String? = nil,
title: String,
authors: [String] = [],
publishedYear: Int? = nil,
isbn10: String? = nil,
isbn13: String? = nil,
coverImageUrl: URL? = nil,
language: String? = nil,
description: String? = nil,
pageCount: Int? = nil,
categories: [String] = [],
publisher: String? = nil,
sourceLocale: String? = nil,
readingStatus: ReadingStatus? = nil,
readingProgress: Double? = nil
) {
self.remoteId = remoteId
self.title = title
self.authors = authors
self.publishedYear = publishedYear
self.isbn10 = isbn10
self.isbn13 = isbn13
self.coverImageUrl = coverImageUrl
self.language = language
self.description = description
self.pageCount = pageCount
self.categories = categories
self.publisher = publisher
self.sourceLocale = sourceLocale
self.readingStatus = readingStatus
self.readingProgress = readingProgress
}
}
struct BookSearchResponse: Decodable {
let items: [BookRemote]
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Endpoint 1: /isbn returns { data: { tr: {...}, en: {...} } }
if let localeMap = try? container.decode([String: RawBook].self, forKey: .data) {
self.items = localeMap.map { locale, raw in raw.toBook(locale: locale) }
return
}
// Endpoint 2/3: /title and /filter returns { data: [{ locale, items: [...] }] }
if let groups = try? container.decode([LocaleGroup].self, forKey: .data) {
self.items = groups.flatMap { group in
group.items.map { $0.toBook(locale: group.locale) }
}
return
}
self.items = []
}
private enum CodingKeys: String, CodingKey {
case data
}
}
private struct LocaleGroup: Decodable {
let locale: String
let items: [RawBook]
}
private struct RawBook: Decodable {
let asin: String?
let title: String?
let authorName: String?
let author: String?
let isbn: String?
let thumbImage: String?
let image: String?
let date: String?
let publisher: String?
let page: Int?
let description: String?
let categories: [String]?
let locale: String?
func toBook(locale: String?) -> BookRemote {
let coverRaw = thumbImage ?? image
let authorsText = authorName ?? author ?? ""
let authors = authorsText
.replacingOccurrences(of: "[Yazar]", with: "")
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
let isbnClean = isbn?.filter { $0.isNumber || $0.uppercased() == "X" }
let isbn10 = isbnClean?.count == 10 ? isbnClean : nil
let isbn13 = isbnClean?.count == 13 ? isbnClean : nil
return BookRemote(
remoteId: asin,
title: title ?? "Untitled",
authors: authors,
publishedYear: Self.extractYear(from: date),
isbn10: isbn10,
isbn13: isbn13,
coverImageUrl: coverRaw.flatMap(URL.init(string:)),
language: locale,
description: description,
pageCount: page,
categories: categories ?? [],
publisher: publisher,
sourceLocale: locale
)
}
private static func extractYear(from value: String?) -> Int? {
guard let value else { return nil }
let digits = value.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
guard digits.count >= 4 else { return nil }
return Int(String(digits.prefix(4)))
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
import SwiftData
@Model
final class LibraryBook {
@Attribute(.unique) var localId: UUID
var title: String
var authorsString: String
var coverUrlString: String?
var isbn10: String?
var isbn13: String?
var publishedYear: Int?
var categoriesString: String
var summary: String?
var dateAdded: Date
var language: String?
var sourceLocale: String?
var remotePayloadJson: String?
// Optional tutulur ki eski store'lar migration sırasında kırılmasın.
var statusRaw: String?
var readingProgress: Double?
init(
localId: UUID = UUID(),
title: String,
authorsString: String,
coverUrlString: String? = nil,
isbn10: String? = nil,
isbn13: String? = nil,
publishedYear: Int? = nil,
categoriesString: String = "",
summary: String? = nil,
dateAdded: Date = .now,
language: String? = nil,
sourceLocale: String? = nil,
remotePayloadJson: String? = nil,
statusRaw: String = ReadingStatus.wantToRead.rawValue,
readingProgress: Double = 0
) {
self.localId = localId
self.title = title
self.authorsString = authorsString
self.coverUrlString = coverUrlString
self.isbn10 = isbn10
self.isbn13 = isbn13
self.publishedYear = publishedYear
self.categoriesString = categoriesString
self.summary = summary
self.dateAdded = dateAdded
self.language = language
self.sourceLocale = sourceLocale
self.remotePayloadJson = remotePayloadJson
self.statusRaw = statusRaw
self.readingProgress = min(max(readingProgress, 0), 1)
}
var authors: [String] {
authorsString
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
var categories: [String] {
categoriesString
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
var status: ReadingStatus {
get { ReadingStatus(rawValue: statusRaw ?? "") ?? .wantToRead }
set { statusRaw = newValue.rawValue }
}
var readingProgressValue: Double {
get { min(max(readingProgress ?? 0, 0), 1) }
set { readingProgress = min(max(newValue, 0), 1) }
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
import SwiftUI
enum ReadingStatus: String, CaseIterable, Codable, Identifiable {
case wantToRead
case reading
case finished
var id: String { rawValue }
var title: String {
switch self {
case .wantToRead: return "Want to Read"
case .reading: return "Reading"
case .finished: return "Finished"
}
}
var symbol: String {
switch self {
case .wantToRead: return "bookmark"
case .reading: return "book"
case .finished: return "checkmark.circle"
}
}
var color: Color {
switch self {
case .wantToRead: return .orange
case .reading: return .blue
case .finished: return .green
}
}
}

View File

@@ -0,0 +1,10 @@
import Foundation
struct UserProfile: Codable, Equatable {
struct User: Codable, Equatable {
let id: String
let email: String
}
let user: User
}

View File

@@ -0,0 +1,20 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.580",
"green" : "0.424",
"red" : "0.294"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,53 @@
{
"images" : [
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "20x20"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "29x29"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "40x40"
},
{
"idiom" : "iphone",
"scale" : "2x",
"size" : "60x60"
},
{
"idiom" : "iphone",
"scale" : "3x",
"size" : "60x60"
},
{
"idiom" : "ios-marketing",
"scale" : "1x",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1 @@
API_BASE_URL = http://192.168.1.141:8080

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-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>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>API_BASE_URL</key>
<string>http://192.168.1.141:8080</string>
<key>NSCameraUsageDescription</key>
<string>ISBN barkodu taramak için kameraya erişim gerekiyor.</string>
</dict>
</plist>

View File

@@ -0,0 +1 @@
API_BASE_URL = http://192.168.1.141:8080

View File

@@ -0,0 +1,18 @@
"auth.login" = "Login";
"auth.register" = "Register";
"auth.email" = "Email";
"auth.password" = "Password";
"auth.continue" = "Continue";
"home.myFavourite" = "My Favourite";
"home.books" = "BOOKS";
"home.addBooks" = "Add Books";
"home.noBooks" = "No books yet. Tap Add Books.";
"add.title" = "Add Books";
"add.searchByTitle" = "Search by Title";
"add.scanBarcode" = "Scan Barcode";
"add.filter" = "Filter";
"add.searchPlaceholder" = "Type book title";
"detail.add" = "Add to My Books";
"detail.remove" = "Remove from My Books";
"common.retry" = "Retry";
"common.empty" = "No results";

View File

@@ -0,0 +1,21 @@
{
"data": [
{
"locale": "tr",
"items": [
{
"asin": "6053757810",
"title": "Fahrenheit 451",
"authorName": "Ray Bradbury",
"isbn": "9786053757818",
"thumbImage": "https://example.com/cover.jpg",
"date": "2018",
"publisher": "Ithaki",
"page": 280,
"description": "Distopik klasik.",
"categories": ["Novels", "Science Fiction"]
}
]
}
]
}

View File

@@ -0,0 +1,18 @@
"auth.login" = "Giriş Yap";
"auth.register" = "Kayıt Ol";
"auth.email" = "E-posta";
"auth.password" = "Şifre";
"auth.continue" = "Devam Et";
"home.myFavourite" = "My Favourite";
"home.books" = "BOOKS";
"home.addBooks" = "Kitap Ekle";
"home.noBooks" = "Henüz kitap yok. Kitap Ekle'ye dokunun.";
"add.title" = "Kitap Ekle";
"add.searchByTitle" = "Başlığa Göre";
"add.scanBarcode" = "Barkod Tara";
"add.filter" = "Filtre";
"add.searchPlaceholder" = "Kitap adı yazın";
"detail.add" = "Kitaplığıma Ekle";
"detail.remove" = "Kitaplığımdan Kaldır";
"common.retry" = "Tekrar Dene";
"common.empty" = "Sonuç bulunamadı";

View File

@@ -0,0 +1,129 @@
import Foundation
protocol APIClientProtocol {
func get<T: Decodable>(path: String, queryItems: [URLQueryItem], token: String?) async throws -> T
func post<T: Decodable, Body: Encodable>(path: String, body: Body, token: String?) async throws -> T
}
enum APIError: LocalizedError {
case invalidURL
case invalidResponse
case unauthorized
case server(status: Int, message: String)
case decoding(Error)
case transport(Error)
var errorDescription: String? {
switch self {
case .invalidURL: return "Geçersiz URL"
case .invalidResponse: return "Geçersiz sunucu yanıtı"
case .unauthorized: return "Oturum süresi doldu"
case .server(_, let message): return message
case .decoding: return "Sunucu verisi çözümlenemedi"
case .transport(let error): return error.localizedDescription
}
}
}
final class APIClient: APIClientProtocol {
private let baseURL: URL
private let session: URLSession
private let decoder: JSONDecoder
private let encoder: JSONEncoder
init(baseURL: URL, session: URLSession = .shared) {
self.baseURL = baseURL
self.session = session
self.decoder = JSONDecoder()
self.encoder = JSONEncoder()
}
func get<T: Decodable>(path: String, queryItems: [URLQueryItem] = [], token: String? = nil) async throws -> T {
var request = try buildRequest(path: path, method: "GET", queryItems: queryItems, token: token)
request.httpBody = nil
return try await perform(request)
}
func post<T: Decodable, Body: Encodable>(path: String, body: Body, token: String? = nil) async throws -> T {
var request = try buildRequest(path: path, method: "POST", queryItems: [], token: token)
request.httpBody = try encoder.encode(body)
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
return try await perform(request)
}
private func buildRequest(path: String, method: String, queryItems: [URLQueryItem], token: String?) throws -> URLRequest {
guard var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else {
throw APIError.invalidURL
}
if !queryItems.isEmpty {
components.queryItems = queryItems
}
guard let url = components.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = 20
if let token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return request
}
private func perform<T: Decodable>(_ request: URLRequest) async throws -> T {
#if DEBUG
print("[API] \(request.httpMethod ?? "") \(request.url?.absoluteString ?? "")")
#endif
do {
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
if http.statusCode == 401 { throw APIError.unauthorized }
guard (200...299).contains(http.statusCode) else {
let message = (try? JSONSerialization.jsonObject(with: data) as? [String: Any])?["message"] as? String ?? "Sunucu hatası"
throw APIError.server(status: http.statusCode, message: message)
}
do {
return try decoder.decode(T.self, from: data)
} catch {
throw APIError.decoding(error)
}
} catch let error as APIError {
throw error
} catch {
throw APIError.transport(error)
}
}
}
extension Bundle {
var apiBaseURL: URL {
let raw = (object(forInfoDictionaryKey: "API_BASE_URL") as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// 1) Normal case: full URL in Info.plist / xcconfig.
if let url = URL(string: raw), let host = url.host, !host.isEmpty {
return url
}
// 2) If scheme is missing (e.g. "192.168.1.141:8080"), prepend http://.
if !raw.isEmpty, !raw.contains("://"),
let url = URL(string: "http://\(raw)"),
let host = url.host, !host.isEmpty {
return url
}
// 3) Device-local fallback for current dev network.
if let fallback = URL(string: "http://192.168.1.141:8080") {
#if DEBUG
print("[API] Invalid API_BASE_URL='\(raw)'. Falling back to \(fallback.absoluteString)")
#endif
return fallback
}
// 4) Last resort.
return URL(string: "http://localhost:8080")!
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
struct AuthResponse: Codable {
struct User: Codable {
let id: String
let email: String
}
let token: String
let user: User
}
protocol AuthServiceProtocol {
func login(email: String, password: String) async throws -> AuthResponse
func register(email: String, password: String) async throws -> AuthResponse
func profile(token: String) async throws -> UserProfile
}
final class AuthService: AuthServiceProtocol {
private let client: APIClientProtocol
init(client: APIClientProtocol) {
self.client = client
}
func login(email: String, password: String) async throws -> AuthResponse {
try await client.post(path: "api/auth/login", body: Credentials(email: email, password: password), token: nil)
}
func register(email: String, password: String) async throws -> AuthResponse {
try await client.post(path: "api/auth/register", body: Credentials(email: email, password: password), token: nil)
}
func profile(token: String) async throws -> UserProfile {
try await client.get(path: "api/auth/profile", queryItems: [], token: token)
}
}
private struct Credentials: Codable {
let email: String
let password: String
}

View File

@@ -0,0 +1,52 @@
import Foundation
protocol BooksServiceProtocol {
func searchByTitle(_ title: String, locales: String) async throws -> [BookRemote]
func searchByISBN(_ isbn: String, locales: String) async throws -> [BookRemote]
func filter(title: String, year: String, locales: String) async throws -> [BookRemote]
}
final class BooksService: BooksServiceProtocol {
private let client: APIClientProtocol
init(client: APIClientProtocol) {
self.client = client
}
func searchByTitle(_ title: String, locales: String = "tr,en") async throws -> [BookRemote] {
let response: BookSearchResponse = try await client.get(
path: "api/books/title",
queryItems: [
.init(name: "title", value: title),
.init(name: "locales", value: locales)
],
token: nil
)
return response.items
}
func searchByISBN(_ isbn: String, locales: String = "tr,en") async throws -> [BookRemote] {
let response: BookSearchResponse = try await client.get(
path: "api/books/isbn/\(isbn)",
queryItems: [
.init(name: "locales", value: locales),
.init(name: "withGemini", value: "false")
],
token: nil
)
return response.items
}
func filter(title: String, year: String, locales: String = "tr,en") async throws -> [BookRemote] {
let response: BookSearchResponse = try await client.get(
path: "api/books/filter",
queryItems: [
.init(name: "title", value: title),
.init(name: "published", value: year),
.init(name: "locales", value: locales)
],
token: nil
)
return response.items
}
}

View File

@@ -0,0 +1,34 @@
import Foundation
import UIKit
protocol ImageCacheProtocol {
func image(for url: URL) async throws -> UIImage
}
final class ImageCache: ImageCacheProtocol {
static let shared = ImageCache()
private let cache = NSCache<NSURL, UIImage>()
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
cache.countLimit = 200
}
func image(for url: URL) async throws -> UIImage {
if let existing = cache.object(forKey: url as NSURL) {
return existing
}
let request = URLRequest(url: url, cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 20)
let (data, _) = try await session.data(for: request)
guard let image = UIImage(data: data) else {
throw APIError.invalidResponse
}
cache.setObject(image, forKey: url as NSURL)
return image
}
}

View File

@@ -0,0 +1,47 @@
import Foundation
import Security
protocol KeychainStoreProtocol {
func save(_ value: String, for key: String) -> Bool
func read(for key: String) -> String?
func delete(for key: String) -> Bool
}
final class KeychainStore: KeychainStoreProtocol {
func save(_ value: String, for key: String) -> Bool {
let data = Data(value.utf8)
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecValueData: data
]
SecItemDelete(query as CFDictionary)
return SecItemAdd(query as CFDictionary, nil) == errSecSuccess
}
func read(for key: String) -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var item: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess,
let data = item as? Data else {
return nil
}
return String(data: data, encoding: .utf8)
}
func delete(for key: String) -> Bool {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrAccount: key
]
return SecItemDelete(query as CFDictionary) == errSecSuccess
}
}

View File

@@ -0,0 +1,105 @@
import Foundation
@MainActor
final class AddBooksViewModel: ObservableObject {
enum Mode: String, CaseIterable {
case title
case scan
case filter
var title: String {
switch self {
case .title: return String(localized: "add.searchByTitle")
case .scan: return String(localized: "add.scanBarcode")
case .filter: return String(localized: "add.filter")
}
}
}
@Published var mode: Mode = .title
@Published var titleQuery = ""
@Published var filterTitle = ""
@Published var filterYear = ""
@Published var results: [BookRemote] = []
@Published var isLoading = false
@Published var errorMessage: String?
private var debounceTask: Task<Void, Never>?
private let booksService: BooksServiceProtocol
init(booksService: BooksServiceProtocol) {
self.booksService = booksService
}
func titleChanged() {
debounceTask?.cancel()
let query = titleQuery.trimmingCharacters(in: .whitespacesAndNewlines)
guard query.count >= 2 else {
results = []
return
}
debounceTask = Task {
try? await Task.sleep(for: .milliseconds(400))
await searchByTitle(query)
}
}
func searchByTitle(_ query: String? = nil) async {
let value = query ?? titleQuery
guard !value.isEmpty else { return }
isLoading = true
defer { isLoading = false }
do {
results = try await booksService.searchByTitle(value, locales: "tr,en")
errorMessage = nil
} catch {
guard !isIgnorable(error) else { return }
errorMessage = error.localizedDescription
}
}
func searchByISBN(_ isbn: String) async {
guard !isbn.isEmpty else { return }
isLoading = true
defer { isLoading = false }
do {
results = try await booksService.searchByISBN(isbn, locales: "tr,en")
errorMessage = nil
} catch {
guard !isIgnorable(error) else { return }
errorMessage = error.localizedDescription
}
}
func applyFilter() async {
guard !filterTitle.isEmpty, !filterYear.isEmpty else { return }
isLoading = true
defer { isLoading = false }
do {
results = try await booksService.filter(title: filterTitle, year: filterYear, locales: "tr,en")
errorMessage = nil
} catch {
guard !isIgnorable(error) else { return }
errorMessage = error.localizedDescription
}
}
private func isIgnorable(_ error: Error) -> Bool {
if error is CancellationError {
return true
}
let nsError = error as NSError
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
return true
}
let message = error.localizedDescription.lowercased()
return message.contains("cancelled") || message.contains("vazgeç")
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
@MainActor
final class AuthViewModel: ObservableObject {
enum Mode: String, CaseIterable {
case login
case register
}
static let tokenKey = "bookibra.jwt"
static let emailKey = "bookibra.email"
@Published var mode: Mode = .login
@Published var email = ""
@Published var password = ""
@Published var isLoading = false
@Published var errorMessage: String?
private let authService: AuthServiceProtocol
private let keychain: KeychainStoreProtocol
init(authService: AuthServiceProtocol, keychain: KeychainStoreProtocol) {
self.authService = authService
self.keychain = keychain
}
func submit(onSuccess: @escaping () -> Void) async {
guard !email.isEmpty, !password.isEmpty else {
errorMessage = "E-posta ve şifre gerekli"
return
}
isLoading = true
defer { isLoading = false }
do {
let response: AuthResponse
switch mode {
case .login:
response = try await authService.login(email: email, password: password)
case .register:
response = try await authService.register(email: email, password: password)
}
_ = try await authService.profile(token: response.token)
_ = keychain.save(response.token, for: Self.tokenKey)
_ = keychain.save(response.user.email, for: Self.emailKey)
onSuccess()
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
import SwiftData
import UIKit
@MainActor
final class BookDetailViewModel: ObservableObject {
@Published var book: BookRemote
@Published var isInLibrary = false
init(book: BookRemote) {
self.book = book
}
func refresh(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<LibraryBook>())) ?? []
isInLibrary = all.contains(where: { local in
match(local: local, remote: book)
})
}
func toggleLibrary(context: ModelContext) {
let all = (try? context.fetch(FetchDescriptor<LibraryBook>())) ?? []
if let existing = all.first(where: { match(local: $0, remote: book) }) {
context.delete(existing)
isInLibrary = false
UINotificationFeedbackGenerator().notificationOccurred(.warning)
} else {
let local = LibraryBook(
title: book.title,
authorsString: book.authors.joined(separator: ", "),
coverUrlString: book.coverImageUrl?.absoluteString,
isbn10: book.isbn10,
isbn13: book.isbn13,
publishedYear: book.publishedYear,
categoriesString: book.categories.joined(separator: ", "),
summary: book.description,
language: book.language,
sourceLocale: book.sourceLocale,
remotePayloadJson: nil,
statusRaw: (book.readingStatus ?? .wantToRead).rawValue,
readingProgress: book.readingProgress ?? 0
)
context.insert(local)
isInLibrary = true
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
try? context.save()
}
private func match(local: LibraryBook, remote: BookRemote) -> Bool {
if let lhs = local.isbn13, let rhs = remote.isbn13 { return lhs == rhs }
if let lhs = local.isbn10, let rhs = remote.isbn10 { return lhs == rhs }
return local.title == remote.title
}
}

View File

@@ -0,0 +1,39 @@
import Foundation
@MainActor
final class CategoryViewModel: ObservableObject {
enum SortOption: String, CaseIterable {
case recentlyAdded = "Recently Added"
case titleAZ = "Title A-Z"
case author = "Author"
}
let categoryName: String
@Published var searchText = ""
@Published var sortOption: SortOption = .recentlyAdded
init(categoryName: String) {
self.categoryName = categoryName
}
func books(from allBooks: [LibraryBook]) -> [LibraryBook] {
var filtered = allBooks.filter { $0.categories.contains(categoryName) || (categoryName == "Design" && $0.categories.isEmpty) }
if !searchText.isEmpty {
filtered = filtered.filter {
$0.title.localizedCaseInsensitiveContains(searchText)
|| $0.authorsString.localizedCaseInsensitiveContains(searchText)
}
}
switch sortOption {
case .recentlyAdded:
filtered.sort { $0.dateAdded > $1.dateAdded }
case .titleAZ:
filtered.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
case .author:
filtered.sort { $0.authorsString.localizedCaseInsensitiveCompare($1.authorsString) == .orderedAscending }
}
return filtered
}
}

View File

@@ -0,0 +1,63 @@
import Foundation
import SwiftUI
struct ShelfCategory: Identifiable {
let id: String
let name: String
let books: [BookRemote]
}
@MainActor
final class HomeViewModel: ObservableObject {
@Published var categories: [ShelfCategory] = []
func refresh(from localBooks: [LibraryBook]) {
var map: [String: [BookRemote]] = [:]
for local in localBooks {
let targets = local.categories.isEmpty ? ["Design"] : local.categories
let remote = Self.makeRemote(from: local)
for name in targets {
map[name, default: []].append(remote)
}
}
let preferred = ["Design", "Psychology", "Novels"]
var built = preferred.map { name in
ShelfCategory(id: name, name: name, books: map[name] ?? [])
}
let extras = map.keys
.filter { !preferred.contains($0) }
.sorted()
.map { ShelfCategory(id: $0, name: $0, books: map[$0] ?? []) }
built.append(contentsOf: extras)
categories = built
}
func gradient(for name: String) -> LinearGradient {
switch name.lowercased() {
case "design": return Theme.designShelf
case "psychology": return Theme.psychologyShelf
case "novels": return Theme.novelsShelf
default: return Theme.novelsShelf
}
}
private static func makeRemote(from local: LibraryBook) -> BookRemote {
BookRemote(
title: local.title,
authors: local.authors,
publishedYear: local.publishedYear,
isbn10: local.isbn10,
isbn13: local.isbn13,
coverImageUrl: local.coverUrlString.flatMap(URL.init(string:)),
language: local.language,
description: local.summary,
categories: local.categories,
sourceLocale: local.sourceLocale,
readingStatus: local.status,
readingProgress: local.readingProgressValue
)
}
}

View File

@@ -0,0 +1,298 @@
import SwiftUI
import SwiftData
struct AddBooksView: View {
enum FlowStep: Int {
case source
case confirm
case categorize
case success
}
@EnvironmentObject private var router: AppRouter
@Environment(\.modelContext) private var modelContext
@ObservedObject var viewModel: AddBooksViewModel
@State private var step: FlowStep = .source
@State private var selectedBook: BookRemote?
@State private var selectedCategory = "Design"
@State private var selectedStatus: ReadingStatus = .wantToRead
@State private var manualISBN = ""
@State private var startReadingNow = false
@State private var animateSuccess = false
private let defaultCategories = ["Design", "Psychology", "Novels"]
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.medium) {
progressHeader
switch step {
case .source:
sourceStep
case .confirm:
confirmStep
case .categorize:
categorizeStep
case .success:
successStep
}
}
.frame(maxWidth: .infinity, alignment: .top)
.padding(.bottom, 24)
}
.padding(.horizontal, 16)
.navigationTitle(String(localized: "add.title"))
.navigationBarTitleDisplayMode(.inline)
.overlay {
if viewModel.isLoading {
ProgressView()
.controlSize(.large)
}
}
.sensoryFeedback(.success, trigger: animateSuccess)
}
private var progressHeader: some View {
HStack(spacing: 8) {
ForEach(0..<4, id: \.self) { index in
Capsule()
.fill(index <= step.rawValue ? Color.accentColor : Color.gray.opacity(0.25))
.frame(height: 6)
.animation(.easeOut(duration: 0.2), value: step.rawValue)
}
}
.padding(.top, 4)
}
private var sourceStep: some View {
VStack(spacing: Theme.Spacing.medium) {
Picker("Mode", selection: $viewModel.mode) {
ForEach(AddBooksViewModel.Mode.allCases, id: \.self) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
Group {
switch viewModel.mode {
case .title:
TextField(String(localized: "add.searchPlaceholder"), text: $viewModel.titleQuery)
.textFieldStyle(.roundedBorder)
.onChange(of: viewModel.titleQuery) { _, _ in
viewModel.titleChanged()
}
case .scan:
VStack(spacing: 10) {
BarcodeScannerView { isbn in
Task { await viewModel.searchByISBN(isbn) }
}
.frame(height: 240)
.clipShape(RoundedRectangle(cornerRadius: 16))
HStack {
TextField("ISBN manuel gir", text: $manualISBN)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
Button("Ara") {
Task { await viewModel.searchByISBN(manualISBN) }
}
.buttonStyle(.borderedProminent)
}
}
case .filter:
VStack(spacing: 8) {
TextField("Title", text: $viewModel.filterTitle)
.textFieldStyle(.roundedBorder)
TextField("YYYY", text: $viewModel.filterYear)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
Button("Apply") {
Task { await viewModel.applyFilter() }
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
if let error = viewModel.errorMessage {
NetworkErrorView(message: error) {
Task { await viewModel.searchByTitle() }
}
}
if viewModel.results.isEmpty, !viewModel.isLoading {
EmptyStateView(
symbol: "magnifyingglass",
title: "Sonuç bulunamadı",
message: "Farklı bir başlık dene veya ISBN'i manuel gir.",
buttonTitle: nil,
action: nil
)
} else {
LazyVStack(spacing: 10) {
ForEach(viewModel.results, id: \.id) { book in
Button {
selectedBook = book
step = .confirm
} label: {
HStack(spacing: 12) {
AsyncImage(url: book.coverImageUrl) { image in
image.resizable().scaledToFill()
} placeholder: {
RoundedRectangle(cornerRadius: 8).fill(.gray.opacity(0.2))
}
.frame(width: 48, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: 4) {
Text(book.title)
.font(.headline)
.lineLimit(2)
Text(book.authors.joined(separator: ", "))
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
.padding(12)
.background(Color.white.opacity(0.75), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
}
}
}
private var confirmStep: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
if let book = selectedBook {
BookCardView(
title: book.title,
author: book.authors.first ?? "",
coverURL: book.coverImageUrl,
status: .wantToRead,
progress: nil
)
}
HStack {
Button("Geri") { step = .source }
.buttonStyle(.bordered)
Spacer()
Button("Bilgileri Onayla") { step = .categorize }
.buttonStyle(.borderedProminent)
}
}
}
private var categorizeStep: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.medium) {
Text("Kategori & Durum")
.font(.title3.weight(.semibold))
Picker("Kategori", selection: $selectedCategory) {
ForEach(defaultCategories, id: \.self) { category in
Text(category).tag(category)
}
}
.pickerStyle(.menu)
Picker("Durum", selection: $selectedStatus) {
ForEach(ReadingStatus.allCases) { status in
Text(status.title).tag(status)
}
}
.pickerStyle(.segmented)
Toggle("Hemen okumaya başla", isOn: $startReadingNow)
Button("Kitabı Kaydet") {
saveSelectedBook()
}
.buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity, alignment: .trailing)
Button("Geri") { step = .confirm }
.buttonStyle(.bordered)
}
}
private var successStep: some View {
VStack(spacing: Theme.Spacing.large) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.green)
.scaleEffect(animateSuccess ? 1 : 0.7)
.opacity(animateSuccess ? 1 : 0.4)
.animation(.spring(response: 0.5, dampingFraction: 0.7), value: animateSuccess)
Text("Kitap başarıyla eklendi")
.font(.title3.weight(.semibold))
HStack {
Button("Yeni Kitap Ekle") {
resetFlow()
}
.buttonStyle(.bordered)
Button("Kitaplığa Dön") {
router.selectedTab = .library
resetFlow()
}
.buttonStyle(.borderedProminent)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear { animateSuccess = true }
}
private func saveSelectedBook() {
guard let book = selectedBook else { return }
let progress = startReadingNow ? 0.1 : (selectedStatus == .finished ? 1 : 0)
let status = startReadingNow ? ReadingStatus.reading : selectedStatus
let local = LibraryBook(
title: book.title,
authorsString: book.authors.joined(separator: ", "),
coverUrlString: book.coverImageUrl?.absoluteString,
isbn10: book.isbn10,
isbn13: book.isbn13,
publishedYear: book.publishedYear,
categoriesString: selectedCategory,
summary: book.description,
language: book.language,
sourceLocale: book.sourceLocale,
remotePayloadJson: nil,
statusRaw: status.rawValue,
readingProgress: progress
)
modelContext.insert(local)
try? modelContext.save()
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
withAnimation(.easeInOut(duration: 0.25)) {
step = .success
animateSuccess = false
}
}
private func resetFlow() {
selectedBook = nil
selectedCategory = defaultCategories[0]
selectedStatus = .wantToRead
startReadingNow = false
manualISBN = ""
step = .source
animateSuccess = false
}
}

View File

@@ -0,0 +1,196 @@
import SwiftUI
import AVFoundation
import VisionKit
struct BarcodeScannerView: View {
let onScanned: (String) -> Void
@State private var cameraAuthorized = AVCaptureDevice.authorizationStatus(for: .video) == .authorized
var body: some View {
Group {
if cameraAuthorized {
if DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
DataScannerRepresentable(onScanned: onScanned)
} else {
AVScannerRepresentable(onScanned: onScanned)
}
} else {
scannerUnavailableView
}
}
.background(Color.black.opacity(0.85))
.overlay {
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white.opacity(0.75), lineWidth: 2)
}
.accessibilityLabel("ISBN barkod tarayıcı")
.onAppear {
requestCameraPermissionIfNeeded()
}
}
private var scannerUnavailableView: some View {
VStack(spacing: 10) {
Image(systemName: "camera.viewfinder")
.font(.system(size: 30))
.foregroundStyle(.white.opacity(0.9))
Text("Kamera erişimi gerekli")
.font(.headline)
.foregroundStyle(.white)
Text("Barkod taramak için Ayarlar > Gizlilik > Kamera üzerinden izin verin.")
.font(.footnote)
.foregroundStyle(.white.opacity(0.85))
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func requestCameraPermissionIfNeeded() {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
cameraAuthorized = true
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
DispatchQueue.main.async {
cameraAuthorized = granted
}
}
default:
cameraAuthorized = false
}
}
}
@available(iOS 16.0, *)
private struct DataScannerRepresentable: UIViewControllerRepresentable {
let onScanned: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
func makeUIViewController(context: Context) -> DataScannerViewController {
let vc = DataScannerViewController(
recognizedDataTypes: [.barcode(symbologies: [.ean8, .ean13, .upce, .code128])],
qualityLevel: .balanced,
recognizesMultipleItems: false,
isHighFrameRateTrackingEnabled: true,
isPinchToZoomEnabled: true,
isGuidanceEnabled: true,
isHighlightingEnabled: true
)
vc.delegate = context.coordinator
try? vc.startScanning()
return vc
}
func updateUIViewController(_ uiViewController: DataScannerViewController, context: Context) {}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onScanned: (String) -> Void
private var lastISBN: String?
private var lastEmitAt: Date = .distantPast
init(onScanned: @escaping (String) -> Void) {
self.onScanned = onScanned
}
func dataScanner(_ dataScanner: DataScannerViewController, didTapOn item: RecognizedItem) {
guard case .barcode(let code) = item,
let payload = code.payloadStringValue,
let normalized = ISBNNormalizer.normalize(payload) else { return }
emitIfNeeded(normalized)
}
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
guard let first = addedItems.first,
case .barcode(let code) = first,
let payload = code.payloadStringValue,
let normalized = ISBNNormalizer.normalize(payload) else { return }
emitIfNeeded(normalized)
}
private func emitIfNeeded(_ isbn: String) {
let now = Date()
if lastISBN == isbn, now.timeIntervalSince(lastEmitAt) < 1.5 {
return
}
lastISBN = isbn
lastEmitAt = now
onScanned(isbn)
}
}
}
private struct AVScannerRepresentable: UIViewRepresentable {
let onScanned: (String) -> Void
func makeUIView(context: Context) -> ScannerPreviewView {
let view = ScannerPreviewView()
context.coordinator.configure(preview: view)
return view
}
func updateUIView(_ uiView: ScannerPreviewView, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(onScanned: onScanned) }
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "bookibra.av.capture.session")
private let onScanned: (String) -> Void
private var lastISBN: String?
private var lastEmitAt: Date = .distantPast
init(onScanned: @escaping (String) -> Void) {
self.onScanned = onScanned
}
func configure(preview: ScannerPreviewView) {
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device) else { return }
if session.canAddInput(input) { session.addInput(input) }
let output = AVCaptureMetadataOutput()
if session.canAddOutput(output) {
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.ean8, .ean13, .upce, .code128]
}
preview.previewLayer.session = session
preview.previewLayer.videoGravity = .resizeAspectFill
sessionQueue.async { [session] in
session.startRunning()
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let code = metadataObjects.compactMap({ $0 as? AVMetadataMachineReadableCodeObject }).first,
let value = code.stringValue,
let normalized = ISBNNormalizer.normalize(value) else { return }
let now = Date()
if lastISBN == normalized, now.timeIntervalSince(lastEmitAt) < 1.5 {
return
}
lastISBN = normalized
lastEmitAt = now
onScanned(normalized)
}
}
}
private final class ScannerPreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
enum ISBNNormalizer {
static func normalize(_ value: String) -> String? {
let cleaned = value.uppercased().filter { $0.isNumber || $0 == "X" }
if cleaned.count == 13 { return cleaned }
if cleaned.count == 10 { return cleaned }
return nil
}
}

View File

@@ -0,0 +1,71 @@
import SwiftUI
struct AuthView: View {
@EnvironmentObject private var router: AppRouter
@ObservedObject var viewModel: AuthViewModel
var body: some View {
VStack(spacing: 24) {
Spacer()
Text("Bookibra")
.font(Theme.headerSerif(size: 48))
.foregroundStyle(.black)
Picker("Mode", selection: $viewModel.mode) {
Text(String(localized: "auth.login")).tag(AuthViewModel.Mode.login)
Text(String(localized: "auth.register")).tag(AuthViewModel.Mode.register)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
VStack(spacing: 14) {
TextField(String(localized: "auth.email"), text: $viewModel.email)
.textInputAutocapitalization(.never)
.keyboardType(.emailAddress)
.padding()
.background(Color.white, in: RoundedRectangle(cornerRadius: 12))
SecureField(String(localized: "auth.password"), text: $viewModel.password)
.padding()
.background(Color.white, in: RoundedRectangle(cornerRadius: 12))
}
.padding(.horizontal, 24)
if let error = viewModel.errorMessage {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.padding(.horizontal, 24)
.multilineTextAlignment(.center)
}
Button {
Task {
await viewModel.submit {
router.isAuthenticated = true
}
}
} label: {
if viewModel.isLoading {
ProgressView()
.tint(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
} else {
Text(String(localized: "auth.continue"))
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
}
}
.background(Color.black)
.clipShape(Capsule())
.padding(.horizontal, 24)
Spacer()
}
.background(Theme.background.ignoresSafeArea())
}
}

View File

@@ -0,0 +1,111 @@
import SwiftUI
import SwiftData
import UIKit
struct CategoryListView: View {
@EnvironmentObject private var router: AppRouter
@Environment(\.modelContext) private var modelContext
@Query(sort: \LibraryBook.dateAdded, order: .reverse) private var allBooks: [LibraryBook]
@ObservedObject var viewModel: CategoryViewModel
var body: some View {
let books = viewModel.books(from: allBooks)
List {
controlRow
if books.isEmpty {
EmptyStateView(
symbol: "books.vertical",
title: "Bu kategoride kitap yok",
message: "Kitap ekleyerek bu rafı doldurabilirsin.",
buttonTitle: "Kitap Keşfet",
action: { router.selectedTab = .discover }
)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} else {
ForEach(books, id: \.localId) { book in
let remote = BookRemote(
title: book.title,
authors: book.authors,
publishedYear: book.publishedYear,
isbn10: book.isbn10,
isbn13: book.isbn13,
coverImageUrl: book.coverUrlString.flatMap(URL.init(string:)),
language: book.language,
description: book.summary,
categories: book.categories,
readingStatus: book.status,
readingProgress: book.readingProgressValue
)
Button {
router.path.append(.detail(remote))
} label: {
BookCardView(
title: remote.title,
author: remote.authors.first ?? "",
coverURL: remote.coverImageUrl,
status: remote.readingStatus ?? .wantToRead,
progress: remote.readingProgress
)
}
.buttonStyle(.plain)
.listRowInsets(EdgeInsets(top: 10, leading: 16, bottom: 10, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
statusButton(.finished, for: book)
statusButton(.reading, for: book)
statusButton(.wantToRead, for: book)
}
}
}
}
.scrollContentBackground(.hidden)
.background(Theme.background.ignoresSafeArea())
.navigationTitle(viewModel.categoryName)
.navigationBarTitleDisplayMode(.inline)
}
private var controlRow: some View {
HStack(spacing: 10) {
TextField("Kategori içinde ara", text: $viewModel.searchText)
.textFieldStyle(.roundedBorder)
Menu {
ForEach(CategoryViewModel.SortOption.allCases, id: \.self) { option in
Button(option.rawValue) { viewModel.sortOption = option }
}
} label: {
Label("Sırala", systemImage: "arrow.up.arrow.down.circle")
}
}
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
private func statusButton(_ status: ReadingStatus, for book: LibraryBook) -> some View {
Button {
withAnimation(.spring(duration: 0.3)) {
book.status = status
if status == .finished { book.readingProgressValue = 1 }
if status == .wantToRead { book.readingProgressValue = 0 }
try? modelContext.save()
}
UIImpactFeedbackGenerator(style: .soft).impactOccurred()
} label: {
Label(status.title, systemImage: status.symbol)
}
.tint(color(for: status))
}
private func color(for status: ReadingStatus) -> Color {
switch status {
case .wantToRead: return .orange
case .reading: return .blue
case .finished: return .green
}
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
import SwiftData
struct BookDetailView: View {
@Environment(\.modelContext) private var modelContext
@ObservedObject var viewModel: BookDetailViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
AsyncImage(url: viewModel.book.coverImageUrl) { phase in
if let image = phase.image {
image.resizable().scaledToFill()
} else {
RoundedRectangle(cornerRadius: 16).fill(.gray.opacity(0.2))
}
}
.frame(maxWidth: .infinity)
.frame(height: 320)
.clipShape(RoundedRectangle(cornerRadius: 16))
Text(viewModel.book.title)
.font(.title.bold())
Text(viewModel.book.authors.joined(separator: ", "))
.font(.headline)
.foregroundStyle(.secondary)
if let year = viewModel.book.publishedYear {
Text("\(year)")
.font(.subheadline)
}
if !viewModel.book.categories.isEmpty {
Text(viewModel.book.categories.joined(separator: ""))
.font(.subheadline)
.foregroundStyle(.secondary)
}
if let description = viewModel.book.description {
Text(description)
.font(.body)
}
PrimaryPillButton(
title: viewModel.isInLibrary ? String(localized: "detail.remove") : String(localized: "detail.add")
) {
viewModel.toggleLibrary(context: modelContext)
}
.padding(.top, 8)
}
.padding(20)
}
.background(Theme.background.ignoresSafeArea())
.task {
viewModel.refresh(context: modelContext)
}
}
}

View File

@@ -0,0 +1,75 @@
import SwiftUI
import SwiftData
struct HomeView: View {
@EnvironmentObject private var router: AppRouter
@Environment(\.dependencies) private var dependencies
@Query(sort: \LibraryBook.dateAdded, order: .reverse) private var libraryBooks: [LibraryBook]
@StateObject private var viewModel: HomeViewModel
init(viewModel: HomeViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
ScrollView {
VStack(spacing: 28) {
header
ForEach(viewModel.categories) { category in
ShelfSectionView(
title: category.name,
books: category.books,
gradient: viewModel.gradient(for: category.name),
imageCache: dependencies.imageCache,
onTapCategory: { router.path.append(.category(name: category.name)) },
onTapBook: { router.path.append(.detail($0)) }
)
}
}
.padding(.top, 16)
.padding(.bottom, 120)
}
.background(Theme.background.ignoresSafeArea())
.safeAreaInset(edge: .bottom, spacing: 0) {
ZStack {
BlurFogOverlay()
.frame(height: 96)
PrimaryPillButton(title: String(localized: "home.addBooks")) {
withAnimation(.easeInOut(duration: 0.2)) {
router.selectedTab = .discover
}
}
.padding(.horizontal, 24)
.padding(.bottom, 12)
}
.frame(height: 100)
}
.onAppear {
viewModel.refresh(from: libraryBooks)
}
.onChange(of: libraryBooks.map(\.localId)) { _, _ in
viewModel.refresh(from: libraryBooks)
}
.task(id: libraryBooks.count) {
viewModel.refresh(from: libraryBooks)
}
}
private var header: some View {
VStack(spacing: 4) {
Text(String(localized: "home.myFavourite"))
.font(.footnote.weight(.light))
.kerning(1.2)
.foregroundStyle(Color.black.opacity(0.7))
Text(String(localized: "home.books"))
.font(Theme.headerSerif(size: 56).weight(.bold))
.foregroundStyle(.black)
.kerning(1)
}
.frame(maxWidth: .infinity)
.padding(.top, 12)
.accessibilityElement(children: .combine)
}
}

23
ios/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Bookibra iOS (SwiftUI)
## API Base URL ayarı
1. `ios/Bookibra/Resources/Info.plist` içindeki `API_BASE_URL` anahtarını güncelle.
2. İstersen `ios/Bookibra/Resources/Debug.xcconfig` ve `ios/Bookibra/Resources/Release.xcconfig` içinde de aynı değeri tut.
3. Lokal backend için varsayılan: `http://localhost:8080`
Not: iOS Simulator'dan host makinedeki backend'e erişimde genelde `http://127.0.0.1:8080` çalışır. Gerekirse bunu kullan.
## Çalıştırma
1. Xcode ile `ios/Bookibra.xcodeproj` aç.
2. Scheme: `Bookibra` seç.
3. iOS 17+ simulator/device seçip Run (`Cmd+R`) yap.
## Barkod test etme
- Gerçek kamera gerektiği için en stabil test fiziksel cihazdadır.
- Simulator'da kamera akışı sınırlı olabilir.
- Add Books > Barkod Tara ekranında ISBN-10 / ISBN-13 okutulduğunda otomatik `/api/books/isbn/:isbn` çağrılır.
## Notlar
- JWT token Keychain'de tutulur.
- Kitaplık (`My Books`) SwiftData ile lokalde saklanır.
- Backend'de kitaplık CRUD olmasa da uygulama lokalde çalışır.

80
ios/create_xcodeproj.rb Normal file
View File

@@ -0,0 +1,80 @@
require 'xcodeproj'
require 'fileutils'
root = File.expand_path(__dir__)
project_path = File.join(root, 'Bookibra.xcodeproj')
FileUtils.rm_rf(project_path)
project = Xcodeproj::Project.new(project_path)
target = project.new_target(:application, 'Bookibra', :ios, '17.0')
project.build_configurations.each do |config|
config.build_settings['SWIFT_VERSION'] = '5.9'
config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.bookibra.ios'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'
config.build_settings['TARGETED_DEVICE_FAMILY'] = '1,2'
config.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
config.build_settings['MARKETING_VERSION'] = '1.0'
config.build_settings['CURRENT_PROJECT_VERSION'] = '1'
end
target.build_configurations.each do |config|
config.build_settings['SWIFT_VERSION'] = '5.9'
config.build_settings['INFOPLIST_FILE'] = 'Bookibra/Resources/Info.plist'
config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.bookibra.ios'
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '17.0'
config.build_settings['TARGETED_DEVICE_FAMILY'] = '1,2'
config.build_settings['ASSETCATALOG_COMPILER_APPICON_NAME'] = 'AppIcon'
config.build_settings['GENERATE_INFOPLIST_FILE'] = 'NO'
config.build_settings['SWIFT_EMIT_LOC_STRINGS'] = 'YES'
config.build_settings['LD_RUNPATH_SEARCH_PATHS'] = ['$(inherited)', '@executable_path/Frameworks']
end
# xcconfig mapping
config_map = {
'Debug' => 'Bookibra/Resources/Debug.xcconfig',
'Release' => 'Bookibra/Resources/Release.xcconfig'
}
project.build_configurations.each do |cfg|
path = config_map[cfg.name]
next unless path
cfg.base_configuration_reference = project.files.find { |f| f.path == path } || project.main_group.new_file(path)
end
target.build_configurations.each do |cfg|
path = config_map[cfg.name]
next unless path
cfg.base_configuration_reference = project.files.find { |f| f.path == path } || project.main_group.new_file(path)
end
main = project.main_group
bookibra_group = main.new_group('Bookibra', 'Bookibra')
# Source files
swift_files = Dir.glob(File.join(root, 'Bookibra', '**', '*.swift')).sort
swift_files.each do |abs|
rel = abs.sub(root + '/', '')
ref = bookibra_group.new_file(rel.sub('Bookibra/', ''))
target.source_build_phase.add_file_reference(ref)
end
# Resource files/directories (only top-level bundles, no nested xcassets internals)
resource_paths = [
'Bookibra/Resources/Assets.xcassets',
'Bookibra/Resources/en.lproj/Localizable.strings',
'Bookibra/Resources/tr.lproj/Localizable.strings',
'Bookibra/Resources/mock_book_remote.json'
]
resource_paths.each do |rel|
ref = bookibra_group.new_file(rel.sub('Bookibra/', ''))
target.resources_build_phase.add_file_reference(ref)
end
# Frameworks
frameworks_group = main['Frameworks'] || main.new_group('Frameworks')
['AVFoundation.framework', 'VisionKit.framework', 'SwiftData.framework'].each do |framework|
ref = frameworks_group.new_file("System/Library/Frameworks/#{framework}")
target.frameworks_build_phase.add_file_reference(ref)
end
project.save
puts "Created #{project_path}"