Compare commits

...

10 Commits

Author SHA1 Message Date
Krishna Kumar
b7ef5c2215 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>
2026-05-21 08:33:01 -05:00
Juan Gilsanz Polo
cd85f9c67f Updated ios deps 2026-05-16 14:17:20 +02:00
Juan Gilsanz Polo
582deb6cb1 Bump app version 2026-05-15 21:42:39 +02:00
Juan Gilsanz Polo
efc03a2e15 Added key properties sample 2026-05-15 21:42:29 +02:00
Juan Gilsanz Polo
7af27d361b Updated dependencies, removed split view package, removed super context menu and fix android 2026-05-15 21:30:18 +02:00
Juan Gilsanz Polo
bd1411041e Bump app version 2026-05-12 22:26:42 +02:00
Juan
06811c50f1 Merge pull request #40 from jaandrle/master 2026-05-07 22:47:32 +02:00
Jan Andrle
4998155415 Checking and rewording (cs) 2026-04-28 10:25:55 +02:00
Jan Andrle
f6d88c05b8 Added new language (cs) 2026-04-16 17:03:13 +02:00
Juan Gilsanz Polo
d0542fa3f0 Bump app version 2026-03-22 21:02:27 +01:00
304 changed files with 2337 additions and 17087 deletions

View File

@@ -1,2 +0,0 @@
SENTRY_DSN=
ENABLE_SENTRY=false

65
.gitignore vendored
View File

@@ -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

View File

@@ -1,45 +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: "41456452f29d64e8deb623a3c927524bcf9f111b"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
- platform: android
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
- platform: ios
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
- platform: linux
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
- platform: macos
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
- platform: web
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
- platform: windows
create_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
base_revision: 41456452f29d64e8deb623a3c927524bcf9f111b
# 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
View File

@@ -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",
}

View File

@@ -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.

View 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 */;
}

View File

@@ -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">

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

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

View File

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

28
Marks/Info.plist Normal file
View 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>

View File

@@ -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
View 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)
}
}
}
}

View 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]
}

View 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)
}
}

View 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
}
}

View 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)
}
}
}

View 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
}
}
}
}

View 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))
}
}
}

View 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() }
}
}

View 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]
}
}
}
}

View 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() }
}
}
}
}
}

View 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"
)
}
}

View 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 {}
}
}
}

View 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() }
}
}
}
}
}

View 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
View File

@@ -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
View 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>

View File

@@ -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>

View 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)
}
}
}
}

View File

@@ -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

13
android/.gitignore vendored
View File

@@ -1,13 +0,0 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
# Remember to never publicly share your keystore.
# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app
key.properties
**/*.keystore
**/*.jks

View File

@@ -1,78 +0,0 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace "com.jgeek00.linkdy"
compileSdkVersion 36
ndkVersion '27.0.12077973'
compileOptions {
sourceCompatibility JavaVersion.VERSION_18
targetCompatibility JavaVersion.VERSION_18
}
kotlinOptions {
jvmTarget = 18
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
applicationId "com.jgeek00.linkdy"
minSdkVersion 26
targetSdkVersion 36
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
signingConfigs {
release {
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
}
}
}
flutter {
source '../..'
}
dependencies {}

View File

@@ -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>

View File

@@ -1,54 +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"
android:enableOnBackInvokedCallback="true">
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
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>
</manifest>

View File

@@ -1,24 +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)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 B

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#5856E0</color>
</resources>

View File

@@ -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>

View File

@@ -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>

View File

@@ -1,30 +0,0 @@
buildscript {
ext.kotlin_version = '1.9.25'
repositories {
google()
mavenCentral()
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -1,6 +0,0 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
android.defaults.buildfeatures.buildconfig=true
android.nonTransitiveRClass=false
android.nonFinalResIds=false

View File

@@ -1,8 +0,0 @@
#Tue Jun 17 20:55:04 CEST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View File

@@ -1,4 +0,0 @@
storePassword= # keystore password #
keyPassword= # keystore key password #
keyAlias= # key alias #
storeFile= # ./keystore-file-name.jks #

View File

@@ -1,29 +0,0 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
plugins {
id "dev.flutter.flutter-gradle-plugin" version "1.0.0" apply false
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version '8.10.1' apply false
}
include ":app"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Some files were not shown because too many files have changed in this diff Show More