feat: add Stow prototype iOS app and Gin backend

This commit is contained in:
2026-10-02 21:23:31 +08:00
commit fe72c1ebd2
30 changed files with 3252 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Stow(收好)iOS
## 启动与调试
1. 安装完整 Xcode,并在首次启动时下载 iOS 平台组件。
2. 双击 `Stow.xcodeproj`,在顶部选择 **Stow** scheme 和一台 iPhone 模拟器(iOS 17+)。
3. 按 **⌘R** 编译运行。若使用真机,先在 Target → Signing & Capabilities 选择自己的 Team,并把 `com.example.Stow` 改为自己的 Bundle Identifier。
4. 打开 App 的「我的 → 加入演示收藏」,检查首页、分类、搜索和详情。再点首页右上角 `+`,分别导入文本、链接、照片;退出并重新启动 App,确认内容仍在。
5. 需要单步调试时,可在 `LocalImportService.saveImage`(图片与 OCR)、`ImportSheet`(选图入口)或 `SearchView.results`(搜索过滤)设断点,再按 **⌘R**。
工程名和代码名为 **Stow**,主屏幕显示名为 **收好**。
当前实现的是第一段本地闭环:SwiftUI 四个 Tab、SwiftData 持久化、手动选择图片、最近截图扫描与选择、Vision 本地 OCR、保存链接或文本、分类浏览、关键词搜索,以及详情编辑和删除。导入图片会压缩到最长边 1800 px 后保存在 App 的 Documents 目录;重复图片按 SHA-256 识别,OCR 失败时仍会保存图片。
当前尚未接入照片库增量扫描游标、Share Extension、Go API、云端 AI、同步、账号和订阅。根目录的 `Stow.html` 是产品交互原型,iOS App 会逐步替换其中的模拟流程。
本环境只有 Xcode Command Line Tools,无法运行 `xcodebuild` 或 iOS 模拟器。已检查 Swift 源码语法与 Xcode 工程文件格式,仍需在完整 Xcode 环境中进行首次编译和模拟器验收。
+45
View File
@@ -0,0 +1,45 @@
import SwiftData
import SwiftUI
private enum StowTab: Hashable {
case home, collection, search, profile
}
struct RootView: View {
@Query(sort: \SavedItem.createdAt, order: .reverse) private var items: [SavedItem]
@State private var selectedTab: StowTab = .home
@State private var showingImport = false
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeView(items: items, openImport: { showingImport = true }, openSearch: { selectedTab = .search })
}
.tabItem { Label("首页", systemImage: "house") }
.tag(StowTab.home)
NavigationStack {
CollectionView(items: items, openImport: { showingImport = true })
}
.tabItem { Label("收藏", systemImage: "square.stack") }
.tag(StowTab.collection)
NavigationStack {
SearchView(items: items)
}
.tabItem { Label("搜索", systemImage: "magnifyingglass") }
.tag(StowTab.search)
NavigationStack {
ProfileView(itemCount: items.count)
}
.tabItem { Label("我的", systemImage: "person.crop.circle") }
.tag(StowTab.profile)
}
.tint(StowStyle.forest)
.sheet(isPresented: $showingImport) {
ImportSheet()
.presentationDetents([.medium, .large])
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import SwiftData
import SwiftUI
@main
struct StowApp: App {
var body: some Scene {
WindowGroup {
RootView()
}
.modelContainer(for: SavedItem.self)
}
}
@@ -0,0 +1,87 @@
import SwiftUI
enum StowStyle {
static let forest = Color(red: 0.16, green: 0.39, blue: 0.27)
static let ink = Color(red: 0.13, green: 0.23, blue: 0.17)
static let muted = Color(red: 0.47, green: 0.57, blue: 0.49)
static let canvas = Color(red: 0.975, green: 0.973, blue: 0.955)
static let paleGreen = Color(red: 0.88, green: 0.94, blue: 0.87)
}
struct StowSectionHeader: View {
let title: String
var body: some View {
Text(title)
.font(.system(size: 19, weight: .bold))
.foregroundStyle(StowStyle.ink)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct ItemArtwork: View {
let item: SavedItem
var size: CGFloat = 70
var body: some View {
Group {
if let url = LocalImportService.imageURL(for: item),
let image = UIImage(contentsOfFile: url.path) {
Image(uiImage: image)
.resizable()
.scaledToFill()
} else {
ZStack {
LinearGradient(
colors: [StowStyle.paleGreen, Color(red: 0.66, green: 0.78, blue: 0.68)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: ItemCategory(rawValue: item.primaryType)?.symbol ?? "square.stack")
.font(.system(size: size * 0.33, weight: .light))
.foregroundStyle(StowStyle.forest)
}
}
}
.frame(width: size, height: size)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 13))
}
}
struct ItemRow: View {
let item: SavedItem
var body: some View {
HStack(spacing: 13) {
ItemArtwork(item: item)
VStack(alignment: .leading, spacing: 5) {
Text(item.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(StowStyle.ink)
.lineLimit(1)
Text(item.summary.isEmpty ? ItemCategory(rawValue: item.primaryType)?.title ?? "收藏" : item.summary)
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
.lineLimit(1)
Text("\(sourceName) · \(item.createdAt.formatted(date: .abbreviated, time: .omitted))")
.font(.system(size: 11))
.foregroundStyle(StowStyle.muted.opacity(0.8))
}
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
.foregroundStyle(StowStyle.muted.opacity(0.6))
}
.padding(9)
.background(.white, in: RoundedRectangle(cornerRadius: 17))
}
private var sourceName: String {
switch item.sourceType {
case "manual_photo", "screenshot": "图片"
case "manual_url", "share_url": "链接"
default: "文本"
}
}
}
@@ -0,0 +1,87 @@
import SwiftUI
struct CollectionView: View {
let items: [SavedItem]
var initialGroup: String? = nil
let openImport: () -> Void
@State private var category: ItemCategory = .all
private var filteredItems: [SavedItem] {
items.filter { item in
(category == .all || item.primaryType == category.rawValue) &&
(initialGroup == nil || item.tags.contains(initialGroup!))
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 5) {
Text("YOUR COLLECTION")
.font(.system(size: 10, weight: .bold))
.tracking(2)
.foregroundStyle(StowStyle.muted)
Text(initialGroup == nil ? "我的收藏" : groupTitle)
.font(.system(size: 29, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
Spacer()
Button(action: openImport) {
Image(systemName: "plus")
.frame(width: 38, height: 38)
.background(.white, in: Circle())
}
.accessibilityLabel("添加收藏")
}
Text(initialGroup == nil ? "一共收好了 \(items.count) 条内容。" : "收好自动放在一起的内容。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 7) {
ForEach(ItemCategory.allCases) { option in
Button { category = option } label: {
Text(option.title)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(category == option ? Color.white : StowStyle.muted)
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(category == option ? StowStyle.forest : Color.white, in: Capsule())
}
}
}
}
Text("\(filteredItems.count) 条收藏")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
if filteredItems.isEmpty {
ContentUnavailableView("这里还没有内容", systemImage: "square.stack", description: Text("试试其他分类,或添加一条新收藏。"))
} else {
LazyVStack(spacing: 10) {
ForEach(filteredItems) { item in
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
.buttonStyle(.plain)
}
}
}
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(initialGroup == nil ? .hidden : .visible, for: .navigationBar)
}
private var groupTitle: String {
switch initialGroup {
case "想吃": "想吃的"
case "想去": "想去的"
case "想买": "想买的"
default: "值得再看"
}
}
}
@@ -0,0 +1,130 @@
import SwiftUI
struct HomeView: View {
let items: [SavedItem]
let openImport: () -> Void
let openSearch: () -> Void
private let groups: [(String, String, String, Color)] = [
("想吃的", "想吃", "fork.knife", Color(red: 0.96, green: 0.89, blue: 0.83)),
("想去的", "想去", "mappin", Color(red: 0.87, green: 0.93, blue: 0.87)),
("想买的", "想买", "bag", Color(red: 0.92, green: 0.90, blue: 0.95)),
("值得再看", "值得再看", "books.vertical", Color(red: 0.96, green: 0.93, blue: 0.83))
]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
HStack {
Label("收好", systemImage: "square.stack.3d.up.fill")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(StowStyle.forest)
Spacer()
Button(action: openImport) {
Image(systemName: "plus")
.font(.system(size: 17, weight: .semibold))
.frame(width: 38, height: 38)
.background(.white, in: Circle())
}
.accessibilityLabel("添加收藏")
}
VStack(alignment: .leading, spacing: 8) {
Text(Date.now.formatted(.dateTime.month(.wide).day().weekday(.wide)))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(StowStyle.muted)
Text("看过的好东西,\n都在这里。")
.font(.system(size: 31, weight: .bold))
.tracking(-1)
.foregroundStyle(StowStyle.ink)
Text("顺手收好,想找时就找得到。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
}
Button(action: openSearch) {
HStack {
Image(systemName: "magnifyingglass")
.font(.system(size: 19))
.foregroundStyle(StowStyle.forest)
Text("找一下之前收藏过的东西…")
.font(.system(size: 14))
.foregroundStyle(StowStyle.muted)
Spacer()
}
.padding(17)
.background(.white, in: RoundedRectangle(cornerRadius: 15))
}
NavigationLink {
ScreenshotScanView()
} label: {
HStack(spacing: 14) {
Image(systemName: "photo.stack")
.font(.system(size: 27))
.frame(width: 48, height: 48)
.background(.white.opacity(0.17), in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 5) {
Text("有新截图想整理?")
.font(.system(size: 16, weight: .bold))
Text("查看最近截图,只导入你选中的内容")
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.8))
}
Spacer()
Image(systemName: "arrow.right")
}
.foregroundStyle(.white)
.padding(17)
.background(StowStyle.forest, in: RoundedRectangle(cornerRadius: 18))
}
VStack(alignment: .leading, spacing: 14) {
StowSectionHeader(title: "为你整理")
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 10) {
ForEach(groups.indices, id: \.self) { index in
let group = groups[index]
NavigationLink {
CollectionView(items: items, initialGroup: group.1, openImport: openImport)
} label: {
VStack(alignment: .leading, spacing: 11) {
HStack {
Image(systemName: group.2)
.font(.system(size: 22))
Spacer()
Text("\(items.filter { $0.tags.contains(group.1) }.count)")
.font(.system(size: 18, weight: .bold))
}
Text(group.0)
.font(.system(size: 15, weight: .semibold))
}
.foregroundStyle(StowStyle.ink)
.padding(17)
.frame(maxWidth: .infinity, minHeight: 112, alignment: .leading)
.background(group.3, in: RoundedRectangle(cornerRadius: 17))
}
}
}
}
VStack(alignment: .leading, spacing: 12) {
StowSectionHeader(title: "刚刚收好")
if items.isEmpty {
Button(action: openImport) {
ContentUnavailableView("还没有收藏", systemImage: "square.and.arrow.down", description: Text("先添加一张图片、一条链接或一段文字。"))
}
.buttonStyle(.plain)
} else {
ForEach(items.prefix(5)) { item in
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
.buttonStyle(.plain)
}
}
}
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(.hidden, for: .navigationBar)
}
}
@@ -0,0 +1,122 @@
import PhotosUI
import SwiftData
import SwiftUI
struct ImportSheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var selectedPhoto: PhotosPickerItem?
@State private var mode: ImportMode = .choices
@State private var text = ""
@State private var url = ""
@State private var isWorking = false
@State private var errorMessage: String?
private enum ImportMode { case choices, text, url }
var body: some View {
NavigationStack {
Group {
switch mode {
case .choices: choices
case .text: textForm
case .url: urlForm
}
}
.navigationTitle("把好东西收进来")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) { Button("关闭") { dismiss() } }
}
}
.tint(StowStyle.forest)
.onChange(of: selectedPhoto) { _, photo in
guard let photo else { return }
Task {
isWorking = true
defer { isWorking = false }
do {
guard let data = try await photo.loadTransferable(type: Data.self) else { throw ImportError.invalidImage }
try await LocalImportService.saveImage(data, in: context)
dismiss()
} catch { errorMessage = error.localizedDescription }
}
}
.alert("无法收好", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("知道了", role: .cancel) { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
.overlay { if isWorking { ProgressView("正在识别并保存…").padding(24).background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) } }
}
private var choices: some View {
VStack(alignment: .leading, spacing: 13) {
Text("选一种最顺手的方式,剩下的交给收好。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
.padding(.bottom, 10)
PhotosPicker(selection: $selectedPhoto, matching: .images) {
choice("选择截图或照片", "从相册中手动选取", "photo.on.rectangle")
}
NavigationLink {
ScreenshotScanView()
} label: {
choice("整理最近截图", "选取最近的截图批量收好", "rectangle.stack")
}
Button { mode = .url } label: { choice("保存链接", "文章、商品、攻略都可以", "link") }
Button { mode = .text } label: { choice("保存文本", "把一段文字收起来", "text.alignleft") }
Spacer()
Text("目前只处理你主动选择的内容,数据保存在本机。")
.font(.system(size: 11))
.foregroundStyle(StowStyle.muted)
}
.padding(22)
.background(StowStyle.canvas)
}
private var textForm: some View {
Form {
Section("内容") {
TextField("粘贴想记住的内容…", text: $text, axis: .vertical)
.lineLimit(5...10)
}
Section {
Button("收好这段文字") { save { try LocalImportService.saveText(text, in: context) } }
.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
private var urlForm: some View {
Form {
Section("链接地址") { TextField("https://example.com", text: $url).textInputAutocapitalization(.never).keyboardType(.URL).autocorrectionDisabled() }
Section {
Button("收好这个链接") { save { try LocalImportService.saveURL(url, in: context) } }
.disabled(url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
private func choice(_ title: String, _ subtitle: String, _ symbol: String) -> some View {
HStack(spacing: 14) {
Image(systemName: symbol)
.font(.system(size: 20))
.frame(width: 42, height: 42)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 12))
VStack(alignment: .leading, spacing: 3) {
Text(title).font(.system(size: 15, weight: .semibold))
Text(subtitle).font(.system(size: 12)).foregroundStyle(StowStyle.muted)
}
Spacer()
Image(systemName: "chevron.right").font(.system(size: 11))
}
.foregroundStyle(StowStyle.ink)
.padding(14)
.background(.white, in: RoundedRectangle(cornerRadius: 15))
}
private func save(_ operation: () throws -> Void) {
do { try operation(); dismiss() }
catch { errorMessage = error.localizedDescription }
}
}
@@ -0,0 +1,210 @@
import Photos
import SwiftData
import SwiftUI
import UIKit
struct ScreenshotScanView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
@State private var candidates: [ScreenshotCandidate] = []
@State private var selectedIDs: Set<String> = []
@State private var phase: Phase = .introduction
@State private var importedCount = 0
@State private var failedCount = 0
private enum Phase { case introduction, loading, selection, importing, complete, denied }
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
switch phase {
case .introduction: introduction
case .loading: loading
case .selection: selection
case .importing: importing
case .complete: completion
case .denied: denied
}
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .leading)
}
.background(StowStyle.canvas)
.navigationTitle("整理截图")
.navigationBarTitleDisplayMode(.inline)
}
private var introduction: some View {
VStack(alignment: .leading, spacing: 16) {
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 39))
.foregroundStyle(StowStyle.forest)
.padding(20)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 20))
Text("帮你整理最近的截图")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(StowStyle.ink)
Text("收好会找出照片库里的截图,只展示最近 30 张,由你确认哪些需要导入。你的内容不会公开。")
.font(.system(size: 14))
.foregroundStyle(StowStyle.muted)
.lineSpacing(5)
Button("查看最近截图") { Task { await loadScreenshots() } }
.buttonStyle(.borderedProminent)
.tint(StowStyle.forest)
Text("也可以返回,使用系统照片选择器手动挑选。")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
.padding(.top, 34)
}
private var loading: some View {
ProgressView("正在查找截图…")
.frame(maxWidth: .infinity, minHeight: 220)
}
private var selection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("发现 \(candidates.count) 张最近的截图")
.font(.system(size: 23, weight: .bold))
.foregroundStyle(StowStyle.ink)
Text("已选择 \(selectedIDs.count) 张 · 只处理你确认的内容")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
if candidates.isEmpty {
ContentUnavailableView("没有发现截图", systemImage: "photo", description: Text("你仍然可以使用手动选择照片。"))
} else {
LazyVGrid(columns: [.init(.flexible()), .init(.flexible())], spacing: 12) {
ForEach(candidates) { candidate in
Button { toggle(candidate.id) } label: {
ScreenshotTile(candidate: candidate, selected: selectedIDs.contains(candidate.id))
}
.buttonStyle(.plain)
}
}
Button("收好这 \(selectedIDs.count) 张") { Task { await importSelected() } }
.buttonStyle(.borderedProminent)
.tint(StowStyle.forest)
.disabled(selectedIDs.isEmpty)
.frame(maxWidth: .infinity)
}
}
}
private var importing: some View {
VStack(spacing: 14) {
ProgressView()
Text("正在识别并保存截图…")
.font(.system(size: 15, weight: .semibold))
Text("已收好 \(importedCount) 张。OCR 失败时,图片仍会保存。")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
.frame(maxWidth: .infinity, minHeight: 240)
}
private var completion: some View {
VStack(alignment: .leading, spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 52))
.foregroundStyle(StowStyle.forest)
Text("\(importedCount) 张截图已经收好")
.font(.system(size: 25, weight: .bold))
if failedCount > 0 {
Text("另有 \(failedCount) 张未能读取,可以稍后重试。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
}
Button("完成") { dismiss() }
.buttonStyle(.borderedProminent)
.tint(StowStyle.forest)
}
.padding(.top, 40)
}
private var denied: some View {
VStack(alignment: .leading, spacing: 16) {
Text("还不能查看截图")
.font(.system(size: 25, weight: .bold))
Text("请到系统设置中允许收好访问照片,或返回使用手动选择照片。")
.font(.system(size: 14))
.foregroundStyle(StowStyle.muted)
if let settings = URL(string: UIApplication.openSettingsURLString) {
Link("打开设置", destination: settings)
}
}
.padding(.top, 40)
}
private func toggle(_ id: String) {
if selectedIDs.contains(id) { selectedIDs.remove(id) }
else { selectedIDs.insert(id) }
}
private func loadScreenshots() async {
phase = .loading
let access = await ScreenshotLibraryService.requestAccess()
guard access == .authorized || access == .limited else { phase = .denied; return }
do {
let recent = ScreenshotLibraryService.recentScreenshots()
candidates = try recent.filter { try !LocalImportService.hasImported($0.id, in: context) }
selectedIDs = Set(candidates.prefix(5).map(\.id))
phase = .selection
} catch {
phase = .denied
}
}
private func importSelected() async {
phase = .importing
importedCount = 0
failedCount = 0
for candidate in candidates where selectedIDs.contains(candidate.id) {
do {
let data = try await ScreenshotLibraryService.imageData(for: candidate.asset)
let saved = try await LocalImportService.saveImage(
data,
sourceType: "screenshot",
photoAssetIdentifier: candidate.id,
capturedAt: candidate.capturedAt,
in: context
)
if saved { importedCount += 1 }
} catch {
failedCount += 1
}
}
phase = .complete
}
}
private struct ScreenshotTile: View {
let candidate: ScreenshotCandidate
let selected: Bool
@State private var thumbnail: UIImage?
var body: some View {
ZStack(alignment: .topTrailing) {
Group {
if let thumbnail {
Image(uiImage: thumbnail).resizable().scaledToFill()
} else {
Rectangle().fill(StowStyle.paleGreen)
.overlay { Image(systemName: "photo").foregroundStyle(StowStyle.muted) }
}
}
.frame(height: 170)
.frame(maxWidth: .infinity)
.clipped()
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 24))
.foregroundStyle(selected ? StowStyle.forest : .white)
.padding(9)
}
.clipShape(RoundedRectangle(cornerRadius: 14))
.overlay { RoundedRectangle(cornerRadius: 14).stroke(selected ? StowStyle.forest : .clear, lineWidth: 2) }
.task(id: candidate.id) { thumbnail = await ScreenshotLibraryService.thumbnail(for: candidate.asset) }
}
}
@@ -0,0 +1,163 @@
import SwiftData
import SwiftUI
struct ItemDetailView: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let item: SavedItem
@State private var showingEdit = false
@State private var showingDelete = false
@State private var errorMessage: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 23) {
ItemArtwork(item: item, size: 270)
.frame(maxWidth: .infinity)
VStack(alignment: .leading, spacing: 7) {
Text((ItemCategory(rawValue: item.primaryType)?.title ?? "其他") + " · " + item.createdAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: 11, weight: .bold))
.foregroundStyle(StowStyle.muted)
Text(item.title)
.font(.system(size: 27, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
VStack(alignment: .leading, spacing: 10) {
Label("收好帮你整理", systemImage: "sparkle")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(StowStyle.forest)
Text(item.summary.isEmpty ? "这条内容已收好。" : item.summary)
.font(.system(size: 14))
.foregroundStyle(StowStyle.ink)
.lineSpacing(5)
}
.padding(18)
.frame(maxWidth: .infinity, alignment: .leading)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 17))
if !item.tags.isEmpty {
VStack(alignment: .leading, spacing: 12) {
StowSectionHeader(title: "标签")
FlowTags(tags: item.tags)
}
}
VStack(alignment: .leading, spacing: 10) {
StowSectionHeader(title: "原始内容")
Text(item.sourceType == "manual_photo" ? "手动选择的图片" : item.sourceType == "manual_url" ? "手动保存的链接" : "手动保存的文本")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
if let sourceURL = item.sourceURL, let url = URL(string: sourceURL) {
Link(destination: url) {
Label("打开原链接", systemImage: "arrow.up.right")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(StowStyle.forest)
}
}
}
HStack(spacing: 10) {
Button { showingEdit = true } label: {
Label("编辑整理结果", systemImage: "pencil")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
Button(role: .destructive) { showingDelete = true } label: {
Image(systemName: "trash")
}
.buttonStyle(.bordered)
.accessibilityLabel("删除收藏")
}
.padding(.top, 10)
}
.padding(22)
}
.background(StowStyle.canvas)
.navigationTitle("收藏详情")
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showingEdit) { EditItemSheet(item: item) }
.confirmationDialog("删除这条收藏?", isPresented: $showingDelete) {
Button("删除", role: .destructive) {
do { try LocalImportService.delete(item, in: context); dismiss() }
catch { errorMessage = error.localizedDescription }
}
}
.alert("操作失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("知道了", role: .cancel) { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
}
private struct FlowTags: View {
let tags: [String]
var body: some View {
ViewThatFits(in: .horizontal) {
HStack { tagViews }
VStack(alignment: .leading) { tagViews }
}
}
@ViewBuilder private var tagViews: some View {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.system(size: 12))
.foregroundStyle(StowStyle.forest)
.padding(.horizontal, 11)
.padding(.vertical, 8)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 8))
}
}
}
private struct EditItemSheet: View {
@Environment(\.modelContext) private var context
@Environment(\.dismiss) private var dismiss
let item: SavedItem
@State private var title: String
@State private var summary: String
@State private var tags: String
@State private var errorMessage: String?
init(item: SavedItem) {
self.item = item
_title = State(initialValue: item.title)
_summary = State(initialValue: item.summary)
_tags = State(initialValue: item.tags.joined(separator: ","))
}
var body: some View {
NavigationStack {
Form {
TextField("标题", text: $title)
TextField("摘要", text: $summary, axis: .vertical)
.lineLimit(3...6)
TextField("标签,用逗号分隔", text: $tags)
}
.navigationTitle("编辑收藏")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
errorMessage = "标题不能为空。"; return
}
item.title = title.trimmingCharacters(in: .whitespacesAndNewlines)
item.summary = summary.trimmingCharacters(in: .whitespacesAndNewlines)
item.tags = tags.split(whereSeparator: { $0 == "," || $0 == "," }).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
do { try context.save(); dismiss() }
catch { errorMessage = error.localizedDescription }
}
}
}
.alert("无法保存", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("知道了", role: .cancel) { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
}
}
@@ -0,0 +1,98 @@
import SwiftData
import SwiftUI
struct ProfileView: View {
@Environment(\.modelContext) private var context
let itemCount: Int
@State private var debugMessage: String?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 23) {
VStack(alignment: .leading, spacing: 5) {
Text("YOUR SPACE")
.font(.system(size: 10, weight: .bold))
.tracking(2)
.foregroundStyle(StowStyle.muted)
Text("我的")
.font(.system(size: 29, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
HStack(spacing: 14) {
Image(systemName: "square.stack.3d.up.fill")
.font(.system(size: 25))
.foregroundStyle(StowStyle.forest)
.frame(width: 50, height: 50)
.background(.white.opacity(0.6), in: RoundedRectangle(cornerRadius: 15))
VStack(alignment: .leading, spacing: 4) {
Text("好东西,都收好了")
.font(.system(size: 15, weight: .semibold))
Text("本地体验 · 无需登录")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(18)
.background(StowStyle.paleGreen, in: RoundedRectangle(cornerRadius: 17))
HStack {
VStack(alignment: .leading) {
Text("\(itemCount)").font(.system(size: 26, weight: .bold)).foregroundStyle(StowStyle.forest)
Text("已收好").font(.system(size: 12)).foregroundStyle(StowStyle.muted)
}
Spacer()
Text("当前内容仅保存在这台设备")
.font(.system(size: 12))
.foregroundStyle(StowStyle.muted)
}
.padding(18)
.background(.white, in: RoundedRectangle(cornerRadius: 16))
VStack(alignment: .leading, spacing: 8) {
StowSectionHeader(title: "关于数据")
Text("你主动保存的图片会存入 App 的本地文件夹,收藏信息保存在本地数据库。当前版本尚未接入账号、同步和云端 AI。")
.font(.system(size: 13))
.foregroundStyle(StowStyle.muted)
.lineSpacing(5)
}
#if DEBUG
VStack(alignment: .leading, spacing: 10) {
StowSectionHeader(title: "开发调试")
Button("加入演示收藏") { addDebugItems() }
.buttonStyle(.bordered)
Text("用于检查首页、分类、搜索和详情。正式构建不会显示此入口。")
.font(.system(size: 11))
.foregroundStyle(StowStyle.muted)
}
#endif
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(.hidden, for: .navigationBar)
.alert("调试数据", isPresented: Binding(get: { debugMessage != nil }, set: { if !$0 { debugMessage = nil } })) {
Button("知道了", role: .cancel) { debugMessage = nil }
} message: { Text(debugMessage ?? "") }
}
#if DEBUG
private func addDebugItems() {
do {
if try context.fetch(FetchDescriptor<SavedItem>()).contains(where: { $0.sourceType == "debug" }) {
debugMessage = "演示收藏已经加入。"
return
}
context.insert(SavedItem(title: "上海静安的咖啡店", summary: "周末想去的咖啡店,推荐 Dirty。", primaryType: "place", sourceType: "debug", tags: ["上海", "静安", "咖啡", "想去", "想吃"]))
context.insert(SavedItem(title: "黑色机械键盘", summary: "收藏的一款黑色机械键盘。", primaryType: "product", sourceType: "debug", tags: ["黑色", "键盘", "想买"]))
context.insert(SavedItem(title: "杭州旅行攻略", summary: "周末两日游,包含西湖和灵隐寺。", primaryType: "content", sourceType: "debug", tags: ["杭州", "旅行", "值得再看"]))
try context.save()
debugMessage = "已加入 3 条演示收藏。"
} catch {
debugMessage = error.localizedDescription
}
}
#endif
}
@@ -0,0 +1,94 @@
import SwiftUI
struct SearchView: View {
let items: [SavedItem]
@State private var query = ""
private let suggestions = ["黑色键盘", "上海的咖啡店", "杭州旅行攻略", "AI Agent 文章"]
private var results: [SavedItem] {
let value = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard !value.isEmpty else { return [] }
let knownTerms = ["黑色", "键盘", "上海", "咖啡", "杭州", "旅行", "攻略", "ai agent", "文章", "餐厅", "展览", "商品"]
let recognized = knownTerms.filter(value.contains)
let terms = recognized.isEmpty
? value.split(whereSeparator: { $0.isWhitespace || $0.isPunctuation }).map(String.init)
: recognized
return items.filter { item in
if value.contains("上个月") {
let daysAgo = Date.now.timeIntervalSince(item.createdAt) / 86400
guard (28.0...62.0).contains(daysAgo) else { return false }
}
let text = ([item.title, item.summary, item.ocrText ?? ""] + item.tags)
.joined(separator: " ").lowercased()
return terms.allSatisfy(text.contains)
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 22) {
VStack(alignment: .leading, spacing: 5) {
Text("FIND IT AGAIN")
.font(.system(size: 10, weight: .bold))
.tracking(2)
.foregroundStyle(StowStyle.muted)
Text("想找什么?")
.font(.system(size: 29, weight: .bold))
.foregroundStyle(StowStyle.ink)
}
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(StowStyle.forest)
TextField("输入标题、地点或关键词", text: $query)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !query.isEmpty {
Button { query = "" } label: { Image(systemName: "xmark.circle.fill") }
.foregroundStyle(StowStyle.muted)
}
}
.padding(15)
.background(.white, in: RoundedRectangle(cornerRadius: 15))
if query.isEmpty {
VStack(alignment: .leading, spacing: 14) {
StowSectionHeader(title: "试着这样找")
ForEach(suggestions, id: \.self) { suggestion in
Button { query = suggestion } label: {
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(StowStyle.forest)
Text(suggestion)
.foregroundStyle(StowStyle.ink)
Spacer()
Image(systemName: "arrow.up.left")
.foregroundStyle(StowStyle.muted)
}
.font(.system(size: 13))
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 13))
}
}
}
} else if results.isEmpty {
ContentUnavailableView.search(text: query)
} else {
VStack(alignment: .leading, spacing: 12) {
Text("找到 \(results.count) 条收藏")
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(StowStyle.ink)
ForEach(results) { item in
NavigationLink { ItemDetailView(item: item) } label: { ItemRow(item: item) }
.buttonStyle(.plain)
}
}
}
}
.padding(22)
}
.background(StowStyle.canvas)
.toolbar(.hidden, for: .navigationBar)
}
}
+83
View File
@@ -0,0 +1,83 @@
import Foundation
import SwiftData
@Model
final class SavedItem {
@Attribute(.unique) var id: UUID
var title: String
var summary: String
var primaryType: String
var sourceType: String
var tags: [String]
var sourceURL: String?
var imageFilename: String?
var photoAssetIdentifier: String?
var imageSHA256: String?
var ocrText: String?
var capturedAt: Date
var createdAt: Date
init(
id: UUID = UUID(),
title: String,
summary: String = "",
primaryType: String = "other",
sourceType: String,
tags: [String] = [],
sourceURL: String? = nil,
imageFilename: String? = nil,
photoAssetIdentifier: String? = nil,
imageSHA256: String? = nil,
ocrText: String? = nil,
capturedAt: Date = .now,
createdAt: Date = .now
) {
self.id = id
self.title = title
self.summary = summary
self.primaryType = primaryType
self.sourceType = sourceType
self.tags = tags
self.sourceURL = sourceURL
self.imageFilename = imageFilename
self.photoAssetIdentifier = photoAssetIdentifier
self.imageSHA256 = imageSHA256
self.ocrText = ocrText
self.capturedAt = capturedAt
self.createdAt = createdAt
}
}
enum ItemCategory: String, CaseIterable, Identifiable {
case all, place, product, content, event, task, document, conversation, other
var id: String { rawValue }
var title: String {
switch self {
case .all: "全部"
case .place: "地点"
case .product: "商品"
case .content: "内容"
case .event: "活动"
case .task: "待办"
case .document: "资料"
case .conversation: "聊天"
case .other: "其他"
}
}
var symbol: String {
switch self {
case .all: "square.grid.2x2"
case .place: "mappin"
case .product: "bag"
case .content: "text.book.closed"
case .event: "calendar"
case .task: "checkmark.circle"
case .document: "doc.text"
case .conversation: "bubble.left.and.bubble.right"
case .other: "square.stack"
}
}
}
@@ -0,0 +1,159 @@
import CryptoKit
import Foundation
import SwiftData
import UIKit
import Vision
@MainActor
enum LocalImportService {
static func saveText(_ text: String, in context: ModelContext) throws {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
let title = String(value.prefix(34))
let item = SavedItem(
title: title,
summary: value,
primaryType: category(for: value).rawValue,
sourceType: "manual_text",
tags: tags(for: value)
)
context.insert(item)
try context.save()
}
static func saveURL(_ urlText: String, in context: ModelContext) throws {
let value = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: value), ["http", "https"].contains(url.scheme?.lowercased() ?? "") else {
throw ImportError.invalidURL
}
let title = url.host?.replacingOccurrences(of: "www.", with: "") ?? "网页链接"
let item = SavedItem(
title: title,
summary: value,
primaryType: "content",
sourceType: "manual_url",
tags: ["链接"],
sourceURL: value
)
context.insert(item)
try context.save()
}
@discardableResult
static func saveImage(
_ data: Data,
sourceType: String = "manual_photo",
photoAssetIdentifier: String? = nil,
capturedAt: Date = .now,
in context: ModelContext
) async throws -> Bool {
if let photoAssetIdentifier, try hasImported(photoAssetIdentifier, in: context) { return false }
guard let image = UIImage(data: data) else { throw ImportError.invalidImage }
let id = UUID()
let filename = "\(id.uuidString).jpg"
let folder = try imageFolder()
let destination = folder.appendingPathComponent(filename)
let preparedImage = resized(image, maxDimension: 1800)
guard let jpeg = preparedImage.jpegData(compressionQuality: 0.82) else { throw ImportError.invalidImage }
let fingerprint = SHA256.hash(data: jpeg).map { String(format: "%02x", $0) }.joined()
if try context.fetch(FetchDescriptor<SavedItem>()).contains(where: { $0.imageSHA256 == fingerprint }) { return false }
try jpeg.write(to: destination, options: .atomic)
do {
// The original selection remains saved even when OCR cannot read it.
let ocr = (try? await recognizeText(in: preparedImage)) ?? ""
let firstLine = ocr.split(separator: "\n").first.map(String.init) ?? "新图片"
let text = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
let item = SavedItem(
id: id,
title: String(text.prefix(42)).isEmpty ? "新图片" : String(text.prefix(42)),
summary: String(ocr.prefix(180)),
primaryType: category(for: ocr).rawValue,
sourceType: sourceType,
tags: tags(for: ocr),
imageFilename: filename,
photoAssetIdentifier: photoAssetIdentifier,
imageSHA256: fingerprint,
ocrText: ocr,
capturedAt: capturedAt
)
context.insert(item)
try context.save()
return true
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
nonisolated static func imageURL(for item: SavedItem) -> URL? {
guard let filename = item.imageFilename else { return nil }
return try? imageFolder().appendingPathComponent(filename)
}
static func delete(_ item: SavedItem, in context: ModelContext) throws {
let fileURL = imageURL(for: item)
context.delete(item)
try context.save()
if let fileURL { try? FileManager.default.removeItem(at: fileURL) }
}
static func hasImported(_ identifier: String, in context: ModelContext) throws -> Bool {
try context.fetch(FetchDescriptor<SavedItem>()).contains { $0.photoAssetIdentifier == identifier }
}
nonisolated private static func imageFolder() throws -> URL {
let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
let folder = documents.appendingPathComponent("SavedImages", isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
return folder
}
private static func recognizeText(in image: UIImage) async throws -> String {
guard let cgImage = image.cgImage else { return "" }
return try await Task.detached(priority: .userInitiated) {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.recognitionLanguages = ["zh-Hans", "en-US"]
request.usesLanguageCorrection = true
try VNImageRequestHandler(cgImage: cgImage).perform([request])
return (request.results ?? [])
.compactMap { $0.topCandidates(1).first?.string }
.joined(separator: "\n")
}.value
}
private static func resized(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
let longestSide = max(image.size.width, image.size.height)
guard longestSide > maxDimension else { return image }
let scale = maxDimension / longestSide
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
return UIGraphicsImageRenderer(size: size).image { _ in
image.draw(in: CGRect(origin: .zero, size: size))
}
}
private static func category(for text: String) -> ItemCategory {
if ["咖啡", "餐厅", "酒店", "地址", "景点"].contains(where: text.contains) { return .place }
if ["商品", "价格", "购买", "键盘", "耳机"].contains(where: text.contains) { return .product }
if ["展览", "演出", "活动", "门票"].contains(where: text.contains) { return .event }
return .content
}
private static func tags(for text: String) -> [String] {
let words = ["咖啡", "餐厅", "旅行", "上海", "杭州", "商品", "键盘", "展览", "AI"]
return words.filter(text.contains).prefix(4).map { $0 }
}
}
enum ImportError: LocalizedError {
case invalidURL
case invalidImage
var errorDescription: String? {
switch self {
case .invalidURL: "请输入有效的 http 或 https 链接。"
case .invalidImage: "无法读取这张图片。"
}
}
}
@@ -0,0 +1,71 @@
import Photos
import UIKit
struct ScreenshotCandidate: Identifiable {
let id: String
let asset: PHAsset
let capturedAt: Date
}
@MainActor
enum ScreenshotLibraryService {
static func requestAccess() async -> PHAuthorizationStatus {
let current = PHPhotoLibrary.authorizationStatus(for: .readWrite)
guard current == .notDetermined else { return current }
return await withCheckedContinuation { continuation in
PHPhotoLibrary.requestAuthorization(for: .readWrite) { result in
continuation.resume(returning: result)
}
}
}
static func recentScreenshots(limit: Int = 30) -> [ScreenshotCandidate] {
let options = PHFetchOptions()
options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
let assets = PHAsset.fetchAssets(with: .image, options: options)
var screenshots: [ScreenshotCandidate] = []
for index in 0..<assets.count {
let asset = assets.object(at: index)
guard asset.mediaSubtypes.contains(.photoScreenshot) else { continue }
screenshots.append(ScreenshotCandidate(
id: asset.localIdentifier,
asset: asset,
capturedAt: asset.creationDate ?? .now
))
if screenshots.count >= limit { break }
}
return screenshots
}
static func thumbnail(for asset: PHAsset) async -> UIImage? {
await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.resizeMode = .fast
options.isNetworkAccessAllowed = false
PHImageManager.default().requestImage(
for: asset,
targetSize: CGSize(width: 300, height: 300),
contentMode: .aspectFill,
options: options
) { image, _ in
continuation.resume(returning: image)
}
}
}
static func imageData(for asset: PHAsset) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isNetworkAccessAllowed = true
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, _, _, _ in
if let data {
continuation.resume(returning: data)
} else {
continuation.resume(throwing: ImportError.invalidImage)
}
}
}
}
}
+416
View File
@@ -0,0 +1,416 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {};
objectVersion = 56;
objects = {
3362B8F6FEB9187AF06C2973 /* file:Sources/App/RootView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "RootView.swift";
sourceTree = "<group>";
};
E8EB797FC7B931792536B82F /* build:Sources/App/RootView.swift */ = {
isa = PBXBuildFile;
fileRef = 3362B8F6FEB9187AF06C2973 /* RootView.swift */;
};
09FA9998D53C167BA164487C /* file:Sources/App/StowApp.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "StowApp.swift";
sourceTree = "<group>";
};
864B5FF1FEBFBD45DF72BC12 /* build:Sources/App/StowApp.swift */ = {
isa = PBXBuildFile;
fileRef = 09FA9998D53C167BA164487C /* StowApp.swift */;
};
72483B00A6425B600C442BC9 /* file:Sources/DesignSystem/StowStyle.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "StowStyle.swift";
sourceTree = "<group>";
};
B9D610B162B1B80416AC881C /* build:Sources/DesignSystem/StowStyle.swift */ = {
isa = PBXBuildFile;
fileRef = 72483B00A6425B600C442BC9 /* StowStyle.swift */;
};
897F8C7966F8C33130E00F5E /* file:Sources/Features/Collection/CollectionView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "CollectionView.swift";
sourceTree = "<group>";
};
6934A69E9E18E3BB525BFF91 /* build:Sources/Features/Collection/CollectionView.swift */ = {
isa = PBXBuildFile;
fileRef = 897F8C7966F8C33130E00F5E /* CollectionView.swift */;
};
7D4460FF2CB0F8CA12712B20 /* file:Sources/Features/Home/HomeView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "HomeView.swift";
sourceTree = "<group>";
};
B58F4DCDC7AAC8EE3F207F8E /* build:Sources/Features/Home/HomeView.swift */ = {
isa = PBXBuildFile;
fileRef = 7D4460FF2CB0F8CA12712B20 /* HomeView.swift */;
};
2493A1FE108FCBA41C360F20 /* file:Sources/Features/Import/ImportSheet.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ImportSheet.swift";
sourceTree = "<group>";
};
775BF6F01D11C687449C2C2C /* build:Sources/Features/Import/ImportSheet.swift */ = {
isa = PBXBuildFile;
fileRef = 2493A1FE108FCBA41C360F20 /* ImportSheet.swift */;
};
B40FD850E9C0416D9B12796E /* file:Sources/Features/ItemDetail/ItemDetailView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ItemDetailView.swift";
sourceTree = "<group>";
};
85BF25C21CD281A060378D37 /* build:Sources/Features/ItemDetail/ItemDetailView.swift */ = {
isa = PBXBuildFile;
fileRef = B40FD850E9C0416D9B12796E /* ItemDetailView.swift */;
};
8EF91CE90E92FE3A7C5B1977 /* file:Sources/Features/Profile/ProfileView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ProfileView.swift";
sourceTree = "<group>";
};
60C5F5DE82A76A1C5BFFAB38 /* build:Sources/Features/Profile/ProfileView.swift */ = {
isa = PBXBuildFile;
fileRef = 8EF91CE90E92FE3A7C5B1977 /* ProfileView.swift */;
};
56E6F9A96C68F16C2A22C62E /* file:Sources/Features/Search/SearchView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "SearchView.swift";
sourceTree = "<group>";
};
785BC5ABE744416B4D6C1AEB /* build:Sources/Features/Search/SearchView.swift */ = {
isa = PBXBuildFile;
fileRef = 56E6F9A96C68F16C2A22C62E /* SearchView.swift */;
};
577D65EF2EC9794678A7A2A6 /* file:Sources/Models/SavedItem.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "SavedItem.swift";
sourceTree = "<group>";
};
D08C04907404CDAB1F64DFDA /* build:Sources/Models/SavedItem.swift */ = {
isa = PBXBuildFile;
fileRef = 577D65EF2EC9794678A7A2A6 /* SavedItem.swift */;
};
8A07040F49C28CF4498F3D61 /* file:Sources/Services/LocalImportService.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "LocalImportService.swift";
sourceTree = "<group>";
};
F621CDD61DDDCE0C3C374485 /* build:Sources/Services/LocalImportService.swift */ = {
isa = PBXBuildFile;
fileRef = 8A07040F49C28CF4498F3D61 /* LocalImportService.swift */;
};
38A007151ABE87CC01A5B6E9 /* product */ = {
isa = PBXFileReference;
explicitFileType = wrapper.application;
includeInIndex = 0;
path = Stow.app;
sourceTree = BUILT_PRODUCTS_DIR;
};
91468586F9881985D065A03B /* group:Sources/App */ = {
isa = PBXGroup;
children = (
3362B8F6FEB9187AF06C2973 /* RootView.swift */,
09FA9998D53C167BA164487C /* StowApp.swift */,
);
path = "App";
sourceTree = "<group>";
};
D40CDA4BC33432C558328A29 /* group:Sources/Models */ = {
isa = PBXGroup;
children = (
577D65EF2EC9794678A7A2A6 /* SavedItem.swift */,
);
path = "Models";
sourceTree = "<group>";
};
EF972FBDF6EB970E6AA4CC4F /* group:Sources/Services */ = {
isa = PBXGroup;
children = (
266BC7E402A0C08B51E4A8F9 /* ScreenshotLibraryService.swift */,
8A07040F49C28CF4498F3D61 /* LocalImportService.swift */,
);
path = "Services";
sourceTree = "<group>";
};
5327D0384DBAC03BEDE08B0C /* group:Sources/DesignSystem */ = {
isa = PBXGroup;
children = (
72483B00A6425B600C442BC9 /* StowStyle.swift */,
);
path = "DesignSystem";
sourceTree = "<group>";
};
3E3E6E83A02BCB9449B155AE /* group:Sources/Features/Home */ = {
isa = PBXGroup;
children = (
7D4460FF2CB0F8CA12712B20 /* HomeView.swift */,
);
path = "Home";
sourceTree = "<group>";
};
D113AB81B7A7C49B08200004 /* group:Sources/Features/Collection */ = {
isa = PBXGroup;
children = (
897F8C7966F8C33130E00F5E /* CollectionView.swift */,
);
path = "Collection";
sourceTree = "<group>";
};
24C271AB4286764A636B744D /* group:Sources/Features/Search */ = {
isa = PBXGroup;
children = (
56E6F9A96C68F16C2A22C62E /* SearchView.swift */,
);
path = "Search";
sourceTree = "<group>";
};
664EB509DC23723B40FA9BF4 /* group:Sources/Features/ItemDetail */ = {
isa = PBXGroup;
children = (
B40FD850E9C0416D9B12796E /* ItemDetailView.swift */,
);
path = "ItemDetail";
sourceTree = "<group>";
};
6EB878375904859CAE2750D4 /* group:Sources/Features/Import */ = {
isa = PBXGroup;
children = (
EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */,
2493A1FE108FCBA41C360F20 /* ImportSheet.swift */,
);
path = "Import";
sourceTree = "<group>";
};
0AEA2CEBCB47EB60E5E6948E /* group:Sources/Features/Profile */ = {
isa = PBXGroup;
children = (
8EF91CE90E92FE3A7C5B1977 /* ProfileView.swift */,
);
path = "Profile";
sourceTree = "<group>";
};
9D756A79786A998CCABA280E /* group:Sources/Features */ = {
isa = PBXGroup;
children = (
3E3E6E83A02BCB9449B155AE /* Home */,
D113AB81B7A7C49B08200004 /* Collection */,
24C271AB4286764A636B744D /* Search */,
664EB509DC23723B40FA9BF4 /* ItemDetail */,
6EB878375904859CAE2750D4 /* Import */,
0AEA2CEBCB47EB60E5E6948E /* Profile */,
);
path = Features;
sourceTree = "<group>";
};
A9012353AFD6A66EB75D4BE2 /* group:Sources */ = {
isa = PBXGroup;
children = (
91468586F9881985D065A03B /* App */,
D40CDA4BC33432C558328A29 /* Models */,
EF972FBDF6EB970E6AA4CC4F /* Services */,
5327D0384DBAC03BEDE08B0C /* DesignSystem */,
9D756A79786A998CCABA280E /* Features */,
);
path = Sources;
sourceTree = "<group>";
};
5151B46607EBC8890D8DFCC2 /* group:Products */ = {
isa = PBXGroup;
children = (
38A007151ABE87CC01A5B6E9 /* Stow.app */,
);
name = Products;
sourceTree = "<group>";
};
931BAC40E0989DC841B8C1D2 /* group:Main */ = {
isa = PBXGroup;
children = (
A9012353AFD6A66EB75D4BE2 /* Sources */,
5151B46607EBC8890D8DFCC2 /* Products */,
);
sourceTree = "<group>";
};
28BE49E1E42D704C38EEA420 /* phase:Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A924CEC2651849DCC7E97538 /* ScreenshotScanView.swift in Sources */,
817FE3FEEED3F37B0725E260 /* ScreenshotLibraryService.swift in Sources */,
E8EB797FC7B931792536B82F /* RootView.swift in Sources */,
864B5FF1FEBFBD45DF72BC12 /* StowApp.swift in Sources */,
B9D610B162B1B80416AC881C /* StowStyle.swift in Sources */,
6934A69E9E18E3BB525BFF91 /* CollectionView.swift in Sources */,
B58F4DCDC7AAC8EE3F207F8E /* HomeView.swift in Sources */,
775BF6F01D11C687449C2C2C /* ImportSheet.swift in Sources */,
85BF25C21CD281A060378D37 /* ItemDetailView.swift in Sources */,
60C5F5DE82A76A1C5BFFAB38 /* ProfileView.swift in Sources */,
785BC5ABE744416B4D6C1AEB /* SearchView.swift in Sources */,
D08C04907404CDAB1F64DFDA /* SavedItem.swift in Sources */,
F621CDD61DDDCE0C3C374485 /* LocalImportService.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
CA8003021206097066E984FF /* phase:Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = ();
runOnlyForDeploymentPostprocessing = 0;
};
E26E44B743A848DED8844ED9 /* phase:Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = ();
runOnlyForDeploymentPostprocessing = 0;
};
1619A08764C450712864E767 /* target:Stow */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7192A84B797C3971CC4FB286 /* Build configuration list for PBXNativeTarget "Stow" */;
buildPhases = (
28BE49E1E42D704C38EEA420 /* Sources */,
CA8003021206097066E984FF /* Frameworks */,
E26E44B743A848DED8844ED9 /* Resources */,
);
buildRules = ();
dependencies = ();
name = Stow;
productName = Stow;
productReference = 38A007151ABE87CC01A5B6E9 /* Stow.app */;
productType = "com.apple.product-type.application";
};
98F54143AB4E86B28C3AFEE0 /* project */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 1500;
};
buildConfigurationList = 58BC0B3212E180EF4DCCBAEC /* Build configuration list for PBXProject "Stow" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (en, Base);
mainGroup = 931BAC40E0989DC841B8C1D2;
productRefGroup = 5151B46607EBC8890D8DFCC2;
projectDirPath = "";
projectRoot = "";
targets = (1619A08764C450712864E767 /* Stow */);
};
A9497CE23FDDC0787512981E /* config:Project:Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
SWIFT_OPTIMIZATION_LEVEL = -Onone;
};
name = Debug;
};
1D69AE994E46541145CBB141 /* config:Target:Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "收好";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "收好需要读取你授权的截图,并由你确认要整理的内容。";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.Stow;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
MARKETING_VERSION = 0.1.0;
CURRENT_PROJECT_VERSION = 1;
};
name = Debug;
};
A9DD297AC466B8C891118663 /* config:Project:Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ENABLE_MODULES = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.0;
SWIFT_OPTIMIZATION_LEVEL = -O;
};
name = Release;
};
409A63BCB0B64CF6220D29A9 /* config:Target:Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = "收好";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "收好需要读取你授权的截图,并由你确认要整理的内容。";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.Stow;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = 1;
MARKETING_VERSION = 0.1.0;
CURRENT_PROJECT_VERSION = 1;
};
name = Release;
};
58BC0B3212E180EF4DCCBAEC /* configs:Project */ = {
isa = XCConfigurationList;
buildConfigurations = (
A9497CE23FDDC0787512981E /* Debug */,
A9DD297AC466B8C891118663 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7192A84B797C3971CC4FB286 /* configs:Target */ = {
isa = XCConfigurationList;
buildConfigurations = (
1D69AE994E46541145CBB141 /* Debug */,
409A63BCB0B64CF6220D29A9 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
266BC7E402A0C08B51E4A8F9 /* file:Sources/Services/ScreenshotLibraryService.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ScreenshotLibraryService.swift";
sourceTree = "<group>";
};
817FE3FEEED3F37B0725E260 /* build:Sources/Services/ScreenshotLibraryService.swift */ = {
isa = PBXBuildFile;
fileRef = 266BC7E402A0C08B51E4A8F9 /* ScreenshotLibraryService.swift */;
};
EA66DC9AC41434DF211AE03E /* file:Sources/Features/Import/ScreenshotScanView.swift */ = {
isa = PBXFileReference;
lastKnownFileType = sourcecode.swift;
path = "ScreenshotScanView.swift";
sourceTree = "<group>";
};
A924CEC2651849DCC7E97538 /* build:Sources/Features/Import/ScreenshotScanView.swift */ = {
isa = PBXBuildFile;
fileRef = EA66DC9AC41434DF211AE03E /* ScreenshotScanView.swift */;
};
};
rootObject = 98F54143AB4E86B28C3AFEE0 /* Project object */;
}