From 409662f7f5f272bde4beb8a8025dc813b209c971 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Sat, 16 Aug 2025 11:13:00 +1000 Subject: [PATCH] Add init working mobile app --- iOS_App_Clone_Prompt.md | 162 ++++++ mobile/.gitignore | 200 +++++++ mobile/Heygo.xcodeproj/project.pbxproj | 408 ++++++++++++++ .../Heygo.xcodeproj/project.pbxproj.corrupted | 500 ++++++++++++++++++ mobile/Heygo.xcodeproj/project.pbxproj.new | 98 ++++ .../contents.xcworkspacedata | 4 + .../UserInterfaceState.xcuserstate | Bin 0 -> 27810 bytes .../xcschemes/xcschememanagement.plist | 14 + mobile/Heygo/ContentView.swift | 34 ++ mobile/Heygo/HeygoApp.swift | 13 + .../Models/ChangeLog+CoreDataClass.swift | 27 + .../Heygo/Models/ClickLog+CoreDataClass.swift | 25 + .../CoreDataModel.xcdatamodel/contents | 43 ++ mobile/Heygo/Models/Link+CoreDataClass.swift | 129 +++++ .../Heygo/Models/PersistenceController.swift | 69 +++ mobile/Heygo/Models/Tag+CoreDataClass.swift | 54 ++ .../Preview Assets.xcassets/Contents.json | 6 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Resources/Assets.xcassets/Contents.json | 6 + mobile/Heygo/Resources/Info.plist | 62 +++ .../Resources/en.lproj/Localizable.strings | 142 +++++ .../zh-Hans.lproj/Localizable.strings | 142 +++++ mobile/Heygo/Utils/AnalyticsCalculator.swift | 411 ++++++++++++++ mobile/Heygo/Utils/MarkdownRenderer.swift | 380 +++++++++++++ mobile/Heygo/Utils/TagManager.swift | 369 +++++++++++++ mobile/Heygo/Utils/URLTemplateProcessor.swift | 149 ++++++ mobile/Heygo/ViewModels/LinkViewModel.swift | 272 ++++++++++ mobile/Heygo/Views/AnalyticsView.swift | 476 +++++++++++++++++ mobile/Heygo/Views/LinkDetailView.swift | 469 ++++++++++++++++ mobile/Heygo/Views/LinkFormView.swift | 308 +++++++++++ mobile/Heygo/Views/LinkListView.swift | 364 +++++++++++++ mobile/Heygo/Views/SearchView.swift | 485 +++++++++++++++++ mobile/Heygo/Views/SimpleTestView.swift | 0 mobile/README.md | 199 +++++++ 35 files changed, 6044 insertions(+) create mode 100644 iOS_App_Clone_Prompt.md create mode 100644 mobile/.gitignore create mode 100644 mobile/Heygo.xcodeproj/project.pbxproj create mode 100644 mobile/Heygo.xcodeproj/project.pbxproj.corrupted create mode 100644 mobile/Heygo.xcodeproj/project.pbxproj.new create mode 100644 mobile/Heygo.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 mobile/Heygo.xcodeproj/project.xcworkspace/xcuserdata/junv.xcuserdatad/UserInterfaceState.xcuserstate create mode 100644 mobile/Heygo.xcodeproj/xcuserdata/junv.xcuserdatad/xcschemes/xcschememanagement.plist create mode 100644 mobile/Heygo/ContentView.swift create mode 100644 mobile/Heygo/HeygoApp.swift create mode 100644 mobile/Heygo/Models/ChangeLog+CoreDataClass.swift create mode 100644 mobile/Heygo/Models/ClickLog+CoreDataClass.swift create mode 100644 mobile/Heygo/Models/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents create mode 100644 mobile/Heygo/Models/Link+CoreDataClass.swift create mode 100644 mobile/Heygo/Models/PersistenceController.swift create mode 100644 mobile/Heygo/Models/Tag+CoreDataClass.swift create mode 100644 mobile/Heygo/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 mobile/Heygo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 mobile/Heygo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 mobile/Heygo/Resources/Assets.xcassets/Contents.json create mode 100644 mobile/Heygo/Resources/Info.plist create mode 100644 mobile/Heygo/Resources/en.lproj/Localizable.strings create mode 100644 mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings create mode 100644 mobile/Heygo/Utils/AnalyticsCalculator.swift create mode 100644 mobile/Heygo/Utils/MarkdownRenderer.swift create mode 100644 mobile/Heygo/Utils/TagManager.swift create mode 100644 mobile/Heygo/Utils/URLTemplateProcessor.swift create mode 100644 mobile/Heygo/ViewModels/LinkViewModel.swift create mode 100644 mobile/Heygo/Views/AnalyticsView.swift create mode 100644 mobile/Heygo/Views/LinkDetailView.swift create mode 100644 mobile/Heygo/Views/LinkFormView.swift create mode 100644 mobile/Heygo/Views/LinkListView.swift create mode 100644 mobile/Heygo/Views/SearchView.swift create mode 100644 mobile/Heygo/Views/SimpleTestView.swift create mode 100644 mobile/README.md diff --git a/iOS_App_Clone_Prompt.md b/iOS_App_Clone_Prompt.md new file mode 100644 index 0000000..2200ad2 --- /dev/null +++ b/iOS_App_Clone_Prompt.md @@ -0,0 +1,162 @@ +# iOS App Clone Prompt for Django Links Application + +Please create an iOS app called Heygo(in Chinese 黑狗) that replicates the functionality of my Django-based link management system. Here are the core features and functionalities to implement: + +## Core Link Management Features + +### 1. Link Model & Data Structure +- Links with unique aliases (alphanumeric slugs) +- Original URLs (supports template parameters like `{param,default=value}`) +- Link types: Regular Links and Custom Pages (with Markdown content), links with templates become Template type, so all the types are(Link, Custom, Template) +- Click tracking with timestamps +- Tags for categorization, optional +- Creation and update timestamps +- Description field, optional + +### 2. Main Views & Functionality + +#### Link List / Home page View +- Display all links in a list with sorting options (alias, type, URL, clicks, date) +- Search and quick go functionality, when typing in the search bar, it filters out on the existing links with fuzz search, when press enter, then open browser with the original url + +#### Link Creation/Editing +- Form to create new links with alias validation +- Support for both regular URLs and custom markdown content +- Template URL support with parameter placeholders +- One more more Tags assignment, when user typing tags, it should auto complete based on existing tag list, auto create new tag on saving Link +- Duplicate alias prevention +- Real-time alias availability checking + +#### Link Detail View +- Comprehensive link information display +- Click analytics with charts (daily/weekly/monthly views) +- Time period selection (3 months, 6 months, 1 year, all time) +- Change history log +- For custom links: rendered markdown content + +### 3. URL Redirection System +- Short URL redirection (`/alias` → original URL) +- Template parameter support (`/alias/param` for parameterized URLs) +- Click logging for analytics +- Error handling for non-existent aliases + +### 4. Analytics & Tracking +- Click count tracking per link +- Detailed click logs with timestamps +- Visual charts showing click trends over time +- Change history tracking + +### 5. Additional Features +- Database backup capabilities to icloud +- Search across links and aliases +- Tag-based filtering +- Custom markdown pages with full rendering +- Help documentation + +### 6. Technical Requirements +- Core Data for local storage +- Charts framework for analytics visualization +- Markdown rendering for custom content +- Network requests for URL validation +- Share extensions for adding links from other apps +- Dark mode support +- Accessibility features +- i18n multi language support, now supports Chinese and English + +### 7. UI/UX Considerations +- Clean, modern interface similar to link management tools +- Quick access to popular links +- Intuitive navigation between list, detail, and creation views +- Visual feedback for user actions +- Offline functionality where possible +- Pull-to-refresh for data updates + +### 8. Advanced Features to Include +- Backup and sync capabilities +- Batch operations (multi-select delete/edit) + +## Implementation Details + +### Core Data Model Structure +```swift +// Link Entity +class Link: NSManagedObject { + @NSManaged var alias: String + @NSManaged var originalURL: String? + @NSManaged var text: String? + @NSManaged var linkType: String // "LINK" or "CUSTOM" + @NSManaged var clickCount: Int32 + @NSManaged var createdAt: Date + @NSManaged var updatedAt: Date + @NSManaged var linkDescription: String? + @NSManaged var tags: NSSet? + @NSManaged var clickLogs: NSSet? + @NSManaged var changeLogs: NSSet? +} + +// ClickLog Entity +class ClickLog: NSManagedObject { + @NSManaged var clickedAt: Date + @NSManaged var link: Link +} + +// Tag Entity +class Tag: NSManagedObject { + @NSManaged var name: String + @NSManaged var slug: String + @NSManaged var tagDescription: String? + @NSManaged var createdAt: Date + @NSManaged var links: NSSet? +} +``` + +### Key Features to Replicate + +#### Template URL Processing +- Parse URLs with parameters like `{query,default=search}` +- Extract parameter names and default values +- Allow dynamic URL generation with user input +- Handle missing parameters gracefully + +#### Analytics Dashboard +- Implement charts using Charts framework +- Support multiple time periods (3m, 6m, 1y, all time) +- Calculate intervals (daily, weekly, monthly) based on date range +- Real-time click tracking + +#### Import/Export System +- JSON format compatibility with Django backend +- Validation of imported data +- Conflict resolution for duplicate aliases +- Progress indication for large imports + +#### Search & Filtering +- Real-time search across aliases and URLs +- Tag-based filtering +- Sort by multiple criteria (clicks, date, alphabetical) +- Search history and suggestions + +## Architecture Requirements + +Please structure the app with proper MVC architecture, implement proper error handling, and ensure good performance with large numbers of links. Include comprehensive test coverage and follow iOS Human Interface Guidelines. + +### Recommended Architecture +- **MVVM** pattern with Combine framework +- **Core Data** for persistent storage +- **SwiftUI** for modern UI development +- **Charts** framework for analytics +- **Network** layer for URL validation and TTS integration + +### Performance Considerations +- Lazy loading for large link collections +- Efficient Core Data queries with proper indexing +- Background processing for analytics calculations +- Memory management for image and data caching + +### Testing Strategy +- Unit tests for business logic +- UI tests for critical user flows +- Performance tests for large datasets +- Accessibility tests for VoiceOver support + +This comprehensive iOS app should provide all the functionality of the Django web application while taking advantage of iOS-specific features and following platform conventions. diff --git a/mobile/.gitignore b/mobile/.gitignore new file mode 100644 index 0000000..7085dd5 --- /dev/null +++ b/mobile/.gitignore @@ -0,0 +1,200 @@ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +# .swiftpm + +.build/ + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +# Xcode Patch +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +*.xcodeproj/project.xcworkspace/* +!*.xcodeproj/project.xcworkspace/contents.xcworkspacedata +!*.xcodeproj/project.xcworkspace/xcshareddata/ + +# IDEDocumentVersioning +/.LSOverride + +# Simulator +/.Simulator + +# OSX +# +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.log + +# IDE +# +# AppCode +.idea/ + +# VSCode +.vscode/ + +# Vim +*.swp +*.swo +*~ + +# Emacs +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* + +# Local environment variables +.env +.env.local + +# Core Data +*.sqlite +*.sqlite-shm +*.sqlite-wal + +# Backup files +*~.nib/ +*.swp +*~ +*.tmp +*.bak + +# Local configuration file (sdk path, etc) +local.properties + +# iOS +*.ipa +*.dSYM +*.dSYM.zip + +# macOS +.AppleDouble +.LSOverride + +# iCloud generated files +*.icloud + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Heygo specific +# Add any app-specific files you want to ignore +Screenshots/ +Logs/ +Config/development.plist +Config/staging.plist diff --git a/mobile/Heygo.xcodeproj/project.pbxproj b/mobile/Heygo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..fea1327 --- /dev/null +++ b/mobile/Heygo.xcodeproj/project.pbxproj @@ -0,0 +1,408 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + B001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* HeygoApp.swift */; }; + B002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* ContentView.swift */; }; + B003 /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* LinkListView.swift */; }; + B004 /* LinkDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F004 /* LinkDetailView.swift */; }; + B005 /* LinkFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F005 /* LinkFormView.swift */; }; + B006 /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F006 /* AnalyticsView.swift */; }; + B007 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F007 /* SearchView.swift */; }; + B008 /* LinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008 /* LinkViewModel.swift */; }; + B009 /* TagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009 /* TagManager.swift */; }; + B00A /* URLTemplateProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00A /* URLTemplateProcessor.swift */; }; + B00B /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00B /* MarkdownRenderer.swift */; }; + B00C /* AnalyticsCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00C /* AnalyticsCalculator.swift */; }; + B00D /* Link+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00D /* Link+CoreDataClass.swift */; }; + B00E /* Tag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00E /* Tag+CoreDataClass.swift */; }; + B00F /* ClickLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00F /* ClickLog+CoreDataClass.swift */; }; + B010 /* ChangeLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F010 /* ChangeLog+CoreDataClass.swift */; }; + B011 /* en in Resources */ = {isa = PBXBuildFile; fileRef = F011 /* en */; }; + B012 /* zh-Hans in Resources */ = {isa = PBXBuildFile; fileRef = F012 /* zh-Hans */; }; + B013 /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F014 /* PersistenceController.swift */; }; + B014 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = V001 /* CoreDataModel.xcdatamodeld */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + F001 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = ""; }; + F002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + F003 /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = ""; }; + F004 /* LinkDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDetailView.swift; sourceTree = ""; }; + F005 /* LinkFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkFormView.swift; sourceTree = ""; }; + F006 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = ""; }; + F007 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + F008 /* LinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkViewModel.swift; sourceTree = ""; }; + F009 /* TagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManager.swift; sourceTree = ""; }; + F00A /* URLTemplateProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTemplateProcessor.swift; sourceTree = ""; }; + F00B /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = ""; }; + F00C /* AnalyticsCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCalculator.swift; sourceTree = ""; }; + F00D /* Link+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Link+CoreDataClass.swift"; sourceTree = ""; }; + F00E /* Tag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataClass.swift"; sourceTree = ""; }; + F00F /* ClickLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClickLog+CoreDataClass.swift"; sourceTree = ""; }; + F010 /* ChangeLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangeLog+CoreDataClass.swift"; sourceTree = ""; }; + F011 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Heygo/Resources/en.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + F012 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "Heygo/Resources/zh-Hans.lproj/Localizable.strings"; sourceTree = SOURCE_ROOT; }; + F013 /* Heygo/Resources/Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Heygo/Resources/Info.plist; sourceTree = SOURCE_ROOT; }; + F014 /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + F015 /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = ""; }; + P001 /* Heygo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Heygo.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + PHASEFRAME /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + GAPP /* Heygo */ = { + isa = PBXGroup; + children = ( + GVIEW /* Views */, + GVM /* ViewModels */, + GMODEL /* Models */, + GUTIL /* Utils */, + GRES /* Resources */, + F001 /* HeygoApp.swift */, + F002 /* ContentView.swift */, + ); + path = Heygo; + sourceTree = ""; + }; + GMODEL /* Models */ = { + isa = PBXGroup; + children = ( + V001 /* CoreDataModel.xcdatamodeld */, + F00D /* Link+CoreDataClass.swift */, + F00E /* Tag+CoreDataClass.swift */, + F00F /* ClickLog+CoreDataClass.swift */, + F010 /* ChangeLog+CoreDataClass.swift */, + F014 /* PersistenceController.swift */, + ); + path = Models; + sourceTree = ""; + }; + GPROD /* Products */ = { + isa = PBXGroup; + children = ( + P001 /* Heygo.app */, + ); + name = Products; + sourceTree = ""; + }; + GRES /* Resources */ = { + isa = PBXGroup; + children = ( + F011 /* en */, + F012 /* zh-Hans */, + F013 /* Heygo/Resources/Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; + GROOT = { + isa = PBXGroup; + children = ( + GAPP /* Heygo */, + GPROD /* Products */, + ); + sourceTree = ""; + }; + GUTIL /* Utils */ = { + isa = PBXGroup; + children = ( + F009 /* TagManager.swift */, + F00A /* URLTemplateProcessor.swift */, + F00B /* MarkdownRenderer.swift */, + F00C /* AnalyticsCalculator.swift */, + ); + path = Utils; + sourceTree = ""; + }; + GVIEW /* Views */ = { + isa = PBXGroup; + children = ( + F003 /* LinkListView.swift */, + F004 /* LinkDetailView.swift */, + F005 /* LinkFormView.swift */, + F006 /* AnalyticsView.swift */, + F007 /* SearchView.swift */, + ); + path = Views; + sourceTree = ""; + }; + GVM /* ViewModels */ = { + isa = PBXGroup; + children = ( + F008 /* LinkViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + T001 /* Heygo */ = { + isa = PBXNativeTarget; + buildConfigurationList = CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */; + buildPhases = ( + PHASESOURCES /* Sources */, + PHASERES /* Resources */, + PHASEFRAME /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Heygo; + productName = Heygo; + productReference = P001 /* Heygo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + PROJ /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1640; + }; + buildConfigurationList = CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + "zh-Hans", + ); + mainGroup = GROOT; + productRefGroup = GPROD /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + T001 /* Heygo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + PHASERES /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B011 /* en in Resources */, + B012 /* zh-Hans in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + PHASESOURCES /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + B001 /* HeygoApp.swift in Sources */, + B002 /* ContentView.swift in Sources */, + B003 /* LinkListView.swift in Sources */, + B004 /* LinkDetailView.swift in Sources */, + B005 /* LinkFormView.swift in Sources */, + B006 /* AnalyticsView.swift in Sources */, + B007 /* SearchView.swift in Sources */, + B008 /* LinkViewModel.swift in Sources */, + B009 /* TagManager.swift in Sources */, + B00A /* URLTemplateProcessor.swift in Sources */, + B00B /* MarkdownRenderer.swift in Sources */, + B00C /* AnalyticsCalculator.swift in Sources */, + B00D /* Link+CoreDataClass.swift in Sources */, + B00E /* Tag+CoreDataClass.swift in Sources */, + B00F /* ClickLog+CoreDataClass.swift in Sources */, + B010 /* ChangeLog+CoreDataClass.swift in Sources */, + B013 /* PersistenceController.swift in Sources */, + B014 /* CoreDataModel.xcdatamodeld in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + CDBG /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = 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_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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = Heygo/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CDBG_T /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = AQ879ZQMD2; + INFOPLIST_FILE = Heygo/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HeyGo; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = cc.heygo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + CREL /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = 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_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_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + INFOPLIST_FILE = Heygo/Resources/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + CREL_T /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_OBJC_WEAK = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = AQ879ZQMD2; + INFOPLIST_FILE = Heygo/Resources/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = HeyGo; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = cc.heygo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CDBG /* Debug */, + CREL /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + CDBG_T /* Debug */, + CREL_T /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + V001 /* CoreDataModel.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + F015 /* CoreDataModel.xcdatamodel */, + ); + currentVersion = F015 /* CoreDataModel.xcdatamodel */; + path = CoreDataModel.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ + }; + rootObject = PROJ /* Project object */; +} diff --git a/mobile/Heygo.xcodeproj/project.pbxproj.corrupted b/mobile/Heygo.xcodeproj/project.pbxproj.corrupted new file mode 100644 index 0000000..4bf4b79 --- /dev/null +++ b/mobile/Heygo.xcodeproj/project.pbxproj.corrupted @@ -0,0 +1,500 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000001A000001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000002 /* HeygoApp.swift */; }; + A10000001A000003 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000004 /* ContentView.swift */; }; + A10000001A000005 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000001A000006 /* Assets.xcassets */; }; + A10000001A000007 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A10000001A000008 /* Preview Assets.xcassets */; }; + A10000001A000009 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00000A /* CoreDataModel.xcdatamodeld */; }; + A10000001A00000C /* PersistenceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00000D /* PersistenceController.swift */; }; + A10000001A00000E /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00000F /* LinkListView.swift */; }; + A10000001A000010 /* LinkDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000011 /* LinkDetailView.swift */; }; + A10000001A000012 /* LinkFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000013 /* LinkFormView.swift */; }; + A10000001A000014 /* LinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000015 /* LinkViewModel.swift */; }; + A10000001A000016 /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000017 /* AnalyticsView.swift */; }; + A10000001A000018 /* TagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000019 /* TagManager.swift */; }; + A10000001A00001A /* URLTemplateProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00001B /* URLTemplateProcessor.swift */; }; + A10000001A00001C /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00001D /* SearchView.swift */; }; + A10000001A00001E /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00001F /* MarkdownRenderer.swift */; }; + A10000001A000020 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = A10000001A000021 /* Localizable.strings */; }; + A10000001A000024 /* Link+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000025 /* Link+CoreDataClass.swift */; }; + A10000001A000026 /* Tag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000027 /* Tag+CoreDataClass.swift */; }; + A10000001A000028 /* ClickLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A000029 /* ClickLog+CoreDataClass.swift */; }; + A10000001A00002A /* ChangeLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00002B /* ChangeLog+CoreDataClass.swift */; }; + A10000001A00002C /* AnalyticsCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000001A00002D /* AnalyticsCalculator.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A10000001A000001 /* Heygo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Heygo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A10000001A000002 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = ""; }; + A10000001A000004 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A10000001A000006 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + A10000001A000008 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; + A10000001A00000A /* CoreDataModel.xcdatamodeld */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodeld; path = CoreDataModel.xcdatamodeld; sourceTree = ""; }; + A10000001A00000B /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = ""; }; + A10000001A00000D /* PersistenceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistenceController.swift; sourceTree = ""; }; + A10000001A00000F /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = ""; }; + A10000001A000011 /* LinkDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDetailView.swift; sourceTree = ""; }; + A10000001A000013 /* LinkFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkFormView.swift; sourceTree = ""; }; + A10000001A000015 /* LinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkViewModel.swift; sourceTree = ""; }; + A10000001A000017 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = ""; }; + A10000001A000019 /* TagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManager.swift; sourceTree = ""; }; + A10000001A00001B /* URLTemplateProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTemplateProcessor.swift; sourceTree = ""; }; + A10000001A00001D /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + A10000001A00001F /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = ""; }; + A10000001A000022 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + A10000001A000023 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; + A10000001A000030 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A10000001A000025 /* Link+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Link+CoreDataClass.swift"; sourceTree = ""; }; + A10000001A000027 /* Tag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataClass.swift"; sourceTree = ""; }; + A10000001A000029 /* ClickLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClickLog+CoreDataClass.swift"; sourceTree = ""; }; + A10000001A00002B /* ChangeLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangeLog+CoreDataClass.swift"; sourceTree = ""; }; + A10000001A00002D /* AnalyticsCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCalculator.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000001A0000FD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000001A000031 = { + isa = PBXGroup; + children = ( + A10000001A000039 /* Heygo */, + A10000001A000038 /* Products */, + ); + sourceTree = ""; + }; + A10000001A000038 /* Products */ = { + isa = PBXGroup; + children = ( + A10000001A000001 /* Heygo.app */, + ); + name = Products; + sourceTree = ""; + }; + A10000001A000039 /* Heygo */ = { + isa = PBXGroup; + children = ( + A10000001A000002 /* HeygoApp.swift */, + A10000001A000004 /* ContentView.swift */, + A10000001A000032 /* Views */, + A10000001A000033 /* ViewModels */, + A10000001A000034 /* Models */, + A10000001A000035 /* Utils */, + A10000001A000036 /* Resources */, + A10000001A000037 /* Preview Content */, + ); + path = Heygo; + sourceTree = ""; + }; + A10000001A000032 /* Views */ = { + isa = PBXGroup; + children = ( + A10000001A00000F /* LinkListView.swift */, + A10000001A000011 /* LinkDetailView.swift */, + A10000001A000013 /* LinkFormView.swift */, + A10000001A000017 /* AnalyticsView.swift */, + A10000001A00001D /* SearchView.swift */, + ); + path = Views; + sourceTree = ""; + }; + A10000001A000033 /* ViewModels */ = { + isa = PBXGroup; + children = ( + A10000001A000015 /* LinkViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + A10000001A000034 /* Models */ = { + isa = PBXGroup; + children = ( + A10000001A00000A /* CoreDataModel.xcdatamodeld */, + A10000001A00000D /* PersistenceController.swift */, + A10000001A000025 /* Link+CoreDataClass.swift */, + A10000001A000027 /* Tag+CoreDataClass.swift */, + A10000001A000029 /* ClickLog+CoreDataClass.swift */, + A10000001A00002B /* ChangeLog+CoreDataClass.swift */, + ); + path = Models; + sourceTree = ""; + }; + A10000001A000035 /* Utils */ = { + isa = PBXGroup; + children = ( + A10000001A000019 /* TagManager.swift */, + A10000001A00001B /* URLTemplateProcessor.swift */, + A10000001A00001F /* MarkdownRenderer.swift */, + A10000001A00002D /* AnalyticsCalculator.swift */, + ); + path = Utils; + sourceTree = ""; + }; + A10000001A000036 /* Resources */ = { + isa = PBXGroup; + children = ( + A10000001A000006 /* Assets.xcassets */, + A10000001A000021 /* Localizable.strings */, + A10000001A000030 /* Info.plist */, + ); + path = Resources; + sourceTree = ""; + }; + A10000001A000037 /* Preview Content */ = { + isa = PBXGroup; + children = ( + A10000001A000008 /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A10000001A0000FE /* Heygo */ = { + isa = PBXNativeTarget; + buildConfigurationList = A10000001A000108 /* Build configuration list for PBXNativeTarget "Heygo" */; + buildPhases = ( + A10000001A000105 /* Sources */, + A10000001A0000FD /* Frameworks */, + A10000001A0000FC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Heygo; + productName = Heygo; + productReference = A10000001A000001 /* Heygo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A10000001A0000F8 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + A10000001A0000FE = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = A10000001A000104 /* Build configuration list for PBXProject "Heygo" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + "zh-Hans", + ); + mainGroup = A10000001A000031; + productRefGroup = A10000001A000038 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + A10000001A0000FE /* Heygo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A10000001A0000FC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000001A000007 /* Preview Assets.xcassets in Resources */, + A10000001A000005 /* Assets.xcassets in Resources */, + A10000001A000020 /* Localizable.strings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000001A0000FD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A10000001A000105 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + A10000001A000001 /* HeygoApp.swift in Sources */, + A10000001A000003 /* ContentView.swift in Sources */, + A10000001A000009 /* CoreDataModel.xcdatamodeld in Sources */, + A10000001A00000C /* PersistenceController.swift in Sources */, + A10000001A00000E /* LinkListView.swift in Sources */, + A10000001A000010 /* LinkDetailView.swift in Sources */, + A10000001A000012 /* LinkFormView.swift in Sources */, + A10000001A000014 /* LinkViewModel.swift in Sources */, + A10000001A000016 /* AnalyticsView.swift in Sources */, + A10000001A000018 /* TagManager.swift in Sources */, + A10000001A00001A /* URLTemplateProcessor.swift in Sources */, + A10000001A00001C /* SearchView.swift in Sources */, + A10000001A00001E /* MarkdownRenderer.swift in Sources */, + A10000001A000024 /* Link+CoreDataClass.swift in Sources */, + A10000001A000026 /* Tag+CoreDataClass.swift in Sources */, + A10000001A000028 /* ClickLog+CoreDataClass.swift in Sources */, + A10000001A00002A /* ChangeLog+CoreDataClass.swift in Sources */, + A10000001A00002C /* AnalyticsCalculator.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + A10000001A000021 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + A10000001A000022 /* en */, + A10000001A000023 /* zh-Hans */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + A10000001A000106 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + A10000001A000107 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + A10000001A000109 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Heygo/Preview Content\""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Heygo/Resources/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.heygo.links; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + A10000001A00010A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"Heygo/Preview Content\""; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Heygo/Resources/Info.plist"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.heygo.links; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A10000001A000104 /* Build configuration list for PBXProject "Heygo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000001A000106 /* Debug */, + A10000001A000107 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A10000001A000108 /* Build configuration list for PBXNativeTarget "Heygo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + A10000001A000109 /* Debug */, + A10000001A00010A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + A10000001A00000A /* CoreDataModel.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + A10000001A00000B /* CoreDataModel.xcdatamodel */, + ); + currentVersion = A10000001A00000B /* CoreDataModel.xcdatamodel */; + path = CoreDataModel.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ + }; + rootObject = A10000001A0000F8 /* Project object */; +} diff --git a/mobile/Heygo.xcodeproj/project.pbxproj.new b/mobile/Heygo.xcodeproj/project.pbxproj.new new file mode 100644 index 0000000..646d16f --- /dev/null +++ b/mobile/Heygo.xcodeproj/project.pbxproj.new @@ -0,0 +1,98 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + B001 /* HeygoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F001 /* HeygoApp.swift */; }; + B002 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F002 /* ContentView.swift */; }; + B003 /* LinkListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F003 /* LinkListView.swift */; }; + B004 /* LinkDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F004 /* LinkDetailView.swift */; }; + B005 /* LinkFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F005 /* LinkFormView.swift */; }; + B006 /* AnalyticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F006 /* AnalyticsView.swift */; }; + B007 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F007 /* SearchView.swift */; }; + B008 /* LinkViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F008 /* LinkViewModel.swift */; }; + B009 /* TagManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F009 /* TagManager.swift */; }; + B00A /* URLTemplateProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00A /* URLTemplateProcessor.swift */; }; + B00B /* MarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00B /* MarkdownRenderer.swift */; }; + B00C /* AnalyticsCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00C /* AnalyticsCalculator.swift */; }; + B00D /* Link+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00D /* Link+CoreDataClass.swift */; }; + B00E /* Tag+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00E /* Tag+CoreDataClass.swift */; }; + B00F /* ClickLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F00F /* ClickLog+CoreDataClass.swift */; }; + B010 /* ChangeLog+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = F010 /* ChangeLog+CoreDataClass.swift */; }; + B011 /* en Localizable.strings */ = {isa = PBXBuildFile; fileRef = F011 /* en */; }; + B012 /* zh-Hans Localizable.strings */ = {isa = PBXBuildFile; fileRef = F012 /* zh-Hans */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + P001 /* Heygo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Heygo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + F001 /* HeygoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeygoApp.swift; sourceTree = ""; }; + F002 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + F003 /* LinkListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkListView.swift; sourceTree = ""; }; + F004 /* LinkDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkDetailView.swift; sourceTree = ""; }; + F005 /* LinkFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkFormView.swift; sourceTree = ""; }; + F006 /* AnalyticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsView.swift; sourceTree = ""; }; + F007 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; + F008 /* LinkViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkViewModel.swift; sourceTree = ""; }; + F009 /* TagManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagManager.swift; sourceTree = ""; }; + F00A /* URLTemplateProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLTemplateProcessor.swift; sourceTree = ""; }; + F00B /* MarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownRenderer.swift; sourceTree = ""; }; + F00C /* AnalyticsCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsCalculator.swift; sourceTree = ""; }; + F00D /* Link+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Link+CoreDataClass.swift"; sourceTree = ""; }; + F00E /* Tag+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Tag+CoreDataClass.swift"; sourceTree = ""; }; + F00F /* ClickLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ClickLog+CoreDataClass.swift"; sourceTree = ""; }; + F010 /* ChangeLog+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ChangeLog+CoreDataClass.swift"; sourceTree = ""; }; + F011 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Heygo/Resources/en.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + F012 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = Heygo/Resources/zh-Hans.lproj/Localizable.strings; sourceTree = SOURCE_ROOT; }; + F013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Heygo/Resources/Info.plist; sourceTree = SOURCE_ROOT; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + GROOT = {isa = PBXGroup; children = ( GAPP, GPROD ); sourceTree = ""; }; + GAPP /* Heygo */ = {isa = PBXGroup; path = Heygo; sourceTree = ""; children = ( GVIEW, GVM, GMODEL, GUTIL, GRES, F001, F002 ); }; + GVIEW /* Views */ = {isa = PBXGroup; path = Views; sourceTree = ""; children = ( F003, F004, F005, F006, F007 ); }; + GVM /* ViewModels */ = {isa = PBXGroup; path = ViewModels; sourceTree = ""; children = ( F008 ); }; + GMODEL /* Models */ = {isa = PBXGroup; path = Models; sourceTree = ""; children = ( F00D, F00E, F00F, F010 ); }; + GUTIL /* Utils */ = {isa = PBXGroup; path = Utils; sourceTree = ""; children = ( F009, F00A, F00B, F00C ); }; + GRES /* Resources */ = {isa = PBXGroup; path = Resources; sourceTree = ""; children = ( F011, F012, F013 ); }; + GPROD /* Products */ = {isa = PBXGroup; name = Products; sourceTree = ""; children = ( P001 ); }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + T001 /* Heygo */ = {isa = PBXNativeTarget; buildConfigurationList = CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */; buildPhases = ( PHASESOURCES, PHASERES, PHASEFRAME ); buildRules = ( ); dependencies = ( ); name = Heygo; productName = Heygo; productReference = P001 /* Heygo.app */; productType = "com.apple.product-type.application"; }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + PROJ /* Project object */ = {isa = PBXProject; attributes = { LastUpgradeCheck = 1500; }; buildConfigurationList = CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */; compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, "zh-Hans" ); mainGroup = GROOT; productRefGroup = GPROD; projectDirPath = ""; projectRoot = ""; targets = ( T001 ); }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + PHASESOURCES /* Sources */ = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( B001, B002, B003, B004, B005, B006, B007, B008, B009, B00A, B00B, B00C, B00D, B00E, B00F, B010 ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXResourcesBuildPhase section */ + PHASERES /* Resources */ = {isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( B011, B012 ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXFrameworksBuildPhase section */ + PHASEFRAME /* Frameworks */ = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + CDBG /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Debug; }; + CREL /* Release */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Release; }; + CDBG_T /* Debug */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Debug; }; + CREL_T /* Release */ = {isa = XCBuildConfiguration; buildSettings = { PRODUCT_NAME = "$(TARGET_NAME)"; INFOPLIST_FILE = Heygo/Resources/Info.plist; SDKROOT = iphoneos; IPHONEOS_DEPLOYMENT_TARGET = 17.0; TARGETED_DEVICE_FAMILY = "1,2"; SWIFT_VERSION = 5.0; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; MARKETING_VERSION = 1.0; }; name = Release; }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + CLCONFPROJ /* Build configuration list for PBXProject "Heygo" */ = {isa = XCConfigurationList; buildConfigurations = ( CDBG, CREL ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + CLCONFTARGET /* Build configuration list for PBXNativeTarget "Heygo" */ = {isa = XCConfigurationList; buildConfigurations = ( CDBG_T, CREL_T ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; +/* End XCConfigurationList section */ + +}; + rootObject = PROJ /* Project object */; +} diff --git a/mobile/Heygo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mobile/Heygo.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..94b2795 --- /dev/null +++ b/mobile/Heygo.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,4 @@ + + + diff --git a/mobile/Heygo.xcodeproj/project.xcworkspace/xcuserdata/junv.xcuserdatad/UserInterfaceState.xcuserstate b/mobile/Heygo.xcodeproj/project.xcworkspace/xcuserdata/junv.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000000000000000000000000000000000000..94736cd6003096b375b6425633e6bd5b578e173e GIT binary patch literal 27810 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 HgrxriU_>Y` literal 0 HcmV?d00001 diff --git a/mobile/Heygo.xcodeproj/xcuserdata/junv.xcuserdatad/xcschemes/xcschememanagement.plist b/mobile/Heygo.xcodeproj/xcuserdata/junv.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..57332a4 --- /dev/null +++ b/mobile/Heygo.xcodeproj/xcuserdata/junv.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + Heygo.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/mobile/Heygo/ContentView.swift b/mobile/Heygo/ContentView.swift new file mode 100644 index 0000000..5ac4572 --- /dev/null +++ b/mobile/Heygo/ContentView.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct ContentView: View { + @Environment(\.managedObjectContext) private var viewContext + @StateObject private var linkViewModel = LinkViewModel() + + var body: some View { + TabView { + LinkListView() + .tabItem { + Image(systemName: "link") + Text("links") + } + + SearchView() + .tabItem { + Image(systemName: "magnifyingglass") + Text("search") + } + + AnalyticsView() + .tabItem { + Image(systemName: "chart.bar") + Text("analytics") + } + } + .environmentObject(linkViewModel) + } +} + +#Preview { + ContentView() + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) +} diff --git a/mobile/Heygo/HeygoApp.swift b/mobile/Heygo/HeygoApp.swift new file mode 100644 index 0000000..97add31 --- /dev/null +++ b/mobile/Heygo/HeygoApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct HeygoApp: App { + let persistenceController = PersistenceController.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environment(\.managedObjectContext, persistenceController.container.viewContext) + } + } +} diff --git a/mobile/Heygo/Models/ChangeLog+CoreDataClass.swift b/mobile/Heygo/Models/ChangeLog+CoreDataClass.swift new file mode 100644 index 0000000..b736967 --- /dev/null +++ b/mobile/Heygo/Models/ChangeLog+CoreDataClass.swift @@ -0,0 +1,27 @@ +import Foundation +import CoreData + +@objc(ChangeLog) +public class ChangeLog: NSManagedObject { + +} + +extension ChangeLog { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ChangeLog") + } + + @NSManaged public var changedAt: Date + @NSManaged public var changeDescription: String + @NSManaged public var changeType: String + @NSManaged public var link: Link + +} + +extension ChangeLog : Identifiable { + + public var id: NSManagedObjectID { + return self.objectID + } +} diff --git a/mobile/Heygo/Models/ClickLog+CoreDataClass.swift b/mobile/Heygo/Models/ClickLog+CoreDataClass.swift new file mode 100644 index 0000000..29b1ddc --- /dev/null +++ b/mobile/Heygo/Models/ClickLog+CoreDataClass.swift @@ -0,0 +1,25 @@ +import Foundation +import CoreData + +@objc(ClickLog) +public class ClickLog: NSManagedObject { + +} + +extension ClickLog { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "ClickLog") + } + + @NSManaged public var clickedAt: Date + @NSManaged public var link: Link + +} + +extension ClickLog : Identifiable { + + public var id: NSManagedObjectID { + return self.objectID + } +} diff --git a/mobile/Heygo/Models/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/mobile/Heygo/Models/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents new file mode 100644 index 0000000..600169f --- /dev/null +++ b/mobile/Heygo/Models/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mobile/Heygo/Models/Link+CoreDataClass.swift b/mobile/Heygo/Models/Link+CoreDataClass.swift new file mode 100644 index 0000000..80dc891 --- /dev/null +++ b/mobile/Heygo/Models/Link+CoreDataClass.swift @@ -0,0 +1,129 @@ +import Foundation +import CoreData + +@objc(Link) +public class Link: NSManagedObject { + +} + +extension Link { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Link") + } + + @NSManaged public var alias: String + @NSManaged public var originalURL: String? + @NSManaged public var text: String? + @NSManaged public var linkType: String + @NSManaged public var clickCount: Int32 + @NSManaged public var createdAt: Date + @NSManaged public var updatedAt: Date + @NSManaged public var linkDescription: String? + @NSManaged public var tags: NSSet? + @NSManaged public var clickLogs: NSSet? + @NSManaged public var changeLogs: NSSet? + +} + +// MARK: Generated accessors for tags +extension Link { + + @objc(addTagsObject:) + @NSManaged public func addToTags(_ value: Tag) + + @objc(removeTagsObject:) + @NSManaged public func removeFromTags(_ value: Tag) + + @objc(addTags:) + @NSManaged public func addToTags(_ values: NSSet) + + @objc(removeTags:) + @NSManaged public func removeFromTags(_ values: NSSet) + +} + +// MARK: Generated accessors for clickLogs +extension Link { + + @objc(addClickLogsObject:) + @NSManaged public func addToClickLogs(_ value: ClickLog) + + @objc(removeClickLogsObject:) + @NSManaged public func removeFromClickLogs(_ value: ClickLog) + + @objc(addClickLogs:) + @NSManaged public func addToClickLogs(_ values: NSSet) + + @objc(removeClickLogs:) + @NSManaged public func removeFromClickLogs(_ values: NSSet) + +} + +// MARK: Generated accessors for changeLogs +extension Link { + + @objc(addChangeLogsObject:) + @NSManaged public func addToChangeLogs(_ value: ChangeLog) + + @objc(removeChangeLogsObject:) + @NSManaged public func removeFromChangeLogs(_ value: ChangeLog) + + @objc(addChangeLogs:) + @NSManaged public func addToChangeLogs(_ values: NSSet) + + @objc(removeChangeLogs:) + @NSManaged public func removeFromChangeLogs(_ values: NSSet) + +} + +extension Link : Identifiable { + + public var id: NSManagedObjectID { + return self.objectID + } + + var linkTypeEnum: LinkType { + get { + return LinkType(rawValue: linkType) ?? .link + } + set { + linkType = newValue.rawValue + } + } + + var sortedTags: [Tag] { + let tagSet = tags as? Set ?? [] + return tagSet.sorted { $0.name < $1.name } + } + + var sortedClickLogs: [ClickLog] { + let clickLogSet = clickLogs as? Set ?? [] + return clickLogSet.sorted { $0.clickedAt > $1.clickedAt } + } + + var isTemplate: Bool { + return originalURL?.contains("{") == true && originalURL?.contains("}") == true + } + + func hasParametersInURL() -> Bool { + return isTemplate + } +} + +enum LinkType: String, CaseIterable { + case link = "LINK" + case custom = "CUSTOM" + case template = "TEMPLATE" + + var displayName: String { + switch self { + case .link: + return NSLocalizedString("link", comment: "Link type") + case .custom: + return NSLocalizedString("custom", comment: "Custom type") + case .template: + return NSLocalizedString("template", comment: "Template type") + } + } +} diff --git a/mobile/Heygo/Models/PersistenceController.swift b/mobile/Heygo/Models/PersistenceController.swift new file mode 100644 index 0000000..9a264ff --- /dev/null +++ b/mobile/Heygo/Models/PersistenceController.swift @@ -0,0 +1,69 @@ +import CoreData +import Foundation + +struct PersistenceController { + static let shared = PersistenceController() + + static var preview: PersistenceController = { + let result = PersistenceController(inMemory: true) + let viewContext = result.container.viewContext + + // Create sample data for previews + let sampleLink = Link(context: viewContext) + sampleLink.alias = "example" + sampleLink.originalURL = "https://example.com" + sampleLink.linkType = "LINK" + sampleLink.clickCount = 5 + sampleLink.createdAt = Date() + sampleLink.updatedAt = Date() + sampleLink.linkDescription = "An example link" + + let sampleTag = Tag(context: viewContext) + sampleTag.name = "Sample" + sampleTag.slug = "sample" + sampleTag.createdAt = Date() + + sampleLink.addToTags(sampleTag) + + do { + try viewContext.save() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + return result + }() + + let container: NSPersistentContainer + + init(inMemory: Bool = false) { + container = NSPersistentContainer(name: "CoreDataModel") + if inMemory { + container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null") + } + + container.loadPersistentStores(completionHandler: { (storeDescription, error) in + if let error = error as NSError? { + fatalError("Unresolved error \(error), \(error.userInfo)") + } + }) + + container.viewContext.automaticallyMergesChangesFromParent = true + } +} + +// MARK: - Save context +extension PersistenceController { + func save() { + let context = container.viewContext + + if context.hasChanges { + do { + try context.save() + } catch { + let nsError = error as NSError + fatalError("Unresolved error \(nsError), \(nsError.userInfo)") + } + } + } +} diff --git a/mobile/Heygo/Models/Tag+CoreDataClass.swift b/mobile/Heygo/Models/Tag+CoreDataClass.swift new file mode 100644 index 0000000..09c05bc --- /dev/null +++ b/mobile/Heygo/Models/Tag+CoreDataClass.swift @@ -0,0 +1,54 @@ +import Foundation +import CoreData + +@objc(Tag) +public class Tag: NSManagedObject { + +} + +extension Tag { + + @nonobjc public class func fetchRequest() -> NSFetchRequest { + return NSFetchRequest(entityName: "Tag") + } + + @NSManaged public var name: String + @NSManaged public var slug: String + @NSManaged public var tagDescription: String? + @NSManaged public var createdAt: Date + @NSManaged public var links: NSSet? + +} + +// MARK: Generated accessors for links +extension Tag { + + @objc(addLinksObject:) + @NSManaged public func addToLinks(_ value: Link) + + @objc(removeLinksObject:) + @NSManaged public func removeFromLinks(_ value: Link) + + @objc(addLinks:) + @NSManaged public func addToLinks(_ values: NSSet) + + @objc(removeLinks:) + @NSManaged public func removeFromLinks(_ values: NSSet) + +} + +extension Tag : Identifiable { + + public var id: NSManagedObjectID { + return self.objectID + } + + var linkCount: Int { + return links?.count ?? 0 + } + + var sortedLinks: [Link] { + let linkSet = links as? Set ?? [] + return linkSet.sorted { $0.alias < $1.alias } + } +} diff --git a/mobile/Heygo/Preview Content/Preview Assets.xcassets/Contents.json b/mobile/Heygo/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/mobile/Heygo/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/Heygo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/mobile/Heygo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/mobile/Heygo/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/Heygo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/mobile/Heygo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/mobile/Heygo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/Heygo/Resources/Assets.xcassets/Contents.json b/mobile/Heygo/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/mobile/Heygo/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mobile/Heygo/Resources/Info.plist b/mobile/Heygo/Resources/Info.plist new file mode 100644 index 0000000..bb371d6 --- /dev/null +++ b/mobile/Heygo/Resources/Info.plist @@ -0,0 +1,62 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Heygo + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSApplicationQueriesSchemes + + http + https + + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/mobile/Heygo/Resources/en.lproj/Localizable.strings b/mobile/Heygo/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..cbd3ee8 --- /dev/null +++ b/mobile/Heygo/Resources/en.lproj/Localizable.strings @@ -0,0 +1,142 @@ +/* App */ +"heygo" = "Heygo"; + +/* Navigation */ +"links" = "Links"; +"search" = "Search"; +"analytics" = "Analytics"; +"close" = "Close"; +"done" = "Done"; +"cancel" = "Cancel"; +"save" = "Save"; +"edit" = "Edit"; +"delete" = "Delete"; +"add" = "Add"; + +/* Link Types */ +"link" = "Link"; +"custom" = "Custom"; +"template" = "Template"; + +/* Link Management */ +"add_link" = "Add Link"; +"edit_link" = "Edit Link"; +"delete_link" = "Delete Link"; +"link_details" = "Link Details"; +"alias" = "Alias"; +"original_url" = "Original URL"; +"description_optional" = "Description (Optional)"; +"link_type" = "Link Type"; +"markdown_content" = "Markdown Content"; +"template_help" = "Use {parameter} or {parameter,default=value} for templates"; + +/* Tags */ +"tags" = "Tags"; +"search_or_create_tag" = "Search or create tag"; +"selected_tag" = "Selected Tag"; +"all_tags" = "All Tags"; +"popular_tags" = "Popular Tags"; + +/* Search */ +"search_links" = "Search links"; +"search_or_enter_url" = "Search or enter URL"; +"no_results_found" = "No results found"; +"open_as_url" = "Open as URL"; +"recent_searches" = "Recent Searches"; +"search_tips" = "Search Tips"; +"search_by_alias" = "Search by alias"; +"search_by_alias_description" = "Find links by their short names"; +"search_by_url" = "Search by URL"; +"search_by_url_description" = "Find links by their destination"; +"search_by_tag" = "Search by tag"; +"search_by_tag_description" = "Find links by their tags"; + +/* Filters */ +"filter" = "Filter"; +"filters" = "Filters"; +"filter_links" = "Filter Links"; +"link_types" = "Link Types"; +"clear_filters" = "Clear Filters"; + +/* Sorting */ +"date_created" = "Date Created"; +"date_updated" = "Date Updated"; +"type" = "Type"; +"clicks" = "Clicks"; + +/* Analytics */ +"analytics" = "Analytics"; +"total_links" = "Total Links"; +"total_clicks" = "Total Clicks"; +"avg_clicks_per_link" = "Avg Clicks per Link"; +"active_links" = "Active Links"; +"clicks_over_time" = "Clicks Over Time"; +"links_created" = "Links Created"; +"top_links" = "Top Links"; +"top_performing_links" = "Top Performing Links"; +"link_type_distribution" = "Link Type Distribution"; +"recent_activity" = "Recent Activity"; +"no_data_available" = "No data available"; +"no_data_for_period" = "No data for selected period"; + +/* Time Ranges */ +"1_week" = "1 Week"; +"1_month" = "1 Month"; +"3_months" = "3 Months"; +"6_months" = "6 Months"; +"1_year" = "1 Year"; +"all_time" = "All Time"; +"time_range" = "Time Range"; +"metric" = "Metric"; + +/* Template Parameters */ +"template_parameters" = "Template Parameters"; +"enter_parameters" = "Enter Parameters"; +"enter_value" = "Enter value"; +"open_with_parameters" = "Open with Parameters"; +"open_link" = "Open Link"; + +/* Content */ +"custom_content" = "Custom Content"; +"content_preview" = "Content Preview"; +"recent_clicks" = "Recent Clicks"; +"change_history" = "Change History"; + +/* Export */ +"export_data" = "Export Data"; +"export_analytics" = "Export Analytics"; +"export_options" = "Export Options"; +"format" = "Format"; +"include_chart_data" = "Include Chart Data"; +"include_top_links" = "Include Top Links"; +"preview" = "Preview"; +"export" = "Export"; +"refresh_data" = "Refresh Data"; + +/* Empty States */ +"no_links_found" = "No links found"; +"tap_plus_to_add_link" = "Tap + to add your first link"; + +/* Validation Messages */ +"alias_already_exists" = "Alias already exists"; +"alias_invalid_characters" = "Alias contains invalid characters"; +"tag_already_exists" = "Tag already exists"; + +/* Error Messages */ +"error" = "Error"; +"unclosed_bold_markers" = "Unclosed bold markers"; +"unclosed_italic_markers" = "Unclosed italic markers"; +"unclosed_code_markers" = "Unclosed code markers"; +"malformed_links" = "Malformed links"; + +/* Actions */ +"open" = "Open"; +"preview" = "Preview"; +"edit" = "Edit"; + +/* Statistics */ +"chart_data_points" = "Chart data points"; + +/* Basic Information */ +"basic_information" = "Basic Information"; +"actions" = "Actions"; diff --git a/mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings b/mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings new file mode 100644 index 0000000..0f475a7 --- /dev/null +++ b/mobile/Heygo/Resources/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,142 @@ +/* App */ +"heygo" = "黑狗"; + +/* Navigation */ +"links" = "链接"; +"search" = "搜索"; +"analytics" = "分析"; +"close" = "关闭"; +"done" = "完成"; +"cancel" = "取消"; +"save" = "保存"; +"edit" = "编辑"; +"delete" = "删除"; +"add" = "添加"; + +/* Link Types */ +"link" = "链接"; +"custom" = "自定义"; +"template" = "模板"; + +/* Link Management */ +"add_link" = "添加链接"; +"edit_link" = "编辑链接"; +"delete_link" = "删除链接"; +"link_details" = "链接详情"; +"alias" = "别名"; +"original_url" = "原始网址"; +"description_optional" = "描述(可选)"; +"link_type" = "链接类型"; +"markdown_content" = "Markdown 内容"; +"template_help" = "使用 {参数} 或 {参数,default=值} 创建模板"; + +/* Tags */ +"tags" = "标签"; +"search_or_create_tag" = "搜索或创建标签"; +"selected_tag" = "已选标签"; +"all_tags" = "全部标签"; +"popular_tags" = "热门标签"; + +/* Search */ +"search_links" = "搜索链接"; +"search_or_enter_url" = "搜索或输入网址"; +"no_results_found" = "未找到结果"; +"open_as_url" = "作为网址打开"; +"recent_searches" = "最近搜索"; +"search_tips" = "搜索技巧"; +"search_by_alias" = "按别名搜索"; +"search_by_alias_description" = "通过简短名称查找链接"; +"search_by_url" = "按网址搜索"; +"search_by_url_description" = "通过目标地址查找链接"; +"search_by_tag" = "按标签搜索"; +"search_by_tag_description" = "通过标签查找链接"; + +/* Filters */ +"filter" = "筛选"; +"filters" = "筛选器"; +"filter_links" = "筛选链接"; +"link_types" = "链接类型"; +"clear_filters" = "清除筛选"; + +/* Sorting */ +"date_created" = "创建日期"; +"date_updated" = "更新日期"; +"type" = "类型"; +"clicks" = "点击"; + +/* Analytics */ +"analytics" = "分析"; +"total_links" = "总链接数"; +"total_clicks" = "总点击数"; +"avg_clicks_per_link" = "平均点击数"; +"active_links" = "活跃链接"; +"clicks_over_time" = "点击趋势"; +"links_created" = "创建的链接"; +"top_links" = "热门链接"; +"top_performing_links" = "表现最佳的链接"; +"link_type_distribution" = "链接类型分布"; +"recent_activity" = "最近活动"; +"no_data_available" = "无可用数据"; +"no_data_for_period" = "所选时间段无数据"; + +/* Time Ranges */ +"1_week" = "1周"; +"1_month" = "1个月"; +"3_months" = "3个月"; +"6_months" = "6个月"; +"1_year" = "1年"; +"all_time" = "全部时间"; +"time_range" = "时间范围"; +"metric" = "指标"; + +/* Template Parameters */ +"template_parameters" = "模板参数"; +"enter_parameters" = "输入参数"; +"enter_value" = "输入值"; +"open_with_parameters" = "使用参数打开"; +"open_link" = "打开链接"; + +/* Content */ +"custom_content" = "自定义内容"; +"content_preview" = "内容预览"; +"recent_clicks" = "最近点击"; +"change_history" = "更改历史"; + +/* Export */ +"export_data" = "导出数据"; +"export_analytics" = "导出分析"; +"export_options" = "导出选项"; +"format" = "格式"; +"include_chart_data" = "包含图表数据"; +"include_top_links" = "包含热门链接"; +"preview" = "预览"; +"export" = "导出"; +"refresh_data" = "刷新数据"; + +/* Empty States */ +"no_links_found" = "未找到链接"; +"tap_plus_to_add_link" = "点击 + 添加您的第一个链接"; + +/* Validation Messages */ +"alias_already_exists" = "别名已存在"; +"alias_invalid_characters" = "别名包含无效字符"; +"tag_already_exists" = "标签已存在"; + +/* Error Messages */ +"error" = "错误"; +"unclosed_bold_markers" = "未闭合的粗体标记"; +"unclosed_italic_markers" = "未闭合的斜体标记"; +"unclosed_code_markers" = "未闭合的代码标记"; +"malformed_links" = "格式错误的链接"; + +/* Actions */ +"open" = "打开"; +"preview" = "预览"; +"edit" = "编辑"; + +/* Statistics */ +"chart_data_points" = "图表数据点"; + +/* Basic Information */ +"basic_information" = "基本信息"; +"actions" = "操作"; diff --git a/mobile/Heygo/Utils/AnalyticsCalculator.swift b/mobile/Heygo/Utils/AnalyticsCalculator.swift new file mode 100644 index 0000000..5ca70dd --- /dev/null +++ b/mobile/Heygo/Utils/AnalyticsCalculator.swift @@ -0,0 +1,411 @@ +import Foundation + +struct AnalyticsCalculator { + static func calculateAnalytics(for links: [Link], timeRange: AnalyticsView.TimeRange, metric: AnalyticsView.Metric) -> AnalyticsData { + let filteredData = filterDataByTimeRange(links: links, timeRange: timeRange) + + return AnalyticsData( + totalLinks: filteredData.totalLinks, + totalClicks: filteredData.totalClicks, + activeLinks: filteredData.activeLinks, + averageClicksPerLink: filteredData.averageClicksPerLink, + linksChange: calculatePercentageChange(current: Double(filteredData.totalLinks), previous: Double(filteredData.previousTotalLinks)), + clicksChange: calculatePercentageChange(current: Double(filteredData.totalClicks), previous: Double(filteredData.previousTotalClicks)), + avgClicksChange: calculatePercentageChange(current: filteredData.averageClicksPerLink, previous: filteredData.previousAverageClicksPerLink), + activeLinksChange: calculatePercentageChange(current: Double(filteredData.activeLinks), previous: Double(filteredData.previousActiveLinks)), + chartData: generateChartData(for: links, timeRange: timeRange, metric: metric), + topLinks: getTopLinks(from: links, timeRange: timeRange), + linkTypeDistribution: getLinkTypeDistribution(from: links, timeRange: timeRange), + recentActivity: getRecentActivity(from: links, timeRange: timeRange) + ) + } + + private static func filterDataByTimeRange(links: [Link], timeRange: AnalyticsView.TimeRange) -> FilteredAnalyticsData { + let calendar = Calendar.current + + // Current period + let currentPeriodLinks: [Link] + let currentClickLogs: [ClickLog] + + if let dateRange = timeRange.dateRange { + currentPeriodLinks = links.filter { link in + dateRange.contains(link.createdAt) + } + + currentClickLogs = links.flatMap { link in + link.sortedClickLogs.filter { clickLog in + dateRange.contains(clickLog.clickedAt) + } + } + } else { + // All time + currentPeriodLinks = links + currentClickLogs = links.flatMap { $0.sortedClickLogs } + } + + // Previous period (for comparison) + let previousPeriodLinks: [Link] + let previousClickLogs: [ClickLog] + + if let dateRange = timeRange.dateRange { + let duration = dateRange.duration + let previousStartDate = calendar.date(byAdding: .second, value: -Int(duration), to: dateRange.start)! + let previousEndDate = dateRange.start + let previousDateRange = DateInterval(start: previousStartDate, end: previousEndDate) + + previousPeriodLinks = links.filter { link in + previousDateRange.contains(link.createdAt) + } + + previousClickLogs = links.flatMap { link in + link.sortedClickLogs.filter { clickLog in + previousDateRange.contains(clickLog.clickedAt) + } + } + } else { + // For all time, compare with half the data as "previous" + let midpoint = links.count / 2 + previousPeriodLinks = Array(links.prefix(midpoint)) + previousClickLogs = previousPeriodLinks.flatMap { $0.sortedClickLogs } + } + + // Calculate metrics + let currentTotalLinks = currentPeriodLinks.count + let currentTotalClicks = currentClickLogs.count + let currentActiveLinks = currentPeriodLinks.filter { $0.clickCount > 0 }.count + let currentAverageClicksPerLink = currentTotalLinks > 0 ? Double(currentTotalClicks) / Double(currentTotalLinks) : 0.0 + + let previousTotalLinks = previousPeriodLinks.count + let previousTotalClicks = previousClickLogs.count + let previousActiveLinks = previousPeriodLinks.filter { $0.clickCount > 0 }.count + let previousAverageClicksPerLink = previousTotalLinks > 0 ? Double(previousTotalClicks) / Double(previousTotalLinks) : 0.0 + + return FilteredAnalyticsData( + totalLinks: currentTotalLinks, + totalClicks: currentTotalClicks, + activeLinks: currentActiveLinks, + averageClicksPerLink: currentAverageClicksPerLink, + previousTotalLinks: previousTotalLinks, + previousTotalClicks: previousTotalClicks, + previousActiveLinks: previousActiveLinks, + previousAverageClicksPerLink: previousAverageClicksPerLink + ) + } + + private static func calculatePercentageChange(current: Double, previous: Double) -> Double? { + guard previous > 0 else { return nil } + return ((current - previous) / previous) * 100 + } + + private static func generateChartData(for links: [Link], timeRange: AnalyticsView.TimeRange, metric: AnalyticsView.Metric) -> [ChartDataPoint] { + switch metric { + case .clicks: + return generateClicksChartData(for: links, timeRange: timeRange) + case .links: + return generateLinksChartData(for: links, timeRange: timeRange) + case .topLinks: + return generateTopLinksChartData(for: links, timeRange: timeRange) + } + } + + private static func generateClicksChartData(for links: [Link], timeRange: AnalyticsView.TimeRange) -> [ChartDataPoint] { + let calendar = Calendar.current + let allClickLogs: [ClickLog] + let dateRange: DateInterval + + if let range = timeRange.dateRange { + dateRange = range + allClickLogs = links.flatMap { link in + link.sortedClickLogs.filter { clickLog in + range.contains(clickLog.clickedAt) + } + } + } else { + // All time - create a range from first click to now + let allClicks = links.flatMap { $0.sortedClickLogs } + guard let firstClick = allClicks.min(by: { $0.clickedAt < $1.clickedAt }) else { + return [] + } + dateRange = DateInterval(start: firstClick.clickedAt, end: Date()) + allClickLogs = allClicks + } + + // Determine the grouping interval + let component: Calendar.Component + let dateFormat: String + + let duration = dateRange.duration + if duration <= 7 * 24 * 3600 { // 1 week + component = .day + dateFormat = "MMM d" + } else if duration <= 90 * 24 * 3600 { // 3 months + component = .day + dateFormat = "MMM d" + } else if duration <= 365 * 24 * 3600 { // 1 year + component = .weekOfYear + dateFormat = "MMM d" + } else { + component = .month + dateFormat = "MMM yyyy" + } + + // Group clicks by time interval + let groupedClicks = Dictionary(grouping: allClickLogs) { clickLog in + calendar.dateInterval(of: component, for: clickLog.clickedAt)?.start ?? clickLog.clickedAt + } + + // Create chart data points + let formatter = DateFormatter() + formatter.dateFormat = dateFormat + + return groupedClicks.map { date, clicks in + ChartDataPoint( + date: date, + count: clicks.count, + label: formatter.string(from: date) + ) + }.sorted { $0.date < $1.date } + } + + private static func generateLinksChartData(for links: [Link], timeRange: AnalyticsView.TimeRange) -> [ChartDataPoint] { + let calendar = Calendar.current + let filteredLinks: [Link] + let dateRange: DateInterval + + if let range = timeRange.dateRange { + dateRange = range + filteredLinks = links.filter { link in + range.contains(link.createdAt) + } + } else { + // All time + guard let firstLink = links.min(by: { $0.createdAt < $1.createdAt }) else { + return [] + } + dateRange = DateInterval(start: firstLink.createdAt, end: Date()) + filteredLinks = links + } + + // Determine the grouping interval + let component: Calendar.Component + let dateFormat: String + + let duration = dateRange.duration + if duration <= 30 * 24 * 3600 { // 1 month + component = .day + dateFormat = "MMM d" + } else if duration <= 365 * 24 * 3600 { // 1 year + component = .weekOfYear + dateFormat = "MMM d" + } else { + component = .month + dateFormat = "MMM yyyy" + } + + // Group links by creation date + let groupedLinks = Dictionary(grouping: filteredLinks) { link in + calendar.dateInterval(of: component, for: link.createdAt)?.start ?? link.createdAt + } + + // Create chart data points + let formatter = DateFormatter() + formatter.dateFormat = dateFormat + + return groupedLinks.map { date, links in + ChartDataPoint( + date: date, + count: links.count, + label: formatter.string(from: date) + ) + }.sorted { $0.date < $1.date } + } + + private static func generateTopLinksChartData(for links: [Link], timeRange: AnalyticsView.TimeRange) -> [ChartDataPoint] { + let topLinks = getTopLinks(from: links, timeRange: timeRange) + + return topLinks.enumerated().map { index, linkData in + ChartDataPoint( + date: Date(), // Not used for top links chart + count: linkData.clicks, + label: linkData.link.alias + ) + } + } + + private static func getTopLinks(from links: [Link], timeRange: AnalyticsView.TimeRange, limit: Int = 10) -> [TopLinkData] { + let filteredLinks: [Link] + + if let dateRange = timeRange.dateRange { + // Filter clicks within the time range + filteredLinks = links.map { link in + let filteredClickLogs = link.sortedClickLogs.filter { clickLog in + dateRange.contains(clickLog.clickedAt) + } + + // Create a temporary link-like structure with filtered click count + return (link, filteredClickLogs.count) + } + .filter { $0.1 > 0 } // Only include links with clicks in the period + .sorted { $0.1 > $1.1 } // Sort by click count + .prefix(limit) + .map { $0.0 } // Extract the link + } else { + // All time + filteredLinks = Array(links.sorted { $0.clickCount > $1.clickCount }.prefix(limit)) + } + + return filteredLinks.map { link in + let clickCount: Int + if let dateRange = timeRange.dateRange { + clickCount = link.sortedClickLogs.filter { dateRange.contains($0.clickedAt) }.count + } else { + clickCount = Int(link.clickCount) + } + + return TopLinkData(link: link, clicks: clickCount) + } + } + + private static func getLinkTypeDistribution(from links: [Link], timeRange: AnalyticsView.TimeRange) -> [LinkTypeData] { + let filteredLinks: [Link] + + if let dateRange = timeRange.dateRange { + filteredLinks = links.filter { link in + dateRange.contains(link.createdAt) + } + } else { + filteredLinks = links + } + + let typeGroups = Dictionary(grouping: filteredLinks) { $0.linkTypeEnum } + + return typeGroups.map { type, links in + LinkTypeData(type: type, count: links.count) + }.sorted { $0.count > $1.count } + } + + private static func getRecentActivity(from links: [Link], timeRange: AnalyticsView.TimeRange, limit: Int = 20) -> [RecentActivity] { + var activities: [RecentActivity] = [] + + // Add link creation activities + let filteredLinks: [Link] + if let dateRange = timeRange.dateRange { + filteredLinks = links.filter { dateRange.contains($0.createdAt) } + } else { + filteredLinks = Array(links.sorted { $0.createdAt > $1.createdAt }.prefix(limit / 2)) + } + + for link in filteredLinks { + activities.append(RecentActivity( + id: UUID(), + type: .linkCreated, + description: "Created link '\(link.alias)'", + timestamp: link.createdAt, + link: link + )) + } + + // Add click activities + let allClickLogs: [ClickLog] + if let dateRange = timeRange.dateRange { + allClickLogs = links.flatMap { link in + link.sortedClickLogs.filter { dateRange.contains($0.clickedAt) } + } + } else { + allClickLogs = links.flatMap { $0.sortedClickLogs }.sorted { $0.clickedAt > $1.clickedAt } + } + + for clickLog in Array(allClickLogs.prefix(limit / 2)) { + activities.append(RecentActivity( + id: UUID(), + type: .linkClicked, + description: "Clicked link '\(clickLog.link.alias)'", + timestamp: clickLog.clickedAt, + link: clickLog.link + )) + } + + return Array(activities.sorted { $0.timestamp > $1.timestamp }.prefix(limit)) + } +} + +// MARK: - Supporting Data Structures + +struct AnalyticsData { + let totalLinks: Int + let totalClicks: Int + let activeLinks: Int + let averageClicksPerLink: Double + let linksChange: Double? + let clicksChange: Double? + let avgClicksChange: Double? + let activeLinksChange: Double? + let chartData: [ChartDataPoint] + let topLinks: [TopLinkData] + let linkTypeDistribution: [LinkTypeData] + let recentActivity: [RecentActivity] +} + +private struct FilteredAnalyticsData { + let totalLinks: Int + let totalClicks: Int + let activeLinks: Int + let averageClicksPerLink: Double + let previousTotalLinks: Int + let previousTotalClicks: Int + let previousActiveLinks: Int + let previousAverageClicksPerLink: Double +} + +struct TopLinkData { + let link: Link + let clicks: Int +} + +struct LinkTypeData { + let type: LinkType + let count: Int +} + +struct RecentActivity: Identifiable { + let id: UUID + let type: ActivityType + let description: String + let timestamp: Date + let link: Link? + + enum ActivityType { + case linkCreated + case linkUpdated + case linkClicked + case linkDeleted + + var icon: String { + switch self { + case .linkCreated: + return "plus.circle" + case .linkUpdated: + return "pencil.circle" + case .linkClicked: + return "hand.tap" + case .linkDeleted: + return "trash.circle" + } + } + + var color: Color { + switch self { + case .linkCreated: + return .green + case .linkUpdated: + return .blue + case .linkClicked: + return .orange + case .linkDeleted: + return .red + } + } + } +} + +import SwiftUI diff --git a/mobile/Heygo/Utils/MarkdownRenderer.swift b/mobile/Heygo/Utils/MarkdownRenderer.swift new file mode 100644 index 0000000..b39624a --- /dev/null +++ b/mobile/Heygo/Utils/MarkdownRenderer.swift @@ -0,0 +1,380 @@ +import SwiftUI +import Foundation + +struct MarkdownRenderer { + static func renderToAttributedString(_ markdown: String) -> AttributedString { + // Basic markdown parsing + var attributedString = AttributedString(markdown) + + // Apply basic styling + attributedString = processHeaders(attributedString) + attributedString = processBold(attributedString) + attributedString = processItalic(attributedString) + attributedString = processCode(attributedString) + attributedString = processLinks(attributedString) + attributedString = processLists(attributedString) + + return attributedString + } + + private static func processHeaders(_ text: AttributedString) -> AttributedString { + var result = text + let string = String(result.characters) + + // Process headers (# ## ### etc.) + // Use (?m) for multiline so ^ and $ match line boundaries; avoid deprecated anchorsMatchLines + let headerPattern = "(?m)^(#{1,6})\\s+(.+)$" + let regex = try? NSRegularExpression(pattern: headerPattern, options: []) + + let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? [] + + for match in matches.reversed() { + let headerLevelRange = match.range(at: 1) + let headerTextRange = match.range(at: 2) + + if let headerLevelSwiftRange = Range(headerLevelRange, in: string), + let headerTextSwiftRange = Range(headerTextRange, in: string) { + + let headerLevel = string[headerLevelSwiftRange].count + let headerText = String(string[headerTextSwiftRange]) + + let fontSize: CGFloat = { + switch headerLevel { + case 1: return 24 + case 2: return 20 + case 3: return 18 + case 4: return 16 + case 5: return 14 + case 6: return 12 + default: return 16 + } + }() + + if let range = Range(match.range, in: result) { + var headerAttributedText = AttributedString(headerText) + headerAttributedText.font = .system(size: fontSize, weight: .bold) + result.replaceSubrange(range, with: headerAttributedText) + } + } + } + + return result + } + + private static func processBold(_ text: AttributedString) -> AttributedString { + var result = text + let string = String(result.characters) + + // Process bold (**text** or __text__) + let boldPattern = "(\\*\\*|__)(.+?)\\1" + let regex = try? NSRegularExpression(pattern: boldPattern, options: []) + + let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? [] + + for match in matches.reversed() { + let boldTextRange = match.range(at: 2) + + if let boldTextSwiftRange = Range(boldTextRange, in: string) { + let boldText = String(string[boldTextSwiftRange]) + + if let range = Range(match.range, in: result) { + var boldAttributedText = AttributedString(boldText) + boldAttributedText.font = .boldSystemFont(ofSize: 16) + result.replaceSubrange(range, with: boldAttributedText) + } + } + } + + return result + } + + private static func processItalic(_ text: AttributedString) -> AttributedString { + var result = text + let string = String(result.characters) + + // Process italic (*text* or _text_) + let italicPattern = "(? AttributedString { + var result = text + let string = String(result.characters) + + // Process inline code (`code`) + let codePattern = "`([^`]+)`" + let regex = try? NSRegularExpression(pattern: codePattern, options: []) + + let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? [] + + for match in matches.reversed() { + let codeTextRange = match.range(at: 1) + + if let codeTextSwiftRange = Range(codeTextRange, in: string) { + let codeText = String(string[codeTextSwiftRange]) + + if let range = Range(match.range, in: result) { + var codeAttributedText = AttributedString(codeText) + codeAttributedText.font = .monospacedSystemFont(ofSize: 14, weight: .regular) + codeAttributedText.backgroundColor = .gray.opacity(0.2) + result.replaceSubrange(range, with: codeAttributedText) + } + } + } + + return result + } + + private static func processLinks(_ text: AttributedString) -> AttributedString { + var result = text + let string = String(result.characters) + + // Process links [text](url) + let linkPattern = "\\[([^\\]]+)\\]\\(([^\\)]+)\\)" + let regex = try? NSRegularExpression(pattern: linkPattern, options: []) + + let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? [] + + for match in matches.reversed() { + let linkTextRange = match.range(at: 1) + let linkURLRange = match.range(at: 2) + + if let linkTextSwiftRange = Range(linkTextRange, in: string), + let linkURLSwiftRange = Range(linkURLRange, in: string) { + + let linkText = String(string[linkTextSwiftRange]) + let linkURL = String(string[linkURLSwiftRange]) + + if let range = Range(match.range, in: result) { + var linkAttributedText = AttributedString(linkText) + linkAttributedText.foregroundColor = .blue + linkAttributedText.underlineStyle = .single + if let url = URL(string: linkURL) { + linkAttributedText.link = url + } + result.replaceSubrange(range, with: linkAttributedText) + } + } + } + + return result + } + + private static func processLists(_ text: AttributedString) -> AttributedString { + var result = text + let string = String(result.characters) + + // Process unordered lists (- item or * item) + let listPattern = "(?m)^\\s*[-*+]\\s+(.+)$" + let regex = try? NSRegularExpression(pattern: listPattern, options: []) + + let matches = regex?.matches(in: string, options: [], range: NSRange(location: 0, length: string.utf16.count)) ?? [] + + for match in matches.reversed() { + let listItemRange = match.range(at: 1) + + if let listItemSwiftRange = Range(listItemRange, in: string) { + let listItemText = String(string[listItemSwiftRange]) + + if let range = Range(match.range, in: result) { + let bulletText = "• " + listItemText + var listAttributedText = AttributedString(bulletText) + listAttributedText.font = .systemFont(ofSize: 16) + result.replaceSubrange(range, with: listAttributedText) + } + } + } + + return result + } +} + +// MARK: - SwiftUI Views + +struct MarkdownView: View { + let text: String + + var body: some View { + ScrollView { + Text(MarkdownRenderer.renderToAttributedString(text)) + .textSelection(.enabled) + .padding() + } + } +} + +struct MarkdownEditor: View { + @Binding var text: String + @State private var isPreviewMode: Bool = false + + var body: some View { + VStack { + // Toolbar + HStack { + Button(action: { + isPreviewMode.toggle() + }) { + HStack { + Image(systemName: isPreviewMode ? "pencil" : "eye") + Text(isPreviewMode ? "edit" : "preview") + } + } + .font(.caption) + + Spacer() + + if !isPreviewMode { + Menu { + Button("bold") { + insertMarkdown("**", "**") + } + Button("italic") { + insertMarkdown("*", "*") + } + Button("code") { + insertMarkdown("`", "`") + } + Button("link") { + insertMarkdown("[", "](url)") + } + Button("header") { + insertMarkdown("# ", "") + } + Button("list_item") { + insertMarkdown("- ", "") + } + } label: { + Image(systemName: "textformat") + } + .font(.caption) + } + } + .padding(.horizontal) + .padding(.bottom, 8) + + // Content + if isPreviewMode { + MarkdownView(text: text) + } else { + TextEditor(text: $text) + .font(.system(.body, design: .monospaced)) + } + } + } + + private func insertMarkdown(_ prefix: String, _ suffix: String) { + text += prefix + "text" + suffix + } +} + +// MARK: - Markdown Utilities + +extension MarkdownRenderer { + static func extractPlainText(_ markdown: String) -> String { + var plainText = markdown + + // Remove headers + plainText = plainText.replacingOccurrences(of: "^#{1,6}\\s+", with: "", options: .regularExpression) + + // Remove bold and italic + plainText = plainText.replacingOccurrences(of: "\\*\\*([^*]+)\\*\\*", with: "$1", options: .regularExpression) + plainText = plainText.replacingOccurrences(of: "__([^_]+)__", with: "$1", options: .regularExpression) + plainText = plainText.replacingOccurrences(of: "\\*([^*]+)\\*", with: "$1", options: .regularExpression) + plainText = plainText.replacingOccurrences(of: "_([^_]+)_", with: "$1", options: .regularExpression) + + // Remove inline code + plainText = plainText.replacingOccurrences(of: "`([^`]+)`", with: "$1", options: .regularExpression) + + // Remove links + plainText = plainText.replacingOccurrences(of: "\\[([^\\]]+)\\]\\([^\\)]+\\)", with: "$1", options: .regularExpression) + + // Remove list markers + plainText = plainText.replacingOccurrences(of: "(?m)^\\s*[-*+]\\s+", with: "", options: .regularExpression) + + return plainText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func estimateReadingTime(_ markdown: String) -> Int { + let plainText = extractPlainText(markdown) + let wordCount = plainText.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + let wordsPerMinute = 200 // Average reading speed + return max(1, wordCount / wordsPerMinute) + } + + static func getWordCount(_ markdown: String) -> Int { + let plainText = extractPlainText(markdown) + return plainText.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.count + } + + static func validateMarkdown(_ markdown: String) -> [MarkdownValidationError] { + var errors: [MarkdownValidationError] = [] + + // Check for unclosed bold/italic markers + let boldCount = markdown.components(separatedBy: "**").count - 1 + if boldCount % 2 != 0 { + errors.append(.unclosedbold) + } + + let italicCount = markdown.components(separatedBy: "*").count - 1 - boldCount + if italicCount % 2 != 0 { + errors.append(.unclosedItalic) + } + + // Check for unclosed code blocks + let codeCount = markdown.components(separatedBy: "`").count - 1 + if codeCount % 2 != 0 { + errors.append(.unclosedCode) + } + + // Check for malformed links + let malformedLinkPattern = "\\[[^\\]]*\\]\\([^\\)]*$|\\[[^\\]]*$\\)|^[^\\[]*\\]\\([^\\)]*\\)" + + if let regex = try? NSRegularExpression(pattern: malformedLinkPattern, options: []) { + let matches = regex.matches(in: markdown, options: [], range: NSRange(location: 0, length: markdown.utf16.count)) + if !matches.isEmpty { + errors.append(.malformedLink) + } + } + + return errors + } +} + +enum MarkdownValidationError: Error, LocalizedError { + case unclosedbold + case unclosedItalic + case unclosedCode + case malformedLink + + var errorDescription: String? { + switch self { + case .unclosedbold: + return NSLocalizedString("unclosed_bold_markers", comment: "Unclosed bold markers") + case .unclosedItalic: + return NSLocalizedString("unclosed_italic_markers", comment: "Unclosed italic markers") + case .unclosedCode: + return NSLocalizedString("unclosed_code_markers", comment: "Unclosed code markers") + case .malformedLink: + return NSLocalizedString("malformed_links", comment: "Malformed links") + } + } +} diff --git a/mobile/Heygo/Utils/TagManager.swift b/mobile/Heygo/Utils/TagManager.swift new file mode 100644 index 0000000..bb5e85f --- /dev/null +++ b/mobile/Heygo/Utils/TagManager.swift @@ -0,0 +1,369 @@ +import Foundation +import CoreData + +class TagManager: ObservableObject { + private let persistenceController = PersistenceController.shared + + @Published var allTags: [Tag] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + init() { + fetchTags() + } + + func fetchTags() { + isLoading = true + let context = persistenceController.container.viewContext + let request: NSFetchRequest = Tag.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \Tag.name, ascending: true)] + + do { + allTags = try context.fetch(request) + isLoading = false + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + + func createTag(name: String, description: String? = nil) -> Tag? { + let context = persistenceController.container.viewContext + + // Check if tag already exists + if tagExists(name: name) { + errorMessage = NSLocalizedString("tag_already_exists", comment: "Tag already exists") + return nil + } + + let newTag = Tag(context: context) + newTag.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + newTag.slug = generateSlug(from: name) + newTag.tagDescription = description + newTag.createdAt = Date() + + do { + try context.save() + fetchTags() + return newTag + } catch { + errorMessage = error.localizedDescription + return nil + } + } + + func updateTag(_ tag: Tag, name: String, description: String?) -> Bool { + let context = persistenceController.container.viewContext + + // Check if name already exists (excluding current tag) + if name != tag.name && tagExists(name: name) { + errorMessage = NSLocalizedString("tag_already_exists", comment: "Tag already exists") + return false + } + + tag.name = name.trimmingCharacters(in: .whitespacesAndNewlines) + tag.slug = generateSlug(from: name) + tag.tagDescription = description + + do { + try context.save() + fetchTags() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func deleteTag(_ tag: Tag) -> Bool { + let context = persistenceController.container.viewContext + + // Remove tag from all associated links + if let links = tag.links as? Set { + for link in links { + link.removeFromTags(tag) + } + } + + context.delete(tag) + + do { + try context.save() + fetchTags() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func findOrCreateTag(name: String) -> Tag? { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + + // First, try to find existing tag + if let existingTag = findTag(name: trimmedName) { + return existingTag + } + + // Create new tag if not found + return createTag(name: trimmedName) + } + + func findTag(name: String) -> Tag? { + return allTags.first { $0.name.lowercased() == name.lowercased() } + } + + func findTagBySlug(_ slug: String) -> Tag? { + return allTags.first { $0.slug == slug } + } + + func searchTags(query: String) -> [Tag] { + if query.isEmpty { + return allTags + } + + return allTags.filter { tag in + tag.name.localizedCaseInsensitiveContains(query) || + (tag.tagDescription?.localizedCaseInsensitiveContains(query) ?? false) + } + } + + func getTagsWithPrefix(_ prefix: String) -> [Tag] { + if prefix.isEmpty { + return allTags + } + + return allTags.filter { tag in + tag.name.lowercased().hasPrefix(prefix.lowercased()) + } + } + + func getMostPopularTags(limit: Int = 10) -> [Tag] { + return allTags + .sorted { $0.linkCount > $1.linkCount } + .prefix(limit) + .map { $0 } + } + + func getRecentlyUsedTags(limit: Int = 5) -> [Tag] { + // Get tags that were recently added to links + let context = persistenceController.container.viewContext + let request: NSFetchRequest = Link.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \Link.updatedAt, ascending: false)] + request.fetchLimit = limit * 3 // Fetch more links to get variety of tags + + do { + let recentLinks = try context.fetch(request) + var tagCounts: [Tag: Int] = [:] + + for link in recentLinks { + for tag in link.sortedTags { + tagCounts[tag, default: 0] += 1 + } + } + + return tagCounts + .sorted { $0.value > $1.value } + .prefix(limit) + .map { $0.key } + } catch { + return [] + } + } + + func getTagStatistics() -> TagStatistics { + let totalTags = allTags.count + let totalLinks = allTags.reduce(0) { $0 + $1.linkCount } + let averageLinksPerTag = totalTags > 0 ? Double(totalLinks) / Double(totalTags) : 0.0 + let mostPopularTag = allTags.max { $0.linkCount < $1.linkCount } + let unusedTags = allTags.filter { $0.linkCount == 0 } + + return TagStatistics( + totalTags: totalTags, + totalLinks: totalLinks, + averageLinksPerTag: averageLinksPerTag, + mostPopularTag: mostPopularTag, + unusedTagsCount: unusedTags.count + ) + } + + func mergeTags(_ sourceTags: [Tag], into targetTag: Tag) -> Bool { + let context = persistenceController.container.viewContext + + do { + // Move all links from source tags to target tag + for sourceTag in sourceTags { + if let links = sourceTag.links as? Set { + for link in links { + link.removeFromTags(sourceTag) + link.addToTags(targetTag) + } + } + + // Delete source tag + context.delete(sourceTag) + } + + try context.save() + fetchTags() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func cleanupUnusedTags() -> Int { + let unusedTags = allTags.filter { $0.linkCount == 0 } + let context = persistenceController.container.viewContext + + for tag in unusedTags { + context.delete(tag) + } + + do { + try context.save() + fetchTags() + return unusedTags.count + } catch { + errorMessage = error.localizedDescription + return 0 + } + } + + // MARK: - Private Helper Methods + + private func tagExists(name: String) -> Bool { + return findTag(name: name) != nil + } + + private func generateSlug(from name: String) -> String { + return name + .lowercased() + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: " ", with: "-") + .replacingOccurrences(of: "[^a-z0-9-]", with: "", options: .regularExpression) + } +} + +// MARK: - Supporting Types + +struct TagStatistics { + let totalTags: Int + let totalLinks: Int + let averageLinksPerTag: Double + let mostPopularTag: Tag? + let unusedTagsCount: Int +} + +struct TagSuggestion { + let name: String + let reason: String + let confidence: Double +} + +// MARK: - Tag Suggestions + +extension TagManager { + func suggestTagsForLink(_ link: Link) -> [TagSuggestion] { + var suggestions: [TagSuggestion] = [] + + // Suggest based on URL domain + if let url = link.originalURL, let domain = extractDomain(from: url) { + if let domainTag = suggestTagFromDomain(domain) { + suggestions.append(domainTag) + } + } + + // Suggest based on link type + let typeTag = TagSuggestion( + name: link.linkTypeEnum.displayName.lowercased(), + reason: NSLocalizedString("suggested_based_on_type", comment: "Suggested based on link type"), + confidence: 0.8 + ) + suggestions.append(typeTag) + + // Suggest based on alias keywords + let aliasKeywords = extractKeywords(from: link.alias) + for keyword in aliasKeywords { + if keyword.count > 2 { + suggestions.append(TagSuggestion( + name: keyword, + reason: NSLocalizedString("suggested_from_alias", comment: "Suggested from alias"), + confidence: 0.6 + )) + } + } + + // Suggest based on description + if let description = link.linkDescription { + let descriptionKeywords = extractKeywords(from: description) + for keyword in descriptionKeywords.prefix(3) { + if keyword.count > 3 { + suggestions.append(TagSuggestion( + name: keyword, + reason: NSLocalizedString("suggested_from_description", comment: "Suggested from description"), + confidence: 0.5 + )) + } + } + } + + return suggestions + .filter { suggestion in + !allTags.contains { $0.name.lowercased() == suggestion.name.lowercased() } + } + .sorted { $0.confidence > $1.confidence } + } + + private func extractDomain(from url: String) -> String? { + guard let urlObj = URL(string: url), + let host = urlObj.host else { return nil } + + let components = host.components(separatedBy: ".") + return components.count >= 2 ? components[components.count - 2] : host + } + + private func suggestTagFromDomain(_ domain: String) -> TagSuggestion? { + let commonDomainTags: [String: String] = [ + "github": "code", + "stackoverflow": "programming", + "youtube": "video", + "twitter": "social", + "facebook": "social", + "linkedin": "professional", + "medium": "articles", + "wikipedia": "reference", + "google": "search", + "apple": "tech", + "microsoft": "tech", + "amazon": "shopping" + ] + + if let tagName = commonDomainTags[domain.lowercased()] { + return TagSuggestion( + name: tagName, + reason: NSLocalizedString("suggested_from_domain", comment: "Suggested from domain"), + confidence: 0.7 + ) + } + + return TagSuggestion( + name: domain, + reason: NSLocalizedString("suggested_from_domain", comment: "Suggested from domain"), + confidence: 0.6 + ) + } + + private func extractKeywords(from text: String) -> [String] { + let words = text + .lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty } + + // Filter out common stop words + let stopWords = Set(["the", "a", "an", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by"]) + + return words.filter { !stopWords.contains($0) } + } +} diff --git a/mobile/Heygo/Utils/URLTemplateProcessor.swift b/mobile/Heygo/Utils/URLTemplateProcessor.swift new file mode 100644 index 0000000..f6bf1b6 --- /dev/null +++ b/mobile/Heygo/Utils/URLTemplateProcessor.swift @@ -0,0 +1,149 @@ +import Foundation + +struct URLTemplateProcessor { + static func processTemplate(_ template: String, with parameters: [String: String]) -> String { + var processedURL = template + let extractedParams = extractParameters(from: template) + + for param in extractedParams { + let placeholder = "{\(param.name)" + (param.defaultValue != nil ? ",default=\(param.defaultValue!)" : "") + "}" + let value = parameters[param.name] ?? param.defaultValue ?? "" + processedURL = processedURL.replacingOccurrences(of: placeholder, with: value) + } + + return processedURL + } + + static func extractParameters(from template: String) -> [TemplateParameter] { + var parameters: [TemplateParameter] = [] + let pattern = "\\{([^,}]+)(?:,default=([^}]+))?\\}" + + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return parameters + } + + let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count)) + + for match in matches { + let nameRange = match.range(at: 1) + let defaultRange = match.range(at: 2) + + if let nameSwiftRange = Range(nameRange, in: template) { + let name = String(template[nameSwiftRange]) + var defaultValue: String? = nil + + if defaultRange.location != NSNotFound, + let defaultSwiftRange = Range(defaultRange, in: template) { + defaultValue = String(template[defaultSwiftRange]) + } + + parameters.append(TemplateParameter(name: name, defaultValue: defaultValue)) + } + } + + return parameters + } + + static func validateTemplate(_ template: String) -> Bool { + let extractedParams = extractParameters(from: template) + + // Check if all placeholders are valid + let pattern = "\\{[^{}]*\\}" + guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { + return false + } + + let matches = regex.matches(in: template, options: [], range: NSRange(location: 0, length: template.utf16.count)) + + // Each match should correspond to a valid parameter + for match in matches { + if let matchRange = Range(match.range, in: template) { + let matchString = String(template[matchRange]) + let isValid = extractedParams.contains { param in + matchString.contains(param.name) + } + if !isValid { + return false + } + } + } + + return true + } + + static func getRequiredParameters(from template: String) -> [String] { + return extractParameters(from: template) + .filter { $0.defaultValue == nil } + .map { $0.name } + } + + static func hasParameters(_ template: String) -> Bool { + return template.contains("{") && template.contains("}") + } +} + +struct TemplateParameter { + let name: String + let defaultValue: String? + + var isRequired: Bool { + return defaultValue == nil + } +} + +// MARK: - Template Examples and Helpers +extension URLTemplateProcessor { + static let commonTemplates: [TemplateExample] = [ + TemplateExample( + name: "Google Search", + template: "https://google.com/search?q={query}", + description: "Search Google with a custom query" + ), + TemplateExample( + name: "GitHub Repository", + template: "https://github.com/{user}/{repo}", + description: "Open a GitHub repository" + ), + TemplateExample( + name: "YouTube Video", + template: "https://youtube.com/watch?v={videoId}", + description: "Open a YouTube video" + ), + TemplateExample( + name: "Maps Location", + template: "https://maps.apple.com/?q={location,default=New York}", + description: "Open a location in Apple Maps" + ), + TemplateExample( + name: "Wikipedia Article", + template: "https://en.wikipedia.org/wiki/{article}", + description: "Open a Wikipedia article" + ) + ] + + static func suggestTemplate(for url: String) -> String? { + // Simple template suggestion based on common patterns + if url.contains("google.com/search") { + return url.replacingOccurrences(of: "q=[^&]*", with: "q={query}", options: .regularExpression) + } else if url.contains("github.com") && url.components(separatedBy: "/").count >= 5 { + let components = url.components(separatedBy: "/") + if components.count >= 5 { + return "https://github.com/{user}/{repo}" + } + } else if url.contains("youtube.com/watch") { + return url.replacingOccurrences(of: "v=[^&]*", with: "v={videoId}", options: .regularExpression) + } + + return nil + } +} + +struct TemplateExample { + let name: String + let template: String + let description: String + + var parameters: [TemplateParameter] { + return URLTemplateProcessor.extractParameters(from: template) + } +} diff --git a/mobile/Heygo/ViewModels/LinkViewModel.swift b/mobile/Heygo/ViewModels/LinkViewModel.swift new file mode 100644 index 0000000..a6e5f14 --- /dev/null +++ b/mobile/Heygo/ViewModels/LinkViewModel.swift @@ -0,0 +1,272 @@ +import Foundation +import CoreData +import Combine + +class LinkViewModel: ObservableObject { + @Published var links: [Link] = [] + @Published var filteredLinks: [Link] = [] + @Published var searchText: String = "" { + didSet { + filterLinks() + } + } + @Published var selectedSortOption: SortOption = .alias { + didSet { + sortLinks() + } + } + @Published var selectedTag: Tag? { + didSet { + filterLinks() + } + } + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + private var cancellables = Set() + private let persistenceController = PersistenceController.shared + + enum SortOption: String, CaseIterable { + case alias = "alias" + case type = "linkType" + case clicks = "clickCount" + case dateCreated = "createdAt" + case dateUpdated = "updatedAt" + + var displayName: String { + switch self { + case .alias: + return NSLocalizedString("alias", comment: "Sort by alias") + case .type: + return NSLocalizedString("type", comment: "Sort by type") + case .clicks: + return NSLocalizedString("clicks", comment: "Sort by clicks") + case .dateCreated: + return NSLocalizedString("date_created", comment: "Sort by date created") + case .dateUpdated: + return NSLocalizedString("date_updated", comment: "Sort by date updated") + } + } + } + + init() { + fetchLinks() + } + + func fetchLinks() { + isLoading = true + let context = persistenceController.container.viewContext + let request: NSFetchRequest = Link.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(keyPath: \Link.alias, ascending: true)] + + do { + links = try context.fetch(request) + filterLinks() + isLoading = false + } catch { + errorMessage = error.localizedDescription + isLoading = false + } + } + + func createLink(alias: String, originalURL: String?, text: String?, linkType: LinkType, description: String?, tags: [Tag]) -> Bool { + let context = persistenceController.container.viewContext + + // Check if alias already exists + if aliasExists(alias) { + errorMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists") + return false + } + + let newLink = Link(context: context) + newLink.alias = alias + newLink.originalURL = originalURL + newLink.text = text + newLink.linkType = linkType.rawValue + newLink.linkDescription = description + newLink.clickCount = 0 + newLink.createdAt = Date() + newLink.updatedAt = Date() + + // Add tags + for tag in tags { + newLink.addToTags(tag) + } + + // Create change log + let changeLog = ChangeLog(context: context) + changeLog.changeType = "CREATE" + changeLog.changeDescription = "Link created" + changeLog.changedAt = Date() + changeLog.link = newLink + + do { + try context.save() + fetchLinks() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func updateLink(_ link: Link, alias: String, originalURL: String?, text: String?, linkType: LinkType, description: String?, tags: [Tag]) -> Bool { + let context = persistenceController.container.viewContext + + // Check if alias already exists (excluding current link) + if alias != link.alias && aliasExists(alias) { + errorMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists") + return false + } + + link.alias = alias + link.originalURL = originalURL + link.text = text + link.linkType = linkType.rawValue + link.linkDescription = description + link.updatedAt = Date() + + // Update tags + link.removeFromTags(link.tags ?? NSSet()) + for tag in tags { + link.addToTags(tag) + } + + // Create change log + let changeLog = ChangeLog(context: context) + changeLog.changeType = "UPDATE" + changeLog.changeDescription = "Link updated" + changeLog.changedAt = Date() + changeLog.link = link + + do { + try context.save() + fetchLinks() + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + func deleteLink(_ link: Link) { + let context = persistenceController.container.viewContext + context.delete(link) + + do { + try context.save() + fetchLinks() + } catch { + errorMessage = error.localizedDescription + } + } + + func recordClick(for link: Link) { + let context = persistenceController.container.viewContext + + // Create click log + let clickLog = ClickLog(context: context) + clickLog.clickedAt = Date() + clickLog.link = link + + // Increment click count + link.clickCount += 1 + + do { + try context.save() + fetchLinks() + } catch { + errorMessage = error.localizedDescription + } + } + + func aliasExists(_ alias: String) -> Bool { + let context = persistenceController.container.viewContext + let request: NSFetchRequest = Link.fetchRequest() + request.predicate = NSPredicate(format: "alias == %@", alias) + request.fetchLimit = 1 + + do { + let count = try context.count(for: request) + return count > 0 + } catch { + return false + } + } + + private func filterLinks() { + var filtered = links + + // Filter by search text + if !searchText.isEmpty { + filtered = filtered.filter { link in + link.alias.localizedCaseInsensitiveContains(searchText) || + (link.originalURL?.localizedCaseInsensitiveContains(searchText) ?? false) || + (link.linkDescription?.localizedCaseInsensitiveContains(searchText) ?? false) + } + } + + // Filter by selected tag + if let selectedTag = selectedTag { + filtered = filtered.filter { link in + link.sortedTags.contains(selectedTag) + } + } + + filteredLinks = filtered + sortLinks() + } + + private func sortLinks() { + switch selectedSortOption { + case .alias: + filteredLinks.sort { $0.alias < $1.alias } + case .type: + filteredLinks.sort { $0.linkType < $1.linkType } + case .clicks: + filteredLinks.sort { $0.clickCount > $1.clickCount } + case .dateCreated: + filteredLinks.sort { $0.createdAt > $1.createdAt } + case .dateUpdated: + filteredLinks.sort { $0.updatedAt > $1.updatedAt } + } + } + + func openLink(_ link: Link, with parameters: [String: String] = [:]) { + recordClick(for: link) + + guard let urlString = link.originalURL else { return } + + var processedURL = urlString + + // Process template parameters + if link.isTemplate { + processedURL = URLTemplateProcessor.processTemplate(urlString, with: parameters) + } + + guard let url = URL(string: processedURL) else { return } + + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } +} + +// MARK: - Search functionality +extension LinkViewModel { + func performQuickSearch(_ query: String) { + searchText = query + + // If user presses enter with search text and no results, open as URL + if filteredLinks.isEmpty && !query.isEmpty { + if let url = URL(string: query.hasPrefix("http") ? query : "https://\(query)") { + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + } + } +} + +// Import UIKit for UIApplication +import UIKit diff --git a/mobile/Heygo/Views/AnalyticsView.swift b/mobile/Heygo/Views/AnalyticsView.swift new file mode 100644 index 0000000..63d5d3d --- /dev/null +++ b/mobile/Heygo/Views/AnalyticsView.swift @@ -0,0 +1,476 @@ +import SwiftUI +import Charts + +struct AnalyticsView: View { + @EnvironmentObject var linkViewModel: LinkViewModel + @State private var selectedTimeRange: TimeRange = .oneMonth + @State private var selectedMetric: Metric = .clicks + @State private var showingExport = false + + enum TimeRange: String, CaseIterable { + case oneWeek = "1w" + case oneMonth = "1m" + case threeMonths = "3m" + case sixMonths = "6m" + case oneYear = "1y" + case allTime = "all" + + var displayName: String { + switch self { + case .oneWeek: + return NSLocalizedString("1_week", comment: "1 week") + case .oneMonth: + return NSLocalizedString("1_month", comment: "1 month") + case .threeMonths: + return NSLocalizedString("3_months", comment: "3 months") + case .sixMonths: + return NSLocalizedString("6_months", comment: "6 months") + case .oneYear: + return NSLocalizedString("1_year", comment: "1 year") + case .allTime: + return NSLocalizedString("all_time", comment: "All time") + } + } + + var dateRange: DateInterval? { + let calendar = Calendar.current + let endDate = Date() + + switch self { + case .oneWeek: + let startDate = calendar.date(byAdding: .weekOfYear, value: -1, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .oneMonth: + let startDate = calendar.date(byAdding: .month, value: -1, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .threeMonths: + let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .sixMonths: + let startDate = calendar.date(byAdding: .month, value: -6, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .oneYear: + let startDate = calendar.date(byAdding: .year, value: -1, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .allTime: + return nil + } + } + } + + enum Metric: String, CaseIterable { + case clicks = "clicks" + case links = "links" + case topLinks = "top_links" + + var displayName: String { + switch self { + case .clicks: + return NSLocalizedString("clicks_over_time", comment: "Clicks over time") + case .links: + return NSLocalizedString("links_created", comment: "Links created") + case .topLinks: + return NSLocalizedString("top_links", comment: "Top links") + } + } + } + + var analyticsData: AnalyticsData { + return AnalyticsCalculator.calculateAnalytics( + for: linkViewModel.links, + timeRange: selectedTimeRange, + metric: selectedMetric + ) + } + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + timeRangePicker + overviewCards + metricSelector + mainChart + topLinksSection + linkTypeDistributionSection + recentActivitySection + } + .padding() + } + .navigationTitle("analytics") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + analyticsToolbar + } + } + } + .sheet(isPresented: $showingExport) { + ExportView(analyticsData: analyticsData) + } + } + + private var timeRangePicker: some View { + VStack(alignment: .leading) { + Text("time_range") + .font(.headline) + + Picker("time_range", selection: $selectedTimeRange) { + ForEach(TimeRange.allCases, id: \.self) { range in + Text(range.displayName).tag(range) + } + } + .pickerStyle(SegmentedPickerStyle()) + } + } + + private var overviewCards: some View { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 16) { + OverviewCard( + title: "total_links", + value: "\(analyticsData.totalLinks)", + change: analyticsData.linksChange, + icon: "link" + ) + + OverviewCard( + title: "total_clicks", + value: "\(analyticsData.totalClicks)", + change: analyticsData.clicksChange, + icon: "hand.tap" + ) + + OverviewCard( + title: "avg_clicks_per_link", + value: String(format: "%.1f", analyticsData.averageClicksPerLink), + change: analyticsData.avgClicksChange, + icon: "chart.bar" + ) + + OverviewCard( + title: "active_links", + value: "\(analyticsData.activeLinks)", + change: analyticsData.activeLinksChange, + icon: "bolt" + ) + } + } + + private var metricSelector: some View { + VStack(alignment: .leading) { + Text("metric") + .font(.headline) + + Picker("metric", selection: $selectedMetric) { + ForEach(Metric.allCases, id: \.self) { metric in + Text(metric.displayName).tag(metric) + } + } + .pickerStyle(SegmentedPickerStyle()) + } + } + + private var mainChart: some View { + VStack(alignment: .leading) { + Text(selectedMetric.displayName) + .font(.headline) + + if analyticsData.chartData.isEmpty { + Text("no_data_available") + .font(.body) + .foregroundColor(.secondary) + .frame(height: 200) + .frame(maxWidth: .infinity) + .background(Color(.systemGray6)) + .cornerRadius(12) + } else { + Chart(analyticsData.chartData) { dataPoint in + switch selectedMetric { + case .clicks: + LineMark( + x: .value("Date", dataPoint.date), + y: .value("Clicks", dataPoint.count) + ) + .foregroundStyle(.blue) + case .links: + BarMark( + x: .value("Date", dataPoint.date), + y: .value("Links", dataPoint.count) + ) + .foregroundStyle(.green) + case .topLinks: + BarMark( + x: .value("Link", dataPoint.label), + y: .value("Clicks", dataPoint.count) + ) + .foregroundStyle(.orange) + } + } + .frame(height: 200) + .chartXAxisLabel(selectedMetric == .topLinks ? "Links" : "Date") + .chartYAxisLabel(selectedMetric == .links ? "Links Created" : "Clicks") + } + } + } + + private var topLinksSection: some View { + VStack(alignment: .leading) { + Text("top_performing_links") + .font(.headline) + + ForEach(analyticsData.topLinks, id: \.link.id) { linkData in + TopLinkRow(linkData: linkData) + } + } + } + + private var linkTypeDistributionSection: some View { + VStack(alignment: .leading) { + Text("link_type_distribution") + .font(.headline) + + if !analyticsData.linkTypeDistribution.isEmpty { + Chart(analyticsData.linkTypeDistribution, id: \.type) { data in + SectorMark( + angle: .value("Count", data.count), + innerRadius: .ratio(0.5), + angularInset: 2 + ) + .foregroundStyle(linkTypeColor(data.type)) + } + .frame(height: 200) + } + + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ], spacing: 12) { + ForEach(analyticsData.linkTypeDistribution, id: \.type) { data in + VStack { + Text(data.type.displayName) + .font(.caption) + .foregroundColor(.secondary) + Text("\(data.count)") + .font(.headline) + .foregroundColor(linkTypeColor(data.type)) + } + .frame(maxWidth: .infinity) + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } + } + + private var recentActivitySection: some View { + VStack(alignment: .leading) { + Text("recent_activity") + .font(.headline) + + ForEach(analyticsData.recentActivity.prefix(10), id: \.id) { activity in + RecentActivityRow(activity: activity) + } + } + } + + private var analyticsToolbar: some View { + Menu { + Button("export_data") { + showingExport = true + } + + Button("refresh_data") { + linkViewModel.fetchLinks() + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + + private func linkTypeColor(_ linkType: LinkType) -> Color { + switch linkType { + case .link: + return .blue + case .custom: + return .green + case .template: + return .orange + } + } +} + +struct OverviewCard: View { + let title: String + let value: String + let change: Double? + let icon: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Image(systemName: icon) + .foregroundColor(.blue) + Text(title) + .font(.caption) + .foregroundColor(.secondary) + Spacer() + } + + Text(value) + .font(.title2) + .fontWeight(.semibold) + + if let change = change { + HStack { + Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.caption2) + Text(String(format: "%.1f%%", abs(change))) + .font(.caption2) + } + .foregroundColor(change >= 0 ? .green : .red) + } + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6)) + .cornerRadius(12) + } +} + +struct TopLinkRow: View { + let linkData: TopLinkData + + var body: some View { + HStack { + VStack(alignment: .leading) { + Text(linkData.link.alias) + .font(.body) + .fontWeight(.medium) + + if let url = linkData.link.originalURL { + Text(url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + + Spacer() + + VStack(alignment: .trailing) { + Text("\(linkData.clicks)") + .font(.headline) + .foregroundColor(.blue) + + Text("clicks") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(8) + } +} + +struct RecentActivityRow: View { + let activity: RecentActivity + + var body: some View { + HStack { + Image(systemName: activity.type.icon) + .foregroundColor(activity.type.color) + .frame(width: 24) + + VStack(alignment: .leading) { + Text(activity.description) + .font(.body) + Text(activity.timestamp, format: .relative(presentation: .named)) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 4) + } +} + +struct ExportView: View { + let analyticsData: AnalyticsData + @Environment(\.presentationMode) var presentationMode + @State private var exportFormat: ExportFormat = .json + @State private var includeChartData = true + @State private var includeTopLinks = true + @State private var isExporting = false + + enum ExportFormat: String, CaseIterable { + case json = "JSON" + case csv = "CSV" + + var displayName: String { + return rawValue + } + } + + var body: some View { + NavigationView { + Form { + Section("export_options") { + Picker("format", selection: $exportFormat) { + ForEach(ExportFormat.allCases, id: \.self) { format in + Text(format.displayName).tag(format) + } + } + + Toggle("include_chart_data", isOn: $includeChartData) + Toggle("include_top_links", isOn: $includeTopLinks) + } + + Section("preview") { + Text("total_links: \(analyticsData.totalLinks)") + Text("total_clicks: \(analyticsData.totalClicks)") + Text("chart_data_points: \(analyticsData.chartData.count)") + Text("top_links: \(analyticsData.topLinks.count)") + } + } + .navigationTitle("export_analytics") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("export") { + exportData() + } + .disabled(isExporting) + } + } + } + } + + private func exportData() { + isExporting = true + + // Implementation would depend on the specific export requirements + // For now, we'll just simulate the export + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + isExporting = false + presentationMode.wrappedValue.dismiss() + } + } +} + +#Preview { + AnalyticsView() + .environmentObject(LinkViewModel()) +} diff --git a/mobile/Heygo/Views/LinkDetailView.swift b/mobile/Heygo/Views/LinkDetailView.swift new file mode 100644 index 0000000..b45a824 --- /dev/null +++ b/mobile/Heygo/Views/LinkDetailView.swift @@ -0,0 +1,469 @@ +import SwiftUI +import Charts + +struct LinkDetailView: View { + let link: Link + @EnvironmentObject var linkViewModel: LinkViewModel + @Environment(\.presentationMode) var presentationMode + @State private var selectedTimeRange: TimeRange = .threeMonths + @State private var showingEdit = false + @State private var templateParameters: [String: String] = [:] + @State private var showingTemplateDialog = false + + enum TimeRange: String, CaseIterable { + case threeMonths = "3m" + case sixMonths = "6m" + case oneYear = "1y" + case allTime = "all" + + var displayName: String { + switch self { + case .threeMonths: + return NSLocalizedString("3_months", comment: "3 months") + case .sixMonths: + return NSLocalizedString("6_months", comment: "6 months") + case .oneYear: + return NSLocalizedString("1_year", comment: "1 year") + case .allTime: + return NSLocalizedString("all_time", comment: "All time") + } + } + + var dateRange: DateInterval? { + let calendar = Calendar.current + let endDate = Date() + + switch self { + case .threeMonths: + let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .sixMonths: + let startDate = calendar.date(byAdding: .month, value: -6, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .oneYear: + let startDate = calendar.date(byAdding: .year, value: -1, to: endDate)! + return DateInterval(start: startDate, end: endDate) + case .allTime: + return nil + } + } + } + + var filteredClickLogs: [ClickLog] { + let logs = link.sortedClickLogs + + guard let dateRange = selectedTimeRange.dateRange else { + return logs + } + + return logs.filter { log in + dateRange.contains(log.clickedAt) + } + } + + var chartData: [ChartDataPoint] { + let calendar = Calendar.current + let logs = filteredClickLogs + + guard !logs.isEmpty else { return [] } + + // Determine the grouping based on time range + let component: Calendar.Component + let dateFormat: String + + switch selectedTimeRange { + case .threeMonths, .sixMonths: + component = .day + dateFormat = "MMM d" + case .oneYear: + component = .weekOfYear + dateFormat = "MMM d" + case .allTime: + component = .month + dateFormat = "MMM yyyy" + } + + // Group clicks by the component + let groupedLogs = Dictionary(grouping: logs) { log in + calendar.dateInterval(of: component, for: log.clickedAt)?.start ?? log.clickedAt + } + + // Create chart data points + let formatter = DateFormatter() + formatter.dateFormat = dateFormat + + return groupedLogs.map { date, logs in + ChartDataPoint( + date: date, + count: logs.count, + label: formatter.string(from: date) + ) + }.sorted { $0.date < $1.date } + } + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + headerSection + tagsSection + analyticsSection + customContentSection + recentClicksSection + changeHistorySection + } + .padding() + } + .navigationTitle("link_details") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("close") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + toolbarMenu + } + } + } + .sheet(isPresented: $showingEdit) { + LinkFormView(link: link) + .environmentObject(linkViewModel) + } + .sheet(isPresented: $showingTemplateDialog) { + TemplateParameterView( + link: link, + parameters: $templateParameters, + onOpen: { params in + linkViewModel.openLink(link, with: params) + presentationMode.wrappedValue.dismiss() + } + ) + } + } + + private var headerSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(link.alias) + .font(.largeTitle) + .fontWeight(.bold) + + Spacer() + + Text(link.linkTypeEnum.displayName) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(linkTypeColor(link.linkTypeEnum)) + .foregroundColor(.white) + .cornerRadius(6) + } + + if let url = link.originalURL { + Text(url) + .font(.body) + .foregroundColor(.blue) + .lineLimit(nil) + } + + if let description = link.linkDescription { + Text(description) + .font(.body) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(.systemGray6)) + .cornerRadius(12) + } + + private var tagsSection: some View { + Group { + if !link.sortedTags.isEmpty { + VStack(alignment: .leading) { + Text("tags") + .font(.headline) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(link.sortedTags, id: \.id) { tag in + Text(tag.name) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.2)) + .foregroundColor(.blue) + .cornerRadius(6) + } + } + .padding(.horizontal) + } + } + } + } + } + + private var analyticsSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("analytics") + .font(.headline) + + Spacer() + + Picker("time_range", selection: $selectedTimeRange) { + ForEach(TimeRange.allCases, id: \.self) { range in + Text(range.displayName).tag(range) + } + } + .pickerStyle(SegmentedPickerStyle()) + .frame(maxWidth: 200) + } + + analyticsStatsCards + analyticsChart + } + } + + private var analyticsStatsCards: some View { + HStack(spacing: 16) { + StatCard( + title: "total_clicks", + value: "\(link.clickCount)", + icon: "hand.tap" + ) + + StatCard( + title: "clicks_in_period", + value: "\(filteredClickLogs.count)", + icon: "chart.bar" + ) + } + } + + private var analyticsChart: some View { + Group { + if !chartData.isEmpty { + Chart(chartData) { dataPoint in + BarMark( + x: .value("Date", dataPoint.date), + y: .value("Clicks", dataPoint.count) + ) + .foregroundStyle(.blue) + } + .frame(height: 200) + .chartXAxisLabel("Date") + .chartYAxisLabel("Clicks") + } else { + Text("no_data_for_period") + .font(.body) + .foregroundColor(.secondary) + .frame(height: 200) + .frame(maxWidth: .infinity) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } + + private var customContentSection: some View { + Group { + if link.linkTypeEnum == .custom, let text = link.text { + VStack(alignment: .leading) { + Text("content_preview") + .font(.headline) + + MarkdownView(text: text) + .frame(maxHeight: 300) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + } + } + } + + private var recentClicksSection: some View { + Group { + if !filteredClickLogs.isEmpty { + VStack(alignment: .leading) { + Text("recent_clicks") + .font(.headline) + + ForEach(filteredClickLogs.prefix(10), id: \.id) { clickLog in + HStack { + Image(systemName: "hand.tap") + .foregroundColor(.blue) + + Text(clickLog.clickedAt, format: .dateTime) + .font(.body) + + Spacer() + } + .padding(.vertical, 2) + } + } + } + } + } + + private var changeHistorySection: some View { + Group { + if ((link.changeLogs?.allObjects.isEmpty) == nil) { + VStack(alignment: .leading) { + Text("change_history") + .font(.headline) + + let changeLogs = (link.changeLogs?.allObjects as? [ChangeLog] ?? []) + .sorted { $0.changedAt > $1.changedAt } + + ForEach(changeLogs.prefix(10), id: \.id) { changeLog in + HStack { + Image(systemName: changeLog.changeType == "CREATE" ? "plus.circle" : "pencil.circle") + .foregroundColor(changeLog.changeType == "CREATE" ? .green : .orange) + + VStack(alignment: .leading) { + Text(changeLog.changeDescription) + .font(.body) + Text(changeLog.changedAt, format: .dateTime) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.vertical, 2) + } + } + } + } + } + + private var toolbarMenu: some View { + Menu { + Button("edit") { + showingEdit = true + } + + if link.isTemplate { + Button("open_with_parameters") { + showingTemplateDialog = true + } + } else { + Button("open_link") { + linkViewModel.openLink(link) + } + } + + Button("delete", role: .destructive) { + linkViewModel.deleteLink(link) + presentationMode.wrappedValue.dismiss() + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + + private func linkTypeColor(_ linkType: LinkType) -> Color { + switch linkType { + case .link: + return .blue + case .custom: + return .green + case .template: + return .orange + } + } +} + +struct StatCard: View { + let title: String + let value: String + let icon: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: icon) + .foregroundColor(.blue) + Text(title) + .font(.caption) + .foregroundColor(.secondary) + } + + Text(value) + .font(.title2) + .fontWeight(.semibold) + } + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6)) + .cornerRadius(8) + } +} + +struct ChartDataPoint: Identifiable { + let id = UUID() + let date: Date + let count: Int + let label: String +} + +struct TemplateParameterView: View { + let link: Link + @Binding var parameters: [String: String] + let onOpen: ([String: String]) -> Void + @Environment(\.presentationMode) var presentationMode + @State private var extractedParams: [TemplateParameter] = [] + + var body: some View { + NavigationView { + Form { + Section("template_parameters") { + ForEach(extractedParams, id: \.name) { param in + VStack(alignment: .leading) { + Text(param.name) + .font(.headline) + + TextField(param.defaultValue ?? "enter_value", text: Binding( + get: { parameters[param.name] ?? param.defaultValue ?? "" }, + set: { parameters[param.name] = $0 } + )) + .textFieldStyle(RoundedBorderTextFieldStyle()) + } + } + } + } + .navigationTitle("enter_parameters") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("open") { + onOpen(parameters) + } + } + } + } + .onAppear { + extractTemplateParameters() + } + } + + private func extractTemplateParameters() { + guard let url = link.originalURL else { return } + extractedParams = URLTemplateProcessor.extractParameters(from: url) + } +} + +#Preview { + LinkDetailView(link: PersistenceController.preview.container.viewContext.registeredObjects.first { $0 is Link } as! Link) + .environmentObject(LinkViewModel()) +} diff --git a/mobile/Heygo/Views/LinkFormView.swift b/mobile/Heygo/Views/LinkFormView.swift new file mode 100644 index 0000000..fa6d620 --- /dev/null +++ b/mobile/Heygo/Views/LinkFormView.swift @@ -0,0 +1,308 @@ +import SwiftUI + +struct LinkFormView: View { + @EnvironmentObject var linkViewModel: LinkViewModel + @Environment(\.presentationMode) var presentationMode + @Environment(\.managedObjectContext) private var viewContext + + let link: Link? + + @State private var alias: String = "" + @State private var originalURL: String = "" + @State private var text: String = "" + @State private var linkDescription: String = "" + @State private var selectedLinkType: LinkType = .link + @State private var selectedTags: [Tag] = [] + @State private var tagSearchText: String = "" + @State private var isValidatingAlias: Bool = false + @State private var aliasValidationMessage: String = "" + @State private var showingTagPicker: Bool = false + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)], + animation: .default) + private var allTags: FetchedResults + + var isEditing: Bool { + return link != nil + } + + var filteredTags: [Tag] { + if tagSearchText.isEmpty { + return Array(allTags) + } else { + return allTags.filter { $0.name.localizedCaseInsensitiveContains(tagSearchText) } + } + } + + var canSave: Bool { + return !alias.isEmpty && + (selectedLinkType == .custom ? !text.isEmpty : !originalURL.isEmpty) && + aliasValidationMessage.isEmpty + } + + var body: some View { + NavigationView { + Form { + Section("basic_information") { + // Alias field + VStack(alignment: .leading) { + TextField("alias", text: $alias) + .autocapitalization(.none) + .autocorrectionDisabled() + .onChange(of: alias) { _, newValue in + validateAlias(newValue) + } + + if !aliasValidationMessage.isEmpty { + Text(aliasValidationMessage) + .font(.caption) + .foregroundColor(.red) + } + } + + // Link Type Picker + Picker("link_type", selection: $selectedLinkType) { + ForEach(LinkType.allCases, id: \.self) { linkType in + Text(linkType.displayName).tag(linkType) + } + } + .pickerStyle(SegmentedPickerStyle()) + + // URL or Text field based on type + if selectedLinkType == .custom { + VStack(alignment: .leading) { + Text("markdown_content") + .font(.caption) + .foregroundColor(.secondary) + TextEditor(text: $text) + .frame(minHeight: 100) + } + } else { + VStack(alignment: .leading) { + TextField("original_url", text: $originalURL) + .autocapitalization(.none) + .autocorrectionDisabled() + .keyboardType(.URL) + + if selectedLinkType == .template { + Text("template_help") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + // Description field + TextField("description_optional", text: $linkDescription) + } + + Section("tags") { + // Selected tags + if !selectedTags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(selectedTags, id: \.id) { tag in + TagChip(tag: tag, onRemove: { + selectedTags.removeAll { $0.id == tag.id } + }) + } + } + .padding(.horizontal) + } + } + + // Tag search and selection + HStack { + TextField("search_or_create_tag", text: $tagSearchText) + + Button("add") { + addOrCreateTag() + } + .disabled(tagSearchText.isEmpty) + } + + // Filtered tags list + ForEach(filteredTags.prefix(5), id: \.id) { tag in + HStack { + Text(tag.name) + Spacer() + if selectedTags.contains(where: { $0.id == tag.id }) { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + .onTapGesture { + toggleTag(tag) + } + } + } + + if isEditing { + Section("actions") { + Button("delete_link", role: .destructive) { + if let link = link { + linkViewModel.deleteLink(link) + presentationMode.wrappedValue.dismiss() + } + } + } + } + } + .navigationTitle(isEditing ? "edit_link" : "add_link") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("cancel") { + presentationMode.wrappedValue.dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + Button("save") { + saveLink() + } + .disabled(!canSave) + } + } + } + .onAppear { + setupForm() + } + } + + private func setupForm() { + if let link = link { + alias = link.alias + originalURL = link.originalURL ?? "" + text = link.text ?? "" + linkDescription = link.linkDescription ?? "" + selectedLinkType = link.linkTypeEnum + selectedTags = link.sortedTags + } + } + + private func validateAlias(_ newAlias: String) { + isValidatingAlias = true + + // Basic validation + if newAlias.isEmpty { + aliasValidationMessage = "" + isValidatingAlias = false + return + } + + // Check format (alphanumeric and some special characters) + let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + if newAlias.unicodeScalars.contains(where: { !allowedCharacters.contains($0) }) { + aliasValidationMessage = NSLocalizedString("alias_invalid_characters", comment: "Alias contains invalid characters") + isValidatingAlias = false + return + } + + // Check if alias exists (excluding current link if editing) + if !isEditing || (isEditing && newAlias != link?.alias) { + if linkViewModel.aliasExists(newAlias) { + aliasValidationMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists") + } else { + aliasValidationMessage = "" + } + } else { + aliasValidationMessage = "" + } + + isValidatingAlias = false + } + + private func toggleTag(_ tag: Tag) { + if selectedTags.contains(where: { $0.id == tag.id }) { + selectedTags.removeAll { $0.id == tag.id } + } else { + selectedTags.append(tag) + } + } + + private func addOrCreateTag() { + let trimmedText = tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { return } + + // Check if tag already exists + if let existingTag = allTags.first(where: { $0.name.lowercased() == trimmedText.lowercased() }) { + if !selectedTags.contains(where: { $0.id == existingTag.id }) { + selectedTags.append(existingTag) + } + } else { + // Create new tag + let newTag = Tag(context: viewContext) + newTag.name = trimmedText + newTag.slug = trimmedText.lowercased().replacingOccurrences(of: " ", with: "-") + newTag.createdAt = Date() + + do { + try viewContext.save() + selectedTags.append(newTag) + } catch { + print("Error creating tag: \(error)") + } + } + + tagSearchText = "" + } + + private func saveLink() { + let success: Bool + + if isEditing, let link = link { + success = linkViewModel.updateLink( + link, + alias: alias, + originalURL: selectedLinkType == .custom ? nil : originalURL, + text: selectedLinkType == .custom ? text : nil, + linkType: selectedLinkType, + description: linkDescription.isEmpty ? nil : linkDescription, + tags: selectedTags + ) + } else { + success = linkViewModel.createLink( + alias: alias, + originalURL: selectedLinkType == .custom ? nil : originalURL, + text: selectedLinkType == .custom ? text : nil, + linkType: selectedLinkType, + description: linkDescription.isEmpty ? nil : linkDescription, + tags: selectedTags + ) + } + + if success { + presentationMode.wrappedValue.dismiss() + } + } +} + +struct TagChip: View { + let tag: Tag + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 4) { + Text(tag.name) + .font(.caption) + + Button(action: onRemove) { + Image(systemName: "xmark") + .font(.caption2) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue.opacity(0.2)) + .foregroundColor(.blue) + .cornerRadius(6) + } +} + +#Preview { + LinkFormView(link: nil) + .environmentObject(LinkViewModel()) + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) +} diff --git a/mobile/Heygo/Views/LinkListView.swift b/mobile/Heygo/Views/LinkListView.swift new file mode 100644 index 0000000..7cf2ee1 --- /dev/null +++ b/mobile/Heygo/Views/LinkListView.swift @@ -0,0 +1,364 @@ +import SwiftUI + +struct LinkListView: View { + @EnvironmentObject var linkViewModel: LinkViewModel + @Environment(\.managedObjectContext) private var viewContext + @State private var showingAddLink = false + @State private var showingFilterSheet = false + @State private var selectedLink: Link? + + var body: some View { + NavigationView { + VStack { + // Test UI + Text("Heygo Links") + .font(.title) + .padding() + + // Search bar + SearchBar(text: $linkViewModel.searchText) + .padding(.horizontal) + + // Filter and sort bar + HStack { + Button(action: { + showingFilterSheet = true + }) { + HStack { + Image(systemName: "line.horizontal.3.decrease.circle") + Text("filter") + if linkViewModel.selectedTag != nil { + Text("•") + .foregroundColor(.blue) + } + } + } + + Spacer() + + Menu { + ForEach(LinkViewModel.SortOption.allCases, id: \.self) { option in + Button(action: { + linkViewModel.selectedSortOption = option + }) { + HStack { + Text(option.displayName) + if linkViewModel.selectedSortOption == option { + Image(systemName: "checkmark") + } + } + } + } + } label: { + HStack { + Image(systemName: "arrow.up.arrow.down") + Text(linkViewModel.selectedSortOption.displayName) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + + // Links list + if linkViewModel.isLoading { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else if linkViewModel.filteredLinks.isEmpty { + VStack { + Image(systemName: "link.badge.plus") + .font(.system(size: 60)) + .foregroundColor(.secondary) + Text("no_links_found") + .font(.title2) + .foregroundColor(.secondary) + Text("tap_plus_to_add_link") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + List { + ForEach(linkViewModel.filteredLinks) { link in + LinkRowView(link: link) + .onTapGesture { + selectedLink = link + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button("delete", role: .destructive) { + linkViewModel.deleteLink(link) + } + + Button("edit") { + selectedLink = link + showingAddLink = true + } + .tint(.blue) + } + } + } + .refreshable { + linkViewModel.fetchLinks() + } + } + } + .navigationTitle("heygo") + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + selectedLink = nil + showingAddLink = true + }) { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $showingAddLink) { + LinkFormView(link: selectedLink) + .environmentObject(linkViewModel) + } + .sheet(isPresented: $showingFilterSheet) { + FilterView() + .environmentObject(linkViewModel) + } + .sheet(item: $selectedLink) { link in + LinkDetailView(link: link) + .environmentObject(linkViewModel) + } + } + .onAppear { + if linkViewModel.links.isEmpty { + linkViewModel.fetchLinks() + } + } + .alert("Error", isPresented: .constant(linkViewModel.errorMessage != nil)) { + Button("OK") { + linkViewModel.errorMessage = nil + } + } message: { + Text(linkViewModel.errorMessage ?? "") + } + } +} + +struct LinkRowView: View { + let link: Link + @EnvironmentObject var linkViewModel: LinkViewModel + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + // Alias + HStack { + Text(link.alias) + .font(.headline) + .foregroundColor(.primary) + + Spacer() + + // Link type badge + Text(link.linkTypeEnum.displayName) + .font(.caption) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(linkTypeColor(link.linkTypeEnum)) + .foregroundColor(.white) + .cornerRadius(4) + } + + // URL or description + if let url = link.originalURL, !url.isEmpty { + Text(url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } else if let text = link.text, !text.isEmpty { + Text("custom_content") + .font(.caption) + .foregroundColor(.secondary) + .italic() + } + + // Description + if let description = link.linkDescription, !description.isEmpty { + Text(description) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + } + + // Tags and stats + HStack { + // Tags + if !link.sortedTags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(link.sortedTags, id: \.id) { tag in + Text(tag.name) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.2)) + .foregroundColor(.blue) + .cornerRadius(4) + } + } + } + } + + Spacer() + + // Click count + HStack(spacing: 2) { + Image(systemName: "hand.tap") + Text("\(link.clickCount)") + } + .font(.caption2) + .foregroundColor(.secondary) + } + } + + // Action button + Button(action: { + if link.isTemplate { + // Handle template parameters + // For now, just open without parameters + linkViewModel.openLink(link) + } else { + linkViewModel.openLink(link) + } + }) { + Image(systemName: link.isTemplate ? "arrow.right.circle.fill" : "arrow.up.right.circle.fill") + .foregroundColor(.blue) + .font(.title2) + } + } + .padding(.vertical, 4) + } + + private func linkTypeColor(_ linkType: LinkType) -> Color { + switch linkType { + case .link: + return .blue + case .custom: + return .green + case .template: + return .orange + } + } +} + +struct SearchBar: View { + @Binding var text: String + @State private var isEditing = false + + var body: some View { + HStack { + TextField("search_or_enter_url", text: $text) + .padding(7) + .padding(.horizontal, 25) + .background(Color(.systemGray6)) + .cornerRadius(8) + .overlay( + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.leading, 8) + + if isEditing { + Button(action: { + self.text = "" + }) { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.gray) + .padding(.trailing, 8) + } + } + } + ) + .onTapGesture { + self.isEditing = true + } + + if isEditing { + Button(action: { + self.isEditing = false + self.text = "" + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + }) { + Text("cancel") + } + .padding(.trailing, 10) + .transition(.move(edge: .trailing)) + .animation(.default, value: isEditing) + } + } + } +} + +struct FilterView: View { + @EnvironmentObject var linkViewModel: LinkViewModel + @Environment(\.presentationMode) var presentationMode + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)], + animation: .default) + private var tags: FetchedResults + + var body: some View { + NavigationView { + List { + Section("filters") { + HStack { + Text("selected_tag") + Spacer() + if let selectedTag = linkViewModel.selectedTag { + Text(selectedTag.name) + .foregroundColor(.secondary) + } else { + Text("all_tags") + .foregroundColor(.secondary) + } + } + .onTapGesture { + linkViewModel.selectedTag = nil + } + } + + Section("tags") { + ForEach(tags) { tag in + HStack { + Text(tag.name) + Spacer() + Text("\(tag.linkCount)") + .foregroundColor(.secondary) + if linkViewModel.selectedTag == tag { + Image(systemName: "checkmark") + .foregroundColor(.blue) + } + } + .onTapGesture { + linkViewModel.selectedTag = tag + presentationMode.wrappedValue.dismiss() + } + } + } + } + .navigationTitle("filter_links") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("done") { + presentationMode.wrappedValue.dismiss() + } + } + } + } + } +} + +#Preview { + LinkListView() + .environmentObject(LinkViewModel()) + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) +} diff --git a/mobile/Heygo/Views/SearchView.swift b/mobile/Heygo/Views/SearchView.swift new file mode 100644 index 0000000..cd2bc8b --- /dev/null +++ b/mobile/Heygo/Views/SearchView.swift @@ -0,0 +1,485 @@ +import SwiftUI + +struct SearchView: View { + @EnvironmentObject var linkViewModel: LinkViewModel + @State private var searchText: String = "" + @State private var searchHistory: [String] = [] + @State private var selectedFilters: Set = [] + @State private var selectedTags: Set = [] + @State private var showingAdvancedFilters = false + + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)], + animation: .default) + private var allTags: FetchedResults + + var filteredLinks: [Link] { + var links = linkViewModel.links + + // Apply search text filter + if !searchText.isEmpty { + links = links.filter { link in + link.alias.localizedCaseInsensitiveContains(searchText) || + (link.originalURL?.localizedCaseInsensitiveContains(searchText) ?? false) || + (link.linkDescription?.localizedCaseInsensitiveContains(searchText) ?? false) || + (link.text?.localizedCaseInsensitiveContains(searchText) ?? false) || + link.sortedTags.contains { tag in + tag.name.localizedCaseInsensitiveContains(searchText) + } + } + } + + // Apply type filters + if !selectedFilters.isEmpty { + links = links.filter { link in + selectedFilters.contains(link.linkTypeEnum) + } + } + + // Apply tag filters + if !selectedTags.isEmpty { + links = links.filter { link in + let linkTags = Set(link.sortedTags) + return !selectedTags.intersection(linkTags).isEmpty + } + } + + return links + } + + var body: some View { + NavigationView { + VStack { + // Search bar + HStack { + TextField("search_links", text: $searchText) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .onSubmit { + performSearch() + } + + Button("search") { + performSearch() + } + .disabled(searchText.isEmpty) + } + .padding(.horizontal) + + // Quick actions + HStack { + Button(action: { + showingAdvancedFilters.toggle() + }) { + HStack { + Image(systemName: "slider.horizontal.3") + Text("filters") + if !selectedFilters.isEmpty || !selectedTags.isEmpty { + Text("(\(selectedFilters.count + selectedTags.count))") + .foregroundColor(.blue) + } + } + } + + Spacer() + + if !searchText.isEmpty && filteredLinks.isEmpty { + Button("open_as_url") { + openSearchAsURL() + } + .foregroundColor(.blue) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + + // Advanced filters (collapsible) + if showingAdvancedFilters { + VStack { + // Type filters + VStack(alignment: .leading) { + Text("link_types") + .font(.headline) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(LinkType.allCases, id: \.self) { linkType in + FilterChip( + title: linkType.displayName, + isSelected: selectedFilters.contains(linkType), + onTap: { + if selectedFilters.contains(linkType) { + selectedFilters.remove(linkType) + } else { + selectedFilters.insert(linkType) + } + } + ) + } + } + .padding(.horizontal) + } + } + + // Tag filters + if !allTags.isEmpty { + VStack(alignment: .leading) { + Text("tags") + .font(.headline) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(Array(allTags.prefix(10)), id: \.id) { tag in + FilterChip( + title: tag.name, + isSelected: selectedTags.contains(tag), + onTap: { + if selectedTags.contains(tag) { + selectedTags.remove(tag) + } else { + selectedTags.insert(tag) + } + } + ) + } + } + .padding(.horizontal) + } + } + } + + // Clear filters button + if !selectedFilters.isEmpty || !selectedTags.isEmpty { + Button("clear_filters") { + selectedFilters.removeAll() + selectedTags.removeAll() + } + .foregroundColor(.red) + .padding(.horizontal) + } + } + .padding(.vertical) + .background(Color(.systemGray6)) + } + + // Results + if searchText.isEmpty && selectedFilters.isEmpty && selectedTags.isEmpty { + // Show search suggestions and history + SearchSuggestionsView( + searchHistory: searchHistory, + allTags: Array(allTags), + onSearchTap: { suggestion in + searchText = suggestion + performSearch() + } + ) + } else if filteredLinks.isEmpty { + // No results + VStack { + Image(systemName: "magnifyingglass") + .font(.system(size: 60)) + .foregroundColor(.secondary) + + Text("no_results_found") + .font(.title2) + .foregroundColor(.secondary) + + if !searchText.isEmpty { + Button("open_as_url") { + openSearchAsURL() + } + .padding(.top) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Results list + List { + ForEach(filteredLinks) { link in + SearchResultRow(link: link) + } + } + } + } + .navigationTitle("search") + .navigationBarTitleDisplayMode(.large) + } + .onAppear { + loadSearchHistory() + } + } + + private func performSearch() { + let trimmedText = searchText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedText.isEmpty else { return } + + // Add to search history + if !searchHistory.contains(trimmedText) { + searchHistory.insert(trimmedText, at: 0) + searchHistory = Array(searchHistory.prefix(10)) // Keep only last 10 + saveSearchHistory() + } + + // Hide keyboard + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + + private func openSearchAsURL() { + let urlString = searchText.hasPrefix("http") ? searchText : "https://\(searchText)" + + if let url = URL(string: urlString) { + UIApplication.shared.open(url) + } + } + + private func loadSearchHistory() { + if let data = UserDefaults.standard.data(forKey: "searchHistory"), + let history = try? JSONDecoder().decode([String].self, from: data) { + searchHistory = history + } + } + + private func saveSearchHistory() { + if let data = try? JSONEncoder().encode(searchHistory) { + UserDefaults.standard.set(data, forKey: "searchHistory") + } + } +} + +struct FilterChip: View { + let title: String + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + Text(title) + .font(.caption) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(isSelected ? Color.blue : Color(.systemGray5)) + .foregroundColor(isSelected ? .white : .primary) + .cornerRadius(16) + } + } +} + +struct SearchSuggestionsView: View { + let searchHistory: [String] + let allTags: [Tag] + let onSearchTap: (String) -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + // Search history + if !searchHistory.isEmpty { + VStack(alignment: .leading) { + Text("recent_searches") + .font(.headline) + .padding(.horizontal) + + ForEach(searchHistory, id: \.self) { search in + Button(action: { + onSearchTap(search) + }) { + HStack { + Image(systemName: "clock") + .foregroundColor(.secondary) + Text(search) + .foregroundColor(.primary) + Spacer() + Image(systemName: "arrow.up.left") + .foregroundColor(.secondary) + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + } + } + + // Popular tags + if !allTags.isEmpty { + VStack(alignment: .leading) { + Text("popular_tags") + .font(.headline) + .padding(.horizontal) + + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(allTags.prefix(10), id: \.id) { tag in + Button(action: { + onSearchTap(tag.name) + }) { + VStack { + Text(tag.name) + .font(.caption) + .fontWeight(.medium) + Text("\(tag.linkCount)") + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6)) + .cornerRadius(8) + } + .foregroundColor(.primary) + } + } + .padding(.horizontal) + } + } + } + + // Quick tips + VStack(alignment: .leading) { + Text("search_tips") + .font(.headline) + .padding(.horizontal) + + VStack(alignment: .leading, spacing: 8) { + SearchTip( + icon: "magnifyingglass", + title: "search_by_alias", + description: "search_by_alias_description" + ) + + SearchTip( + icon: "link", + title: "search_by_url", + description: "search_by_url_description" + ) + + SearchTip( + icon: "tag", + title: "search_by_tag", + description: "search_by_tag_description" + ) + } + .padding(.horizontal) + } + } + .padding(.vertical) + } + } +} + +struct SearchTip: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(alignment: .top) { + Image(systemName: icon) + .foregroundColor(.blue) + .frame(width: 20) + + VStack(alignment: .leading, spacing: 2) { + Text(NSLocalizedString(title, comment: "")) + .font(.caption) + .fontWeight(.medium) + Text(NSLocalizedString(description, comment: "")) + .font(.caption2) + .foregroundColor(.secondary) + } + } + } +} + +struct SearchResultRow: View { + let link: Link + @EnvironmentObject var linkViewModel: LinkViewModel + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + // Alias with highlighting + Text(link.alias) + .font(.headline) + .foregroundColor(.primary) + + // URL or content type + if let url = link.originalURL { + Text(url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } else { + Text("custom_content") + .font(.caption) + .foregroundColor(.secondary) + .italic() + } + + // Tags + if !link.sortedTags.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(link.sortedTags.prefix(3), id: \.id) { tag in + Text(tag.name) + .font(.caption2) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.2)) + .foregroundColor(.blue) + .cornerRadius(4) + } + } + } + } + + // Stats + HStack { + HStack(spacing: 2) { + Image(systemName: "hand.tap") + Text("\(link.clickCount)") + } + .font(.caption2) + .foregroundColor(.secondary) + + Spacer() + + Text(link.linkTypeEnum.displayName) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(linkTypeColor(link.linkTypeEnum)) + .foregroundColor(.white) + .cornerRadius(4) + } + } + + Spacer() + + Button(action: { + linkViewModel.openLink(link) + }) { + Image(systemName: "arrow.up.right.circle.fill") + .foregroundColor(.blue) + .font(.title2) + } + } + .padding(.vertical, 4) + } + + private func linkTypeColor(_ linkType: LinkType) -> Color { + switch linkType { + case .link: + return .blue + case .custom: + return .green + case .template: + return .orange + } + } +} + +#Preview { + SearchView() + .environmentObject(LinkViewModel()) + .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) +} diff --git a/mobile/Heygo/Views/SimpleTestView.swift b/mobile/Heygo/Views/SimpleTestView.swift new file mode 100644 index 0000000..e69de29 diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..e8a1c50 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,199 @@ +# Heygo iOS App (黑狗) + +A comprehensive iOS link management application that replicates the functionality of the Django-based link management system. Built with SwiftUI, Core Data, and modern iOS development practices. + +## Features + +### Core Link Management +- ✅ Create, edit, and delete links with unique aliases +- ✅ Support for regular links, custom markdown content, and template URLs +- ✅ URL template processing with parameter placeholders (`{param,default=value}`) +- ✅ Real-time alias validation and duplicate prevention +- ✅ Click tracking and analytics + +### Advanced Features +- ✅ Tag-based organization with auto-completion +- ✅ Powerful search with fuzzy matching +- ✅ Comprehensive analytics with charts +- ✅ Markdown rendering for custom content +- ✅ Multi-language support (English and Chinese) +- ✅ Dark mode support +- ✅ Accessibility features + +### Analytics & Insights +- ✅ Click tracking with timestamps +- ✅ Visual charts showing usage trends +- ✅ Top performing links analysis +- ✅ Link type distribution +- ✅ Time-based filtering (1 week to all time) +- ✅ Export capabilities + +### User Experience +- ✅ Clean, modern SwiftUI interface +- ✅ Intuitive navigation with tab-based structure +- ✅ Pull-to-refresh functionality +- ✅ Swipe actions for quick operations +- ✅ Real-time search and filtering +- ✅ Template parameter input dialogs + +## Architecture + +### Design Pattern +- **MVVM** (Model-View-ViewModel) with Combine framework +- **Core Data** for persistent storage +- **SwiftUI** for modern, declarative UI + +### Key Components + +#### Models +- `Link` - Core link entity with alias, URL, type, and metadata +- `Tag` - Categorization system with many-to-many relationship +- `ClickLog` - Track individual clicks with timestamps +- `ChangeLog` - Audit trail for link modifications + +#### ViewModels +- `LinkViewModel` - Manages link CRUD operations and business logic +- `TagManager` - Handles tag creation, search, and suggestions + +#### Views +- `LinkListView` - Main interface showing all links with search/filter +- `LinkFormView` - Create/edit links with validation +- `LinkDetailView` - Comprehensive link information and analytics +- `SearchView` - Advanced search with filters and suggestions +- `AnalyticsView` - Charts and insights dashboard + +#### Utilities +- `URLTemplateProcessor` - Parse and process template URLs +- `MarkdownRenderer` - Render markdown content to AttributedString +- `AnalyticsCalculator` - Generate chart data and statistics +- `PersistenceController` - Core Data stack management + +## Technology Stack + +### Frameworks & Libraries +- **SwiftUI** - Modern declarative UI framework +- **Core Data** - Local data persistence +- **Charts** - Native charting framework (iOS 16+) +- **Combine** - Reactive programming framework +- **Foundation** - Core system services + +### iOS Features Used +- **Universal App** - Supports iPhone and iPad +- **Internationalization** - English and Chinese localization +- **Accessibility** - VoiceOver and accessibility features +- **URL Schemes** - Handle external link opening +- **Share Extensions** - (Future enhancement) + +## Project Structure + +``` +Heygo/ +├── HeygoApp.swift # App entry point +├── ContentView.swift # Main tab navigation +├── Views/ # SwiftUI views +│ ├── LinkListView.swift # Main link list interface +│ ├── LinkFormView.swift # Link creation/editing +│ ├── LinkDetailView.swift # Detailed link information +│ ├── SearchView.swift # Advanced search interface +│ └── AnalyticsView.swift # Analytics dashboard +├── ViewModels/ # Business logic +│ └── LinkViewModel.swift # Main view model +├── Models/ # Core Data entities +│ ├── CoreDataModel.xcdatamodeld +│ ├── PersistenceController.swift +│ └── [Entity]+CoreDataClass.swift +├── Utils/ # Utility classes +│ ├── URLTemplateProcessor.swift +│ ├── TagManager.swift +│ ├── MarkdownRenderer.swift +│ └── AnalyticsCalculator.swift +└── Resources/ # Assets and localization + ├── Assets.xcassets + ├── Info.plist + ├── en.lproj/ + └── zh-Hans.lproj/ +``` + +## Requirements + +- **iOS 17.0+** (for Charts framework and latest SwiftUI features) +- **Xcode 15.0+** +- **Swift 5.9+** + +## Installation & Setup + +1. Open `Heygo.xcodeproj` in Xcode +2. Select your development team in project settings +3. Choose your target device or simulator +4. Build and run (⌘+R) + +## Key Features Implementation + +### URL Template System +The app supports dynamic URL templates with parameters: +``` +https://google.com/search?q={query} +https://github.com/{user}/{repo} +https://maps.apple.com/?q={location,default=New York} +``` + +### Analytics Engine +Comprehensive analytics with multiple time ranges and metrics: +- Click trends over time +- Top performing links +- Link type distribution +- Activity timeline + +### Tag Management +Intelligent tag system with: +- Auto-completion based on existing tags +- Tag suggestions based on URL patterns +- Bulk tag operations +- Usage statistics + +### Search & Filter +Advanced search capabilities: +- Fuzzy text matching +- Filter by link type and tags +- Search history +- Quick URL opening + +## Performance Considerations + +- **Lazy Loading** - Large link collections loaded incrementally +- **Core Data Optimization** - Proper indexing and fetch request optimization +- **Memory Management** - Efficient image and data caching +- **Background Processing** - Analytics calculations performed off main thread + +## Accessibility + +- **VoiceOver Support** - All UI elements properly labeled +- **Dynamic Type** - Respects user's text size preferences +- **High Contrast** - Supports accessibility color schemes +- **Keyboard Navigation** - Full keyboard accessibility + +## Localization + +Currently supports: +- **English (en)** +- **Simplified Chinese (zh-Hans)** + +Easy to extend for additional languages by adding new `.lproj` directories. + +## Future Enhancements + +- [ ] iCloud sync capabilities +- [ ] Share extension for adding links from other apps +- [ ] Widgets for quick access to popular links +- [ ] Shortcuts app integration +- [ ] Export/import functionality +- [ ] QR code generation for links +- [ ] Backup and restore features + +## Contributing + +This iOS app is designed to be a faithful replica of the Django web application. When adding new features, ensure they maintain parity with the web version while taking advantage of iOS-specific capabilities. + +## License + +[Add your license information here]