Add iOS app, SvelteKit JSON API endpoints, and Gitea CI workflow
Some checks failed
Deploy / Deploy Production (push) Has been skipped
Deploy / Cleanup Preview (push) Has been skipped
Deploy / Deploy Preview (push) Failing after 0s
CI / Scraper / Test (pull_request) Successful in 11s
CI / UI / Build (pull_request) Failing after 14s
CI / Scraper / Lint (pull_request) Successful in 23s
CI / Scraper / Build (pull_request) Successful in 24s
iOS CI / Build (push) Has been cancelled
iOS CI / Test (push) Has been cancelled
iOS CI / Build (pull_request) Failing after 2s
iOS CI / Test (pull_request) Has been skipped

- iOS SwiftUI app (ios/LibNovel/) targeting iOS 17+, generated via xcodegen
  - Full feature set: auth, home, library, book detail, chapter reader, browse, audio player, profile
  - Kingfisher for image loading, swift-markdown-ui for chapter rendering
  - Base URL: https://v2.libnovel.kalekber.cc
- SvelteKit JSON API routes (ui/src/routes/api/) for iOS consumption:
  auth/login, auth/register, auth/me, auth/logout, auth/change-password,
  home, library, book/[slug], chapter/[slug]/[n], search, ranking,
  progress/[slug], presign/audio (updated)
- Gitea Actions CI: .gitea/workflows/ios.yaml (build + test on macos-latest)
- justfile: ios-gen, ios-build, ios-test recipes
This commit is contained in:
Admin
2026-03-07 18:17:51 +05:00
parent 1eb70e9b9b
commit f51113a2f8
50 changed files with 4875 additions and 1 deletions

14
ios/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
# Xcode build artifacts — regenerate with: xcodegen generate --spec project.yml
xcuserdata/
*.xcuserstate
*.xcworkspace/xcuserdata/
DerivedData/
build/
# Swift Package Manager — resolved by Xcode on first open
LibNovel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/
.build/
# Package.resolved is committed so SPM builds are reproducible
# OS
.DS_Store

View File

@@ -0,0 +1,693 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F56C8E2BC3614530B81569D /* LibNovelApp.swift */; };
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */; };
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 762E378B9BC2161A7AA2CC36 /* Models.swift */; };
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */; };
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */; };
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC338B05EA6DB22900712000 /* LibraryViewModel.swift */; };
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D5C115992F1CE2326236765 /* RootTabView.swift */; };
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C918833E173D6B44D06955 /* LibNovelTests.swift */; };
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */; };
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F219788AE5ACBD6F240674F5 /* AuthStore.swift */; };
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0B17D50389C6C98FC78BDBC /* ProfileView.swift */; };
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39DE056C37FBC5EED8771821 /* BookDetailView.swift */; };
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5A776719B77EDDB5E44743B0 /* Assets.xcassets */; };
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */; };
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B820081FA4817765A39939A /* ContentView.swift */; };
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CEF6782A2A28B2A485CBD48 /* AuthView.swift */; };
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB13E89E50529E3081533A66 /* AudioPlayerService.swift */; };
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 6313AB4B3A5464F647791174 /* MarkdownUI */; };
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */; };
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AB2E843D93461074A89A171 /* HomeViewModel.swift */; };
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 09584EAB68A07B47F876A062 /* Kingfisher */; };
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C21107BECA55C07416E0CB8B /* LibraryView.swift */; };
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D83BB88C4306BE7A4F947CB /* Color+App.swift */; };
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6268D60803940CBD38FB921 /* HomeView.swift */; };
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E89FD8F46747CA653C5203D /* CommonViews.swift */; };
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CAFB96D2500F34F0B0C860C /* NavDestination.swift */; };
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B593F179EC3E9112126B540B /* APIClient.swift */; };
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = A10A669C0C8B43078C0FEE9F /* Project object */;
proxyType = 1;
remoteGlobalIDString = D039EDECDE3998D8534BB680;
remoteInfo = LibNovel;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
1B8BF3DB582A658386E402C7 /* LibNovel.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = LibNovel.app; sourceTree = BUILT_PRODUCTS_DIR; };
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseView.swift; sourceTree = "<group>"; };
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = LibNovelTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
2D5C115992F1CE2326236765 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
39DE056C37FBC5EED8771821 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
3AB2E843D93461074A89A171 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = "<group>"; };
4B820081FA4817765A39939A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelApp.swift; sourceTree = "<group>"; };
5A776719B77EDDB5E44743B0 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
762E378B9BC2161A7AA2CC36 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavDestination.swift; sourceTree = "<group>"; };
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderView.swift; sourceTree = "<group>"; };
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterReaderViewModel.swift; sourceTree = "<group>"; };
8E89FD8F46747CA653C5203D /* CommonViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonViews.swift; sourceTree = "<group>"; };
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModel.swift; sourceTree = "<group>"; };
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowseViewModel.swift; sourceTree = "<group>"; };
9D83BB88C4306BE7A4F947CB /* Color+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Color+App.swift"; sourceTree = "<group>"; };
B4C918833E173D6B44D06955 /* LibNovelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibNovelTests.swift; sourceTree = "<group>"; };
B593F179EC3E9112126B540B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
C21107BECA55C07416E0CB8B /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
D6268D60803940CBD38FB921 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViews.swift; sourceTree = "<group>"; };
F219788AE5ACBD6F240674F5 /* AuthStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStore.swift; sourceTree = "<group>"; };
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
EFE3211B202EDF04EB141EFB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
CFDAA4776344B075A1E3CD6B /* Kingfisher in Frameworks */,
BD2CA5EE70D102CA3B153485 /* MarkdownUI in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2C0FB0EDFF9B3E24B97F4214 /* Resources */ = {
isa = PBXGroup;
children = (
5A776719B77EDDB5E44743B0 /* Assets.xcassets */,
);
path = Resources;
sourceTree = "<group>";
};
2C57B93EAF19A3B18E7B7E87 /* Views */ = {
isa = PBXGroup;
children = (
2F18D1275D6022B9847E310E /* Auth */,
FB5C0D4925633786D28C6DE3 /* BookDetail */,
8E8AAA58A33084ADB8AEA80C /* Browse */,
4EAB87A1ED4943A311F26F84 /* ChapterReader */,
5D5809803A3D74FAE19DB218 /* Common */,
811FC0F6B9C209D6EC8543BD /* Home */,
FA994FD601E79EC811D822A4 /* Library */,
89F2CB14192E7D7565A588E0 /* Player */,
3DB66C5703A4CCAFFA1B7AFE /* Profile */,
);
path = Views;
sourceTree = "<group>";
};
2F18D1275D6022B9847E310E /* Auth */ = {
isa = PBXGroup;
children = (
7CEF6782A2A28B2A485CBD48 /* AuthView.swift */,
);
path = Auth;
sourceTree = "<group>";
};
3DB66C5703A4CCAFFA1B7AFE /* Profile */ = {
isa = PBXGroup;
children = (
C0B17D50389C6C98FC78BDBC /* ProfileView.swift */,
);
path = Profile;
sourceTree = "<group>";
};
426F7C5465758645B93A1AB1 /* Networking */ = {
isa = PBXGroup;
children = (
B593F179EC3E9112126B540B /* APIClient.swift */,
);
path = Networking;
sourceTree = "<group>";
};
4EAB87A1ED4943A311F26F84 /* ChapterReader */ = {
isa = PBXGroup;
children = (
81E3939152E23B4985FAF7E2 /* ChapterReaderView.swift */,
);
path = ChapterReader;
sourceTree = "<group>";
};
5D5809803A3D74FAE19DB218 /* Common */ = {
isa = PBXGroup;
children = (
8E89FD8F46747CA653C5203D /* CommonViews.swift */,
);
path = Common;
sourceTree = "<group>";
};
6318D3C6F0DC6C8E2C377103 /* Products */ = {
isa = PBXGroup;
children = (
1B8BF3DB582A658386E402C7 /* LibNovel.app */,
235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
646952B9CE927F8038FF0A13 /* LibNovelTests */ = {
isa = PBXGroup;
children = (
B4C918833E173D6B44D06955 /* LibNovelTests.swift */,
);
path = LibNovelTests;
sourceTree = "<group>";
};
80148B5E27BD0A3DEDB3ADAA /* Models */ = {
isa = PBXGroup;
children = (
762E378B9BC2161A7AA2CC36 /* Models.swift */,
);
path = Models;
sourceTree = "<group>";
};
811FC0F6B9C209D6EC8543BD /* Home */ = {
isa = PBXGroup;
children = (
D6268D60803940CBD38FB921 /* HomeView.swift */,
);
path = Home;
sourceTree = "<group>";
};
89F2CB14192E7D7565A588E0 /* Player */ = {
isa = PBXGroup;
children = (
DF49C3AEF9D010F9FEDAB1FC /* PlayerViews.swift */,
);
path = Player;
sourceTree = "<group>";
};
8E8AAA58A33084ADB8AEA80C /* Browse */ = {
isa = PBXGroup;
children = (
1FA3F0FCA383180EE4C93BBA /* BrowseView.swift */,
);
path = Browse;
sourceTree = "<group>";
};
9AF55E5D62F980C72431782A = {
isa = PBXGroup;
children = (
A28A184E73B15138A4D13F31 /* LibNovel */,
646952B9CE927F8038FF0A13 /* LibNovelTests */,
6318D3C6F0DC6C8E2C377103 /* Products */,
);
indentWidth = 4;
sourceTree = "<group>";
tabWidth = 4;
usesTabs = 0;
};
A28A184E73B15138A4D13F31 /* LibNovel */ = {
isa = PBXGroup;
children = (
FE92158CC5DA9AD446062724 /* App */,
FD5EDEE9747643D45CA6423E /* Extensions */,
80148B5E27BD0A3DEDB3ADAA /* Models */,
426F7C5465758645B93A1AB1 /* Networking */,
2C0FB0EDFF9B3E24B97F4214 /* Resources */,
DA6F6F625578875F3E74F1D3 /* Services */,
B6916C5C762A37AB1279DF44 /* ViewModels */,
2C57B93EAF19A3B18E7B7E87 /* Views */,
);
path = LibNovel;
sourceTree = "<group>";
};
B6916C5C762A37AB1279DF44 /* ViewModels */ = {
isa = PBXGroup;
children = (
837F83AA12B59924FDF16617 /* BookDetailViewModel.swift */,
9812F5FE30ED657FB40ABD7A /* BrowseViewModel.swift */,
8995E667B3DD9CFCAD8A91D7 /* ChapterReaderViewModel.swift */,
3AB2E843D93461074A89A171 /* HomeViewModel.swift */,
FC338B05EA6DB22900712000 /* LibraryViewModel.swift */,
937A589F84FD412BBB6FBC45 /* ProfileViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
DA6F6F625578875F3E74F1D3 /* Services */ = {
isa = PBXGroup;
children = (
DB13E89E50529E3081533A66 /* AudioPlayerService.swift */,
F219788AE5ACBD6F240674F5 /* AuthStore.swift */,
);
path = Services;
sourceTree = "<group>";
};
FA994FD601E79EC811D822A4 /* Library */ = {
isa = PBXGroup;
children = (
C21107BECA55C07416E0CB8B /* LibraryView.swift */,
);
path = Library;
sourceTree = "<group>";
};
FB5C0D4925633786D28C6DE3 /* BookDetail */ = {
isa = PBXGroup;
children = (
39DE056C37FBC5EED8771821 /* BookDetailView.swift */,
);
path = BookDetail;
sourceTree = "<group>";
};
FD5EDEE9747643D45CA6423E /* Extensions */ = {
isa = PBXGroup;
children = (
9D83BB88C4306BE7A4F947CB /* Color+App.swift */,
7CAFB96D2500F34F0B0C860C /* NavDestination.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
FE92158CC5DA9AD446062724 /* App */ = {
isa = PBXGroup;
children = (
4B820081FA4817765A39939A /* ContentView.swift */,
4F56C8E2BC3614530B81569D /* LibNovelApp.swift */,
2D5C115992F1CE2326236765 /* RootTabView.swift */,
);
path = App;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */;
buildPhases = (
247D45B3DB26CAC41FA78A0B /* Sources */,
);
buildRules = (
);
dependencies = (
9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */,
);
name = LibNovelTests;
packageProductDependencies = (
);
productName = LibNovelTests;
productReference = 235967A21B386BE13F56F3F8 /* LibNovelTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
D039EDECDE3998D8534BB680 /* LibNovel */ = {
isa = PBXNativeTarget;
buildConfigurationList = 29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */;
buildPhases = (
48661ADCA15B54E048CF694C /* Sources */,
27446CA4728C022832398376 /* Resources */,
EFE3211B202EDF04EB141EFB /* Frameworks */,
);
buildRules = (
);
dependencies = (
);
name = LibNovel;
packageProductDependencies = (
09584EAB68A07B47F876A062 /* Kingfisher */,
6313AB4B3A5464F647791174 /* MarkdownUI */,
);
productName = LibNovel;
productReference = 1B8BF3DB582A658386E402C7 /* LibNovel.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
A10A669C0C8B43078C0FEE9F /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1600;
};
buildConfigurationList = D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = 9AF55E5D62F980C72431782A;
minimizedProjectReferenceProxies = 1;
packageReferences = (
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */,
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = 6318D3C6F0DC6C8E2C377103 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
D039EDECDE3998D8534BB680 /* LibNovel */,
5E6D3E8266BFCF0AAF5EC79D /* LibNovelTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
27446CA4728C022832398376 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7C74C10317D389121922A5E3 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
247D45B3DB26CAC41FA78A0B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
4BB2C76262D5BD5DAD0D5D28 /* LibNovelTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
48661ADCA15B54E048CF694C /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FB32F3772CA09684F00497F3 /* APIClient.swift in Sources */,
A9B95BAD7CE2DCD1DDDABD4C /* AudioPlayerService.swift in Sources */,
5D8D783259EF54C773788AAB /* AuthStore.swift in Sources */,
9B2D6F241E707312AB80DC31 /* AuthView.swift in Sources */,
749292A18C57FA41EC88A30B /* BookDetailView.swift in Sources */,
7D81DEB2EEFF9CA5079AEEF7 /* BookDetailViewModel.swift in Sources */,
08DFB5F626BA769556C8D145 /* BrowseView.swift in Sources */,
2790B8C051BE389D83645047 /* BrowseViewModel.swift in Sources */,
FEFB5FDC2424D22914458001 /* ChapterReaderView.swift in Sources */,
2A15157AD2AE2271675C3485 /* ChapterReaderViewModel.swift in Sources */,
E2572692178FD17145FDAF77 /* Color+App.swift in Sources */,
F2AF05B9C8C23132A73ACDD3 /* CommonViews.swift in Sources */,
94D0C4B15734B4056BF3B127 /* ContentView.swift in Sources */,
EF3C57C400BF05CBEAC1F7FE /* HomeView.swift in Sources */,
C807AD8D627CF6BED47D517C /* HomeViewModel.swift in Sources */,
032E049A4BB3CF0EA990C0CD /* LibNovelApp.swift in Sources */,
E1F564399D1325F6A1B2B84F /* LibraryView.swift in Sources */,
3521DFD5FCBBED7B90368829 /* LibraryViewModel.swift in Sources */,
0A52BC1CE71BED9E75D20D35 /* Models.swift in Sources */,
F4FDA3C44752EB979235C042 /* NavDestination.swift in Sources */,
BE7805A4E78037A82B12AE56 /* PlayerViews.swift in Sources */,
64D80AACB8E1967B17921EE3 /* ProfileView.swift in Sources */,
58E440CE4360D755401D1672 /* ProfileViewModel.swift in Sources */,
367C88FFC11701D2BAD8CCD0 /* RootTabView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
9FD4A50EB175FC09D6BFD28D /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D039EDECDE3998D8534BB680 /* LibNovel */;
targetProxy = 698AC3AA533BC05C985595D0 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
428871329DC9E7B31FA1664B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
};
name = Release;
};
49CBF0D367E562629E002A4B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel.tests;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/LibNovel.app/LibNovel";
};
name = Debug;
};
8098D4A97F989064EC71E5A1 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = GHZXC6FVMU;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
9C182367114E72FF84D54A2F /* Debug */ = {
isa = XCBuildConfiguration;
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;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
"DEBUG=1",
);
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;
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.10;
};
name = Debug;
};
D9977A0FA70F052FD0C126D3 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_IDENTITY = "iPhone Developer";
DEVELOPMENT_TEAM = GHZXC6FVMU;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = LibNovel/Resources/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = cc.kalekber.libnovel;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
F9ED141CFB1E2EC6F5E9F089 /* Release */ = {
isa = XCBuildConfiguration;
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;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 1;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_PREVIEWS = YES;
ENABLE_STRICT_OBJC_MSGSEND = 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;
LIBNOVEL_BASE_URL = "https://v2.libnovel.kalekber.cc";
MARKETING_VERSION = 1.0.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.10;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
29B2DE7267A3A4B2D89B32DA /* Build configuration list for PBXNativeTarget "LibNovel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
8098D4A97F989064EC71E5A1 /* Debug */,
D9977A0FA70F052FD0C126D3 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
964FF85B62FA35E819BE7661 /* Build configuration list for PBXNativeTarget "LibNovelTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
49CBF0D367E562629E002A4B /* Debug */,
428871329DC9E7B31FA1664B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
D27899EE96A9AFCBBE62EA3C /* Build configuration list for PBXProject "LibNovel" */ = {
isa = XCConfigurationList;
buildConfigurations = (
9C182367114E72FF84D54A2F /* Debug */,
F9ED141CFB1E2EC6F5E9F089 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 8.0.0;
};
};
C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/swift-markdown-ui";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.4.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
09584EAB68A07B47F876A062 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = AFEF7128801A76181793EA9C /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
6313AB4B3A5464F647791174 /* MarkdownUI */ = {
isa = XCSwiftPackageProductDependency;
package = C963DFA5885608981692ADF1 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = A10A669C0C8B43078C0FEE9F /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,42 @@
{
"originHash" : "18350c2bfa3935125b6f4e9817e7ed4508588c07142d420b8b8ee00640a57853",
"pins" : [
{
"identity" : "kingfisher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Kingfisher",
"state" : {
"revision" : "c92b84898e34ab46ff0dad86c02a0acbe2d87008",
"version" : "8.8.0"
}
},
{
"identity" : "networkimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/NetworkImage",
"state" : {
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
"version" : "6.0.1"
}
},
{
"identity" : "swift-cmark",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-cmark",
"state" : {
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
"version" : "0.7.1"
}
},
{
"identity" : "swift-markdown-ui",
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
"state" : {
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
"version" : "2.4.1"
}
}
],
"version" : 3
}

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
runPostActionsOnFailure = "NO">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
onlyGenerateCoverageForSpecifiedTargets = "NO">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5E6D3E8266BFCF0AAF5EC79D"
BuildableName = "LibNovelTests.xctest"
BlueprintName = "LibNovelTests"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "LIBNOVEL_BASE_URL"
value = "[&quot;isEnabled&quot;: true, &quot;value&quot;: &quot;https://v2.libnovel.kalekber.cc&quot;]"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "D039EDECDE3998D8534BB680"
BuildableName = "LibNovel.app"
BlueprintName = "LibNovel"
ReferencedContainer = "container:LibNovel.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
</CommandLineArguments>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -0,0 +1,16 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject var authStore: AuthStore
@EnvironmentObject var audioPlayer: AudioPlayerService
var body: some View {
Group {
if authStore.isAuthenticated {
RootTabView()
} else {
AuthView()
}
}
}
}

View File

@@ -0,0 +1,15 @@
import SwiftUI
@main
struct LibNovelApp: App {
@StateObject private var authStore = AuthStore()
@StateObject private var audioPlayer = AudioPlayerService()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(authStore)
.environmentObject(audioPlayer)
}
}
}

View File

@@ -0,0 +1,63 @@
import SwiftUI
// MARK: - Root tab container with persistent mini-player overlay
struct RootTabView: View {
@EnvironmentObject var authStore: AuthStore
@EnvironmentObject var audioPlayer: AudioPlayerService
@State private var selectedTab: Tab = .home
@State private var showFullPlayer: Bool = false
enum Tab: Hashable {
case home, library, browse, profile
}
var body: some View {
ZStack(alignment: .bottom) {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house.fill") }
.tag(Tab.home)
LibraryView()
.tabItem { Label("Library", systemImage: "books.vertical.fill") }
.tag(Tab.library)
BrowseView()
.tabItem { Label("Discover", systemImage: "globe") }
.tag(Tab.browse)
ProfileView()
.tabItem { Label("Profile", systemImage: "person.fill") }
.tag(Tab.profile)
}
// Push tab bar up when mini-player is visible
.safeAreaInset(edge: .bottom) {
if audioPlayer.isActive {
Color.clear.frame(height: 64)
}
}
// Mini-player pinned above the tab bar
if audioPlayer.isActive {
MiniPlayerView(showFullPlayer: $showFullPlayer)
.padding(.bottom, tabBarHeight)
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: audioPlayer.isActive)
}
}
.sheet(isPresented: $showFullPlayer) {
FullPlayerView()
}
}
// Approximate safe-area-aware tab bar height
private var tabBarHeight: CGFloat {
let window = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.windows.first(where: \.isKeyWindow)
let bottomInset = window?.safeAreaInsets.bottom ?? 0
return 49 + bottomInset // 49pt is the standard iOS tab bar height
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
// MARK: - App accent color (amber mirrors Tailwind amber-500 #f59e0b)
extension Color {
static let amber = Color(red: 0.96, green: 0.62, blue: 0.04)
static let amberLight = Color(red: 1.0, green: 0.84, blue: 0.40)
}
extension ShapeStyle where Self == Color {
static var amber: Color { .amber }
}

View File

@@ -0,0 +1,8 @@
import SwiftUI
// MARK: - Navigation destination enum used across all tabs
enum NavDestination: Hashable {
case book(String) // slug
case chapter(String, Int) // slug + chapter number
}

View File

@@ -0,0 +1,259 @@
import Foundation
// MARK: - Book
struct Book: Identifiable, Codable, Hashable {
let id: String
let slug: String
let title: String
let author: String
let cover: String
let status: String
let genres: [String]
let summary: String
let totalChapters: Int
let sourceURL: String
let ranking: Int
let metaUpdated: String
enum CodingKeys: String, CodingKey {
case id, slug, title, author, cover, status, genres, summary
case totalChapters = "total_chapters"
case sourceURL = "source_url"
case ranking
case metaUpdated = "meta_updated"
}
// PocketBase returns genres as either a JSON string array or a real array
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
slug = try container.decode(String.self, forKey: .slug)
title = try container.decode(String.self, forKey: .title)
author = try container.decode(String.self, forKey: .author)
cover = try container.decodeIfPresent(String.self, forKey: .cover) ?? ""
status = try container.decodeIfPresent(String.self, forKey: .status) ?? ""
totalChapters = try container.decodeIfPresent(Int.self, forKey: .totalChapters) ?? 0
sourceURL = try container.decodeIfPresent(String.self, forKey: .sourceURL) ?? ""
ranking = try container.decodeIfPresent(Int.self, forKey: .ranking) ?? 0
metaUpdated = try container.decodeIfPresent(String.self, forKey: .metaUpdated) ?? ""
summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? ""
// genres is sometimes a JSON-encoded string, sometimes a real array
if let arr = try? container.decode([String].self, forKey: .genres) {
genres = arr
} else if let str = try? container.decode(String.self, forKey: .genres),
let data = str.data(using: .utf8),
let arr = try? JSONDecoder().decode([String].self, from: data) {
genres = arr
} else {
genres = []
}
}
}
// MARK: - ChapterIndex
struct ChapterIndex: Identifiable, Codable, Hashable {
let id: String
let slug: String
let number: Int
let title: String
let dateLabel: String
enum CodingKeys: String, CodingKey {
case id, slug, number, title
case dateLabel = "date_label"
}
}
struct ChapterIndexBrief: Codable, Hashable {
let number: Int
let title: String
}
// MARK: - Progress
struct ReadingProgress: Codable {
var id: String?
let sessionId: String
var userId: String?
let slug: String
var chapter: Int
var audioTime: Double?
let updated: String
enum CodingKeys: String, CodingKey {
case id
case sessionId = "session_id"
case userId = "user_id"
case slug, chapter
case audioTime = "audio_time"
case updated
}
}
// MARK: - User Settings
struct UserSettings: Codable {
var id: String?
var autoNext: Bool
var voice: String
var speed: Double
enum CodingKeys: String, CodingKey {
case id
case autoNext = "auto_next"
case voice, speed
}
static let `default` = UserSettings(id: nil, autoNext: false, voice: "af_bella", speed: 1.0)
}
// MARK: - User
struct AppUser: Codable, Identifiable {
let id: String
let username: String
let role: String
let created: String
var isAdmin: Bool { role == "admin" }
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = try c.decode(String.self, forKey: .id)
username = try c.decode(String.self, forKey: .username)
role = try c.decodeIfPresent(String.self, forKey: .role) ?? "user"
created = try c.decodeIfPresent(String.self, forKey: .created) ?? ""
}
}
// MARK: - Browse / Novel Listing
struct NovelListing: Identifiable, Hashable {
var id: String { slug.isEmpty ? url : slug }
let slug: String
let title: String
let cover: String
let rank: String
let rating: String
let chapters: String
let url: String
let author: String
let status: String
let genres: [String]
}
// MARK: - Ranking
struct RankingItem: Codable, Identifiable {
var id: String { slug }
let rank: Int
let slug: String
let title: String
let author: String
let cover: String
let status: String
let genres: [String]
let sourceURL: String
enum CodingKeys: String, CodingKey {
case rank, slug, title, author, cover, status, genres
case sourceURL = "source_url"
}
}
// MARK: - Home
struct HomeData {
let continueReading: [ContinueReadingItem]
let recentlyUpdated: [Book]
let stats: HomeStats
}
struct ContinueReadingItem: Identifiable {
var id: String { book.id }
let book: Book
let chapter: Int
}
struct HomeStats: Codable {
let totalBooks: Int
let totalChapters: Int
let booksInProgress: Int
}
// MARK: - Session
struct UserSession: Codable, Identifiable {
let id: String
let userAgent: String
let ip: String
let createdAt: String
let lastSeen: String
var isCurrent: Bool
enum CodingKeys: String, CodingKey {
case id
case userAgent = "user_agent"
case ip
case createdAt = "created_at"
case lastSeen = "last_seen"
case isCurrent = "is_current"
}
}
// MARK: - Book Detail
struct BookDetailData {
let book: Book
let chapters: [ChapterIndex]
let previewChapters: [PreviewChapter]?
let inLib: Bool
let saved: Bool
let lastChapter: Int?
}
struct PreviewChapter: Codable, Identifiable {
var id: Int { number }
let number: Int
let title: String
let url: String
}
// MARK: - Chapter Content
struct ChapterContent {
let book: BookBrief
let chapter: ChapterIndex
let html: String
let voices: [String]
let prev: Int?
let next: Int?
let chapters: [ChapterIndexBrief]
let isPreview: Bool
}
struct BookBrief: Codable {
let slug: String
let title: String
let cover: String
}
// MARK: - Audio
enum AudioStatus {
case idle, loading, generating, ready, error(String)
}
enum NextPrefetchStatus {
case none, prefetching, prefetched, failed
}
// MARK: - PocketBase list response
struct PBList<T: Codable>: Codable {
let items: [T]
let totalItems: Int
}

View File

@@ -0,0 +1,416 @@
import Foundation
// MARK: - API Client
// Communicates with the SvelteKit UI server (not directly with the Go scraper).
// The SvelteKit layer handles auth, PocketBase queries, and MinIO presigning.
// For the iOS app we talk to the same /api/* endpoints the web UI uses,
// so we reuse the exact same HMAC-cookie auth flow.
actor APIClient {
static let shared = APIClient()
private var baseURL: URL
private var authCookie: String? // raw "libnovel_auth=<token>" header value
private var sessionId: String? // anon session id (UUID)
// URLSession with persistent cookie storage
private let session: URLSession = {
let config = URLSessionConfiguration.default
config.httpCookieAcceptPolicy = .always
config.httpShouldSetCookies = true
config.httpCookieStorage = HTTPCookieStorage.shared
return URLSession(configuration: config)
}()
private init() {
// Default: point at the UI server. Override via Settings bundle or compile flag.
let urlString = Bundle.main.object(forInfoDictionaryKey: "LIBNOVEL_BASE_URL") as? String
?? "https://v2.libnovel.kalekber.cc"
baseURL = URL(string: urlString)!
}
// MARK: - Auth cookie management
func setAuthCookie(_ value: String?) {
authCookie = value
if let value {
// Also inject into shared cookie storage so redirects carry the cookie
let cookieProps: [HTTPCookiePropertyKey: Any] = [
.name: "libnovel_auth",
.value: value,
.domain: baseURL.host ?? "localhost",
.path: "/"
]
if let cookie = HTTPCookie(properties: cookieProps) {
HTTPCookieStorage.shared.setCookie(cookie)
}
} else {
// Clear
let cookieStorage = HTTPCookieStorage.shared
cookieStorage.cookies(for: baseURL)?.forEach { cookieStorage.deleteCookie($0) }
}
}
func setSessionId(_ id: String) {
sessionId = id
}
// MARK: - Low-level request builder
private func makeRequest(_ path: String, method: String = "GET", body: Encodable? = nil) throws -> URLRequest {
let url = baseURL.appendingPathComponent(path)
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("application/json", forHTTPHeaderField: "Accept")
if let body {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONEncoder().encode(body)
}
return req
}
// MARK: - Generic fetch
func fetch<T: Decodable>(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> T {
let req = try makeRequest(path, method: method, body: body)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else {
throw APIError.invalidResponse
}
guard (200..<300).contains(http.statusCode) else {
let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
throw APIError.httpError(http.statusCode, message)
}
do {
return try JSONDecoder.iso8601.decode(T.self, from: data)
} catch {
throw APIError.decodingError(error)
}
}
func fetchRaw(_ path: String, method: String = "GET", body: Encodable? = nil) async throws -> (Data, HTTPURLResponse) {
let req = try makeRequest(path, method: method, body: body)
let (data, response) = try await session.data(for: req)
guard let http = response as? HTTPURLResponse else { throw APIError.invalidResponse }
return (data, http)
}
// MARK: - Auth
struct LoginRequest: Encodable {
let username: String
let password: String
}
struct LoginResponse: Decodable {
let token: String
let user: AppUser
}
func login(username: String, password: String) async throws -> LoginResponse {
try await fetch("/api/auth/login", method: "POST",
body: LoginRequest(username: username, password: password))
}
func register(username: String, password: String) async throws -> LoginResponse {
try await fetch("/api/auth/register", method: "POST",
body: LoginRequest(username: username, password: password))
}
func logout() async throws {
let (_, _) = try await fetchRaw("/api/auth/logout", method: "POST")
await setAuthCookie(nil)
}
// MARK: - Home
func homeData() async throws -> HomeDataResponse {
try await fetch("/api/home")
}
// MARK: - Library
func library() async throws -> [LibraryItem] {
try await fetch("/api/library")
}
func saveBook(slug: String) async throws {
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "POST")
}
func unsaveBook(slug: String) async throws {
let _: EmptyResponse = try await fetch("/api/library/\(slug)", method: "DELETE")
}
// MARK: - Book Detail
func bookDetail(slug: String) async throws -> BookDetailResponse {
try await fetch("/api/book/\(slug)")
}
// MARK: - Chapter
func chapterContent(slug: String, chapter: Int) async throws -> ChapterResponse {
try await fetch("/api/chapter/\(slug)/\(chapter)")
}
// MARK: - Browse
struct BrowseParams: Encodable {
let page: Int
let genre: String
let sort: String
let status: String
}
func browse(page: Int, genre: String = "all", sort: String = "popular", status: String = "all") async throws -> BrowseResponse {
let query = "?page=\(page)&genre=\(genre)&sort=\(sort)&status=\(status)"
return try await fetch("/api/browse-page\(query)")
}
func search(query: String) async throws -> SearchResponse {
let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query
return try await fetch("/api/search?q=\(encoded)")
}
func ranking() async throws -> [RankingItem] {
try await fetch("/api/ranking")
}
// MARK: - Progress
func progress() async throws -> [ProgressEntry] {
try await fetch("/api/progress")
}
func setProgress(slug: String, chapter: Int) async throws {
struct Body: Encodable { let chapter: Int }
let _: EmptyResponse = try await fetch("/api/progress/\(slug)", method: "POST", body: Body(chapter: chapter))
}
func audioTime(slug: String, chapter: Int) async throws -> Double? {
struct Response: Decodable { let audioTime: Double?; enum CodingKeys: String, CodingKey { case audioTime = "audio_time" } }
let r: Response = try await fetch("/api/progress/audio-time?slug=\(slug)&chapter=\(chapter)")
return r.audioTime
}
func setAudioTime(slug: String, chapter: Int, time: Double) async throws {
struct Body: Encodable { let slug: String; let chapter: Int; let audioTime: Double; enum CodingKeys: String, CodingKey { case slug, chapter; case audioTime = "audio_time" } }
let _: EmptyResponse = try await fetch("/api/progress/audio-time", method: "PATCH", body: Body(slug: slug, chapter: chapter, audioTime: time))
}
// MARK: - Audio
func triggerAudio(slug: String, chapter: Int, voice: String, speed: Double) async throws -> AudioGenerateResponse {
struct Body: Encodable { let voice: String; let speed: Double }
return try await fetch("/api/audio/\(slug)/\(chapter)", method: "POST", body: Body(voice: voice, speed: speed))
}
func presignAudio(slug: String, chapter: Int, voice: String) async throws -> String {
struct Response: Decodable { let url: String }
let r: Response = try await fetch("/api/presign/audio?slug=\(slug)&chapter=\(chapter)&voice=\(voice)")
return r.url
}
func presignVoiceSample(voice: String) async throws -> String {
struct Response: Decodable { let url: String }
let r: Response = try await fetch("/api/presign/voice-sample?voice=\(voice)")
return r.url
}
func voices() async throws -> [String] {
struct Response: Decodable { let voices: [String] }
let r: Response = try await fetch("/api/voices")
return r.voices
}
// MARK: - Settings
func settings() async throws -> UserSettings {
try await fetch("/api/settings")
}
func updateSettings(_ settings: UserSettings) async throws {
let _: EmptyResponse = try await fetch("/api/settings", method: "PUT", body: settings)
}
// MARK: - Sessions
func sessions() async throws -> [UserSession] {
try await fetch("/api/sessions")
}
func revokeSession(id: String) async throws {
let _: EmptyResponse = try await fetch("/api/sessions/\(id)", method: "DELETE")
}
}
// MARK: - Response types
struct HomeDataResponse: Decodable {
struct ContinueItem: Decodable {
let book: Book
let chapter: Int
}
let continueReading: [ContinueItem]
let recentlyUpdated: [Book]
let stats: HomeStats
enum CodingKeys: String, CodingKey {
case continueReading = "continue_reading"
case recentlyUpdated = "recently_updated"
case stats
}
}
struct LibraryItem: Decodable, Identifiable {
var id: String { book.id }
let book: Book
let savedAt: String
let lastChapter: Int?
enum CodingKeys: String, CodingKey {
case book
case savedAt = "saved_at"
case lastChapter = "last_chapter"
}
}
struct BookDetailResponse: Decodable {
let book: Book
let chapters: [ChapterIndex]
let previewChapters: [PreviewChapter]?
let inLib: Bool
let saved: Bool
let lastChapter: Int?
enum CodingKeys: String, CodingKey {
case book, chapters
case previewChapters = "preview_chapters"
case inLib = "in_lib"
case saved
case lastChapter = "last_chapter"
}
}
struct ChapterResponse: Decodable {
let book: BookBrief
let chapter: ChapterIndex
let html: String
let voices: [String]
let prev: Int?
let next: Int?
let chapters: [ChapterIndexBrief]
let isPreview: Bool
enum CodingKeys: String, CodingKey {
case book, chapter, html, voices, prev, next, chapters
case isPreview = "is_preview"
}
}
struct BrowseResponse: Decodable {
let novels: [BrowseNovel]
let page: Int
let hasNext: Bool
enum CodingKeys: String, CodingKey {
case novels, page
case hasNext = "has_next"
}
}
struct BrowseNovel: Decodable, Identifiable, Hashable {
var id: String { slug.isEmpty ? url : slug }
let slug: String
let title: String
let cover: String
let rank: String
let rating: String
let chapters: String
let url: String
let author: String
let status: String
let genres: [String]
enum CodingKeys: String, CodingKey {
case slug, title, cover, rank, rating, chapters, url, author, status, genres
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
slug = try c.decodeIfPresent(String.self, forKey: .slug) ?? ""
title = try c.decode(String.self, forKey: .title)
cover = try c.decodeIfPresent(String.self, forKey: .cover) ?? ""
rank = try c.decodeIfPresent(String.self, forKey: .rank) ?? ""
rating = try c.decodeIfPresent(String.self, forKey: .rating) ?? ""
chapters = try c.decodeIfPresent(String.self, forKey: .chapters) ?? ""
url = try c.decodeIfPresent(String.self, forKey: .url) ?? ""
author = try c.decodeIfPresent(String.self, forKey: .author) ?? ""
status = try c.decodeIfPresent(String.self, forKey: .status) ?? ""
genres = try c.decodeIfPresent([String].self, forKey: .genres) ?? []
}
}
struct SearchResponse: Decodable {
let results: [BrowseNovel]
let localCount: Int
let remoteCount: Int
enum CodingKeys: String, CodingKey {
case results
case localCount = "local_count"
case remoteCount = "remote_count"
}
}
struct AudioGenerateResponse: Decodable {
let status: String // "generating" | "ready" | "error"
let url: String?
let message: String?
}
struct ProgressEntry: Decodable, Identifiable {
var id: String { slug }
let slug: String
let chapter: Int
let audioTime: Double?
let updated: String
enum CodingKeys: String, CodingKey {
case slug, chapter, updated
case audioTime = "audio_time"
}
}
struct EmptyResponse: Decodable {}
// MARK: - API Error
enum APIError: LocalizedError {
case invalidResponse
case httpError(Int, String)
case decodingError(Error)
case unauthorized
case networkError(Error)
var errorDescription: String? {
switch self {
case .invalidResponse: return "Invalid server response"
case .httpError(let code, let msg): return "HTTP \(code): \(msg)"
case .decodingError(let e): return "Decode error: \(e.localizedDescription)"
case .unauthorized: return "Not authenticated"
case .networkError(let e): return e.localizedDescription
}
}
}
// MARK: - JSONDecoder helper
extension JSONDecoder {
static let iso8601: JSONDecoder = {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}()
}

View File

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

View File

@@ -0,0 +1,13 @@
{
"images": [
{
"idiom": "universal",
"platform": "ios",
"size": "1024x1024"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View File

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

View File

@@ -0,0 +1,43 @@
<?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>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleName</key>
<string>LibNovel</string>
<key>CFBundleDisplayName</key>
<string>LibNovel</string>
<key>CFBundleIdentifier</key>
<string>cc.kalekber.libnovel</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UILaunchScreen</key>
<dict/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>LIBNOVEL_BASE_URL</key>
<string>$(LIBNOVEL_BASE_URL)</string>
</dict>
</plist>

View File

@@ -0,0 +1,368 @@
import Foundation
import AVFoundation
import MediaPlayer
import Combine
// MARK: - AudioPlayerService
// Central singleton that owns AVPlayer, drives audio state, handles lock-screen
// controls (NowPlayingInfoCenter + MPRemoteCommandCenter), and pre-fetches the
// next chapter audio mirroring the web AudioStore.
@MainActor
final class AudioPlayerService: ObservableObject {
// MARK: - Published state (mirrors web AudioStore fields)
@Published var slug: String = ""
@Published var chapter: Int = 0
@Published var chapterTitle: String = ""
@Published var bookTitle: String = ""
@Published var coverURL: String = ""
@Published var voice: String = "af_bella"
@Published var speed: Double = 1.0
@Published var chapters: [ChapterIndexBrief] = []
@Published var status: AudioPlayerStatus = .idle
@Published var audioURL: String = ""
@Published var errorMessage: String = ""
@Published var generationProgress: Double = 0
@Published var currentTime: Double = 0
@Published var duration: Double = 0
@Published var isPlaying: Bool = false
@Published var autoNext: Bool = false
@Published var nextChapter: Int? = nil
@Published var nextPrefetchStatus: NextPrefetchStatus = .none
@Published var nextAudioURL: String = ""
@Published var nextProgress: Double = 0
@Published var nextPrefetchedChapter: Int? = nil
var isActive: Bool {
switch status {
case .idle: return false
default: return true
}
}
// MARK: - Private
private var player: AVPlayer?
private var playerItem: AVPlayerItem?
private var timeObserver: Any?
private var statusObserver: AnyCancellable?
private var finishObserver: AnyCancellable?
private var generationTask: Task<Void, Never>?
private var prefetchTask: Task<Void, Never>?
// MARK: - Init
init() {
configureAudioSession()
setupRemoteCommandCenter()
}
// MARK: - Public API
/// Load audio for a specific chapter. Triggers TTS generation if not cached.
func load(slug: String, chapter: Int, chapterTitle: String,
bookTitle: String, coverURL: String, voice: String, speed: Double,
chapters: [ChapterIndexBrief], nextChapter: Int?) {
// Cancel any in-flight generation
generationTask?.cancel()
prefetchTask?.cancel()
stop()
self.slug = slug
self.chapter = chapter
self.chapterTitle = chapterTitle
self.bookTitle = bookTitle
self.coverURL = coverURL
self.voice = voice
self.speed = speed
self.chapters = chapters
self.nextChapter = nextChapter
self.nextPrefetchStatus = .none
self.nextAudioURL = ""
self.nextPrefetchedChapter = nil
status = .loading
generationProgress = 0
generationTask = Task { await generateAudio() }
}
func play() {
player?.play()
player?.rate = Float(speed)
isPlaying = true
updateNowPlaying()
}
func pause() {
player?.pause()
isPlaying = false
updateNowPlaying()
}
func togglePlayPause() {
isPlaying ? pause() : play()
}
func seek(to seconds: Double) {
let time = CMTime(seconds: seconds, preferredTimescale: 600)
player?.seek(to: time)
currentTime = seconds
updateNowPlaying()
}
func skip(by seconds: Double) {
seek(to: max(0, min(currentTime + seconds, duration)))
}
func setSpeed(_ newSpeed: Double) {
speed = newSpeed
if isPlaying { player?.rate = Float(newSpeed) }
updateNowPlaying()
}
func stop() {
player?.pause()
teardownPlayer()
isPlaying = false
currentTime = 0
duration = 0
status = .idle
}
// MARK: - Audio generation loop (mirrors web polling pattern)
private func generateAudio() async {
guard !slug.isEmpty, chapter > 0 else { return }
do {
var pollCount = 0
while !Task.isCancelled {
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: chapter, voice: voice, speed: speed)
switch response.status {
case "ready":
if let url = response.url {
await MainActor.run {
self.audioURL = url
self.status = .ready
self.generationProgress = 100
}
await playURL(url)
await prefetchNext()
}
return
case "generating":
await MainActor.run {
self.status = .generating
// Simulate progress ramp (same trick as the web UI)
self.generationProgress = min(95, Double(pollCount) * 8)
}
pollCount += 1
try await Task.sleep(for: .seconds(2))
case "error":
await MainActor.run {
self.status = .error(response.message ?? "Unknown error")
}
return
default:
break
}
}
} catch is CancellationError {
// Cancelled no-op
} catch {
await MainActor.run {
self.status = .error(error.localizedDescription)
self.errorMessage = error.localizedDescription
}
}
}
// MARK: - Prefetch next chapter
private func prefetchNext() async {
guard autoNext, let next = nextChapter, !Task.isCancelled else { return }
nextPrefetchStatus = .prefetching
nextPrefetchedChapter = next
do {
var pollCount = 0
while !Task.isCancelled {
let response = try await APIClient.shared.triggerAudio(slug: slug, chapter: next, voice: voice, speed: speed)
switch response.status {
case "ready":
if let url = response.url {
nextAudioURL = url
nextPrefetchStatus = .prefetched
}
return
case "generating":
nextProgress = min(95, Double(pollCount) * 8)
pollCount += 1
try await Task.sleep(for: .seconds(2))
case "error":
nextPrefetchStatus = .failed
return
default:
break
}
}
} catch {
nextPrefetchStatus = .failed
}
}
// MARK: - AVPlayer management
private func playURL(_ urlString: String) async {
guard let url = URL(string: urlString) else { return }
teardownPlayer()
let item = AVPlayerItem(url: url)
playerItem = item
player = AVPlayer(playerItem: item)
player?.rate = Float(speed)
// Observe playback time
timeObserver = player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self else { return }
Task { @MainActor in
self.currentTime = time.seconds
if let dur = self.playerItem?.duration.seconds, dur.isFinite, dur > 0 {
self.duration = dur
}
}
}
// Observe when playback ends
finishObserver = NotificationCenter.default
.publisher(for: AVPlayerItem.didPlayToEndTimeNotification, object: item)
.sink { [weak self] _ in
Task { @MainActor in
self?.handlePlaybackFinished()
}
}
player?.play()
isPlaying = true
updateNowPlaying()
}
private func teardownPlayer() {
if let observer = timeObserver { player?.removeTimeObserver(observer) }
timeObserver = nil
finishObserver = nil
player = nil
playerItem = nil
}
private func handlePlaybackFinished() {
isPlaying = false
if autoNext, let next = nextChapter {
// The chapter view model listens for this signal to navigate
NotificationCenter.default.post(name: .audioDidFinishChapter,
object: nil,
userInfo: ["next": next])
}
}
// MARK: - Audio Session
private func configureAudioSession() {
do {
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .spokenAudio)
try AVAudioSession.sharedInstance().setActive(true)
} catch {
// Non-fatal
}
}
// MARK: - Lock Screen / Control Center (NowPlayingInfoCenter)
private func setupRemoteCommandCenter() {
let center = MPRemoteCommandCenter.shared()
center.playCommand.addTarget { [weak self] _ in
self?.play()
return .success
}
center.pauseCommand.addTarget { [weak self] _ in
self?.pause()
return .success
}
center.togglePlayPauseCommand.addTarget { [weak self] _ in
self?.togglePlayPause()
return .success
}
center.skipForwardCommand.preferredIntervals = [30]
center.skipForwardCommand.addTarget { [weak self] _ in
self?.skip(by: 30)
return .success
}
center.skipBackwardCommand.preferredIntervals = [15]
center.skipBackwardCommand.addTarget { [weak self] _ in
self?.skip(by: -15)
return .success
}
center.changePlaybackPositionCommand.addTarget { [weak self] event in
if let e = event as? MPChangePlaybackPositionCommandEvent {
self?.seek(to: e.positionTime)
}
return .success
}
}
private func updateNowPlaying() {
var info: [String: Any] = [
MPMediaItemPropertyTitle: chapterTitle.isEmpty ? "Chapter \(chapter)" : chapterTitle,
MPMediaItemPropertyArtist: bookTitle,
MPNowPlayingInfoPropertyElapsedPlaybackTime: currentTime,
MPMediaItemPropertyPlaybackDuration: duration,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? speed : 0.0
]
// Cover art (async download)
if !coverURL.isEmpty, let url = URL(string: coverURL) {
URLSession.shared.dataTask(with: url) { data, _, _ in
if let data, let image = UIImage(data: data) {
let artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
var updated = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
updated[MPMediaItemPropertyArtwork] = artwork
MPNowPlayingInfoCenter.default().nowPlayingInfo = updated
}
}.resume()
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
// MARK: - Supporting types
enum AudioPlayerStatus: Equatable {
case idle
case loading
case generating
case ready
case error(String)
static func == (lhs: AudioPlayerStatus, rhs: AudioPlayerStatus) -> Bool {
switch (lhs, rhs) {
case (.idle, .idle), (.loading, .loading), (.generating, .generating), (.ready, .ready):
return true
case (.error(let a), .error(let b)):
return a == b
default:
return false
}
}
}
extension Notification.Name {
static let audioDidFinishChapter = Notification.Name("audioDidFinishChapter")
}

View File

@@ -0,0 +1,139 @@
import Foundation
import Combine
// MARK: - AuthStore
// Owns the authenticated user, the HMAC auth token, and user settings.
// Persists the token to Keychain so the user stays logged in across launches.
@MainActor
final class AuthStore: ObservableObject {
@Published var user: AppUser?
@Published var settings: UserSettings = .default
@Published var isLoading: Bool = false
@Published var error: String?
var isAuthenticated: Bool { user != nil }
private let keychainKey = "libnovel_auth_token"
init() {
// Restore token from Keychain and validate it on launch
if let token = loadToken() {
Task { await validateToken(token) }
}
}
// MARK: - Login / Register
func login(username: String, password: String) async {
isLoading = true
error = nil
do {
let response = try await APIClient.shared.login(username: username, password: password)
await APIClient.shared.setAuthCookie(response.token)
saveToken(response.token)
user = response.user
await loadSettings()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func register(username: String, password: String) async {
isLoading = true
error = nil
do {
let response = try await APIClient.shared.register(username: username, password: password)
await APIClient.shared.setAuthCookie(response.token)
saveToken(response.token)
user = response.user
await loadSettings()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func logout() async {
do {
try await APIClient.shared.logout()
} catch {
// Best-effort; clear local state regardless
}
clearToken()
user = nil
settings = .default
}
// MARK: - Settings
func loadSettings() async {
do {
settings = try await APIClient.shared.settings()
} catch {
// Use defaults if settings endpoint fails
}
}
func saveSettings(_ updated: UserSettings) async {
do {
try await APIClient.shared.updateSettings(updated)
settings = updated
} catch {
self.error = error.localizedDescription
}
}
// MARK: - Token validation (on cold launch)
private func validateToken(_ token: String) async {
await APIClient.shared.setAuthCookie(token)
// Use /api/auth/me to restore the user record and confirm the token is still valid
do {
async let me: AppUser = APIClient.shared.fetch("/api/auth/me")
async let s: UserSettings = APIClient.shared.settings()
let (restoredUser, restoredSettings) = try await (me, s)
user = restoredUser
settings = restoredSettings
} catch let e as APIError {
if case .httpError(let code, _) = e, code == 401 {
clearToken()
}
} catch {}
}
// MARK: - Keychain helpers
private func saveToken(_ token: String) {
let data = Data(token.utf8)
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecValueData as String: data
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
}
private func loadToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecReturnData as String: true,
kSecMatchLimit as String: 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)
}
private func clearToken() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
@MainActor
final class BookDetailViewModel: ObservableObject {
let slug: String
@Published var book: Book?
@Published var chapters: [ChapterIndex] = []
@Published var saved: Bool = false
@Published var lastChapter: Int?
@Published var isLoading = false
@Published var chaptersLoading = false
@Published var error: String?
init(slug: String) {
self.slug = slug
}
func load() async {
isLoading = true
error = nil
do {
let detail = try await APIClient.shared.bookDetail(slug: slug)
book = detail.book
chapters = detail.chapters
saved = detail.saved
lastChapter = detail.lastChapter
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func toggleSaved() async {
do {
if saved {
try await APIClient.shared.unsaveBook(slug: slug)
} else {
try await APIClient.shared.saveBook(slug: slug)
}
saved.toggle()
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,69 @@
import Foundation
@MainActor
final class BrowseViewModel: ObservableObject {
@Published var novels: [BrowseNovel] = []
@Published var sort: String = "popular"
@Published var genre: String = "all"
@Published var status: String = "all"
@Published var searchQuery: String = ""
@Published var isLoading = false
@Published var hasNext = false
@Published var error: String?
private var currentPage = 1
private var isSearchMode = false
func loadFirstPage() async {
currentPage = 1
novels = []
isSearchMode = false
await loadPage(1)
}
func loadNextPage() async {
guard hasNext, !isLoading else { return }
await loadPage(currentPage + 1)
}
func search() async {
guard !searchQuery.isEmpty else { await loadFirstPage(); return }
isLoading = true
isSearchMode = true
novels = []
error = nil
do {
let result = try await APIClient.shared.search(query: searchQuery)
novels = result.results
hasNext = false
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func clearSearch() {
searchQuery = ""
Task { await loadFirstPage() }
}
private func loadPage(_ page: Int) async {
isLoading = true
error = nil
do {
let result = try await APIClient.shared.browse(
page: page, genre: genre, sort: sort, status: status
)
if page == 1 {
novels = result.novels
} else {
novels.append(contentsOf: result.novels)
}
hasNext = result.hasNext
currentPage = page
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,53 @@
import Foundation
@MainActor
final class ChapterReaderViewModel: ObservableObject {
let slug: String
let chapter: Int
@Published var content: ChapterResponse?
@Published var isLoading = false
@Published var error: String?
@Published var navigateTo: Int? // set to trigger navigation to next chapter
init(slug: String, chapter: Int) {
self.slug = slug
self.chapter = chapter
}
func load() async {
isLoading = true
error = nil
do {
content = try await APIClient.shared.chapterContent(slug: slug, chapter: chapter)
// Record reading progress
try? await APIClient.shared.setProgress(slug: slug, chapter: chapter)
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func toggleAudio(audioPlayer: AudioPlayerService, settings: UserSettings) {
guard let content else { return }
let isCurrent = audioPlayer.slug == slug && audioPlayer.chapter == chapter
if isCurrent {
audioPlayer.togglePlayPause()
} else {
let nextChapter: Int? = content.next
audioPlayer.load(
slug: slug,
chapter: chapter,
chapterTitle: content.chapter.title,
bookTitle: content.book.title,
coverURL: content.book.cover,
voice: settings.voice,
speed: settings.speed,
chapters: content.chapters,
nextChapter: nextChapter
)
}
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
@MainActor
final class HomeViewModel: ObservableObject {
@Published var continueReading: [ContinueReadingItem] = []
@Published var recentlyUpdated: [Book] = []
@Published var stats: HomeStats?
@Published var isLoading = false
@Published var error: String?
func load() async {
isLoading = true
error = nil
do {
let data = try await APIClient.shared.homeData()
continueReading = data.continueReading.map {
ContinueReadingItem(book: $0.book, chapter: $0.chapter)
}
recentlyUpdated = data.recentlyUpdated
stats = data.stats
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,19 @@
import Foundation
@MainActor
final class LibraryViewModel: ObservableObject {
@Published var items: [LibraryItem] = []
@Published var isLoading = false
@Published var error: String?
func load() async {
isLoading = true
error = nil
do {
items = try await APIClient.shared.library()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
@MainActor
final class ProfileViewModel: ObservableObject {
@Published var sessions: [UserSession] = []
@Published var voices: [String] = []
@Published var sessionsLoading = false
@Published var error: String?
func loadSessions() async {
sessionsLoading = true
do {
sessions = try await APIClient.shared.sessions()
} catch {
self.error = error.localizedDescription
}
sessionsLoading = false
}
func loadVoices() async {
guard voices.isEmpty else { return }
do {
voices = try await APIClient.shared.voices()
} catch {
// Use hardcoded fallback same as Go server helpers.go
voices = ["af_bella", "af_sky", "af_sarah", "af_nicole",
"am_adam", "am_michael", "bf_emma", "bf_isabella",
"bm_george", "bm_lewis"]
}
}
func revokeSession(id: String) async {
do {
try await APIClient.shared.revokeSession(id: id)
sessions.removeAll { $0.id == id }
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,123 @@
import SwiftUI
struct AuthView: View {
@EnvironmentObject var authStore: AuthStore
@State private var mode: Mode = .login
@State private var username: String = ""
@State private var password: String = ""
@State private var confirmPassword: String = ""
@FocusState private var focusedField: Field?
enum Mode { case login, register }
enum Field { case username, password, confirmPassword }
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Logo / header
VStack(spacing: 8) {
Image(systemName: "books.vertical.fill")
.font(.system(size: 56))
.foregroundStyle(.amber)
Text("LibNovel")
.font(.largeTitle.bold())
}
.padding(.top, 60)
.padding(.bottom, 40)
// Tab switcher
Picker("Mode", selection: $mode) {
Text("Sign In").tag(Mode.login)
Text("Create Account").tag(Mode.register)
}
.pickerStyle(.segmented)
.padding(.horizontal, 24)
.padding(.bottom, 32)
// Form
VStack(spacing: 16) {
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($focusedField, equals: .username)
.submitLabel(.next)
.onSubmit { focusedField = .password }
SecureField("Password", text: $password)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .password)
.submitLabel(mode == .register ? .next : .go)
.onSubmit {
if mode == .register { focusedField = .confirmPassword }
else { submit() }
}
if mode == .register {
SecureField("Confirm Password", text: $confirmPassword)
.textFieldStyle(.roundedBorder)
.focused($focusedField, equals: .confirmPassword)
.submitLabel(.go)
.onSubmit { submit() }
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.padding(.horizontal, 24)
.animation(.easeInOut(duration: 0.2), value: mode)
if let error = authStore.error {
Text(error)
.font(.footnote)
.foregroundStyle(.red)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
.padding(.top, 8)
}
Button(action: submit) {
Group {
if authStore.isLoading {
ProgressView()
.progressViewStyle(.circular)
.tint(.white)
} else {
Text(mode == .login ? "Sign In" : "Create Account")
.fontWeight(.semibold)
}
}
.frame(maxWidth: .infinity)
.frame(height: 50)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
.padding(.horizontal, 24)
.padding(.top, 24)
.disabled(authStore.isLoading || !formIsValid)
Spacer()
}
.navigationBarHidden(true)
}
.onChange(of: mode) { _, _ in
authStore.error = nil
confirmPassword = ""
}
}
private var formIsValid: Bool {
let base = !username.isEmpty && password.count >= 4
if mode == .register { return base && password == confirmPassword }
return base
}
private func submit() {
focusedField = nil
Task {
if mode == .login {
await authStore.login(username: username, password: password)
} else {
await authStore.register(username: username, password: password)
}
}
}
}

View File

@@ -0,0 +1,220 @@
import SwiftUI
struct BookDetailView: View {
let slug: String
@StateObject private var vm: BookDetailViewModel
@EnvironmentObject var authStore: AuthStore
@EnvironmentObject var audioPlayer: AudioPlayerService
@State private var summaryExpanded = false
@State private var chapterPage = 0
private let pageSize = 100
init(slug: String) {
self.slug = slug
_vm = StateObject(wrappedValue: BookDetailViewModel(slug: slug))
}
var body: some View {
ScrollView {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity).padding(.top, 80)
} else if let book = vm.book {
VStack(alignment: .leading, spacing: 0) {
heroSection(book: book)
Divider().padding(.vertical, 8)
chapterSection(book: book)
}
}
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar { bookmarkButton }
.task { await vm.load() }
.alert("Error", isPresented: .constant(vm.error != nil)) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
// MARK: - Hero
@ViewBuilder
private func heroSection(book: Book) -> some View {
ZStack(alignment: .bottom) {
// Blurred cover background
AsyncCoverImage(url: book.cover)
.frame(maxWidth: .infinity)
.frame(height: 260)
.blur(radius: 20)
.clipped()
.overlay(Color.black.opacity(0.45))
HStack(alignment: .bottom, spacing: 14) {
AsyncCoverImage(url: book.cover)
.frame(width: 110, height: 160)
.clipShape(RoundedRectangle(cornerRadius: 10))
.shadow(radius: 8)
VStack(alignment: .leading, spacing: 6) {
Text(book.title)
.font(.headline)
.foregroundStyle(.white)
.lineLimit(3)
Text(book.author)
.font(.subheadline)
.foregroundStyle(.white.opacity(0.8))
HStack {
TagChip(label: book.status).colorScheme(.dark)
ForEach(book.genres.prefix(2), id: \.self) {
TagChip(label: $0).colorScheme(.dark)
}
}
}
Spacer(minLength: 0)
}
.padding(.horizontal)
.padding(.bottom, 16)
}
// Summary
VStack(alignment: .leading, spacing: 8) {
Text(book.summary)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(summaryExpanded ? nil : 4)
if book.summary.count > 200 {
Button(summaryExpanded ? "Less" : "More") {
withAnimation { summaryExpanded.toggle() }
}
.font(.caption.bold())
.foregroundStyle(.amber)
}
}
.padding()
// CTA buttons
HStack(spacing: 10) {
if let last = vm.lastChapter, last > 0 {
NavigationLink(value: NavDestination.chapter(slug, last)) {
Label("Continue Ch.\(last)", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("From Ch.1", systemImage: "arrow.counterclockwise")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
.tint(.secondary)
} else {
NavigationLink(value: NavDestination.chapter(slug, 1)) {
Label("Start Reading", systemImage: "book.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
}
.padding(.horizontal)
.padding(.bottom, 8)
}
// MARK: - Chapter list
@ViewBuilder
private func chapterSection(book: Book) -> some View {
let chapters = vm.chapters
let total = chapters.count
let start = chapterPage * pageSize
let end = min(start + pageSize, total)
let pageChapters = Array(chapters[start..<end])
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("Chapters")
.font(.title3.bold())
Spacer()
if total > 0 {
Text("\(start + 1)\(end) of \(total)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
.padding(.vertical, 10)
if vm.chaptersLoading {
ProgressView().frame(maxWidth: .infinity).padding()
} else {
ForEach(pageChapters) { ch in
NavigationLink(value: NavDestination.chapter(slug, ch.number)) {
ChapterRow(chapter: ch, isCurrent: ch.number == vm.lastChapter)
}
.buttonStyle(.plain)
Divider().padding(.leading)
}
}
// Pagination
if total > pageSize {
HStack {
Button("Previous") { chapterPage -= 1 }
.disabled(chapterPage == 0)
Spacer()
Button("Next") { chapterPage += 1 }
.disabled(end >= total)
}
.buttonStyle(.bordered)
.padding()
}
}
}
// MARK: - Toolbar bookmark
@ToolbarContentBuilder
private var bookmarkButton: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
Task { await vm.toggleSaved() }
} label: {
Image(systemName: vm.saved ? "bookmark.fill" : "bookmark")
.foregroundStyle(vm.saved ? .amber : .primary)
}
}
}
}
private struct ChapterRow: View {
let chapter: ChapterIndex
let isCurrent: Bool
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Chapter \(chapter.number)")
.font(.subheadline)
.fontWeight(isCurrent ? .bold : .regular)
.foregroundStyle(isCurrent ? .amber : .primary)
if !chapter.title.isEmpty && chapter.title != "Chapter \(chapter.number)" {
Text(chapter.title)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer()
if !chapter.dateLabel.isEmpty {
Text(chapter.dateLabel)
.font(.caption2)
.foregroundStyle(.tertiary)
}
Image(systemName: "chevron.right")
.font(.caption2)
.foregroundStyle(.tertiary)
}
.padding(.horizontal)
.padding(.vertical, 10)
.contentShape(Rectangle())
}
}

View File

@@ -0,0 +1,198 @@
import SwiftUI
struct BrowseView: View {
@StateObject private var vm = BrowseViewModel()
@State private var showFilters = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search bar
HStack {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Search novels...", text: $vm.searchQuery)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
.onSubmit { Task { await vm.search() } }
if !vm.searchQuery.isEmpty {
Button { vm.clearSearch() } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
}
}
.padding(10)
.background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 10))
.padding(.horizontal)
.padding(.vertical, 8)
// Filter chips row
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
FilterChip(label: "Sort: \(vm.sort.capitalized)", isActive: vm.sort != "popular") {
showFilters = true
}
FilterChip(label: "Genre: \(vm.genre == "all" ? "All" : vm.genre.capitalized)", isActive: vm.genre != "all") {
showFilters = true
}
FilterChip(label: "Status: \(vm.status == "all" ? "All" : vm.status.capitalized)", isActive: vm.status != "all") {
showFilters = true
}
}
.padding(.horizontal)
}
.padding(.bottom, 4)
Divider()
// Results
if vm.isLoading && vm.novels.isEmpty {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.novels.isEmpty && !vm.isLoading {
EmptyStateView(icon: "magnifyingglass", title: "No results", message: "Try a different search or filter.").frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
ForEach(vm.novels) { novel in
NavigationLink(value: NavDestination.book(novel.slug)) {
BrowseCard(novel: novel)
}
.buttonStyle(.plain)
}
// Infinite scroll trigger
if vm.hasNext {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.onAppear { Task { await vm.loadNextPage() } }
}
}
.padding()
}
}
}
.navigationTitle("Discover")
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
}
}
.sheet(isPresented: $showFilters) {
BrowseFiltersView(vm: vm)
}
.task { await vm.loadFirstPage() }
.onChange(of: vm.sort) { _, _ in Task { await vm.loadFirstPage() } }
.onChange(of: vm.genre) { _, _ in Task { await vm.loadFirstPage() } }
.onChange(of: vm.status) { _, _ in Task { await vm.loadFirstPage() } }
}
}
}
// MARK: - Filter chip
private struct FilterChip: View {
let label: String
let isActive: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
Text(label)
.font(.caption.bold())
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(isActive ? Color.amber.opacity(0.15) : Color(.systemGray6), in: Capsule())
.foregroundStyle(isActive ? .amber : .primary)
.overlay(Capsule().strokeBorder(isActive ? Color.amber : .clear, lineWidth: 1))
}
}
}
// MARK: - Browse card
private struct BrowseCard: View {
let novel: BrowseNovel
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .topLeading) {
AsyncCoverImage(url: novel.cover)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 10))
if !novel.rank.isEmpty {
Text(novel.rank)
.font(.caption2.bold())
.padding(.horizontal, 6).padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
}
}
Text(novel.title)
.font(.caption.bold()).lineLimit(2)
if !novel.chapters.isEmpty {
Text(novel.chapters).font(.caption2).foregroundStyle(.secondary)
}
}
}
}
// MARK: - Filters sheet
struct BrowseFiltersView: View {
@ObservedObject var vm: BrowseViewModel
@Environment(\.dismiss) private var dismiss
let sortOptions = ["popular", "new", "updated", "rating", "rank"]
let genreOptions = ["all", "action", "fantasy", "romance", "sci-fi", "mystery",
"horror", "comedy", "drama", "adventure", "martial arts",
"cultivation", "magic", "supernatural", "historical", "slice of life"]
let statusOptions = ["all", "ongoing", "completed"]
var body: some View {
NavigationStack {
Form {
Section("Sort") {
ForEach(sortOptions, id: \.self) { opt in
HStack {
Text(opt.capitalized)
Spacer()
if vm.sort == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
}
.contentShape(Rectangle())
.onTapGesture { vm.sort = opt; dismiss() }
}
}
Section("Genre") {
ForEach(genreOptions, id: \.self) { opt in
HStack {
Text(opt == "all" ? "All Genres" : opt.capitalized)
Spacer()
if vm.genre == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
}
.contentShape(Rectangle())
.onTapGesture { vm.genre = opt; dismiss() }
}
}
Section("Status") {
ForEach(statusOptions, id: \.self) { opt in
HStack {
Text(opt.capitalized)
Spacer()
if vm.status == opt { Image(systemName: "checkmark").foregroundStyle(.amber) }
}
.contentShape(Rectangle())
.onTapGesture { vm.status = opt; dismiss() }
}
}
}
.navigationTitle("Filters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.medium, .large])
}
}

View File

@@ -0,0 +1,157 @@
import SwiftUI
import WebKit
// MARK: - Chapter Reader
struct ChapterReaderView: View {
let slug: String
let chapterNumber: Int
@StateObject private var vm: ChapterReaderViewModel
@EnvironmentObject var audioPlayer: AudioPlayerService
@EnvironmentObject var authStore: AuthStore
init(slug: String, chapterNumber: Int) {
self.slug = slug
self.chapterNumber = chapterNumber
_vm = StateObject(wrappedValue: ChapterReaderViewModel(slug: slug, chapter: chapterNumber))
}
var body: some View {
Group {
if vm.isLoading {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let content = vm.content {
readerContent(content)
}
}
.navigationTitle(vm.content.map { "Ch.\($0.chapter.number)" } ?? "")
.navigationBarTitleDisplayMode(.inline)
.toolbar { audioToolbarButton }
.task { await vm.load() }
.onReceive(NotificationCenter.default.publisher(for: .audioDidFinishChapter)) { note in
if let next = note.userInfo?["next"] as? Int {
vm.navigateTo = next
}
}
.navigationDestination(isPresented: Binding(
get: { vm.navigateTo != nil },
set: { if !$0 { vm.navigateTo = nil } }
)) {
if let next = vm.navigateTo {
ChapterReaderView(slug: slug, chapterNumber: next)
}
}
.alert("Error", isPresented: .constant(vm.error != nil)) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
// MARK: - Content
@ViewBuilder
private func readerContent(_ content: ChapterResponse) -> some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Header
VStack(alignment: .leading, spacing: 4) {
Text(content.chapter.title)
.font(.title2.bold())
if !content.chapter.dateLabel.isEmpty {
Text(content.chapter.dateLabel)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.horizontal)
Divider()
// Chapter body
HTMLContentView(html: content.html)
.padding(.horizontal)
Divider()
// Prev / Next navigation
HStack {
if let prev = content.prev {
NavigationLink(value: NavDestination.chapter(slug, prev)) {
Label("Ch.\(prev)", systemImage: "chevron.left")
}
.buttonStyle(.bordered)
}
Spacer()
if let next = content.next {
NavigationLink(value: NavDestination.chapter(slug, next)) {
Label("Ch.\(next)", systemImage: "chevron.right")
.labelStyle(ReverseLabelStyle())
}
.buttonStyle(.borderedProminent)
.tint(.amber)
}
}
.padding()
}
.padding(.vertical)
}
}
// MARK: - Audio toolbar button
@ToolbarContentBuilder
private var audioToolbarButton: some ToolbarContent {
ToolbarItem(placement: .topBarTrailing) {
Button {
vm.toggleAudio(audioPlayer: audioPlayer, settings: authStore.settings)
} label: {
Image(systemName: audioPlayer.isActive &&
audioPlayer.slug == slug &&
audioPlayer.chapter == chapterNumber
? "speaker.wave.2.fill" : "speaker.wave.2")
.foregroundStyle(.amber)
}
}
}
}
// MARK: - HTML content renderer using WKWebView
struct HTMLContentView: UIViewRepresentable {
let html: String
func makeUIView(context: Context) -> WKWebView {
let wv = WKWebView()
wv.scrollView.isScrollEnabled = false
wv.isOpaque = false
wv.backgroundColor = .clear
return wv
}
func updateUIView(_ uiView: WKWebView, context: Context) {
let css = """
body {
font-family: -apple-system, Georgia, serif;
font-size: 17px;
line-height: 1.7;
color: \(UITraitCollection.current.userInterfaceStyle == .dark ? "#e5e5e5" : "#1a1a1a");
background: transparent;
margin: 0; padding: 0;
}
p { margin: 0 0 1em 0; }
"""
let wrapped = "<html><head><style>\(css)</style><meta name='viewport' content='width=device-width, initial-scale=1'></head><body>\(html)</body></html>"
uiView.loadHTMLString(wrapped, baseURL: nil)
}
}
// MARK: - Reverse label style (icon on right)
struct ReverseLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack {
configuration.title
configuration.icon
}
}
}

View File

@@ -0,0 +1,74 @@
import SwiftUI
import Kingfisher
// MARK: - Empty state placeholder used across all screens
struct EmptyStateView: View {
let icon: String
let title: String
let message: String
var body: some View {
VStack(spacing: 14) {
Image(systemName: icon)
.font(.system(size: 48))
.foregroundStyle(.tertiary)
Text(title)
.font(.headline)
Text(message)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
}
// MARK: - Cover image card reused across screens
struct BookCard: View {
let book: Book
var body: some View {
VStack(alignment: .leading, spacing: 6) {
AsyncCoverImage(url: book.cover)
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 10))
Text(book.title)
.font(.caption.bold())
.lineLimit(2)
Text(book.author)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
// MARK: - Async cover image with disk/memory caching via Kingfisher
struct AsyncCoverImage: View {
let url: String
var body: some View {
KFImage(URL(string: url))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
.scaledToFill()
}
}
// MARK: - Tag chip
struct TagChip: View {
let label: String
var body: some View {
Text(label)
.font(.caption2.bold())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color(.systemGray5), in: Capsule())
}
}

View File

@@ -0,0 +1,148 @@
import SwiftUI
import Kingfisher
struct HomeView: View {
@StateObject private var vm = HomeViewModel()
@EnvironmentObject var authStore: AuthStore
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
// Stats bar
if let stats = vm.stats {
HStack(spacing: 0) {
StatCell(value: "\(stats.totalBooks)", label: "Books")
Divider().frame(height: 32)
StatCell(value: "\(stats.totalChapters)", label: "Chapters")
Divider().frame(height: 32)
StatCell(value: "\(stats.booksInProgress)", label: "In Progress")
}
.frame(maxWidth: .infinity)
.padding(.vertical, 16)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 14))
.padding(.horizontal)
}
// Continue reading
if !vm.continueReading.isEmpty {
SectionHeader(title: "Continue Reading")
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(vm.continueReading) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
ContinueReadingCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
}
// Recently updated
if !vm.recentlyUpdated.isEmpty {
SectionHeader(title: "Recently Updated")
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 16) {
ForEach(vm.recentlyUpdated) { book in
NavigationLink(value: NavDestination.book(book.slug)) {
BookCard(book: book)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
}
// Empty state
if vm.continueReading.isEmpty && vm.recentlyUpdated.isEmpty && !vm.isLoading {
EmptyStateView(
icon: "books.vertical",
title: "Your library is empty",
message: "Head to Discover to find novels to read."
)
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
if vm.isLoading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.top, 60)
}
}
.padding(.vertical)
}
.navigationTitle("Home")
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
}
}
.refreshable { await vm.load() }
.task { await vm.load() }
.alert("Error", isPresented: .constant(vm.error != nil)) {
Button("OK") { vm.error = nil }
} message: {
Text(vm.error ?? "")
}
}
}
}
// MARK: - Supporting components
private struct StatCell: View {
let value: String
let label: String
var body: some View {
VStack(spacing: 2) {
Text(value).font(.title2.bold()).foregroundStyle(.primary)
Text(label).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity)
}
}
private struct SectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.title3.bold())
.padding(.horizontal)
}
}
private struct ContinueReadingCard: View {
let item: ContinueReadingItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder { coverPlaceholder }
.scaledToFill()
.frame(width: 120, height: 170)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(alignment: .bottomTrailing) {
Text("Ch.\(item.chapter)")
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
}
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
.frame(width: 120, alignment: .leading)
}
}
private var coverPlaceholder: some View {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
.frame(width: 120, height: 170)
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
}

View File

@@ -0,0 +1,86 @@
import SwiftUI
import Kingfisher
struct LibraryView: View {
@StateObject private var vm = LibraryViewModel()
var body: some View {
NavigationStack {
Group {
if vm.isLoading && vm.items.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if vm.items.isEmpty {
EmptyStateView(
icon: "bookmark",
title: "No saved books",
message: "Books you save or start reading will appear here."
)
} else {
ScrollView {
LazyVGrid(
columns: [GridItem(.adaptive(minimum: 150), spacing: 12)],
spacing: 16
) {
ForEach(vm.items) { item in
NavigationLink(value: NavDestination.book(item.book.slug)) {
LibraryCard(item: item)
}
.buttonStyle(.plain)
}
}
.padding()
}
}
}
.navigationTitle("Library")
.navigationDestination(for: NavDestination.self) { dest in
switch dest {
case .book(let slug): BookDetailView(slug: slug)
case .chapter(let slug, let n): ChapterReaderView(slug: slug, chapterNumber: n)
}
}
.refreshable { await vm.load() }
.task { await vm.load() }
.alert("Error", isPresented: .constant(vm.error != nil)) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
}
}
private struct LibraryCard: View {
let item: LibraryItem
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ZStack(alignment: .bottomTrailing) {
KFImage(URL(string: item.book.cover))
.resizable()
.placeholder {
RoundedRectangle(cornerRadius: 10)
.fill(Color(.systemGray5))
.overlay(Image(systemName: "book.closed").foregroundStyle(.secondary))
}
.scaledToFill()
.frame(height: 200)
.clipShape(RoundedRectangle(cornerRadius: 10))
if let ch = item.lastChapter {
Text("Ch.\(ch)")
.font(.caption2.bold())
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(.ultraThinMaterial, in: Capsule())
.padding(6)
}
}
Text(item.book.title)
.font(.caption.bold())
.lineLimit(2)
Text(item.book.author)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}

View File

@@ -0,0 +1,192 @@
import SwiftUI
// MARK: - Mini player bar (pinned above tab bar)
struct MiniPlayerView: View {
@Binding var showFullPlayer: Bool
@EnvironmentObject var audioPlayer: AudioPlayerService
var body: some View {
VStack(spacing: 0) {
// Seek bar (thin line at top of bar)
GeometryReader { geo in
ZStack(alignment: .leading) {
Rectangle().fill(Color(.systemGray4)).frame(height: 2)
Rectangle()
.fill(Color.amber)
.frame(width: geo.size.width * progress, height: 2)
}
}
.frame(height: 2)
HStack(spacing: 12) {
// Cover thumbnail tap to open full player
Button { showFullPlayer = true } label: {
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 40, height: 40)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
// Track info
VStack(alignment: .leading, spacing: 2) {
Text(audioPlayer.bookTitle)
.font(.caption.bold())
.lineLimit(1)
Text(chapterLabel)
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture { showFullPlayer = true }
// Status spinner or playback controls
switch audioPlayer.status {
case .loading, .generating:
ProgressView()
.scaleEffect(0.8)
.frame(width: 36)
case .ready:
Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.fill" : "play.fill")
.font(.title2)
}
.buttonStyle(.plain)
case .error:
Image(systemName: "exclamationmark.circle").foregroundStyle(.red)
default:
EmptyView()
}
// Dismiss
Button { audioPlayer.stop() } label: {
Image(systemName: "xmark").font(.caption.bold())
}
.buttonStyle(.plain)
.foregroundStyle(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 0))
.shadow(color: .black.opacity(0.12), radius: 4, y: -2)
}
private var progress: CGFloat {
guard audioPlayer.duration > 0 else { return 0 }
return CGFloat(audioPlayer.currentTime / audioPlayer.duration)
}
private var chapterLabel: String {
audioPlayer.chapterTitle.isEmpty
? "Chapter \(audioPlayer.chapter)"
: audioPlayer.chapterTitle
}
}
// MARK: - Full player sheet
struct FullPlayerView: View {
@EnvironmentObject var audioPlayer: AudioPlayerService
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(spacing: 28) {
// Cover art
AsyncCoverImage(url: audioPlayer.coverURL)
.frame(width: 200, height: 290)
.clipShape(RoundedRectangle(cornerRadius: 14))
.shadow(radius: 10)
// Titles
VStack(spacing: 4) {
Text(audioPlayer.chapterTitle.isEmpty ? "Chapter \(audioPlayer.chapter)" : audioPlayer.chapterTitle)
.font(.headline)
.multilineTextAlignment(.center)
Text(audioPlayer.bookTitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
// Seek bar
VStack(spacing: 4) {
Slider(
value: Binding(
get: { audioPlayer.currentTime },
set: { audioPlayer.seek(to: $0) }
),
in: 0...max(audioPlayer.duration, 1)
)
.tint(.amber)
HStack {
Text(formatTime(audioPlayer.currentTime))
Spacer()
Text(formatTime(audioPlayer.duration))
}
.font(.caption2)
.foregroundStyle(.secondary)
}
.padding(.horizontal)
// Controls row
HStack(spacing: 40) {
Button { audioPlayer.skip(by: -15) } label: {
Image(systemName: "gobackward.15").font(.title)
}
Button { audioPlayer.togglePlayPause() } label: {
Image(systemName: audioPlayer.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 64))
.foregroundStyle(.amber)
}
Button { audioPlayer.skip(by: 30) } label: {
Image(systemName: "goforward.30").font(.title)
}
}
.buttonStyle(.plain)
// Speed + auto-next row
HStack(spacing: 20) {
Menu {
ForEach([0.5, 0.75, 1.0, 1.25, 1.5, 2.0], id: \.self) { s in
Button("\(s, specifier: "%.2g")×") { audioPlayer.setSpeed(s) }
}
} label: {
Text("\(audioPlayer.speed, specifier: "%.2g")×")
.font(.subheadline.bold())
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(.systemGray5), in: Capsule())
}
Toggle(isOn: $audioPlayer.autoNext) {
Label("Auto-next", systemImage: "forward.end")
.font(.subheadline)
}
.toggleStyle(.button)
.tint(.amber)
}
Spacer()
}
.padding(.top, 24)
.navigationTitle("Now Playing")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
private func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
let s = Int(seconds)
return "\(s / 60):\(String(format: "%02d", s % 60))"
}
}

View File

@@ -0,0 +1,218 @@
import SwiftUI
struct ProfileView: View {
@EnvironmentObject var authStore: AuthStore
@StateObject private var vm = ProfileViewModel()
@State private var showChangePassword = false
var body: some View {
NavigationStack {
List {
// User header
Section {
HStack(spacing: 14) {
Image(systemName: "person.circle.fill")
.font(.system(size: 48))
.foregroundStyle(.amber)
VStack(alignment: .leading, spacing: 2) {
Text(authStore.user?.username ?? "")
.font(.headline)
Text(authStore.user?.role.capitalized ?? "")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 6)
}
// Reading settings
Section("Reading Settings") {
voicePicker
speedSlider
Toggle("Auto-advance chapter", isOn: Binding(
get: { authStore.settings.autoNext },
set: { newVal in
Task {
var s = authStore.settings
s.autoNext = newVal
await authStore.saveSettings(s)
}
}
))
.tint(.amber)
}
// Sessions
Section("Active Sessions") {
if vm.sessionsLoading {
ProgressView()
} else {
ForEach(vm.sessions) { session in
SessionRow(session: session) {
Task { await vm.revokeSession(id: session.id) }
}
}
}
}
// Account
Section("Account") {
Button("Change Password") { showChangePassword = true }
Button("Sign Out", role: .destructive) {
Task { await authStore.logout() }
}
}
}
.navigationTitle("Profile")
.task { await vm.loadSessions() }
.sheet(isPresented: $showChangePassword) {
ChangePasswordView()
}
.alert("Error", isPresented: .constant(vm.error != nil)) {
Button("OK") { vm.error = nil }
} message: { Text(vm.error ?? "") }
}
}
// MARK: - Voice picker
@ViewBuilder
private var voicePicker: some View {
Picker("TTS Voice", selection: Binding(
get: { authStore.settings.voice },
set: { newVoice in
Task {
var s = authStore.settings
s.voice = newVoice
await authStore.saveSettings(s)
}
}
)) {
if vm.voices.isEmpty {
Text("Default").tag("af_bella")
} else {
ForEach(vm.voices, id: \.self) { v in
Text(v).tag(v)
}
}
}
.task { await vm.loadVoices() }
}
// MARK: - Speed slider
@ViewBuilder
private var speedSlider: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text("Playback Speed")
Spacer()
Text("\(authStore.settings.speed, specifier: "%.1f")×")
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { authStore.settings.speed },
set: { newSpeed in
Task {
var s = authStore.settings
s.speed = newSpeed
await authStore.saveSettings(s)
}
}
),
in: 0.5...3.0, step: 0.25
)
.tint(.amber)
}
}
}
// MARK: - Session row
private struct SessionRow: View {
let session: UserSession
let onRevoke: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: "iphone")
Text(session.userAgent.isEmpty ? "Unknown device" : session.userAgent)
.font(.subheadline)
.lineLimit(1)
Spacer()
if session.isCurrent {
Text("This device")
.font(.caption2.bold())
.foregroundStyle(.amber)
} else {
Button("Revoke", role: .destructive, action: onRevoke)
.font(.caption)
}
}
Text("Last seen: \(session.lastSeen.prefix(10))")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Change password sheet
struct ChangePasswordView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject var authStore: AuthStore
@State private var current = ""
@State private var newPwd = ""
@State private var confirm = ""
@State private var isLoading = false
@State private var error: String?
@State private var success = false
var body: some View {
NavigationStack {
Form {
Section {
SecureField("Current password", text: $current)
SecureField("New password", text: $newPwd)
SecureField("Confirm new password", text: $confirm)
}
if let error {
Text(error).foregroundStyle(.red).font(.caption)
}
if success {
Text("Password changed successfully").foregroundStyle(.green).font(.caption)
}
}
.navigationTitle("Change Password")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Cancel") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button("Save") { save() }
.disabled(isLoading || newPwd.count < 4 || newPwd != confirm)
}
}
}
}
private func save() {
guard newPwd == confirm else { error = "Passwords do not match"; return }
isLoading = true
error = nil
Task {
do {
struct Body: Encodable { let currentPassword, newPassword: String }
let _: EmptyResponse = try await APIClient.shared.fetch(
"/api/auth/change-password", method: "POST",
body: Body(currentPassword: current, newPassword: newPwd)
)
success = true
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { dismiss() }
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
}
}

View File

@@ -0,0 +1,9 @@
import XCTest
@testable import LibNovel
final class LibNovelTests: XCTestCase {
func testExample() throws {
// Placeholder add real tests here
XCTAssert(true)
}
}

90
ios/LibNovel/project.yml Normal file
View File

@@ -0,0 +1,90 @@
name: LibNovel
options:
bundleIdPrefix: cc.kalekber
deploymentTarget:
iOS: "17.0"
xcodeVersion: "16.0"
generateEmptyDirectories: true
indentWidth: 4
tabWidth: 4
usesTabs: false
settings:
base:
SWIFT_VERSION: "5.10"
ENABLE_PREVIEWS: YES
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "1"
LIBNOVEL_BASE_URL: "https://v2.libnovel.kalekber.cc"
configs:
Debug:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: DEBUG
Release:
SWIFT_ACTIVE_COMPILATION_CONDITIONS: ""
packages:
# Markdown rendering — renders chapter HTML from the server
MarkdownUI:
url: https://github.com/gonzalezreal/swift-markdown-ui
from: "2.4.0"
# Async image loading with caching
Kingfisher:
url: https://github.com/onevcat/Kingfisher
from: "8.0.0"
targets:
LibNovel:
type: application
platform: iOS
deploymentTarget: "17.0"
sources:
- path: LibNovel
excludes:
- "**/.DS_Store"
- "Resources/Info.plist"
resources:
- path: LibNovel/Resources/Assets.xcassets
dependencies:
- package: Kingfisher
- package: MarkdownUI
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
GENERATE_INFOPLIST_FILE: NO
INFOPLIST_FILE: LibNovel/Resources/Info.plist
LibNovelTests:
type: bundle.unit-test
platform: iOS
deploymentTarget: "17.0"
sources:
- path: LibNovelTests
dependencies:
- target: LibNovel
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: cc.kalekber.libnovel.tests
schemes:
LibNovel:
build:
targets:
LibNovel: all
run:
config: Debug
environmentVariables:
LIBNOVEL_BASE_URL:
value: "https://v2.libnovel.kalekber.cc"
isEnabled: true
test:
config: Debug
targets:
- LibNovelTests
profile:
config: Release
analyze:
config: Debug
archive:
config: Release