Replace Flutter app with native SwiftUI rewrite (iOS 26)
Complete ground-up rewrite in SwiftUI targeting iOS 26. Drops the Flutter/Dart codebase entirely in favour of a lean native app with no third-party dependencies. Features shipped: - Bookmark list with pagination, pull-to-refresh, swipe delete/archive - Add bookmark form + iOS share extension (zero-tap save from any app) - Tags tab — all tags sorted by count, tap to browse filtered bookmarks - Native search tab (Tab role: .search) with instant client-side filtering - AI enrichment via OpenRouter (google/gemini-2.0-flash-lite-001): auto-summary and tag generation, semantic search, smart collections - Settings: linkding server config, OpenRouter API key - App Groups for credential sharing between main app and share extension - Swift 6 strict concurrency throughout (@Observable, @MainActor) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@@ -1,2 +0,0 @@
|
|||||||
SENTRY_DSN=
|
|
||||||
ENABLE_SENTRY=false
|
|
||||||
65
.gitignore
vendored
@@ -1,50 +1,25 @@
|
|||||||
# Miscellaneous
|
# Xcode user data
|
||||||
*.class
|
xcuserdata/
|
||||||
*.log
|
*.xcuserstate
|
||||||
*.pyc
|
|
||||||
*.swp
|
# Build artifacts
|
||||||
.DS_Store
|
DerivedData/
|
||||||
.atom/
|
build/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
.build/
|
.build/
|
||||||
.buildlog/
|
|
||||||
.history
|
|
||||||
.svn/
|
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
migrate_working_dir/
|
|
||||||
|
|
||||||
# IntelliJ related
|
# CocoaPods
|
||||||
*.iml
|
Pods/
|
||||||
*.ipr
|
|
||||||
*.iws
|
|
||||||
.idea/
|
|
||||||
|
|
||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# macOS
|
||||||
# VS Code which you may wish to be included in version control, so this line
|
.DS_Store
|
||||||
# is commented out by default.
|
.DS_Store?
|
||||||
#.vscode/
|
._*
|
||||||
|
|
||||||
# Flutter/Dart/Pub related
|
# VS Code
|
||||||
**/doc/api/
|
.vscode/
|
||||||
**/ios/Flutter/.last_build_id
|
|
||||||
.dart_tool/
|
|
||||||
.flutter-plugins
|
|
||||||
.flutter-plugins-dependencies
|
|
||||||
.pub-cache/
|
|
||||||
.pub/
|
|
||||||
/build/
|
|
||||||
|
|
||||||
# Symbolication related
|
|
||||||
app.*.symbols
|
|
||||||
|
|
||||||
# Obfuscation related
|
|
||||||
app.*.map.json
|
|
||||||
|
|
||||||
# Android Studio will place build artifacts here
|
|
||||||
/android/app/debug
|
|
||||||
/android/app/profile
|
|
||||||
/android/app/release
|
|
||||||
|
|
||||||
# Env file
|
|
||||||
.env
|
|
||||||
android/app/.cxx
|
|
||||||
android/build/reports/problems
|
|
||||||
|
|||||||
30
.metadata
@@ -1,30 +0,0 @@
|
|||||||
# This file tracks properties of this Flutter project.
|
|
||||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
|
||||||
#
|
|
||||||
# This file should be version controlled and should not be manually edited.
|
|
||||||
|
|
||||||
version:
|
|
||||||
revision: "00b0c91f06209d9e4a41f71b7a512d6eb3b9c694"
|
|
||||||
channel: "stable"
|
|
||||||
|
|
||||||
project_type: app
|
|
||||||
|
|
||||||
# Tracks metadata for the flutter migrate command
|
|
||||||
migration:
|
|
||||||
platforms:
|
|
||||||
- platform: root
|
|
||||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
|
||||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
|
||||||
- platform: android
|
|
||||||
create_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
|
||||||
base_revision: 00b0c91f06209d9e4a41f71b7a512d6eb3b9c694
|
|
||||||
|
|
||||||
# User provided section
|
|
||||||
|
|
||||||
# List of Local paths (relative to this file) that should be
|
|
||||||
# ignored by the migrate tool.
|
|
||||||
#
|
|
||||||
# Files that are not part of the templates will be ignored by default.
|
|
||||||
unmanaged_files:
|
|
||||||
- 'lib/main.dart'
|
|
||||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
|
||||||
17
.vscode/settings.json
vendored
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.defaultFormatter": "Dart-Code.flutter",
|
|
||||||
"dart.lineLength": 120,
|
|
||||||
"[dart]": {
|
|
||||||
"editor.rulers": [
|
|
||||||
120
|
|
||||||
],
|
|
||||||
},
|
|
||||||
"cSpell.words": [
|
|
||||||
"gstatic",
|
|
||||||
"Linkding",
|
|
||||||
"linkdy",
|
|
||||||
"Linkdy"
|
|
||||||
],
|
|
||||||
"cSpell.language": "en,es,tr",
|
|
||||||
}
|
|
||||||
201
LICENSE.md
@@ -1,201 +0,0 @@
|
|||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2024 JGeek00
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
514
Marks.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 77;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */; };
|
||||||
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */; };
|
||||||
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92575C7C710347F226EC74A /* MarksApp.swift */; };
|
||||||
|
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CA428181B35885F7D9F4D55 /* Bookmark.swift */; };
|
||||||
|
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */; };
|
||||||
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE19F7619214271A8C12EEEB /* CollectionsView.swift */; };
|
||||||
|
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADEAC824576633CC77370262 /* ShareViewController.swift */; };
|
||||||
|
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C29CB878BC334639E6194E2 /* SettingsView.swift */; };
|
||||||
|
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 57D20912C19182A45914069B /* Assets.xcassets */; };
|
||||||
|
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B13B9F2D890C7953531AC0D2 /* TagsView.swift */; };
|
||||||
|
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */; };
|
||||||
|
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07C21567B95F5069BA946252 /* ServerConfig.swift */; };
|
||||||
|
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBE3C5E420F078D499B2D926 /* BookmarksView.swift */; };
|
||||||
|
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */; };
|
||||||
|
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */; };
|
||||||
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCC3BB2525F0F63445D419B9 /* SearchView.swift */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = CF31B2655F8F30CF6DF3C26F;
|
||||||
|
remoteInfo = ShareExtension;
|
||||||
|
};
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
|
/* Begin PBXCopyFilesBuildPhase section */
|
||||||
|
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */ = {
|
||||||
|
isa = PBXCopyFilesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
dstPath = "";
|
||||||
|
dstSubfolderSpec = 13;
|
||||||
|
files = (
|
||||||
|
B08F151637384EA9E054AC5F /* ShareExtension.appex in Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
name = "Embed Foundation Extensions";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
03D800669E343FF305468424 /* ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = ShareExtension.entitlements; sourceTree = "<group>"; };
|
||||||
|
07C21567B95F5069BA946252 /* ServerConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerConfig.swift; sourceTree = "<group>"; };
|
||||||
|
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Marks.entitlements; sourceTree = "<group>"; };
|
||||||
|
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingView.swift; sourceTree = "<group>"; };
|
||||||
|
57D20912C19182A45914069B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkdingAPI.swift; sourceTree = "<group>"; };
|
||||||
|
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeService.swift; sourceTree = "<group>"; };
|
||||||
|
86B2DE40098D2B39FFE1AC36 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
8CA428181B35885F7D9F4D55 /* Bookmark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bookmark.swift; sourceTree = "<group>"; };
|
||||||
|
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = "wrapper.app-extension"; path = ShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookmarkView.swift; sourceTree = "<group>"; };
|
||||||
|
AB6C53AB14A38FCD4CC7628D /* Marks.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Marks.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
ADEAC824576633CC77370262 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||||
|
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkRow.swift; sourceTree = "<group>"; };
|
||||||
|
B13B9F2D890C7953531AC0D2 /* TagsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagsView.swift; sourceTree = "<group>"; };
|
||||||
|
BCC3BB2525F0F63445D419B9 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
|
||||||
|
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksView.swift; sourceTree = "<group>"; };
|
||||||
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
D92575C7C710347F226EC74A /* MarksApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarksApp.swift; sourceTree = "<group>"; };
|
||||||
|
E895C34E4D2A1C4709B25FF1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionsView.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
3952A081972B444674BEB51B /* Marks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
57D20912C19182A45914069B /* Assets.xcassets */,
|
||||||
|
86B2DE40098D2B39FFE1AC36 /* Info.plist */,
|
||||||
|
0D2CC53B01DF0FE51ADE5FCB /* Marks.entitlements */,
|
||||||
|
D92575C7C710347F226EC74A /* MarksApp.swift */,
|
||||||
|
3EF3094F53F0378D1BF18508 /* Models */,
|
||||||
|
7ABBFDF43A1EB773E0A49CFE /* Services */,
|
||||||
|
AF9CABF75E77168EF1D60F29 /* Views */,
|
||||||
|
);
|
||||||
|
path = Marks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
3EF3094F53F0378D1BF18508 /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
8CA428181B35885F7D9F4D55 /* Bookmark.swift */,
|
||||||
|
07C21567B95F5069BA946252 /* ServerConfig.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E895C34E4D2A1C4709B25FF1 /* Info.plist */,
|
||||||
|
03D800669E343FF305468424 /* ShareExtension.entitlements */,
|
||||||
|
ADEAC824576633CC77370262 /* ShareViewController.swift */,
|
||||||
|
);
|
||||||
|
path = ShareExtension;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
7ABBFDF43A1EB773E0A49CFE /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
69D868AF1DAF3F1BBEACBFF6 /* ClaudeService.swift */,
|
||||||
|
5CCBB391B1E0E1E4EBE0EFC7 /* LinkdingAPI.swift */,
|
||||||
|
);
|
||||||
|
path = Services;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
AF9CABF75E77168EF1D60F29 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AB2D194AD325ECE80A04979E /* AddBookmarkView.swift */,
|
||||||
|
B0C6ABE160A2C90EB965D811 /* BookmarkRow.swift */,
|
||||||
|
CBE3C5E420F078D499B2D926 /* BookmarksView.swift */,
|
||||||
|
CBFB5EFC9764B22A2622EA4A /* BookmarksViewModel.swift */,
|
||||||
|
FE19F7619214271A8C12EEEB /* CollectionsView.swift */,
|
||||||
|
22E006A11D594BFC00A9C4B4 /* OnboardingView.swift */,
|
||||||
|
BCC3BB2525F0F63445D419B9 /* SearchView.swift */,
|
||||||
|
5C29CB878BC334639E6194E2 /* SettingsView.swift */,
|
||||||
|
B13B9F2D890C7953531AC0D2 /* TagsView.swift */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
BC60767DD65812CCBD41C38F = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3952A081972B444674BEB51B /* Marks */,
|
||||||
|
7A85D09B4E109E1D9CD8F5BB /* ShareExtension */,
|
||||||
|
C6978BB2E0A71E9A349D5EB6 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C6978BB2E0A71E9A349D5EB6 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AB6C53AB14A38FCD4CC7628D /* Marks.app */,
|
||||||
|
938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
76C272776D8EBCCD3FB6715A /* Marks */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */;
|
||||||
|
buildPhases = (
|
||||||
|
81AF93AC59791A6311B9055B /* Sources */,
|
||||||
|
D56AC7DEA48961F856A6CEF6 /* Resources */,
|
||||||
|
93C7F00CFA9EABABB6323F6D /* Embed Foundation Extensions */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = Marks;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = Marks;
|
||||||
|
productReference = AB6C53AB14A38FCD4CC7628D /* Marks.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
CF31B2655F8F30CF6DF3C26F /* ShareExtension */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 94AD43D2CB52900BE36CD8DA /* Build configuration list for PBXNativeTarget "ShareExtension" */;
|
||||||
|
buildPhases = (
|
||||||
|
2210C849B430CC6609106C62 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = ShareExtension;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = ShareExtension;
|
||||||
|
productReference = 938672D3D6ADC73B354B5EA5 /* ShareExtension.appex */;
|
||||||
|
productType = "com.apple.product-type.app-extension";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
0CC2596AE7F05EAF051B2FCD /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 1630;
|
||||||
|
TargetAttributes = {
|
||||||
|
76C272776D8EBCCD3FB6715A = {
|
||||||
|
DevelopmentTeam = AE5DZKJHGN;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
CF31B2655F8F30CF6DF3C26F = {
|
||||||
|
DevelopmentTeam = AE5DZKJHGN;
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = 4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = BC60767DD65812CCBD41C38F;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
preferredProjectObjectVersion = 77;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
76C272776D8EBCCD3FB6715A /* Marks */,
|
||||||
|
CF31B2655F8F30CF6DF3C26F /* ShareExtension */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
D56AC7DEA48961F856A6CEF6 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
8BDB7DAA6A2711B9499F5582 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
2210C849B430CC6609106C62 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
76223AA04DF97B24C1490B20 /* ShareViewController.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
81AF93AC59791A6311B9055B /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
AE2EA9C6B9016C9986ADC8EE /* AddBookmarkView.swift in Sources */,
|
||||||
|
457FCE503CCA82C5F27C6C90 /* Bookmark.swift in Sources */,
|
||||||
|
96698499C0501D0A897D7E08 /* BookmarkRow.swift in Sources */,
|
||||||
|
A8B8D58C5B68F54DC20126C1 /* BookmarksView.swift in Sources */,
|
||||||
|
5D86F3F0F603B248776916C7 /* BookmarksViewModel.swift in Sources */,
|
||||||
|
BD2EAD8200FB69B95972146F /* ClaudeService.swift in Sources */,
|
||||||
|
6BF0E1F0F4DEAF87B0B8DCF6 /* CollectionsView.swift in Sources */,
|
||||||
|
15077853ECD40C9B289FB608 /* LinkdingAPI.swift in Sources */,
|
||||||
|
41F00F4E7FFC1C0ACF71E398 /* MarksApp.swift in Sources */,
|
||||||
|
14E1B3CE58D36BFF1A2199C1 /* OnboardingView.swift in Sources */,
|
||||||
|
EE540B8367519EDE679C1B70 /* SearchView.swift in Sources */,
|
||||||
|
969568D9996EB65550DAA24A /* ServerConfig.swift in Sources */,
|
||||||
|
849C9CB4E1E8A6ABA1BC7251 /* SettingsView.swift in Sources */,
|
||||||
|
927BAD5AD47217E3F396CDA7 /* TagsView.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
A87C510E52F149E7E3CA0283 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = CF31B2655F8F30CF6DF3C26F /* ShareExtension */;
|
||||||
|
targetProxy = BD17B6B2972DB25FA605D8AA /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
12B4EAB4496B20EF8B695599 /* 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;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = AE5DZKJHGN;
|
||||||
|
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 = 26.0;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
21D2ADFC1A27251BB64A773D /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Marks/Marks.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_FILE = Marks/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
46C5C95D9B4F4A1E8ABD1A98 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = Marks/Marks.entitlements;
|
||||||
|
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||||
|
INFOPLIST_FILE = Marks/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
5F64C301FBBC85007BB1F0C2 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.ShareExtension;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
7AD80830605CAD3492E03039 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = ShareExtension/ShareExtension.entitlements;
|
||||||
|
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.magicive.marks.ShareExtension;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
B87C74506F7AEF79B42CC172 /* 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;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = AE5DZKJHGN;
|
||||||
|
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 = 26.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 = 6.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
00981D0FC690B5F5F4CA68B9 /* Build configuration list for PBXNativeTarget "Marks" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
21D2ADFC1A27251BB64A773D /* Debug */,
|
||||||
|
46C5C95D9B4F4A1E8ABD1A98 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
4BACCD33EEE63890F06D7057 /* Build configuration list for PBXProject "Marks" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
B87C74506F7AEF79B42CC172 /* Debug */,
|
||||||
|
12B4EAB4496B20EF8B695599 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
94AD43D2CB52900BE36CD8DA /* Build configuration list for PBXNativeTarget "ShareExtension" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
7AD80830605CAD3492E03039 /* Debug */,
|
||||||
|
5F64C301FBBC85007BB1F0C2 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 0CC2596AE7F05EAF051B2FCD /* Project object */;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1510"
|
LastUpgradeVersion = "1630"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES"
|
||||||
|
runPostActionsOnFailure = "NO">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
@@ -14,10 +15,24 @@
|
|||||||
buildForAnalyzing = "YES">
|
buildForAnalyzing = "YES">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "CF31B2655F8F30CF6DF3C26F"
|
||||||
|
BuildableName = "ShareExtension.appex"
|
||||||
|
BlueprintName = "ShareExtension"
|
||||||
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildActionEntry>
|
</BuildActionEntry>
|
||||||
</BuildActionEntries>
|
</BuildActionEntries>
|
||||||
@@ -26,56 +41,47 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
onlyGenerateCoverageForSpecifiedTargets = "NO">
|
||||||
<MacroExpansion>
|
<MacroExpansion>
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</MacroExpansion>
|
</MacroExpansion>
|
||||||
<Testables>
|
<Testables>
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
|
||||||
BuildableName = "RunnerTests.xctest"
|
|
||||||
BlueprintName = "RunnerTests"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
</Testables>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
debugDocumentVersioning = "YES"
|
debugDocumentVersioning = "YES"
|
||||||
debugServiceExtension = "internal"
|
debugServiceExtension = "internal"
|
||||||
enableGPUValidationMode = "1"
|
|
||||||
allowLocationSimulation = "YES">
|
allowLocationSimulation = "YES">
|
||||||
<BuildableProductRunnable
|
<BuildableProductRunnable
|
||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</LaunchAction>
|
</LaunchAction>
|
||||||
<ProfileAction
|
<ProfileAction
|
||||||
buildConfiguration = "Profile"
|
buildConfiguration = "Release"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
savedToolIdentifier = ""
|
savedToolIdentifier = ""
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
@@ -84,12 +90,14 @@
|
|||||||
runnableDebuggingMode = "0">
|
runnableDebuggingMode = "0">
|
||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
BlueprintIdentifier = "76C272776D8EBCCD3FB6715A"
|
||||||
BuildableName = "Runner.app"
|
BuildableName = "Marks.app"
|
||||||
BlueprintName = "Runner"
|
BlueprintName = "Marks"
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
ReferencedContainer = "container:Marks.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</BuildableProductRunnable>
|
</BuildableProductRunnable>
|
||||||
|
<CommandLineArguments>
|
||||||
|
</CommandLineArguments>
|
||||||
</ProfileAction>
|
</ProfileAction>
|
||||||
<AnalyzeAction
|
<AnalyzeAction
|
||||||
buildConfiguration = "Debug">
|
buildConfiguration = "Debug">
|
||||||
BIN
Marks/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
14
Marks/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images" : [
|
||||||
|
{
|
||||||
|
"filename" : "AppIcon.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
Marks/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Marks/Info.plist
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?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>CFBundleDisplayName</key>
|
||||||
|
<string>Marks</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
<true/>
|
||||||
|
<key>UILaunchScreen</key>
|
||||||
|
<dict/>
|
||||||
|
<key>UISupportedInterfaceOrientations</key>
|
||||||
|
<array>
|
||||||
|
<string>UIInterfaceOrientationPortrait</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,11 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.developer.networking.wifi-info</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.com.jgeek00.linkdy</string>
|
<string>group.com.magicive.marks</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
52
Marks/MarksApp.swift
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct MarksApp: App {
|
||||||
|
@State private var serverConfig: ServerConfig? = ServerConfig.load()
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
if let config = serverConfig {
|
||||||
|
MainContainer(config: config) {
|
||||||
|
config.delete()
|
||||||
|
serverConfig = nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
OnboardingView { newConfig in
|
||||||
|
newConfig.save()
|
||||||
|
serverConfig = newConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Holds the API + ViewModel so they survive re-renders
|
||||||
|
struct MainContainer: View {
|
||||||
|
let config: ServerConfig
|
||||||
|
let onDisconnect: () -> Void
|
||||||
|
|
||||||
|
@State private var viewModel: BookmarksViewModel
|
||||||
|
|
||||||
|
init(config: ServerConfig, onDisconnect: @escaping () -> Void) {
|
||||||
|
self.config = config
|
||||||
|
self.onDisconnect = onDisconnect
|
||||||
|
let api = LinkdingAPI(config: config)
|
||||||
|
let claude = ClaudeService.load()
|
||||||
|
_viewModel = State(wrappedValue: BookmarksViewModel(api: api, claude: claude))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
Tab("Bookmarks", systemImage: "bookmark") {
|
||||||
|
BookmarksView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||||
|
}
|
||||||
|
Tab("Tags", systemImage: "tag") {
|
||||||
|
TagsView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
Tab(role: .search) {
|
||||||
|
SearchView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
Marks/Models/Bookmark.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Bookmark: Codable, Identifiable, Sendable, Hashable {
|
||||||
|
let id: Int
|
||||||
|
var url: String
|
||||||
|
var title: String
|
||||||
|
var description: String
|
||||||
|
var tagNames: [String]
|
||||||
|
var dateAdded: Date
|
||||||
|
var dateModified: Date
|
||||||
|
var isArchived: Bool
|
||||||
|
var unread: Bool
|
||||||
|
var shared: Bool
|
||||||
|
var websiteTitle: String?
|
||||||
|
var websiteDescription: String?
|
||||||
|
var faviconUrl: String?
|
||||||
|
var previewImageUrl: String?
|
||||||
|
|
||||||
|
// AI-enriched fields stored locally
|
||||||
|
var aiSummary: String?
|
||||||
|
var aiTags: [String]?
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id, url, title, description, unread, shared
|
||||||
|
case tagNames = "tag_names"
|
||||||
|
case dateAdded = "date_added"
|
||||||
|
case dateModified = "date_modified"
|
||||||
|
case isArchived = "is_archived"
|
||||||
|
case websiteTitle = "website_title"
|
||||||
|
case websiteDescription = "website_description"
|
||||||
|
case faviconUrl = "favicon_url"
|
||||||
|
case previewImageUrl = "preview_image_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
if !title.isEmpty { return title }
|
||||||
|
if let t = websiteTitle, !t.isEmpty { return t }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
var domain: String {
|
||||||
|
URL(string: url)?.host ?? url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BookmarkResponse: Codable, Sendable {
|
||||||
|
let count: Int
|
||||||
|
let next: String?
|
||||||
|
let previous: String?
|
||||||
|
let results: [Bookmark]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BookmarkCreate: Codable, Sendable {
|
||||||
|
let url: String
|
||||||
|
let title: String
|
||||||
|
let tagNames: [String]
|
||||||
|
let isArchived: Bool
|
||||||
|
let unread: Bool
|
||||||
|
let shared: Bool
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case url, title, unread, shared
|
||||||
|
case tagNames = "tag_names"
|
||||||
|
case isArchived = "is_archived"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SmartCollection: Identifiable, Sendable {
|
||||||
|
let id = UUID()
|
||||||
|
let name: String
|
||||||
|
let description: String
|
||||||
|
let bookmarkIds: [Int]
|
||||||
|
}
|
||||||
38
Marks/Models/ServerConfig.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ServerConfig: Codable, Sendable {
|
||||||
|
var host: String
|
||||||
|
var port: Int?
|
||||||
|
var path: String
|
||||||
|
var token: String
|
||||||
|
var useHttps: Bool
|
||||||
|
|
||||||
|
var baseUrl: String {
|
||||||
|
var url = useHttps ? "https://" : "http://"
|
||||||
|
url += host
|
||||||
|
if let port { url += ":\(port)" }
|
||||||
|
if !path.isEmpty { url += path }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ServerConfig {
|
||||||
|
private static let key = "serverConfig"
|
||||||
|
private static let appGroupId = "group.com.magicive.marks"
|
||||||
|
|
||||||
|
static func load() -> ServerConfig? {
|
||||||
|
guard let data = UserDefaults.standard.data(forKey: key) else { return nil }
|
||||||
|
return try? JSONDecoder().decode(ServerConfig.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
guard let data = try? JSONEncoder().encode(self) else { return }
|
||||||
|
UserDefaults.standard.set(data, forKey: ServerConfig.key)
|
||||||
|
UserDefaults(suiteName: ServerConfig.appGroupId)?.set(data, forKey: ServerConfig.key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete() {
|
||||||
|
UserDefaults.standard.removeObject(forKey: ServerConfig.key)
|
||||||
|
UserDefaults(suiteName: ServerConfig.appGroupId)?.removeObject(forKey: ServerConfig.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
Marks/Services/ClaudeService.swift
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ClaudeService: Sendable {
|
||||||
|
let apiKey: String
|
||||||
|
static let defaultsKey = "openRouterApiKey"
|
||||||
|
// Cheap, fast model — change to any OpenRouter model slug
|
||||||
|
private static let model = "google/gemini-2.0-flash-lite-001"
|
||||||
|
|
||||||
|
static func load() -> ClaudeService? {
|
||||||
|
guard let key = UserDefaults.standard.string(forKey: defaultsKey), !key.isEmpty else { return nil }
|
||||||
|
return ClaudeService(apiKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(apiKey: String) {
|
||||||
|
UserDefaults.standard.set(apiKey, forKey: defaultsKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrich(bookmark: Bookmark) async throws -> (summary: String, tags: [String]) {
|
||||||
|
let prompt = """
|
||||||
|
Analyze this bookmark:
|
||||||
|
URL: \(bookmark.url)
|
||||||
|
Title: \(bookmark.displayTitle)
|
||||||
|
Existing tags: \(bookmark.tagNames.joined(separator: ", "))
|
||||||
|
|
||||||
|
Respond with ONLY valid JSON, no markdown:
|
||||||
|
{"summary": "One or two sentence summary of what this page is about.", "tags": ["tag1", "tag2", "tag3"]}
|
||||||
|
|
||||||
|
Rules: summary under 120 chars, 3-5 tags, lowercase, no # symbol, no existing tags duplicated.
|
||||||
|
"""
|
||||||
|
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
||||||
|
return parseEnrichResponse(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func semanticSearch(query: String, in bookmarks: [Bookmark]) async throws -> [Bookmark] {
|
||||||
|
guard !bookmarks.isEmpty else { return [] }
|
||||||
|
let list = bookmarks.enumerated().map { i, b in
|
||||||
|
"[\(i)] \(b.displayTitle) — \(b.domain)\(b.aiSummary.map { " — \($0)" } ?? "")"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
let prompt = """
|
||||||
|
Search query: "\(query)"
|
||||||
|
|
||||||
|
Find relevant bookmarks from the list. Return ONLY a JSON array of indices in relevance order. Example: [3, 0, 7]
|
||||||
|
Return [] if nothing matches.
|
||||||
|
|
||||||
|
Bookmarks:
|
||||||
|
\(list)
|
||||||
|
"""
|
||||||
|
let text = try await callModel(prompt: prompt, maxTokens: 256)
|
||||||
|
let indices = parseIntArray(text)
|
||||||
|
return indices.compactMap { i in i < bookmarks.count ? bookmarks[i] : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateCollections(from bookmarks: [Bookmark]) async throws -> [SmartCollection] {
|
||||||
|
guard !bookmarks.isEmpty else { return [] }
|
||||||
|
let list = bookmarks.prefix(150).enumerated().map { i, b in
|
||||||
|
"[\(i)] \(b.displayTitle) [\(b.tagNames.joined(separator: ","))]"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
let prompt = """
|
||||||
|
Group these bookmarks into 3-6 meaningful themed collections.
|
||||||
|
Respond with ONLY valid JSON, no markdown:
|
||||||
|
[{"name": "Collection Name", "description": "Brief description", "indices": [0, 1, 2]}]
|
||||||
|
|
||||||
|
Bookmarks:
|
||||||
|
\(list)
|
||||||
|
"""
|
||||||
|
let text = try await callModel(prompt: prompt, maxTokens: 1024)
|
||||||
|
return parseCollections(text, bookmarks: Array(bookmarks.prefix(150)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func callModel(prompt: String, maxTokens: Int) async throws -> String {
|
||||||
|
let url = URL(string: "https://openrouter.ai/api/v1/chat/completions")!
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("https://marks.app", forHTTPHeaderField: "HTTP-Referer")
|
||||||
|
request.setValue("Marks", forHTTPHeaderField: "X-Title")
|
||||||
|
|
||||||
|
let body: [String: Any] = [
|
||||||
|
"model": Self.model,
|
||||||
|
"max_tokens": maxTokens,
|
||||||
|
"messages": [["role": "user", "content": prompt]]
|
||||||
|
]
|
||||||
|
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||||
|
|
||||||
|
print("[AI] callModel: POST openrouter model=\(Self.model) maxTokens=\(maxTokens)")
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
print("[AI] callModel: status=\(status) bytes=\(data.count)")
|
||||||
|
guard status == 200 else {
|
||||||
|
let body = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
print("[AI] callModel: error body=\(body.prefix(200))")
|
||||||
|
throw APIError.badStatus(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Choice: Codable {
|
||||||
|
struct Message: Codable { let content: String }
|
||||||
|
let message: Message
|
||||||
|
}
|
||||||
|
struct Response: Codable { let choices: [Choice] }
|
||||||
|
let content = (try JSONDecoder().decode(Response.self, from: data)).choices.first?.message.content ?? ""
|
||||||
|
print("[AI] callModel: response=\(content.prefix(100))")
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseEnrichResponse(_ text: String) -> (summary: String, tags: [String]) {
|
||||||
|
let cleaned = extractJSON(from: text)
|
||||||
|
guard let data = cleaned.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let summary = json["summary"] as? String,
|
||||||
|
let tags = json["tags"] as? [String] else { return (text, []) }
|
||||||
|
return (summary, tags)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseIntArray(_ text: String) -> [Int] {
|
||||||
|
let cleaned = extractJSON(from: text)
|
||||||
|
guard let data = cleaned.data(using: .utf8),
|
||||||
|
let arr = try? JSONSerialization.jsonObject(with: data) as? [Int] else { return [] }
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseCollections(_ text: String, bookmarks: [Bookmark]) -> [SmartCollection] {
|
||||||
|
let cleaned = extractJSON(from: text)
|
||||||
|
guard let data = cleaned.data(using: .utf8),
|
||||||
|
let arr = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] }
|
||||||
|
return arr.compactMap { dict in
|
||||||
|
guard let name = dict["name"] as? String,
|
||||||
|
let indices = dict["indices"] as? [Int] else { return nil }
|
||||||
|
let desc = dict["description"] as? String ?? ""
|
||||||
|
let ids = indices.compactMap { i -> Int? in i < bookmarks.count ? bookmarks[i].id : nil }
|
||||||
|
return SmartCollection(name: name, description: desc, bookmarkIds: ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractJSON(from text: String) -> String {
|
||||||
|
if let start = text.range(of: "```json\n"), let end = text.range(of: "\n```") {
|
||||||
|
return String(text[start.upperBound..<end.lowerBound])
|
||||||
|
}
|
||||||
|
if let start = text.firstIndex(of: "{"), let end = text.lastIndex(of: "}") {
|
||||||
|
return String(text[start...end])
|
||||||
|
}
|
||||||
|
if let start = text.firstIndex(of: "["), let end = text.lastIndex(of: "]") {
|
||||||
|
return String(text[start...end])
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
123
Marks/Services/LinkdingAPI.swift
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum APIError: Error, LocalizedError {
|
||||||
|
case badStatus(Int)
|
||||||
|
case invalidUrl
|
||||||
|
case noData
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .badStatus(let code): "Server returned \(code)"
|
||||||
|
case .invalidUrl: "Invalid server URL"
|
||||||
|
case .noData: "No data received"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
actor LinkdingAPI {
|
||||||
|
private let config: ServerConfig
|
||||||
|
private let session: URLSession
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
init(config: ServerConfig) {
|
||||||
|
self.config = config
|
||||||
|
self.session = URLSession.shared
|
||||||
|
let d = JSONDecoder()
|
||||||
|
d.dateDecodingStrategy = .custom { decoder in
|
||||||
|
let s = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
let f = ISO8601DateFormatter()
|
||||||
|
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
if let date = f.date(from: s) { return date }
|
||||||
|
f.formatOptions = [.withInternetDateTime]
|
||||||
|
if let date = f.date(from: s) { return date }
|
||||||
|
throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date: \(s)"))
|
||||||
|
}
|
||||||
|
self.decoder = d
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeUrl(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.scheme = config.useHttps ? "https" : "http"
|
||||||
|
components.host = config.host
|
||||||
|
components.port = config.port
|
||||||
|
components.path = config.path.isEmpty ? path : config.path + path
|
||||||
|
if !queryItems.isEmpty { components.queryItems = queryItems }
|
||||||
|
guard let url = components.url else { throw APIError.invalidUrl }
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func authorizedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest {
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = method
|
||||||
|
req.setValue("Token \(config.token)", forHTTPHeaderField: "Authorization")
|
||||||
|
if let body {
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = body
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBookmarks(search: String = "", offset: Int = 0, limit: Int = 50) async throws -> BookmarkResponse {
|
||||||
|
var items: [URLQueryItem] = [
|
||||||
|
.init(name: "limit", value: "\(limit)"),
|
||||||
|
.init(name: "offset", value: "\(offset)")
|
||||||
|
]
|
||||||
|
if !search.isEmpty { items.append(.init(name: "q", value: search)) }
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/", queryItems: items)
|
||||||
|
let (data, response) = try await session.data(for: authorizedRequest(url: url))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
return try decoder.decode(BookmarkResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchBookmarksFromUrl(_ urlString: String) async throws -> BookmarkResponse {
|
||||||
|
guard let url = URL(string: urlString) else { throw APIError.invalidUrl }
|
||||||
|
let (data, _) = try await session.data(for: authorizedRequest(url: url))
|
||||||
|
return try decoder.decode(BookmarkResponse.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func createBookmark(_ create: BookmarkCreate) async throws -> Bookmark {
|
||||||
|
let body = try JSONEncoder().encode(create)
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/")
|
||||||
|
let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "POST", body: body))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 201 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
return try decoder.decode(Bookmark.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateBookmark(id: Int, tagNames: [String]) async throws -> Bookmark {
|
||||||
|
let body = try JSONEncoder().encode(["tag_names": tagNames])
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/\(id)/")
|
||||||
|
let (data, response) = try await session.data(for: authorizedRequest(url: url, method: "PATCH", body: body))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
return try decoder.decode(Bookmark.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteBookmark(id: Int) async throws {
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/\(id)/")
|
||||||
|
let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "DELETE"))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 204 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archiveBookmark(id: Int) async throws {
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/\(id)/archive/")
|
||||||
|
let (_, response) = try await session.data(for: authorizedRequest(url: url, method: "POST"))
|
||||||
|
guard let code = (response as? HTTPURLResponse)?.statusCode, code == 200 || code == 204 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyConnection() async throws {
|
||||||
|
let url = try makeUrl(path: "/api/bookmarks/", queryItems: [.init(name: "limit", value: "1")])
|
||||||
|
let (_, response) = try await session.data(for: authorizedRequest(url: url))
|
||||||
|
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
|
||||||
|
throw APIError.badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
Marks/Views/AddBookmarkView.swift
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AddBookmarkView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var url = ""
|
||||||
|
@State private var title = ""
|
||||||
|
@State private var tagsText = ""
|
||||||
|
@State private var isSaving = false
|
||||||
|
@State private var error: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField("https://", text: $url)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} header: {
|
||||||
|
Text("URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("Optional", text: $title)
|
||||||
|
} header: {
|
||||||
|
Text("Title")
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
TextField("comma separated", text: $tagsText)
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
} header: {
|
||||||
|
Text("Tags")
|
||||||
|
} footer: {
|
||||||
|
Text("Separate tags with commas")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
Section {
|
||||||
|
Text(error)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.font(.footnote)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Add Bookmark")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Save") { save() }
|
||||||
|
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
|
||||||
|
.overlay {
|
||||||
|
if isSaving { ProgressView().scaleEffect(0.7) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
var urlString = url.trimmingCharacters(in: .whitespaces)
|
||||||
|
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
|
||||||
|
urlString = "https://" + urlString
|
||||||
|
}
|
||||||
|
guard URL(string: urlString) != nil else {
|
||||||
|
error = "Invalid URL"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tags = tagsText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
|
||||||
|
isSaving = true
|
||||||
|
error = nil
|
||||||
|
Task {
|
||||||
|
do {
|
||||||
|
try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags)
|
||||||
|
dismiss()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
isSaving = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
Marks/Views/BookmarkRow.swift
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BookmarkRow: View {
|
||||||
|
let bookmark: Bookmark
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(alignment: .top, spacing: 11) {
|
||||||
|
FaviconView(url: bookmark.faviconUrl)
|
||||||
|
.padding(.top, 1)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(bookmark.displayTitle)
|
||||||
|
.font(.system(size: 22, weight: .medium))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Text(bookmark.domain)
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let summary = bookmark.aiSummary {
|
||||||
|
Text(summary)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.padding(.leading, 40)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !effectiveTags.isEmpty {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
ForEach(effectiveTags, id: \.self) { tag in
|
||||||
|
Text(tag)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.padding(.horizontal, 9)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.background(Color.blue.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.leading, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var effectiveTags: [String] {
|
||||||
|
let base = bookmark.tagNames
|
||||||
|
let ai = bookmark.aiTags ?? []
|
||||||
|
let extra = ai.filter { !base.contains($0) }.prefix(3)
|
||||||
|
return (base + extra).prefix(6).map { $0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FaviconView: View {
|
||||||
|
let url: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let urlString = url, let faviconUrl = URL(string: urlString) {
|
||||||
|
AsyncImage(url: faviconUrl) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image.resizable().scaledToFit()
|
||||||
|
default:
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
placeholder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 26, height: 26)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var placeholder: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 4)
|
||||||
|
.fill(Color(.systemGray5))
|
||||||
|
.overlay {
|
||||||
|
Image(systemName: "bookmark")
|
||||||
|
.font(.system(size: 9, weight: .medium))
|
||||||
|
.foregroundStyle(Color(.systemGray2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
142
Marks/Views/BookmarksView.swift
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BookmarksView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
let onDisconnect: () -> Void
|
||||||
|
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
@State private var showSettings = false
|
||||||
|
@State private var showCollections = false
|
||||||
|
@State private var showAddBookmark = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(viewModel.bookmarks) { bookmark in
|
||||||
|
BookmarkRow(bookmark: bookmark)
|
||||||
|
.onTapGesture {
|
||||||
|
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||||
|
}
|
||||||
|
.onAppear { maybeLoadMore(bookmark) }
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||||
|
.listRowSeparator(.visible)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await viewModel.delete(bookmark) }
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.archive(bookmark) }
|
||||||
|
} label: {
|
||||||
|
Label("Archive", systemImage: "archivebox")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.isLoadingMore {
|
||||||
|
HStack { Spacer(); ProgressView(); Spacer() }
|
||||||
|
.listRowSeparator(.hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Bookmarks")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
if viewModel.claude != nil {
|
||||||
|
Menu {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.generateSmartCollections() }
|
||||||
|
showCollections = true
|
||||||
|
} label: {
|
||||||
|
Label("Smart Collections", systemImage: "sparkles")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.enrichAll() }
|
||||||
|
} label: {
|
||||||
|
Label("Enrich All with AI", systemImage: "wand.and.stars")
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "sparkles")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button { showAddBookmark = true } label: {
|
||||||
|
Image(systemName: "plus")
|
||||||
|
}
|
||||||
|
Button { showSettings = true } label: {
|
||||||
|
Image(systemName: "gearshape")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
|
||||||
|
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if viewModel.enrichmentProgress > 0 {
|
||||||
|
enrichmentBanner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showSettings) {
|
||||||
|
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showCollections) {
|
||||||
|
CollectionsView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showAddBookmark) {
|
||||||
|
AddBookmarkView(viewModel: viewModel)
|
||||||
|
}
|
||||||
|
.refreshable {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
if viewModel.bookmarks.isEmpty {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Error", isPresented: Binding(
|
||||||
|
get: { viewModel.error != nil },
|
||||||
|
set: { if !$0 { viewModel.error = nil } }
|
||||||
|
)) {
|
||||||
|
Button("OK") { viewModel.error = nil }
|
||||||
|
} message: {
|
||||||
|
Text(viewModel.error ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var enrichmentBanner: some View {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView(value: viewModel.enrichmentProgress)
|
||||||
|
.progressViewStyle(.linear)
|
||||||
|
.tint(.primary)
|
||||||
|
Text("Adding AI summaries…")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func maybeLoadMore(_ bookmark: Bookmark) {
|
||||||
|
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
|
||||||
|
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
|
||||||
|
Task { await viewModel.loadMore() }
|
||||||
|
}
|
||||||
|
}
|
||||||
220
Marks/Views/BookmarksViewModel.swift
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class BookmarksViewModel {
|
||||||
|
var bookmarks: [Bookmark] = []
|
||||||
|
var isLoading = false
|
||||||
|
var isLoadingMore = false
|
||||||
|
var error: String?
|
||||||
|
var searchQuery = ""
|
||||||
|
var nextPageUrl: String?
|
||||||
|
var smartCollections: [SmartCollection] = []
|
||||||
|
var isGeneratingCollections = false
|
||||||
|
var enrichmentProgress: Double = 0
|
||||||
|
|
||||||
|
private let api: LinkdingAPI
|
||||||
|
private(set) var claude: ClaudeService?
|
||||||
|
private var enrichTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(api: LinkdingAPI, claude: ClaudeService?) {
|
||||||
|
self.api = api
|
||||||
|
self.claude = claude
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateClaude(_ service: ClaudeService?) {
|
||||||
|
claude = service
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
isLoading = true
|
||||||
|
error = nil
|
||||||
|
do {
|
||||||
|
let response = try await api.fetchBookmarks(search: searchQuery)
|
||||||
|
bookmarks = response.results
|
||||||
|
nextPageUrl = response.next
|
||||||
|
restoreAIData()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
startEnrichment()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadMore() async {
|
||||||
|
guard let next = nextPageUrl, !isLoadingMore else { return }
|
||||||
|
isLoadingMore = true
|
||||||
|
do {
|
||||||
|
let response = try await api.fetchBookmarksFromUrl(next)
|
||||||
|
bookmarks.append(contentsOf: response.results)
|
||||||
|
nextPageUrl = response.next
|
||||||
|
restoreAIData()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoadingMore = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func search() async {
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
let response = try await api.fetchBookmarks(search: searchQuery)
|
||||||
|
bookmarks = response.results
|
||||||
|
nextPageUrl = response.next
|
||||||
|
restoreAIData()
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func semanticSearch() async {
|
||||||
|
guard let claude, !searchQuery.isEmpty else { return }
|
||||||
|
isLoading = true
|
||||||
|
do {
|
||||||
|
// Search over all loaded bookmarks semantically
|
||||||
|
let allResponse = try await api.fetchBookmarks(limit: 200)
|
||||||
|
var all = allResponse.results
|
||||||
|
// Restore AI data so summaries are available for ranking
|
||||||
|
restoreAIData(into: &all)
|
||||||
|
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
|
||||||
|
nextPageUrl = nil
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func addBookmark(url: String, title: String, tags: [String]) async throws {
|
||||||
|
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
|
||||||
|
let bookmark = try await api.createBookmark(create)
|
||||||
|
bookmarks.insert(bookmark, at: 0)
|
||||||
|
print("[AI] addBookmark: saved id=\(bookmark.id) claude=\(claude != nil ? "present" : "nil")")
|
||||||
|
guard let claude else { return }
|
||||||
|
Task {
|
||||||
|
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
|
||||||
|
do {
|
||||||
|
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
|
||||||
|
print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)")
|
||||||
|
guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else {
|
||||||
|
print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bookmarks[i].aiSummary = summary
|
||||||
|
bookmarks[i].aiTags = aiTags
|
||||||
|
saveAIData(for: bookmarks[i])
|
||||||
|
} catch {
|
||||||
|
print("[AI] addBookmark: enrich FAILED \(error)")
|
||||||
|
self.error = "AI enrichment failed: \(error.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func delete(_ bookmark: Bookmark) async {
|
||||||
|
do {
|
||||||
|
try await api.deleteBookmark(id: bookmark.id)
|
||||||
|
bookmarks.removeAll { $0.id == bookmark.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func archive(_ bookmark: Bookmark) async {
|
||||||
|
do {
|
||||||
|
try await api.archiveBookmark(id: bookmark.id)
|
||||||
|
bookmarks.removeAll { $0.id == bookmark.id }
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateSmartCollections() async {
|
||||||
|
guard let claude, !bookmarks.isEmpty else { return }
|
||||||
|
isGeneratingCollections = true
|
||||||
|
do {
|
||||||
|
let allResponse = try await api.fetchBookmarks(limit: 200)
|
||||||
|
smartCollections = try await claude.generateCollections(from: allResponse.results)
|
||||||
|
} catch {
|
||||||
|
self.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
isGeneratingCollections = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichAll() async {
|
||||||
|
guard let claude else { return }
|
||||||
|
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
|
||||||
|
guard !toEnrich.isEmpty else { return }
|
||||||
|
enrichmentProgress = 0
|
||||||
|
for (done, i) in toEnrich.enumerated() {
|
||||||
|
do {
|
||||||
|
let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i])
|
||||||
|
bookmarks[i].aiSummary = summary
|
||||||
|
bookmarks[i].aiTags = tags
|
||||||
|
saveAIData(for: bookmarks[i])
|
||||||
|
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
|
||||||
|
} catch {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enrichmentProgress = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
|
||||||
|
private func startEnrichment() {
|
||||||
|
guard claude != nil else {
|
||||||
|
print("[AI] startEnrichment: skipped (no claude)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enrichTask?.cancel()
|
||||||
|
enrichTask = Task { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
|
||||||
|
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
|
||||||
|
for i in indices {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
guard let claude = await MainActor.run(body: { self.claude }) else { return }
|
||||||
|
do {
|
||||||
|
let bm = await MainActor.run { bookmarks[i] }
|
||||||
|
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")
|
||||||
|
let (summary, tags) = try await claude.enrich(bookmark: bm)
|
||||||
|
print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))")
|
||||||
|
await MainActor.run {
|
||||||
|
guard i < bookmarks.count else { return }
|
||||||
|
bookmarks[i].aiSummary = summary
|
||||||
|
bookmarks[i].aiTags = tags
|
||||||
|
saveAIData(for: bookmarks[i])
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: AI persistence
|
||||||
|
|
||||||
|
private func saveAIData(for bookmark: Bookmark) {
|
||||||
|
var store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
|
||||||
|
store["\(bookmark.id)"] = [
|
||||||
|
"summary": bookmark.aiSummary ?? "",
|
||||||
|
"tags": bookmark.aiTags ?? []
|
||||||
|
]
|
||||||
|
UserDefaults.standard.set(store, forKey: "aiData")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreAIData() {
|
||||||
|
restoreAIData(into: &bookmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func restoreAIData(into list: inout [Bookmark]) {
|
||||||
|
let store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
|
||||||
|
for i in list.indices {
|
||||||
|
if let d = store["\(list[i].id)"] {
|
||||||
|
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||||
|
list[i].aiTags = d["tags"] as? [String]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
Marks/Views/CollectionsView.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct CollectionsView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Group {
|
||||||
|
if viewModel.isGeneratingCollections {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
ProgressView()
|
||||||
|
Text("Building smart collections…")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if viewModel.smartCollections.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
"No Collections Yet",
|
||||||
|
systemImage: "sparkles",
|
||||||
|
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List(viewModel.smartCollections) { collection in
|
||||||
|
Section {
|
||||||
|
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
|
||||||
|
ForEach(items) { bookmark in
|
||||||
|
BookmarkRow(bookmark: bookmark)
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(collection.name)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
if !collection.description.isEmpty {
|
||||||
|
Text(collection.description)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Smart Collections")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.generateSmartCollections() }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
}
|
||||||
|
.disabled(viewModel.isGeneratingCollections)
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
144
Marks/Views/OnboardingView.swift
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct OnboardingView: View {
|
||||||
|
let onConnect: (ServerConfig) -> Void
|
||||||
|
|
||||||
|
@State private var urlText = ""
|
||||||
|
@State private var token = ""
|
||||||
|
@State private var isConnecting = false
|
||||||
|
@State private var errorMessage: String?
|
||||||
|
@FocusState private var focused: Field?
|
||||||
|
|
||||||
|
enum Field { case url, token }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Marks")
|
||||||
|
.font(.system(size: 42, weight: .semibold, design: .rounded))
|
||||||
|
Text("Your bookmarks, beautifully.")
|
||||||
|
.font(.system(size: 17))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
.padding(.bottom, 48)
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Server URL")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
TextField("https://links.example.com", text: $urlText)
|
||||||
|
.textContentType(.URL)
|
||||||
|
.keyboardType(.URL)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.focused($focused, equals: .url)
|
||||||
|
.submitLabel(.next)
|
||||||
|
.onSubmit { focused = .token }
|
||||||
|
.padding(14)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("API Token")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
SecureField("Paste your token", text: $token)
|
||||||
|
.textContentType(.password)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.focused($focused, equals: .token)
|
||||||
|
.submitLabel(.done)
|
||||||
|
.onSubmit { Task { await connect() } }
|
||||||
|
.padding(14)
|
||||||
|
.background(Color(.systemGray6))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
Text("Find your token at Settings → API Token in Linkding.")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
|
||||||
|
if let err = errorMessage {
|
||||||
|
Text(err)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.top, 12)
|
||||||
|
.padding(.horizontal, 28)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await connect() }
|
||||||
|
} label: {
|
||||||
|
Group {
|
||||||
|
if isConnecting {
|
||||||
|
ProgressView().tint(.white)
|
||||||
|
} else {
|
||||||
|
Text("Connect")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 52)
|
||||||
|
.background(canConnect ? Color.primary : Color(.systemGray4))
|
||||||
|
.foregroundStyle(canConnect ? Color(UIColor.systemBackground) : Color(.systemGray2))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.disabled(!canConnect || isConnecting)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.top, 24)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canConnect: Bool { !urlText.isEmpty && !token.isEmpty }
|
||||||
|
|
||||||
|
private func connect() async {
|
||||||
|
focused = nil
|
||||||
|
isConnecting = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
let config = try parseConfig()
|
||||||
|
let api = LinkdingAPI(config: config)
|
||||||
|
try await api.verifyConnection()
|
||||||
|
onConnect(config)
|
||||||
|
} catch APIError.badStatus(401) {
|
||||||
|
errorMessage = "Invalid token. Check your API token in Linkding settings."
|
||||||
|
} catch APIError.badStatus(let code) {
|
||||||
|
errorMessage = "Server returned \(code). Check the URL."
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Could not connect. Check the URL and try again."
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnecting = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseConfig() throws -> ServerConfig {
|
||||||
|
var raw = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
if !raw.hasPrefix("http://") && !raw.hasPrefix("https://") {
|
||||||
|
raw = "https://" + raw
|
||||||
|
}
|
||||||
|
guard let comps = URLComponents(string: raw),
|
||||||
|
let host = comps.host, !host.isEmpty else {
|
||||||
|
throw URLError(.badURL)
|
||||||
|
}
|
||||||
|
return ServerConfig(
|
||||||
|
host: host,
|
||||||
|
port: comps.port,
|
||||||
|
path: comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path,
|
||||||
|
token: token.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
useHttps: comps.scheme == "https"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
87
Marks/Views/SearchView.swift
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SearchView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var useSemanticSearch = false
|
||||||
|
@State private var semanticResults: [Bookmark]? = nil
|
||||||
|
@State private var searchTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(results) { bookmark in
|
||||||
|
BookmarkRow(bookmark: bookmark)
|
||||||
|
.onTapGesture {
|
||||||
|
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||||
|
.listRowSeparator(.visible)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await viewModel.delete(bookmark) }
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle("Search")
|
||||||
|
.toolbar {
|
||||||
|
if viewModel.claude != nil {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Toggle(isOn: $useSemanticSearch) {
|
||||||
|
Label("Semantic", systemImage: "sparkles")
|
||||||
|
}
|
||||||
|
.toggleStyle(.button)
|
||||||
|
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if searchText.isEmpty {
|
||||||
|
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
|
||||||
|
} else if results.isEmpty && !viewModel.isLoading {
|
||||||
|
ContentUnavailableView.search(text: searchText)
|
||||||
|
}
|
||||||
|
if viewModel.isLoading {
|
||||||
|
ProgressView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
|
||||||
|
.onChange(of: searchText) { _, _ in scheduleSearch() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var results: [Bookmark] {
|
||||||
|
if let semantic = semanticResults { return semantic }
|
||||||
|
guard !searchText.isEmpty else { return [] }
|
||||||
|
let q = searchText.lowercased()
|
||||||
|
return viewModel.bookmarks.filter { b in
|
||||||
|
b.displayTitle.lowercased().contains(q) ||
|
||||||
|
b.url.lowercased().contains(q) ||
|
||||||
|
b.tagNames.contains { $0.lowercased().contains(q) } ||
|
||||||
|
(b.aiTags ?? []).contains { $0.lowercased().contains(q) } ||
|
||||||
|
(b.aiSummary ?? "").lowercased().contains(q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleSearch() {
|
||||||
|
semanticResults = nil
|
||||||
|
searchTask?.cancel()
|
||||||
|
guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return }
|
||||||
|
searchTask = Task {
|
||||||
|
try? await Task.sleep(for: .milliseconds(500))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
let q = searchText
|
||||||
|
let all = viewModel.bookmarks
|
||||||
|
do {
|
||||||
|
let ranked = try await claude.semanticSearch(query: q, in: all)
|
||||||
|
await MainActor.run { semanticResults = ranked }
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
58
Marks/Views/SettingsView.swift
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
let onDisconnect: () -> Void
|
||||||
|
|
||||||
|
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.defaultsKey) ?? ""
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
Section("AI Features") {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("OpenRouter API Key")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
SecureField("sk-or-…", text: $claudeKey)
|
||||||
|
.textContentType(.password)
|
||||||
|
.autocorrectionDisabled()
|
||||||
|
.textInputAutocapitalization(.never)
|
||||||
|
.onChange(of: claudeKey) { _, new in
|
||||||
|
ClaudeService.save(apiKey: new)
|
||||||
|
viewModel.updateClaude(new.isEmpty ? nil : ClaudeService(apiKey: new))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
if viewModel.claude != nil {
|
||||||
|
Label("AI enabled — semantic search, auto-tagging, and smart collections active.", systemImage: "sparkles")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
Text("Add an OpenRouter API key to enable AI features. Uses Gemini 2.0 Flash Lite — very cheap.")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Server") {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
dismiss()
|
||||||
|
onDisconnect()
|
||||||
|
} label: {
|
||||||
|
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
94
Marks/Views/TagsView.swift
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TagsView: View {
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
List {
|
||||||
|
ForEach(allTags, id: \.tag) { entry in
|
||||||
|
NavigationLink {
|
||||||
|
TagBookmarksView(tag: entry.tag, viewModel: viewModel)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(entry.tag)
|
||||||
|
.font(.system(size: 17))
|
||||||
|
Spacer()
|
||||||
|
Text("\(entry.count)")
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Tags")
|
||||||
|
.overlay {
|
||||||
|
if viewModel.bookmarks.isEmpty {
|
||||||
|
ContentUnavailableView("No Tags", systemImage: "tag", description: Text("Tags from your bookmarks will appear here."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var allTags: [(tag: String, count: Int)] {
|
||||||
|
var counts: [String: Int] = [:]
|
||||||
|
for bookmark in viewModel.bookmarks {
|
||||||
|
for tag in bookmark.tagNames {
|
||||||
|
counts[tag, default: 0] += 1
|
||||||
|
}
|
||||||
|
for tag in bookmark.aiTags ?? [] where !bookmark.tagNames.contains(tag) {
|
||||||
|
counts[tag, default: 0] += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts.map { (tag: $0.key, count: $0.value) }
|
||||||
|
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TagBookmarksView: View {
|
||||||
|
let tag: String
|
||||||
|
@Bindable var viewModel: BookmarksViewModel
|
||||||
|
@Environment(\.openURL) private var openURL
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
List {
|
||||||
|
ForEach(filteredBookmarks) { bookmark in
|
||||||
|
BookmarkRow(bookmark: bookmark)
|
||||||
|
.onTapGesture {
|
||||||
|
if let url = URL(string: bookmark.url) { openURL(url) }
|
||||||
|
}
|
||||||
|
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
|
||||||
|
.listRowSeparator(.visible)
|
||||||
|
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await viewModel.delete(bookmark) }
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.swipeActions(edge: .leading) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.archive(bookmark) }
|
||||||
|
} label: {
|
||||||
|
Label("Archive", systemImage: "archivebox")
|
||||||
|
}
|
||||||
|
.tint(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listStyle(.plain)
|
||||||
|
.navigationTitle(tag)
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.overlay {
|
||||||
|
if filteredBookmarks.isEmpty {
|
||||||
|
ContentUnavailableView("No Bookmarks", systemImage: "tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var filteredBookmarks: [Bookmark] {
|
||||||
|
viewModel.bookmarks.filter {
|
||||||
|
$0.tagNames.contains(tag) || ($0.aiTags ?? []).contains(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
112
README.md
@@ -1,61 +1,69 @@
|
|||||||
<h1 align="center">
|
# Marks
|
||||||
<img src="assets/other/banner.png" />
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p align="center">
|
A native iOS client for [linkding](https://github.com/sissbruecker/linkding), the self-hosted bookmark manager.
|
||||||
<b>Linkdy</b> is a <a href="https://github.com/sissbruecker/linkding" target="_blank" rel="noopener noreferrer"><b>Linkding</b></a> client created with Flutter. <a href="https://github.com/sissbruecker/linkding" target="_blank" rel="noopener noreferrer">Linkding</a> is a self hosted bookmark manager. The objective of this project is to give all it's users an application to manage the bookmarks from a mobile device.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p align="center">
|
Built from scratch in SwiftUI targeting iOS 26. Replaces the previous Flutter-based app.
|
||||||
<a href="https://play.google.com/store/apps/details?id=com.jgeek00.linkdy" target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src="/assets/other/get_google_play.png" width="300px">
|
|
||||||
</a>
|
|
||||||
<a href="https://apps.apple.com/us/app/linkdy/id6479930976" target="_blank" rel="noopener noreferrer">
|
|
||||||
<img src="/assets/other/get-appstore.png" width="300px">
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### Main features
|
## Features
|
||||||
<ul>
|
|
||||||
<li>List your bookmarks.</li>
|
|
||||||
<li>Open the links on an internal browser.</li>
|
|
||||||
<li>Search bookmarks.</li>
|
|
||||||
<li>Create new bookmarks.</li>
|
|
||||||
<li>Create new tags.</li>
|
|
||||||
<li>List the bookmarks associated with each tag.</li>
|
|
||||||
<li>Material You interface with dynamic theming (only Android 12+).</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
## Privacy policy
|
- Browse and search your bookmarks
|
||||||
Check the privacy policy [here](https://pastebin.com/raw/Yiw4h6KK).
|
- Swipe to delete or archive
|
||||||
|
- Add bookmarks manually
|
||||||
|
- Share to Marks from any app (share extension)
|
||||||
|
- Tag browser — see all tags sorted by count, tap to filter
|
||||||
|
- Native search tab with instant client-side filtering
|
||||||
|
- AI enrichment via OpenRouter — auto-generates summaries and tags for each bookmark
|
||||||
|
- Semantic search — rank bookmarks by relevance using AI
|
||||||
|
- Smart Collections — AI groups your bookmarks into themed collections
|
||||||
|
- Pagination — loads more as you scroll
|
||||||
|
|
||||||
### Development
|
## Requirements
|
||||||
1. Clone this repository.
|
|
||||||
2. Run ``flutter pub get`` to install all the dependencies.
|
|
||||||
3. Run the application on the desired virtual or physical device.
|
|
||||||
|
|
||||||
<p>
|
- iOS 26+
|
||||||
<b>State manager</b>
|
- A running [linkding](https://github.com/sissbruecker/linkding) instance with API access
|
||||||
<br>
|
- (Optional) An [OpenRouter](https://openrouter.ai) API key for AI features
|
||||||
Linkdy uses Riverpod as the state management system. Files ended on <code>.g.dart</code> are auto generated by Riverpod's code generator and shouldn't be edited manually.
|
|
||||||
The code generator can be started by running <code>dart run build_runner watch</code>. Then, the process will start on the terminal.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<b>Translations</b>
|
|
||||||
<br>
|
|
||||||
Linkdy uses <a href="https://pub.dev/packages/slang">slang</a> to manage the translations of the application. To add a new translation, create a new file on <code>lib/i18n</code>, with the structure <code>strings_[lang_code].i18n.json</code>. When adding a new string, make sure to add the translation on all <code>.i18n.json</code> files. Then, run <code>dart run slang</code> to generate the translations.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
### Android signing
|
## AI Features
|
||||||
1. Clone ``android/key.properties.sample`` and rename it to ``key.properties``.
|
|
||||||
2. Fill the variables with the values.
|
|
||||||
3. Put your ``keystore.jks`` file inside ``android/app/``.
|
|
||||||
4. Run ``flutter build apk --release`` or ``flutter build appbundle --release`` to compile and sign the production build.
|
|
||||||
|
|
||||||
<br>
|
All AI features are optional and powered by [OpenRouter](https://openrouter.ai) using `google/gemini-2.0-flash-lite-001` — one of the cheapest available models (~$0.075/M input tokens).
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
##### Created by JGeek00
|
| Feature | What it does |
|
||||||
|
|---|---|
|
||||||
|
| Auto-enrichment | Generates a one-sentence summary and 3-5 tags for each bookmark |
|
||||||
|
| Semantic search | Ranks your bookmarks by relevance to a natural language query |
|
||||||
|
| Smart Collections | Groups up to 150 bookmarks into 3-6 themed collections |
|
||||||
|
|
||||||
|
Add your OpenRouter API key in Settings to enable these features.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requires [xcodegen](https://github.com/yonaskolb/XcodeGen).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install xcodegen
|
||||||
|
xcodegen generate
|
||||||
|
open Marks.xcodeproj
|
||||||
|
```
|
||||||
|
|
||||||
|
Then build and run in Xcode. The project uses automatic signing — set your development team in `project.yml` under `DEVELOPMENT_TEAM`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **SwiftUI** with `@Observable` and `@MainActor` throughout (Swift 6 strict concurrency)
|
||||||
|
- **LinkdingAPI** — thin wrapper around the linkding REST API
|
||||||
|
- **ClaudeService** — OpenRouter client for AI enrichment, semantic search, and collections
|
||||||
|
- **BookmarksViewModel** — central state: bookmarks list, pagination, search, enrichment progress
|
||||||
|
- **App Groups** — share extension and main app share credentials via `group.com.magicive.marks`
|
||||||
|
- **UserDefaults** — AI summaries and tags cached locally by bookmark ID
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
Marks/
|
||||||
|
Models/ Bookmark, ServerConfig, SmartCollection
|
||||||
|
Services/ LinkdingAPI, ClaudeService
|
||||||
|
Views/ BookmarksView, TagsView, SearchView, SettingsView, …
|
||||||
|
Assets.xcassets App icon and assets
|
||||||
|
ShareExtension/ iOS share extension (zero-tap bookmark saving)
|
||||||
|
project.yml xcodegen project spec
|
||||||
|
```
|
||||||
|
|||||||
32
ShareExtension/Info.plist
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?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>CFBundleDisplayName</key>
|
||||||
|
<string>Marks</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSExtension</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionAttributes</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSExtensionActivationRule</key>
|
||||||
|
<string>TRUEPREDICATE</string>
|
||||||
|
</dict>
|
||||||
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
<string>com.apple.share-services</string>
|
||||||
|
<key>NSExtensionPrincipalClass</key>
|
||||||
|
<string>ShareViewController</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<true/>
|
<array>
|
||||||
|
<string>group.com.magicive.marks</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
129
ShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import UIKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
@objc(ShareViewController)
|
||||||
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
|
private let appGroupId = "group.com.magicive.marks"
|
||||||
|
private let configKey = "serverConfig"
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||||
|
blur.frame = view.bounds
|
||||||
|
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||||
|
view.addSubview(blur)
|
||||||
|
extractURLAndSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractURLAndSave() {
|
||||||
|
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||||
|
let attachments = item.attachments else {
|
||||||
|
showResult(success: false, message: "Nothing to save")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(typeId: String) -> Bool {
|
||||||
|
guard let attachment = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(typeId) }) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
attachment.loadItem(forTypeIdentifier: typeId, options: nil) { [weak self] item, _ in
|
||||||
|
let urlString = typeId == UTType.url.identifier
|
||||||
|
? ((item as? URL)?.absoluteString ?? (item as? String))
|
||||||
|
: (item as? String)
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let url = urlString { self?.save(url: url) }
|
||||||
|
else { self?.showResult(success: false, message: "No URL found") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
||||||
|
showResult(success: false, message: "No URL found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save(url: String) {
|
||||||
|
guard let groupDefaults = UserDefaults(suiteName: appGroupId),
|
||||||
|
let data = groupDefaults.data(forKey: configKey),
|
||||||
|
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let host = config["host"] as? String,
|
||||||
|
let token = config["token"] as? String,
|
||||||
|
let useHttps = config["useHttps"] as? Bool else {
|
||||||
|
showResult(success: false, message: "Open Marks first")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var base = (useHttps ? "https" : "http") + "://" + host
|
||||||
|
if let port = config["port"] as? Int { base += ":\(port)" }
|
||||||
|
if let path = config["path"] as? String, !path.isEmpty { base += path }
|
||||||
|
|
||||||
|
guard let apiUrl = URL(string: "\(base)/api/bookmarks/") else {
|
||||||
|
showResult(success: false, message: "Bad server URL")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req = URLRequest(url: apiUrl)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||||
|
"url": url, "is_archived": false, "unread": false, "shared": false
|
||||||
|
])
|
||||||
|
|
||||||
|
URLSession.shared.dataTask(with: req) { [weak self] _, response, error in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||||
|
let ok = error == nil && (code == 200 || code == 201)
|
||||||
|
self?.showResult(success: ok, message: ok ? "Bookmarked" : "Failed to save")
|
||||||
|
}
|
||||||
|
}.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showResult(success: Bool, message: String) {
|
||||||
|
let card = UIView()
|
||||||
|
card.backgroundColor = success ? .systemGreen : .systemRed
|
||||||
|
card.layer.cornerRadius = 16
|
||||||
|
card.layer.cornerCurve = .continuous
|
||||||
|
card.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
card.alpha = 0
|
||||||
|
|
||||||
|
let stack = UIStackView()
|
||||||
|
stack.axis = .horizontal
|
||||||
|
stack.spacing = 10
|
||||||
|
stack.alignment = .center
|
||||||
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
|
||||||
|
let icon = UIImageView(image: UIImage(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill"))
|
||||||
|
icon.tintColor = .white
|
||||||
|
icon.preferredSymbolConfiguration = .init(pointSize: 22, weight: .semibold)
|
||||||
|
|
||||||
|
let label = UILabel()
|
||||||
|
label.text = message
|
||||||
|
label.textColor = .white
|
||||||
|
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||||
|
|
||||||
|
stack.addArrangedSubview(icon)
|
||||||
|
stack.addArrangedSubview(label)
|
||||||
|
card.addSubview(stack)
|
||||||
|
view.addSubview(card)
|
||||||
|
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
||||||
|
stack.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
||||||
|
stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 24),
|
||||||
|
card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||||
|
card.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||||
|
card.heightAnchor.constraint(equalToConstant: 56),
|
||||||
|
card.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||||
|
])
|
||||||
|
|
||||||
|
UIView.animate(withDuration: 0.2) { card.alpha = 1 }
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.8 : 2.0)) { [weak self] in
|
||||||
|
UIView.animate(withDuration: 0.15, animations: { card.alpha = 0 }) { _ in
|
||||||
|
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# This file configures the analyzer, which statically analyzes Dart code to
|
|
||||||
# check for errors, warnings, and lints.
|
|
||||||
#
|
|
||||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
|
||||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
|
||||||
# invoked from the command line by running `flutter analyze`.
|
|
||||||
|
|
||||||
# The following line activates a set of recommended lints for Flutter apps,
|
|
||||||
# packages, and plugins designed to encourage good coding practices.
|
|
||||||
include: package:flutter_lints/flutter.yaml
|
|
||||||
|
|
||||||
linter:
|
|
||||||
# The lint rules applied to this project can be customized in the
|
|
||||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
|
||||||
# included above or to enable additional rules. A list of all available lints
|
|
||||||
# and their documentation is published at https://dart.dev/lints.
|
|
||||||
#
|
|
||||||
# Instead of disabling a lint rule for the entire project in the
|
|
||||||
# section below, it can also be suppressed for a single line of code
|
|
||||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
|
||||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
|
||||||
# producing the lint.
|
|
||||||
rules:
|
|
||||||
require_trailing_commas: true
|
|
||||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
|
||||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
|
||||||
|
|
||||||
# Additional information about this file can be found at
|
|
||||||
# https://dart.dev/guides/language/analysis-options
|
|
||||||
14
android/.gitignore
vendored
@@ -1,14 +0,0 @@
|
|||||||
gradle-wrapper.jar
|
|
||||||
/.gradle
|
|
||||||
/captures/
|
|
||||||
/gradlew
|
|
||||||
/gradlew.bat
|
|
||||||
/local.properties
|
|
||||||
GeneratedPluginRegistrant.java
|
|
||||||
.cxx/
|
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
|
||||||
# See https://flutter.dev/to/reference-keystore
|
|
||||||
key.properties
|
|
||||||
**/*.keystore
|
|
||||||
**/*.jks
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
plugins {
|
|
||||||
id("com.android.application")
|
|
||||||
id("kotlin-android")
|
|
||||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
|
||||||
id("dev.flutter.flutter-gradle-plugin")
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.jgeek00.linkdy"
|
|
||||||
compileSdk = flutter.compileSdkVersion
|
|
||||||
ndkVersion = flutter.ndkVersion
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.jgeek00.linkdy"
|
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = flutter.minSdkVersion
|
|
||||||
targetSdk = flutter.targetSdkVersion
|
|
||||||
versionCode = flutter.versionCode
|
|
||||||
versionName = flutter.versionName
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
// TODO: Add your own signing config for the release build.
|
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flutter {
|
|
||||||
source = "../.."
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
|
||||||
the Flutter tool needs it to communicate with the running application
|
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
|
||||||
-->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
|
||||||
<application
|
|
||||||
android:label="Linkdy"
|
|
||||||
android:name="${applicationName}"
|
|
||||||
android:icon="@mipmap/ic_launcher">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
|
||||||
the Android process has started. This theme is visible to the user
|
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
|
||||||
to determine the Window background behind the Flutter UI. -->
|
|
||||||
<meta-data
|
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
|
||||||
android:resource="@style/NormalTheme"
|
|
||||||
/>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN" />
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<data android:mimeType="text/*" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data
|
|
||||||
android:scheme="https"
|
|
||||||
android:host="example.com"
|
|
||||||
android:pathPrefix="/invite" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
</activity>
|
|
||||||
<!-- Don't delete the meta-data below.
|
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
|
||||||
<meta-data
|
|
||||||
android:name="flutterEmbedding"
|
|
||||||
android:value="2" />
|
|
||||||
</application>
|
|
||||||
<!-- Required to query activities that can process text, see:
|
|
||||||
https://developer.android.com/training/package-visibility and
|
|
||||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
|
||||||
|
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
|
||||||
<queries>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT" />
|
|
||||||
<data android:mimeType="text/plain" />
|
|
||||||
</intent>
|
|
||||||
</queries>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
package com.jgeek00.linkdy
|
|
||||||
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
|
||||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
|
||||||
import android.os.Bundle
|
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
|
||||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
|
||||||
GeneratedPluginRegistrant.registerWith(flutterEngine);
|
|
||||||
java.lang.Thread.sleep(1000);
|
|
||||||
}
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
if (intent.getIntExtra("org.chromium.chrome.extra.TASK_ID", -1) == this.taskId) {
|
|
||||||
this.finish()
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
|
Before Width: | Height: | Size: 69 B |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 69 B |
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="fill" android:src="@drawable/background"/>
|
|
||||||
</item>
|
|
||||||
<item>
|
|
||||||
<bitmap android:gravity="center" android:src="@drawable/splash"/>
|
|
||||||
</item>
|
|
||||||
</layer-list>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background" />
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
|
||||||
|
|
||||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome" />
|
|
||||||
</adaptive-icon>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
|
||||||
</adaptive-icon>
|
|
||||||
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
|
||||||
<item name="android:windowFullscreen">false</item>
|
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
<item name="android:windowSplashScreenBackground">#121212</item>
|
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
|
||||||
</style>
|
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
|
||||||
<item name="android:windowFullscreen">false</item>
|
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
</style>
|
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
|
||||||
<item name="android:windowFullscreen">false</item>
|
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
<item name="android:windowSplashScreenBackground">#ffffff</item>
|
|
||||||
<item name="android:windowSplashScreenAnimatedIcon">@drawable/android12splash</item>
|
|
||||||
</style>
|
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<color name="ic_launcher_background">#5856E0</color>
|
|
||||||
</resources>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<resources>
|
|
||||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
|
||||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
|
||||||
<!-- Show a splash screen on the activity. Automatically removed when
|
|
||||||
the Flutter engine draws its first frame -->
|
|
||||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
|
||||||
<item name="android:forceDarkAllowed">false</item>
|
|
||||||
<item name="android:windowFullscreen">false</item>
|
|
||||||
<item name="android:windowDrawsSystemBarBackgrounds">false</item>
|
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
|
||||||
</style>
|
|
||||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
|
||||||
This theme determines the color of the Android Window while your
|
|
||||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
|
||||||
running.
|
|
||||||
|
|
||||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
|
||||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
|
||||||
<item name="android:windowBackground">?android:colorBackground</item>
|
|
||||||
</style>
|
|
||||||
</resources>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
|
||||||
the Flutter tool needs it to communicate with the running application
|
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
|
||||||
-->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
|
||||||
</manifest>
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
allprojects {
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val newBuildDir: Directory =
|
|
||||||
rootProject.layout.buildDirectory
|
|
||||||
.dir("../../build")
|
|
||||||
.get()
|
|
||||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
|
||||||
|
|
||||||
subprojects {
|
|
||||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
|
||||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
|
||||||
}
|
|
||||||
subprojects {
|
|
||||||
project.evaluationDependsOn(":app")
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.register<Delete>("clean") {
|
|
||||||
delete(rootProject.layout.buildDirectory)
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
|
||||||
android.useAndroidX=true
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
|
||||||
distributionPath=wrapper/dists
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
storePassword= # keystore password #
|
|
||||||
keyPassword= # keystore key password #
|
|
||||||
keyAlias= # key alias #
|
|
||||||
storeFile= # ./keystore-file-name.jks #
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
pluginManagement {
|
|
||||||
val flutterSdkPath =
|
|
||||||
run {
|
|
||||||
val properties = java.util.Properties()
|
|
||||||
file("local.properties").inputStream().use { properties.load(it) }
|
|
||||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
|
||||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
|
||||||
flutterSdkPath
|
|
||||||
}
|
|
||||||
|
|
||||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
|
||||||
|
|
||||||
repositories {
|
|
||||||
google()
|
|
||||||
mavenCentral()
|
|
||||||
gradlePluginPortal()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
|
||||||
id("com.android.application") version "8.11.1" apply false
|
|
||||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
|
||||||
}
|
|
||||||
|
|
||||||
include(":app")
|
|
||||||
|
Before Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 56 KiB |