diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 45bdb99063fdd7124f77d7ee48b9a3209e958577..a1576c504dbc0aeceec3164af2261ad672a4abe2 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -25,7 +25,7 @@ + YouTube and a browser's share sheet (docs/06). --> diff --git a/app/ios/.gitignore b/app/ios/.gitignore deleted file mode 100644 index 7a7f9873ad7dceb4dc17087fb06c800fa0191376..0000000000000000000000000000000000000000 --- a/app/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902b2bebe1a3df1b090312fd85029dd99308..0000000000000000000000000000000000000000 --- a/app/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee85b89bd111b779db6116b130509ab6d4b..0000000000000000000000000000000000000000 --- a/app/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee85b89bd111b779db6116b130509ab6d4b..0000000000000000000000000000000000000000 --- a/app/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 1cb194a3b0d44ad2fa70ac46b69fab37d2b66b18..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,623 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = 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_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_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_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - 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 = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = PZA655S72B; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.cachy.cachy; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cachy.cachy.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cachy.cachy.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.cachy.cachy.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = 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_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_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_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - 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 = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = 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_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_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_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - 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 = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = PZA655S72B; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.cachy.cachy; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = PZA655S72B; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.cachy.cachy; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a6254f0e9651f402737811be6634a03e9c..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d0546c4804ac2ff47dd97c6e7921..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5ea15f194be85eb6ee8e6721a87ff4644..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index e3773d42e24c8bb3b9070fc9d10d62032787035e..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,101 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a16ed0f1cd0c2409d848bf489b93fefa3b2..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d981003d68d0546c4804ac2ff47dd97c6e7921..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c5ea15f194be85eb6ee8e6721a87ff4644..0000000000000000000000000000000000000000 --- a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367ec0a99a3f38e09477c49ba6b5adeb352d..0000000000000000000000000000000000000000 --- a/app/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fab2d9dea668a4f83df94d525897d9e68dd..0000000000000000000000000000000000000000 --- a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4725e9b0ddb1deab583e5b5102493aa332..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41ecf9ca08017312dc233d9830079b50717..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452e458972bab9d994556c8305db4c827017..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d933e1120817fe9182483a228007b18ab6ae..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b0099ca80c806f8fe495613e8d6c69460d76..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe730945a01f64a61e2235dbe3f45b08f7729182..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773cd857a8a0f0c9c7d3dc3f5ff4fb298dc10..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452e458972bab9d994556c8305db4c827017..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463a9bc882b461c96aadf492d1729e49e725..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec303439225b78712f49115768196d8d76f6790..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec303439225b78712f49115768196d8d76f6790..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea27c705180eb716271f41b582e76dcbd90..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32ae7d989f82d5e46a60405adcc8279e8001..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba09064923c5daf2d37e7c3c836ccdd794b..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf12aa4d28f374bb26596605a46dcbb3e7c8..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2fd46788ae3a01a423467513ff59b5c120..0000000000000000000000000000000000000000 --- a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19eacad3b03bb08bbddbbf4ac48dd78b3d838..0000000000000000000000000000000000000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725b70f1882be97f5214fafe22d27a0ec01e..0000000000000000000000000000000000000000 --- a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c7c9390ff69a6bbe1e0907e6dc366848e7..0000000000000000000000000000000000000000 --- a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c28516fb38e64d88cfcf5fb1791175df078f2f..0000000000000000000000000000000000000000 --- a/app/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist deleted file mode 100644 index 057ad6488ec3e1b48c506a7beb49f6f563e71dc8..0000000000000000000000000000000000000000 --- a/app/ios/Runner/Info.plist +++ /dev/null @@ -1,72 +0,0 @@ - - - - - NSCalendarsUsageDescription - Cachy adds reminders and sessions from your cards to your calendar. - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Cachy - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - cachy - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a560b42f17aaf3c36e4e9c8cd07182fbb7e..0000000000000000000000000000000000000000 --- a/app/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea2b2adb8d8061af09c75d2480999773ac3..0000000000000000000000000000000000000000 --- a/app/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b1b6119f7dbdb8cec74f1b5b3e076bf949..0000000000000000000000000000000000000000 --- a/app/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/app/lib/data/repositories/card_repository.dart b/app/lib/data/repositories/card_repository.dart index 73894c6653ec25065a93ce5a82f179b8736c95d2..8d16e5a8d27ccbf3453afb28cf530755d08400f3 100644 --- a/app/lib/data/repositories/card_repository.dart +++ b/app/lib/data/repositories/card_repository.dart @@ -7,7 +7,6 @@ import 'dart:convert'; import '../../domain/models/artifact.dart'; import '../../domain/models/card.dart'; -import '../../domain/models/collection.dart'; import '../../domain/models/enums.dart'; import '../../domain/models/graph.dart'; import '../../domain/models/pipeline_event.dart'; @@ -97,6 +96,14 @@ class CardRepository { return card; } + /// Persist the card's action list (docs/13) — follow toggle + per-item done. + Future patchActionItems( + String cardId, Map actionItems) async { + final card = await _api.patchCardActionItems(cardId, actionItems); + await _store.cacheCard(cardId, _rawOf(card)); + return card; + } + Future delete(String cardId) async { await _api.deleteCard(cardId); await _store.removeCard(cardId); @@ -137,21 +144,6 @@ class CardRepository { Future libraryChat(List> messages) => _api.libraryChat(messages); - // --- collections (docs/09) — user-created groups of cards --------------- // - Future> listCollections() => _api.listCollections(); - - Future getCollection(String id) => _api.getCollection(id); - - Future createCollection(String name) => - _api.createCollection(name); - - Future deleteCollection(String id) => _api.deleteCollection(id); - - Future addCardToCollection(String id, String cardId) => - _api.addCardToCollection(id, cardId); - - Future removeCardFromCollection(String id, String cardId) => - _api.removeCardFromCollection(id, cardId); /// Reconstruct the raw JSON for caching. Uses preserved `rawBlocks` so block /// state round-trips losslessly. @@ -180,6 +172,7 @@ class CardRepository { 'label': card.primaryAction.label, 'payload': card.primaryAction.payload, }, + 'action_items': card.actionItems.toJson(), 'blocks': card.rawBlocks, 'media': { 'thumbnail': card.media.thumbnail, diff --git a/app/lib/data/services/api_client.dart b/app/lib/data/services/api_client.dart index 25b2ef240858de59ca7f6b8c9891e37f0a3bb3a6..32ccde0619669e16c3a61bcbca68fa0d1036fa43 100644 --- a/app/lib/data/services/api_client.dart +++ b/app/lib/data/services/api_client.dart @@ -9,7 +9,6 @@ import 'package:http/http.dart' as http; import '../../domain/models/artifact.dart'; import '../../domain/models/card.dart'; -import '../../domain/models/collection.dart'; import '../../domain/models/enums.dart'; import '../../domain/models/graph.dart'; import '../../domain/models/pipeline_event.dart'; @@ -126,6 +125,19 @@ class ApiClient { return Card.fromJson(_decodeMap(resp)); } + /// Persist the card's action list (docs/13): follow toggle + per-item done. + Future patchCardActionItems( + String cardId, + Map actionItems, + ) async { + final resp = await _client.patch( + _uri('/cards/$cardId'), + headers: const {'content-type': 'application/json'}, + body: jsonEncode({'action_items': actionItems}), + ); + return Card.fromJson(_decodeMap(resp)); + } + Future deleteCard(String cardId) async { final resp = await _client.delete(_uri('/cards/$cardId')); if (resp.statusCode >= 400) { @@ -230,47 +242,6 @@ class ApiClient { ); } - // ------------------------------------------------------------------------- // - // Collections — user-created groups of cards (docs/09) - // ------------------------------------------------------------------------- // - - Future> listCollections() async { - final resp = await _client.get(_uri('/collections')); - return _decodeList(resp).map(Collection.fromJson).toList(); - } - - Future getCollection(String id) async { - final resp = await _client.get(_uri('/collections/$id')); - return CollectionDetail.fromJson(_decodeMap(resp)); - } - - Future createCollection(String name) async { - final resp = await _client.post( - _uri('/collections'), - headers: const {'content-type': 'application/json'}, - body: jsonEncode({'name': name}), - ); - return Collection.fromJson(_decodeMap(resp)); - } - - Future deleteCollection(String id) async { - final resp = await _client.delete(_uri('/collections/$id')); - if (resp.statusCode >= 400) throw ApiException(resp.statusCode, resp.body); - } - - Future addCardToCollection(String id, String cardId) async { - final resp = await _client.post( - _uri('/collections/$id/cards'), - headers: const {'content-type': 'application/json'}, - body: jsonEncode({'card_id': cardId}), - ); - return Collection.fromJson(_decodeMap(resp)); - } - - Future removeCardFromCollection(String id, String cardId) async { - final resp = await _client.delete(_uri('/collections/$id/cards/$cardId')); - return Collection.fromJson(_decodeMap(resp)); - } // ------------------------------------------------------------------------- // // SSE pipeline stream — GET /cards/{id}/stream diff --git a/app/lib/domain/models/card.dart b/app/lib/domain/models/card.dart index 269ca36210f8b4a96d87479673161b3910b7dbed..e9deabcad19375e5fda8b6d0c076ebba4fb0180b 100644 --- a/app/lib/domain/models/card.dart +++ b/app/lib/domain/models/card.dart @@ -17,7 +17,7 @@ class Source { }); final String url; - final String? platform; // instagram | tiktok | youtube + final String? platform; // instagram | youtube final String? creator; final String caption; final int? durationSeconds; @@ -78,6 +78,45 @@ class PrimaryAction { ); } +/// One concrete to-do the reel tells the viewer to do (docs/13). +class ActionItem { + const ActionItem({required this.id, required this.text, this.done = false}); + + final String id; + final String text; + final bool done; + + factory ActionItem.fromJson(Map json) => ActionItem( + id: (json['id'] as String?) ?? '', + text: (json['text'] as String?) ?? '', + done: (json['done'] as bool?) ?? false, + ); + + Map toJson() => {'id': id, 'text': text, 'done': done}; +} + +/// Per-card action list (docs/13). `followed` flips true when the user opts the +/// card into the Actions hub; until then the list is inert. +class ActionItems { + const ActionItems({this.followed = false, this.items = const []}); + + final bool followed; + final List items; + + bool get isPresent => items.isNotEmpty; + + factory ActionItems.fromJson(Map json) => ActionItems( + followed: (json['followed'] as bool?) ?? false, + items: ((json['items'] as List?) ?? const []) + .whereType>() + .map(ActionItem.fromJson) + .toList(), + ); + + Map toJson() => + {'followed': followed, 'items': items.map((e) => e.toJson()).toList()}; +} + class Media { const Media({this.thumbnail, this.keyframes = const []}); @@ -134,6 +173,7 @@ class Card { required this.source, this.base = const Base(), this.primaryAction = const PrimaryAction(), + this.actionItems = const ActionItems(), this.blocks = const [], this.media = const Media(), this.meta = const Meta(), @@ -147,6 +187,7 @@ class Card { final Source source; final Base base; final PrimaryAction primaryAction; + final ActionItems actionItems; final List blocks; final Media media; final Meta meta; @@ -179,6 +220,9 @@ class Card { primaryAction: PrimaryAction.fromJson( (json['primary_action'] as Map?) ?? const {}, ), + actionItems: ActionItems.fromJson( + (json['action_items'] as Map?) ?? const {}, + ), blocks: rawBlocks.map(Block.fromJson).toList(), media: Media.fromJson((json['media'] as Map?) ?? const {}), meta: Meta.fromJson((json['meta'] as Map?) ?? const {}), @@ -188,6 +232,7 @@ class Card { Card copyWith({ CardState? state, + ActionItems? actionItems, List? blocks, List>? rawBlocks, }) => @@ -199,6 +244,7 @@ class Card { source: source, base: base, primaryAction: primaryAction, + actionItems: actionItems ?? this.actionItems, blocks: blocks ?? this.blocks, media: media, meta: meta, diff --git a/app/lib/domain/models/collection.dart b/app/lib/domain/models/collection.dart deleted file mode 100644 index d9f6b59291f9ad1afe7b436db278c29926851e24..0000000000000000000000000000000000000000 --- a/app/lib/domain/models/collection.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// A user-created collection: a named group of cards (docs/09). Mirrors the -/// backend contract (backend models/collection.py). Membership is by card id; -/// the cards themselves are unchanged, so this needs no block-schema change. -library; - -import 'card.dart'; - -class Collection { - const Collection({ - required this.id, - required this.name, - this.cardIds = const [], - this.createdAt, - }); - - final String id; - final String name; - final List cardIds; - final DateTime? createdAt; - - int get count => cardIds.length; - - factory Collection.fromJson(Map json) => Collection( - id: (json['id'] as String?) ?? '', - name: (json['name'] as String?) ?? '', - cardIds: (json['card_ids'] as List?) - ?.map((e) => e.toString()) - .toList() ?? - const [], - createdAt: DateTime.tryParse((json['created_at'] as String?) ?? ''), - ); -} - -/// A collection plus its resolved member cards (the detail endpoint payload). -class CollectionDetail { - const CollectionDetail({required this.collection, this.cards = const []}); - - final Collection collection; - final List cards; - - factory CollectionDetail.fromJson(Map json) => - CollectionDetail( - collection: Collection.fromJson( - (json['collection'] as Map?) ?? const {}, - ), - cards: ((json['cards'] as List?) ?? const []) - .whereType>() - .map(Card.fromJson) - .toList(), - ); -} diff --git a/app/lib/ui/core/home_shell.dart b/app/lib/ui/core/home_shell.dart index edea9b5ebe1d9364f399fb6309376b5fb7173d2f..8a2f30e3c463ef5d5e86f09c609f80eb5c26edf0 100644 --- a/app/lib/ui/core/home_shell.dart +++ b/app/lib/ui/core/home_shell.dart @@ -5,8 +5,8 @@ library; import 'package:flutter/material.dart'; +import '../features/actions/views/actions_screen.dart'; import '../features/catalog/views/catalog_screen.dart'; -import '../features/collections/views/collections_screen.dart'; import '../features/graph/views/graph_screen.dart'; import '../features/library/views/library_screen.dart'; @@ -27,8 +27,8 @@ class _HomeShellState extends State { index: _index, children: const [ LibraryScreen(), + ActionsScreen(), GraphScreen(), - CollectionsScreen(), CatalogScreen(), ], ), @@ -41,16 +41,16 @@ class _HomeShellState extends State { selectedIcon: Icon(Icons.video_library_rounded), label: 'Library', ), + NavigationDestination( + icon: Icon(Icons.checklist_outlined), + selectedIcon: Icon(Icons.checklist_rounded), + label: 'Actions', + ), NavigationDestination( icon: Icon(Icons.hub_outlined), selectedIcon: Icon(Icons.hub_rounded), label: 'Graph', ), - NavigationDestination( - icon: Icon(Icons.folder_outlined), - selectedIcon: Icon(Icons.folder_rounded), - label: 'Collections', - ), NavigationDestination( icon: Icon(Icons.auto_stories_outlined), selectedIcon: Icon(Icons.auto_stories_rounded), diff --git a/app/lib/ui/features/actions/view_models/actions_view_model.dart b/app/lib/ui/features/actions/view_models/actions_view_model.dart new file mode 100644 index 0000000000000000000000000000000000000000..ed766861145443a9fc0718a8bd935015e4b10173 --- /dev/null +++ b/app/lib/ui/features/actions/view_models/actions_view_model.dart @@ -0,0 +1,111 @@ +/// Actions hub state (MVVM, docs/13). Loads every card the user has *followed* +/// the actions of, flattens their to-dos grouped by source reel, and toggles +/// done state (optimistic + PATCH). The hub is a read-across-cards view; the +/// per-card list itself is owned by the card. +library; + +import 'package:flutter/foundation.dart'; + +import '../../../../data/repositories/card_repository.dart'; +import '../../../../domain/models/card.dart'; + +enum ActionsStatus { idle, loading, ready, error, empty } + +/// One reel's followed to-dos, for a sectioned display. +class ActionGroup { + const ActionGroup({required this.card, required this.items}); + final Card card; + final List items; + + int get pending => items.where((i) => !i.done).length; + bool get allDone => items.isNotEmpty && pending == 0; +} + +class ActionsViewModel extends ChangeNotifier { + ActionsViewModel({required CardRepository repository}) + : _repository = repository; + + final CardRepository _repository; + + ActionsStatus _status = ActionsStatus.idle; + ActionsStatus get status => _status; + + String? _error; + String? get error => _error; + + List _groups = const []; + List get groups => _groups; + + /// Total to-dos still open across every followed reel — drives a nav badge. + int get pendingCount => + _groups.fold(0, (sum, g) => sum + g.pending); + + Future load({bool showSpinner = true}) async { + if (showSpinner) { + _status = ActionsStatus.loading; + notifyListeners(); + } + try { + final cards = await _repository.list(); + final groups = [ + for (final c in cards) + if (c.actionItems.followed && c.actionItems.items.isNotEmpty) + ActionGroup(card: c, items: c.actionItems.items), + ]; + _groups = groups; + _status = groups.isEmpty ? ActionsStatus.empty : ActionsStatus.ready; + _error = null; + } catch (e) { + _error = e.toString(); + _status = ActionsStatus.error; + } + notifyListeners(); + } + + /// Tick a to-do off (or back on). Optimistic; reverts the group on failure. + Future toggle(String cardId, String itemId, bool done) async { + final idx = _groups.indexWhere((g) => g.card.cardId == cardId); + if (idx < 0) return; + final before = _groups[idx]; + + final updatedItems = [ + for (final it in before.items) + it.id == itemId ? ActionItem(id: it.id, text: it.text, done: done) : it, + ]; + final updatedActions = + ActionItems(followed: true, items: updatedItems); + _groups = [..._groups] + ..[idx] = ActionGroup( + card: before.card.copyWith(actionItems: updatedActions), + items: updatedItems, + ); + notifyListeners(); + + try { + await _repository.patchActionItems(cardId, updatedActions.toJson()); + } catch (_) { + _groups = [..._groups]..[idx] = before; // revert + notifyListeners(); + } + } + + /// Stop following a reel's actions — drops it from the hub. + Future unfollow(String cardId) async { + final idx = _groups.indexWhere((g) => g.card.cardId == cardId); + if (idx < 0) return; + final before = _groups[idx]; + _groups = [..._groups]..removeAt(idx); + _status = _groups.isEmpty ? ActionsStatus.empty : ActionsStatus.ready; + notifyListeners(); + try { + await _repository.patchActionItems( + cardId, + ActionItems(followed: false, items: before.items).toJson(), + ); + } catch (_) { + _groups = [..._groups]..insert(idx, before); // revert + _status = ActionsStatus.ready; + notifyListeners(); + } + } +} diff --git a/app/lib/ui/features/actions/views/actions_screen.dart b/app/lib/ui/features/actions/views/actions_screen.dart new file mode 100644 index 0000000000000000000000000000000000000000..b955899ad04e6e8ab3645cab8985ad8fb1bbf583 --- /dev/null +++ b/app/lib/ui/features/actions/views/actions_screen.dart @@ -0,0 +1,290 @@ +/// The Actions hub (docs/13): every to-do you've followed off a reel, grouped by +/// its source card, split into things still to do and things done. Tick items off +/// here or jump back to the card. Cards you haven't followed never appear. +library; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../../../data/repositories/card_repository.dart'; +import '../../../core/content_accent.dart'; +import '../../../core/theme.dart'; +import '../../reader/views/reader_screen.dart'; +import '../view_models/actions_view_model.dart'; + +class ActionsScreen extends StatelessWidget { + const ActionsScreen({super.key}); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider( + create: (ctx) => + ActionsViewModel(repository: ctx.read())..load(), + child: const _ActionsView(), + ); + } +} + +class _ActionsView extends StatelessWidget { + const _ActionsView(); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + return Scaffold( + appBar: AppBar( + title: const Text('Actions'), + actions: [ + if (vm.pendingCount > 0) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Center( + child: Text( + '${vm.pendingCount} to do', + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ), + body: RefreshIndicator( + onRefresh: () => vm.load(showSpinner: false), + child: switch (vm.status) { + ActionsStatus.loading || ActionsStatus.idle => + const Center(child: CircularProgressIndicator()), + ActionsStatus.error => _Message( + icon: Icons.error_outline_rounded, + text: "Couldn't load your actions", + onRetry: vm.load, + ), + ActionsStatus.empty => const _Message( + icon: Icons.checklist_rounded, + text: 'No actions yet.\nOpen a card and tap “Follow these actions”.', + ), + ActionsStatus.ready => ListView.builder( + padding: const EdgeInsets.fromLTRB( + Insets.page, 12, Insets.page, 32), + itemCount: vm.groups.length, + itemBuilder: (ctx, i) => _GroupCard(group: vm.groups[i]), + ), + }, + ), + ); + } +} + +class _GroupCard extends StatelessWidget { + const _GroupCard({required this.group}); + final ActionGroup group; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final vm = context.read(); + final card = group.card; + final accent = ContentAccent.of(card.base.contentType); + final pending = group.items.where((i) => !i.done).toList(); + final done = group.items.where((i) => i.done).toList(); + + return Container( + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerLow, + borderRadius: BorderRadius.circular(Insets.radius), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header — tap to open the card. + InkWell( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ReaderScreen(cardId: card.cardId), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + _Thumb(url: card.thumbnail, accent: accent), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + card.base.oneLiner.isEmpty + ? 'Saved card' + : card.base.oneLiner, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 2), + Text( + group.allDone + ? 'All done' + : '${group.pending} to do', + style: theme.textTheme.bodySmall?.copyWith( + color: group.allDone + ? accent.color + : theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + PopupMenuButton( + onSelected: (_) => vm.unfollow(card.cardId), + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'unfollow', + child: Text('Stop following'), + ), + ], + ), + ], + ), + ), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 12), + child: Column( + children: [ + for (final item in pending) + _ActionRow( + text: item.text, + done: false, + accent: accent, + onToggle: () => vm.toggle(card.cardId, item.id, true), + ), + for (final item in done) + _ActionRow( + text: item.text, + done: true, + accent: accent, + onToggle: () => vm.toggle(card.cardId, item.id, false), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ActionRow extends StatelessWidget { + const _ActionRow({ + required this.text, + required this.done, + required this.accent, + required this.onToggle, + }); + final String text; + final bool done; + final ContentAccent accent; + final VoidCallback onToggle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: onToggle, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + done + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded, + size: 22, + color: done ? accent.color : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + text, + style: theme.textTheme.bodyMedium?.copyWith( + decoration: done ? TextDecoration.lineThrough : null, + color: done ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Thumb extends StatelessWidget { + const _Thumb({required this.url, required this.accent}); + final String? url; + final ContentAccent accent; + + @override + Widget build(BuildContext context) { + final placeholder = Container( + color: accent.color.withValues(alpha: 0.14), + child: Icon(accent.icon, color: accent.color, size: 22), + ); + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: SizedBox( + width: 52, + height: 52, + child: (url == null || url!.isEmpty) + ? placeholder + : CachedNetworkImage( + imageUrl: url!, + fit: BoxFit.cover, + placeholder: (c, _) => placeholder, + errorWidget: (c, _, __) => placeholder, + ), + ), + ); + } +} + +class _Message extends StatelessWidget { + const _Message({required this.icon, required this.text, this.onRetry}); + final IconData icon; + final String text; + final VoidCallback? onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // ListView so RefreshIndicator still works on the empty/error states. + return ListView( + children: [ + SizedBox(height: MediaQuery.of(context).size.height * 0.28), + Icon(icon, size: 48, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 12), + Text( + text, + textAlign: TextAlign.center, + style: theme.textTheme.bodyLarge + ?.copyWith(color: theme.colorScheme.onSurfaceVariant), + ), + if (onRetry != null) ...[ + const SizedBox(height: 16), + Center( + child: FilledButton(onPressed: onRetry, child: const Text('Retry')), + ), + ], + ], + ); + } +} diff --git a/app/lib/ui/features/collections/view_models/collections_view_model.dart b/app/lib/ui/features/collections/view_models/collections_view_model.dart deleted file mode 100644 index d26e9cd2af5ea72030262a32311d4f4c6226c3a8..0000000000000000000000000000000000000000 --- a/app/lib/ui/features/collections/view_models/collections_view_model.dart +++ /dev/null @@ -1,65 +0,0 @@ -/// Collections list state (MVVM, docs/09): the user's named groups of cards. -/// Loads from the repository, supports create + delete. Detail (member cards) -/// is fetched on demand by the detail screen. -library; - -import 'package:flutter/foundation.dart'; - -import '../../../../data/repositories/card_repository.dart'; -import '../../../../domain/models/collection.dart'; - -enum CollectionsStatus { idle, loading, ready, error, empty } - -class CollectionsViewModel extends ChangeNotifier { - CollectionsViewModel({required CardRepository repository}) - : _repository = repository; - - final CardRepository _repository; - - CollectionsStatus _status = CollectionsStatus.idle; - CollectionsStatus get status => _status; - - List _collections = const []; - List get collections => List.unmodifiable(_collections); - - String? _error; - String? get error => _error; - - Future load({bool showSpinner = true}) async { - if (showSpinner) { - _status = CollectionsStatus.loading; - notifyListeners(); - } - try { - final list = await _repository.listCollections(); - _collections = list; - _status = - list.isEmpty ? CollectionsStatus.empty : CollectionsStatus.ready; - _error = null; - } catch (e) { - _error = '$e'; - _status = CollectionsStatus.error; - } - notifyListeners(); - } - - Future refresh() => load(showSpinner: false); - - Future create(String name) async { - final trimmed = name.trim(); - if (trimmed.isEmpty) return; - await _repository.createCollection(trimmed); - await load(showSpinner: false); - } - - Future delete(String id) async { - _collections = _collections.where((c) => c.id != id).toList(); - if (_collections.isEmpty) _status = CollectionsStatus.empty; - notifyListeners(); - try { - await _repository.deleteCollection(id); - } catch (_) { - await load(showSpinner: false); - } - } -} diff --git a/app/lib/ui/features/collections/views/collections_screen.dart b/app/lib/ui/features/collections/views/collections_screen.dart deleted file mode 100644 index 53059ead09940e9e3a97518958784b06c6390766..0000000000000000000000000000000000000000 --- a/app/lib/ui/features/collections/views/collections_screen.dart +++ /dev/null @@ -1,292 +0,0 @@ -/// Collections: the user's named groups of cards (docs/09). A list of -/// collections; tapping one opens a grid of its member cards. Creating a -/// collection is a single name prompt; cards are added from the reader. -library; - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../../data/repositories/card_repository.dart'; -import '../../../../domain/models/collection.dart'; -import '../../../core/theme.dart'; -import '../../library/views/card_tile.dart'; -import '../../reader/views/reader_screen.dart'; -import '../view_models/collections_view_model.dart'; - -class CollectionsScreen extends StatelessWidget { - const CollectionsScreen({super.key}); - - @override - Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (ctx) => - CollectionsViewModel(repository: ctx.read())..load(), - child: const _CollectionsView(), - ); - } -} - -class _CollectionsView extends StatelessWidget { - const _CollectionsView(); - - @override - Widget build(BuildContext context) { - final vm = context.watch(); - return Scaffold( - appBar: AppBar(title: const Text('Collections')), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _promptCreate(context, vm), - icon: const Icon(Icons.create_new_folder_outlined), - label: const Text('New'), - ), - body: RefreshIndicator( - onRefresh: vm.refresh, - child: _body(context, vm), - ), - ); - } - - Widget _body(BuildContext context, CollectionsViewModel vm) { - switch (vm.status) { - case CollectionsStatus.loading: - return const Center(child: CircularProgressIndicator()); - case CollectionsStatus.error: - return _Message( - icon: Icons.wifi_off_rounded, - title: "Can't reach the backend", - subtitle: vm.error ?? '', - action: FilledButton(onPressed: vm.load, child: const Text('Retry')), - ); - case CollectionsStatus.empty: - return const _Message( - icon: Icons.folder_open_rounded, - title: 'No collections yet', - subtitle: 'Group related cards — tap New, then add cards from a card.', - ); - case CollectionsStatus.idle: - case CollectionsStatus.ready: - final items = vm.collections; - return ListView.separated( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(Insets.page, 8, Insets.page, 96), - itemCount: items.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (ctx, i) => _CollectionRow(collection: items[i], vm: vm), - ); - } - } - - Future _promptCreate( - BuildContext context, CollectionsViewModel vm) async { - final controller = TextEditingController(); - final name = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('New collection'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(hintText: 'e.g. Recipes to try'), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('Create'), - ), - ], - ), - ); - if (name != null && name.trim().isNotEmpty) await vm.create(name); - } -} - -class _CollectionRow extends StatelessWidget { - const _CollectionRow({required this.collection, required this.vm}); - final Collection collection; - final CollectionsViewModel vm; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: const Icon(Icons.folder_rounded), - title: Text(collection.name), - subtitle: Text('${collection.count} ' - '${collection.count == 1 ? 'card' : 'cards'}'), - trailing: IconButton( - icon: const Icon(Icons.delete_outline_rounded), - onPressed: () => _confirmDelete(context), - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => CollectionDetailScreen( - collectionId: collection.id, - name: collection.name, - ), - ), - ), - ); - } - - Future _confirmDelete(BuildContext context) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Delete collection?'), - content: Text('“${collection.name}” will be removed. ' - 'The cards themselves are kept.'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('Delete'), - ), - ], - ), - ); - if (ok == true) vm.delete(collection.id); - } -} - -/// A collection's member cards, fetched on open. Reuses the library [CardTile]. -class CollectionDetailScreen extends StatefulWidget { - const CollectionDetailScreen({ - super.key, - required this.collectionId, - required this.name, - }); - final String collectionId; - final String name; - - @override - State createState() => _CollectionDetailScreenState(); -} - -class _CollectionDetailScreenState extends State { - CollectionDetail? _detail; - String? _error; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - try { - final detail = - await context.read().getCollection(widget.collectionId); - if (mounted) setState(() => _detail = detail); - } catch (e) { - if (mounted) setState(() => _error = '$e'); - } - } - - Future _remove(String cardId) async { - await context - .read() - .removeCardFromCollection(widget.collectionId, cardId); - await _load(); - } - - @override - Widget build(BuildContext context) { - final api = context.read().api; - final detail = _detail; - return Scaffold( - appBar: AppBar(title: Text(widget.name)), - body: _error != null - ? Center(child: Text("Couldn't load this collection")) - : detail == null - ? const Center(child: CircularProgressIndicator()) - : detail.cards.isEmpty - ? const Center( - child: Padding( - padding: EdgeInsets.all(32), - child: Text( - 'No cards yet. Open a card and use "Add to collection".', - textAlign: TextAlign.center, - ), - ), - ) - : GridView.builder( - padding: const EdgeInsets.fromLTRB( - Insets.page, 12, Insets.page, 24), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 14, - crossAxisSpacing: 14, - childAspectRatio: 0.72, - ), - itemCount: detail.cards.length, - itemBuilder: (ctx, i) { - final card = detail.cards[i]; - return CardTile( - card: card, - api: api, - confirmTitle: 'Remove from collection?', - confirmBody: - 'The card stays in your library; it just leaves ' - 'this collection.', - confirmAction: 'Remove', - onTap: () => Navigator.of(ctx).push( - MaterialPageRoute( - builder: (_) => - ReaderScreen(cardId: card.cardId), - ), - ), - onDelete: () => _remove(card.cardId), - ); - }, - ), - ); - } -} - -class _Message extends StatelessWidget { - const _Message({ - required this.icon, - required this.title, - required this.subtitle, - this.action, - }); - final IconData icon; - final String title; - final String subtitle; - final Widget? action; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - children: [ - SizedBox(height: MediaQuery.of(context).size.height * 0.22), - Icon(icon, size: 52, color: theme.colorScheme.outline), - const SizedBox(height: 16), - Text(title, - textAlign: TextAlign.center, style: theme.textTheme.titleLarge), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 48), - child: Text(subtitle, - textAlign: TextAlign.center, - style: theme.textTheme.bodyMedium - ?.copyWith(color: theme.colorScheme.onSurfaceVariant)), - ), - if (action != null) ...[ - const SizedBox(height: 20), - Center(child: action!), - ], - ], - ); - } -} diff --git a/app/lib/ui/features/graph/views/graph_screen.dart b/app/lib/ui/features/graph/views/graph_screen.dart index 3a83c6b85d94ac50e52f9123ce85a96dc9213a7e..77db984e1f74b6ebcf0db08a64e4922541604dd4 100644 --- a/app/lib/ui/features/graph/views/graph_screen.dart +++ b/app/lib/ui/features/graph/views/graph_screen.dart @@ -46,6 +46,7 @@ class _GraphScreenState extends State Offset _lastFocal = Offset.zero; double _baseZoom = 1.0; String? _selected; + String? _draggedNode; static const double _k = 78; // ideal edge length @@ -149,6 +150,7 @@ class _GraphScreenState extends State // Centre gravity + apply with temperature cap. for (final n in data.nodes) { + if (n.id == _draggedNode) continue; var dd = disp[n.id]! - _pos[n.id]! * 0.03; final len = dd.distance; if (len > 0) dd = dd / len * math.min(len, _temp); @@ -161,19 +163,44 @@ class _GraphScreenState extends State // Gestures // ------------------------------------------------------------------------ // - void _onScaleStart(ScaleStartDetails d) { + void _onScaleStart(ScaleStartDetails d, Size size) { _baseZoom = _zoom; _lastFocal = d.focalPoint; + _draggedNode = _hitTest(d.localFocalPoint, size); + if (_draggedNode != null) { + _selected = _draggedNode; + _temp = math.max(_temp, 25); + if (!_ticker.isActive) _ticker.start(); + } } - void _onScaleUpdate(ScaleUpdateDetails d) { + void _onScaleUpdate(ScaleUpdateDetails d, Size size) { setState(() { - _zoom = (_baseZoom * d.scale).clamp(0.3, 4.0); - _pan += d.focalPoint - _lastFocal; + if (d.pointerCount > 1 || (d.scale - 1.0).abs() > 0.03) { + _draggedNode = null; + _zoom = (_baseZoom * d.scale).clamp(0.3, 4.0); + _pan += d.focalPoint - _lastFocal; + } else if (_draggedNode != null) { + final center = Offset(size.width / 2, size.height / 2); + final world = (d.localFocalPoint - center - _pan) / _zoom; + _pos[_draggedNode!] = world; + _temp = math.max(_temp, 25); + if (!_ticker.isActive) _ticker.start(); + } else { + _pan += d.focalPoint - _lastFocal; + } _lastFocal = d.focalPoint; }); } + void _onScaleEnd(ScaleEndDetails d) { + if (_draggedNode != null) { + _draggedNode = null; + _temp = math.max(_temp, 15); + if (!_ticker.isActive) _ticker.start(); + } + } + void _onTapUp(TapUpDetails d, Size size) { final hit = _hitTest(d.localPosition, size); if (hit == null) { @@ -194,7 +221,7 @@ class _GraphScreenState extends State double bestD = double.infinity; for (final entry in _pos.entries) { final d = (entry.value - world).distance; - if (d < 22 && d < bestD) { + if (d < 30 && d < bestD) { bestD = d; best = entry.key; } @@ -242,8 +269,9 @@ class _GraphScreenState extends State builder: (context, constraints) { final size = Size(constraints.maxWidth, constraints.maxHeight); return GestureDetector( - onScaleStart: _onScaleStart, - onScaleUpdate: _onScaleUpdate, + onScaleStart: (d) => _onScaleStart(d, size), + onScaleUpdate: (d) => _onScaleUpdate(d, size), + onScaleEnd: _onScaleEnd, onTapUp: (d) => _onTapUp(d, size), child: CustomPaint( size: size, diff --git a/app/lib/ui/features/library/views/card_tile.dart b/app/lib/ui/features/library/views/card_tile.dart index 6295a10e38e97c62a34d0ce32e6ff27564796ba2..2c5495a85d91345f48cd2b02a2059a4e8fe65f3f 100644 --- a/app/lib/ui/features/library/views/card_tile.dart +++ b/app/lib/ui/features/library/views/card_tile.dart @@ -28,8 +28,8 @@ class CardTile extends StatelessWidget { final VoidCallback onTap; final VoidCallback onDelete; - /// Long-press confirm copy; overridable so the same tile can mean "remove from - /// collection" rather than "delete the card" depending on context. + /// Long-press confirm copy; overridable so the same tile can mean "remove" + /// rather than "delete the card" depending on context. final String confirmTitle; final String confirmBody; final String confirmAction; diff --git a/app/lib/ui/features/reader/view_models/reader_view_model.dart b/app/lib/ui/features/reader/view_models/reader_view_model.dart index b3a6543b13fb1085a9bdafbd60e86b199b6c3d17..8901057aaa6e9641de847c6e51530940a50b18e4 100644 --- a/app/lib/ui/features/reader/view_models/reader_view_model.dart +++ b/app/lib/ui/features/reader/view_models/reader_view_model.dart @@ -122,6 +122,42 @@ class ReaderViewModel extends ChangeNotifier { await _persist(current, updated); } + // ----------------------------------------------------------------------- // + // Action items (docs/13): follow the card's to-dos into the Actions hub, + // then tick them off. Optimistic + PATCH, same pattern as checkable blocks. + // ----------------------------------------------------------------------- // + + Future setActionsFollowed(bool followed) async { + final current = _card; + if (current == null) return; + final updated = ActionItems(followed: followed, items: current.actionItems.items); + await _persistActions(current, updated); + } + + Future toggleActionItem(String itemId, bool done) async { + final current = _card; + if (current == null) return; + final updated = ActionItems( + followed: current.actionItems.followed, + items: [ + for (final it in current.actionItems.items) + it.id == itemId ? ActionItem(id: it.id, text: it.text, done: done) : it, + ], + ); + await _persistActions(current, updated); + } + + Future _persistActions(Card before, ActionItems updated) async { + _card = before.copyWith(actionItems: updated); // optimistic + _safeNotify(); + try { + _card = await _repository.patchActionItems(cardId, updated.toJson()); + } catch (_) { + _card = before; // revert on failure + } + _safeNotify(); + } + void _applyOptimistic(Card current, List> rawBlocks) { _card = Card.fromJson({ ..._rawShallow(current), diff --git a/app/lib/ui/features/reader/views/reader_screen.dart b/app/lib/ui/features/reader/views/reader_screen.dart index 300b77bbab4c33767920f51ec02b864e59492fdb..49e4a612ac0bd64d376e1a8712d53562eff83b67 100644 --- a/app/lib/ui/features/reader/views/reader_screen.dart +++ b/app/lib/ui/features/reader/views/reader_screen.dart @@ -12,7 +12,6 @@ import 'package:provider/provider.dart'; import '../../../../data/repositories/card_repository.dart'; import '../../../../domain/models/artifact.dart'; import '../../../../domain/models/card.dart' as model; -import '../../../../domain/models/collection.dart'; import '../../../../domain/models/enums.dart'; import '../../../../domain/models/pipeline_event.dart'; import '../../../core/content_accent.dart'; @@ -117,6 +116,9 @@ class _ReaderView extends StatelessWidget { artifacts: vm.artifacts, onOpenArtifact: (e) => openLookup(e), ), + // Action items (docs/13): follow into the Actions hub, tick off. + if (card.isReady && card.actionItems.isPresent) + _ActionItemsSection(card: card, accent: accent, vm: vm), // Referenced things (books/products/places) as tappable covers. if (card.isReady) _ReferencesStrip(entries: vm.artifacts), if (card.source.creator != null) ...[ @@ -157,14 +159,7 @@ class _FaceAppBar extends StatelessWidget { expandedHeight: 240, pinned: true, stretch: true, - actions: [ - if (card.isReady) - IconButton( - tooltip: 'Add to collection', - icon: const Icon(Icons.playlist_add_rounded), - onPressed: () => _addToCollection(context, card), - ), - ], + actions: const [], flexibleSpace: FlexibleSpaceBar( background: Hero( tag: 'card-face-${card.cardId}', @@ -173,116 +168,6 @@ class _FaceAppBar extends StatelessWidget { ), ); } - - Future _addToCollection(BuildContext context, model.Card card) async { - final repo = context.read(); - List collections; - try { - collections = await repo.listCollections(); - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Couldn't load collections")), - ); - } - return; - } - if (!context.mounted) return; - final choice = await showModalBottomSheet<_CollectionChoice>( - context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Padding( - padding: EdgeInsets.fromLTRB(20, 18, 20, 8), - child: Align( - alignment: Alignment.centerLeft, - child: Text('Add to collection', - style: TextStyle( - fontSize: 18, fontWeight: FontWeight.w700)), - ), - ), - ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: const Text('New collection…'), - onTap: () => Navigator.pop(ctx, const _CollectionChoice.create()), - ), - for (final c in collections) - ListTile( - leading: const Icon(Icons.folder_rounded), - title: Text(c.name), - onTap: () => Navigator.pop(ctx, _CollectionChoice.existing(c.id)), - ), - const SizedBox(height: 8), - ], - ), - ), - ); - if (choice == null || !context.mounted) return; - - var collectionId = choice.id; - if (choice.isCreate) { - final name = await _promptName(context); - if (name == null || name.trim().isEmpty || !context.mounted) return; - try { - collectionId = (await repo.createCollection(name.trim())).id; - } catch (_) { - return; - } - } - if (collectionId == null) return; - try { - await repo.addCardToCollection(collectionId, card.cardId); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Added to collection')), - ); - } - } catch (_) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("Couldn't add to collection")), - ); - } - } - } - - Future _promptName(BuildContext context) { - final controller = TextEditingController(); - return showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('New collection'), - content: TextField( - controller: controller, - autofocus: true, - decoration: const InputDecoration(hintText: 'e.g. Recipes to try'), - onSubmitted: (v) => Navigator.pop(ctx, v), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () => Navigator.pop(ctx, controller.text), - child: const Text('Create'), - ), - ], - ), - ); - } -} - -/// A pick from the add-to-collection sheet: an existing collection or "create". -class _CollectionChoice { - const _CollectionChoice.existing(this.id) : isCreate = false; - const _CollectionChoice.create() - : id = null, - isCreate = true; - final String? id; - final bool isCreate; } class _TypeChip extends StatelessWidget { @@ -526,6 +411,132 @@ class _ReferenceTile extends StatelessWidget { } } +/// "Do this" section (docs/13): the concrete to-dos the reel hands you. Unfollowed +/// it's a preview with a Follow button; once followed each item is checkable and +/// the card appears in the Actions hub. +class _ActionItemsSection extends StatelessWidget { + const _ActionItemsSection({ + required this.card, + required this.accent, + required this.vm, + }); + final model.Card card; + final ContentAccent accent; + final ReaderViewModel vm; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final actions = card.actionItems; + final followed = actions.followed; + return Container( + margin: const EdgeInsets.only(top: 24), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: accent.color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(Insets.radius), + border: Border.all(color: accent.color.withValues(alpha: 0.25)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.checklist_rounded, size: 18, color: accent.color), + const SizedBox(width: 8), + Text('Do this', + style: theme.textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700)), + ], + ), + const SizedBox(height: 12), + for (final item in actions.items) + followed + ? _FollowedActionTile( + item: item, + accent: accent, + onToggle: (v) => vm.toggleActionItem(item.id, v), + ) + : Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.circle, size: 7, color: accent.color), + const SizedBox(width: 10), + Expanded( + child: Text(item.text, + style: theme.textTheme.bodyMedium), + ), + ], + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: followed + ? OutlinedButton.icon( + onPressed: () => vm.setActionsFollowed(false), + icon: const Icon(Icons.check_circle_rounded, size: 18), + label: const Text('Following — remove from Actions'), + ) + : FilledButton.icon( + onPressed: () => vm.setActionsFollowed(true), + icon: const Icon(Icons.playlist_add_check_rounded, size: 18), + label: const Text('Follow these actions'), + ), + ), + ], + ), + ); + } +} + +class _FollowedActionTile extends StatelessWidget { + const _FollowedActionTile({ + required this.item, + required this.accent, + required this.onToggle, + }); + final model.ActionItem item; + final ContentAccent accent; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: () => onToggle(!item.done), + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + item.done + ? Icons.check_box_rounded + : Icons.check_box_outline_blank_rounded, + size: 22, + color: item.done ? accent.color : theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + item.text, + style: theme.textTheme.bodyMedium?.copyWith( + decoration: item.done ? TextDecoration.lineThrough : null, + color: item.done ? theme.colorScheme.onSurfaceVariant : null, + ), + ), + ), + ], + ), + ), + ); + } +} + class _SourceLine extends StatelessWidget { const _SourceLine({required this.card}); final model.Card card; diff --git a/app/test/tags_and_lookup_test.dart b/app/test/tags_and_lookup_test.dart index 46b886c28e56343745891f20d1f2c493583e85b1..8c670db4aee9a913ebfa224ed6bfc261e7bc8d14 100644 --- a/app/test/tags_and_lookup_test.dart +++ b/app/test/tags_and_lookup_test.dart @@ -100,10 +100,10 @@ void main() { expect(uri.host, 'music.apple.com'); }); - test('app → Play Store', () { + test('app → Google Search', () { final uri = lookupUri(makeEntry(ArtifactType.app, 'Notion')); - expect(uri.host, 'play.google.com'); - expect(uri.queryParameters['c'], 'apps'); + expect(uri.host, 'www.google.com'); + expect(uri.queryParameters['q'], 'Notion'); }); test('place → Google Maps', () { diff --git a/app/test/widget_test.dart b/app/test/widget_test.dart index bc1814dbba915d06cc115cdca0d647aeb180cb1c..95659bd51fe517b8f14da94affaaf4992364a365 100644 --- a/app/test/widget_test.dart +++ b/app/test/widget_test.dart @@ -107,5 +107,42 @@ void main() { }); expect(card.base.contentType, ContentType.other); }); + + test('action_items parse and round-trip through toJson (docs/13)', () { + final card = Card.fromJson({ + 'card_id': 'c4', + 'state': 'ready', + 'source': {'url': 'u'}, + 'action_items': { + 'followed': true, + 'items': [ + {'id': 'a1', 'text': 'Drink water', 'done': true}, + {'id': 'a2', 'text': 'Sleep early', 'done': false}, + ], + }, + }); + expect(card.actionItems.followed, isTrue); + expect(card.actionItems.isPresent, isTrue); + expect(card.actionItems.items.first.text, 'Drink water'); + expect(card.actionItems.items.first.done, isTrue); + // Survives serialization for caching / PATCH. + expect(card.actionItems.toJson(), { + 'followed': true, + 'items': [ + {'id': 'a1', 'text': 'Drink water', 'done': true}, + {'id': 'a2', 'text': 'Sleep early', 'done': false}, + ], + }); + }); + + test('missing action_items defaults to unfollowed empty', () { + final card = Card.fromJson({ + 'card_id': 'c5', + 'state': 'ready', + 'source': {'url': 'u'}, + }); + expect(card.actionItems.followed, isFalse); + expect(card.actionItems.isPresent, isFalse); + }); }); } diff --git a/backend/app/api/cards.py b/backend/app/api/cards.py index e339137c42adfd1af93423df1b72d24c384558aa..9bac39d7be94d0ea1239fb8bc9b8d18280fd480b 100644 --- a/backend/app/api/cards.py +++ b/backend/app/api/cards.py @@ -39,6 +39,8 @@ class CreateCardResponse(BaseModel): class PatchCardRequest(BaseModel): # Phase-1 user-mutable state lives inside blocks JSON (e.g. checked items). blocks: list | None = None + # User-mutable to-do state (docs/13): {followed, items:[{id,text,done}]}. + action_items: dict | None = None state: None = None # state is server-controlled; ignored if sent @@ -158,6 +160,8 @@ async def patch_card(card_id: str, req: PatchCardRequest) -> Card: raise HTTPException(status_code=404, detail="card not found") if req.blocks is not None: row.blocks = req.blocks # e.g. updated checked flags (optimistic client) + if req.action_items is not None: + row.action_items = req.action_items # follow toggle + per-item done state await session.commit() await session.refresh(row) return row.to_card() diff --git a/backend/app/api/catalog.py b/backend/app/api/catalog.py index 1d6e514759731a997d37368cd924668a9f18e6c0..4ca98c67a46a18d275231b9ce3a70d38723deb44 100644 --- a/backend/app/api/catalog.py +++ b/backend/app/api/catalog.py @@ -46,7 +46,7 @@ async def list_catalog( async def get_catalog_entry(artifact_id: str) -> CatalogDetail: async with db.session() as session: row = await session.get(db.ArtifactRow, artifact_id) - if row is None: + if row is None or not row.saved: raise HTTPException(status_code=404, detail="artifact not found") entry = row.to_entry() return CatalogDetail(entry=entry, source_card_ids=entry.source_card_ids) diff --git a/backend/app/api/collections.py b/backend/app/api/collections.py deleted file mode 100644 index 7e1b4f505b01875ce5b4e01c8bbe6d30f4b7e470..0000000000000000000000000000000000000000 --- a/backend/app/api/collections.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Collection endpoints (docs/09): user-created groups of cards. A collection is -just a named list of card ids — no block-schema change. Client-created, unlike -the worker-populated catalog.""" - -from __future__ import annotations - -from fastapi import APIRouter, HTTPException -from pydantic import BaseModel -from sqlalchemy import delete, select - -from app.models.card import Card -from app.models.collection import Collection -from app.store import db - -router = APIRouter(prefix="/collections", tags=["collections"]) - - -class CreateCollectionRequest(BaseModel): - name: str - - -class AddCardRequest(BaseModel): - card_id: str - - -class CollectionDetail(BaseModel): - collection: Collection - cards: list[Card] - - -@router.post("", response_model=Collection) -async def create_collection(req: CreateCollectionRequest) -> Collection: - name = req.name.strip() - if not name: - raise HTTPException(status_code=422, detail="name is required") - async with db.session() as session: - row = db.CollectionRow(name=name, card_ids=[]) - session.add(row) - await session.commit() - await session.refresh(row) - return row.to_collection() - - -@router.get("", response_model=list[Collection]) -async def list_collections() -> list[Collection]: - async with db.session() as session: - stmt = select(db.CollectionRow).order_by(db.CollectionRow.created_at.desc()) - rows = (await session.execute(stmt)).scalars().all() - return [r.to_collection() for r in rows] - - -@router.get("/{collection_id}", response_model=CollectionDetail) -async def get_collection(collection_id: str) -> CollectionDetail: - async with db.session() as session: - row = await session.get(db.CollectionRow, collection_id) - if row is None: - raise HTTPException(status_code=404, detail="collection not found") - collection = row.to_collection() - # Resolve member cards, preserving membership order; skip any deleted ids. - cards: list[Card] = [] - for cid in collection.card_ids: - card_row = await db.get_card_row(session, cid) - if card_row is not None: - cards.append(card_row.to_card()) - return CollectionDetail(collection=collection, cards=cards) - - -@router.delete("/{collection_id}") -async def delete_collection(collection_id: str) -> dict: - async with db.session() as session: - row = await session.get(db.CollectionRow, collection_id) - if row is None: - raise HTTPException(status_code=404, detail="collection not found") - await session.execute( - delete(db.CollectionRow).where(db.CollectionRow.id == collection_id) - ) - await session.commit() - return {"deleted": collection_id} - - -@router.post("/{collection_id}/cards", response_model=Collection) -async def add_card(collection_id: str, req: AddCardRequest) -> Collection: - async with db.session() as session: - row = await session.get(db.CollectionRow, collection_id) - if row is None: - raise HTTPException(status_code=404, detail="collection not found") - if req.card_id not in (row.card_ids or []): - row.card_ids = [*(row.card_ids or []), req.card_id] - await session.commit() - await session.refresh(row) - return row.to_collection() - - -@router.delete("/{collection_id}/cards/{card_id}", response_model=Collection) -async def remove_card(collection_id: str, card_id: str) -> Collection: - async with db.session() as session: - row = await session.get(db.CollectionRow, collection_id) - if row is None: - raise HTTPException(status_code=404, detail="collection not found") - row.card_ids = [c for c in (row.card_ids or []) if c != card_id] - await session.commit() - await session.refresh(row) - return row.to_collection() diff --git a/backend/app/main.py b/backend/app/main.py index 1df2663215bbb42821338d7b0174a7daee4f5dd6..f5a65259b59ab61fae538d9eda75db8d7e595148 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -13,7 +13,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles -from app.api import cards, catalog, collections, graph, library_chat, search +from app.api import cards, catalog, graph, library_chat, search from app.models.card import SCHEMA_VERSION from app.pipeline import worker from app.store import db, media @@ -58,7 +58,6 @@ app.add_middleware( app.include_router(cards.router) app.include_router(catalog.router) -app.include_router(collections.router) app.include_router(library_chat.router) app.include_router(search.router) app.include_router(graph.router) diff --git a/backend/app/models/card.py b/backend/app/models/card.py index f506cae0f8d502affc6e43bcdb1ef9e5b3294688..b66e8140e09d802267a1b98569e80faae44808dc 100644 --- a/backend/app/models/card.py +++ b/backend/app/models/card.py @@ -13,7 +13,7 @@ from typing import Annotated, Literal, Optional, Union from pydantic import BaseModel, Field -SCHEMA_VERSION = "1.2" # 1.1: artifacts list (docs/12); 1.2: base.tags auto-tagging (docs/09) +SCHEMA_VERSION = "1.3" # 1.1: artifacts list (docs/12); 1.2: base.tags (docs/09); 1.3: action_items (docs/13) # --------------------------------------------------------------------------- # @@ -182,7 +182,7 @@ VOCAB: set[str] = { class Source(BaseModel): url: str - platform: Optional[str] = None # instagram | tiktok | youtube + platform: Optional[str] = None # instagram | youtube creator: Optional[str] = None caption: str = "" duration_seconds: Optional[int] = None @@ -203,6 +203,20 @@ class PrimaryAction(BaseModel): payload: dict = Field(default_factory=dict) +class ActionItem(BaseModel): + """One concrete thing the video tells the viewer to do (docs/13).""" + id: str = Field(default_factory=lambda: "a_" + uuid.uuid4().hex[:8]) + text: str + done: bool = False + + +class ActionItems(BaseModel): + """Per-card action list (docs/13). Generated inert at ingestion; `followed` + flips to True only when the user opts the card into the Actions hub.""" + followed: bool = False + items: list[ActionItem] = Field(default_factory=list) + + class Media(BaseModel): thumbnail: Optional[str] = None keyframes: list[str] = Field(default_factory=list) @@ -230,6 +244,7 @@ class Card(BaseModel): source: Source base: Base = Field(default_factory=Base) primary_action: PrimaryAction = Field(default_factory=PrimaryAction) + action_items: ActionItems = Field(default_factory=ActionItems) blocks: list[Block] = Field(default_factory=list) media: Media = Field(default_factory=Media) meta: Meta = Field(default_factory=Meta) diff --git a/backend/app/models/collection.py b/backend/app/models/collection.py deleted file mode 100644 index ea0cdbbc75adb193291f565df68c624b897957ff..0000000000000000000000000000000000000000 --- a/backend/app/models/collection.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Collection models (docs/09 library & retrieval). - -A *collection* is a user-created group of cards — a manual folder over the -library, distinct from the auto-tags on a card and the global artifact catalog. -Membership is just a list of card ids; the cards themselves are unchanged, so -this needs no block-schema change. -""" - -from __future__ import annotations - -import uuid -from datetime import datetime, timezone - -from pydantic import BaseModel, Field - - -def _new_id() -> str: - return "c_" + uuid.uuid4().hex[:8] - - -class Collection(BaseModel): - """One named group of cards (membership by card id).""" - - id: str = Field(default_factory=_new_id) - name: str - card_ids: list[str] = Field(default_factory=list) - created_at: str = Field( - default_factory=lambda: datetime.now(timezone.utc).isoformat() - ) diff --git a/backend/app/pipeline/ingestion/downloader.py b/backend/app/pipeline/ingestion/downloader.py index db13da7351d61e78a4b066bbde7588ed518dbead..89e96b29790bbdfc04c0a79876393e91443d9d2b 100644 --- a/backend/app/pipeline/ingestion/downloader.py +++ b/backend/app/pipeline/ingestion/downloader.py @@ -35,7 +35,7 @@ MediaType = Literal["video", "images", "article"] # URLs on these hosts go to the fragile video resolver cascade; everything else # is treated as a readable article/post (docs/02 article path). -_VIDEO_HOSTS = ("instagram.com", "tiktok.com", "youtube.com", "youtu.be") +_VIDEO_HOSTS = ("instagram.com", "youtube.com", "youtu.be") # Optional keyless resolvers, in cascade order. Probed by name; missing ones skip. _KEYLESS_NAMES = [ @@ -180,36 +180,35 @@ def download_content( os.makedirs(job_dir, exist_ok=True) output_path = os.path.join(job_dir, "video.mp4") - # 1) Optional keyless resolvers — free, fragile, ordered. A raised exception in - # one must not abort the cascade; it just moves to the next. - for name, fn in _keyless_resolvers(): - log.debug("trying keyless resolver: %s", name) - res = _safe_call(name, lambda fn=fn: fn(url, output_path)) - if res: - if len(res) == 3: - m_type, path_or_list, caption = res - else: - path_or_list, caption = res - m_type = "video" - log.info("download ok via %s", name) - return DownloadResult(m_type, path_or_list, caption, name) - - # 2) RapidAPI — optional, only if a key is configured and resolvers exposes it. - rapid = getattr(resolvers, "_download_rapidapi", None) - if config.rapidapi_key and callable(rapid): - log.debug("trying rapidapi") - res = _safe_call( - "rapidapi", - lambda: rapid(url, output_path, config.rapidapi_key), - ) - if res: - if len(res) == 3: - m_type, path_or_list, caption = res - else: - path_or_list, caption = res - m_type = "video" - log.info("download ok via rapidapi") - return DownloadResult(m_type, path_or_list, caption, "rapidapi") + # 1) Optional keyless resolvers & RapidAPI — free, fragile, ordered (Instagram only). + if "instagram.com" in url: + for name, fn in _keyless_resolvers(): + log.debug("trying keyless resolver: %s", name) + res = _safe_call(name, lambda fn=fn: fn(url, output_path)) + if res: + if len(res) == 3: + m_type, path_or_list, caption = res + else: + path_or_list, caption = res + m_type = "video" + log.info("download ok via %s", name) + return DownloadResult(m_type, path_or_list, caption, name) + + rapid = getattr(resolvers, "_download_rapidapi", None) + if config.rapidapi_key and callable(rapid): + log.debug("trying rapidapi") + res = _safe_call( + "rapidapi", + lambda: rapid(url, output_path, config.rapidapi_key), + ) + if res: + if len(res) == 3: + m_type, path_or_list, caption = res + else: + path_or_list, caption = res + m_type = "video" + log.info("download ok via rapidapi") + return DownloadResult(m_type, path_or_list, caption, "rapidapi") # 3) yt-dlp — local fallback for videos/reels. yt = getattr(resolvers, "_download_yt_dlp", None) diff --git a/backend/app/pipeline/ingestion/resolvers.py b/backend/app/pipeline/ingestion/resolvers.py index 5ac7e182761ed626c34bd0d70f21005caa5362bd..498066f534bc80170a311d743d6c59bb33c9f11c 100644 --- a/backend/app/pipeline/ingestion/resolvers.py +++ b/backend/app/pipeline/ingestion/resolvers.py @@ -612,7 +612,7 @@ def _download_yt_dlp( """ out_path = Path(output_path) opts = { - "format": "bestvideo[ext=mp4]+bestaudio/best", + "format": "bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best", "outtmpl": str(out_path), "merge_output_format": "mp4", "quiet": True, diff --git a/backend/app/pipeline/ingestion/source.py b/backend/app/pipeline/ingestion/source.py index c6121bbb8acd1c209c0064daaebfd2d3884ccf3e..6620599ff97514219cca8a4cf367bd97ad511f12 100644 --- a/backend/app/pipeline/ingestion/source.py +++ b/backend/app/pipeline/ingestion/source.py @@ -10,7 +10,6 @@ from urllib.parse import urlparse # host substring -> platform label _KNOWN: tuple[tuple[str, str], ...] = ( ("instagram.com", "instagram"), - ("tiktok.com", "tiktok"), ("youtube.com", "youtube"), ("youtu.be", "youtube"), ("reddit.com", "reddit"), diff --git a/backend/app/pipeline/structuring.py b/backend/app/pipeline/structuring.py index ef7419b519add9bc73b42edc07c1cd821d3997f8..7160fc2c2bee4328f33e6213004781e6e279ff59 100644 --- a/backend/app/pipeline/structuring.py +++ b/backend/app/pipeline/structuring.py @@ -16,6 +16,8 @@ import re from app.config import get_settings from app.models.artifact import Artifact, ArtifactType from app.models.card import ( + ActionItem, + ActionItems, Base, Block, ContentType, @@ -66,6 +68,7 @@ class StructuredCard: blocks: list[dict], primary_action: PrimaryAction, artifacts: list[Artifact] | None = None, + action_items: ActionItems | None = None, degraded: bool = False, degraded_reason: str = "", ): @@ -73,6 +76,9 @@ class StructuredCard: self.blocks = blocks # list of plain dicts (validated), ready for JSON column self.primary_action = primary_action self.artifacts = artifacts or [] # referenced things for the catalog (docs/12) + # Concrete to-dos the video tells the viewer to do (docs/13). Inert + # (followed=False) at ingestion; the user opts a card into the hub later. + self.action_items = action_items or ActionItems() # True when no LLM produced a usable structured card and we fell back to a # plain paragraph — a degraded result the worker surfaces as a warning. self.degraded = degraded @@ -131,6 +137,9 @@ Return ONLY a JSON object (no prose, no markdown fences) with this exact shape: "title": str, // the proper name of the thing "creator": str|null, // author / director / artist / host / brand "year": int|null }} + ], + "action_items": [ // concrete things the viewer should DO (may be empty) + str // one short imperative task, e.g. "Batch emails into two daily windows" ] }} @@ -147,6 +156,11 @@ Rules: (paragraph/bullet/step/callout — NOT table cells), wrap its name in double brackets where you introduce it, e.g. "I loved [[Atomic Habits]]." Wrap only names that are in your artifacts list, and wrap each name at most once. +- action_items: the concrete, doable tasks the video is telling the viewer to take + away and DO (e.g. "Drink water before each meal", "Try the Notion template"). + Short imperative phrases, max ~8. These are the takeaways to act on, NOT a + restatement of every step in a recipe/tutorial. Return an empty list if the + video is purely informational with nothing to act on. {vocab} @@ -307,6 +321,27 @@ def _coerce_tags(raw_tags) -> list[str]: return out +def _coerce_action_items(raw_items) -> ActionItems: + """Validate the to-do list (docs/13): keep non-empty strings, dedupe, cap at 8, + assign ids, done=False, followed=False (inert until the user opts in). Never + trust the model — non-list / non-string / empty entries are dropped.""" + out: list[ActionItem] = [] + if not isinstance(raw_items, list): + return ActionItems() + seen: set[str] = set() + for raw in raw_items: + if not isinstance(raw, str): + continue + text = " ".join(raw.split()).strip() + if not text or len(text) > 200 or text.lower() in seen: + continue + seen.add(text.lower()) + out.append(ActionItem(text=text)) + if len(out) >= 8: + break + return ActionItems(followed=False, items=out) + + def _synthesize_base(bundle: str, transcript: str, caption: str) -> Base: """When the model omits one_liner/tldr, build something usable from raw text.""" source = (transcript or caption or "").strip() @@ -381,11 +416,12 @@ def _validate(raw_text: str, bundle: str, transcript: str, caption: str) -> "Str base.tldr = base.tldr or synth.tldr artifacts = _coerce_artifacts(data.get("artifacts") or []) + action_items = _coerce_action_items(data.get("action_items") or []) blocks = _coerce_blocks(data.get("blocks") or []) if not blocks: # an empty/garbage block list still gets a usable body, but keep any - # artifacts the model did surface (the catalog doesn't need blocks). + # artifacts/action_items the model did surface (neither needs blocks). log.warning( "structuring: LLM produced no valid blocks -> paragraph fallback" ) @@ -393,10 +429,12 @@ def _validate(raw_text: str, bundle: str, transcript: str, caption: str) -> "Str bundle, transcript, caption, reason="no valid blocks in LLM output" ) fallback.artifacts = artifacts + fallback.action_items = action_items return fallback return StructuredCard( - base, blocks, _primary_action_for(base.content_type), artifacts + base, blocks, _primary_action_for(base.content_type), artifacts, + action_items=action_items, ) diff --git a/backend/app/pipeline/worker.py b/backend/app/pipeline/worker.py index 387f8fe77303266ddb20c0b341abf09d45ab92e9..a4bfe19858ec89aeab95215ea55de1bbf05f91ed 100644 --- a/backend/app/pipeline/worker.py +++ b/backend/app/pipeline/worker.py @@ -92,6 +92,7 @@ async def _write_base(session, card_id: str, structured) -> None: type_confidence=base.type_confidence, tags=base.tags, primary_action=structured.primary_action.model_dump(), + action_items=structured.action_items.model_dump(), ) ) await session.commit() diff --git a/backend/app/store/db.py b/backend/app/store/db.py index d3c3f4ef3dd38f5ee2c76611bf9b6218bc288e65..2a1f7cbf668f04f4426868e5b6f7887a9089f123 100644 --- a/backend/app/store/db.py +++ b/backend/app/store/db.py @@ -28,8 +28,8 @@ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column from app.config import get_settings from app.store import media as media_store from app.models.artifact import ArtifactType, CatalogEntry -from app.models.collection import Collection from app.models.card import ( + ActionItems, Base as CardBase, Card, CardState, @@ -78,6 +78,8 @@ class CardRow(Base): embedding: Mapped[list | None] = mapped_column(JSON, nullable=True) # semantic search (docs/09) primary_action: Mapped[dict | None] = mapped_column(JSON, nullable=True) + # {followed: bool, items: [{id, text, done}]} — the to-do list (docs/13). + action_items: Mapped[dict | None] = mapped_column(JSON, nullable=True) blocks: Mapped[list] = mapped_column(JSON, default=list) thumbnail: Mapped[str | None] = mapped_column(String, nullable=True) keyframes: Mapped[list | None] = mapped_column(JSON, nullable=True) @@ -114,6 +116,7 @@ class CardRow(Base): tags=list(self.tags or []), ), primary_action=PrimaryAction(**(self.primary_action or {})), + action_items=ActionItems(**(self.action_items or {})), blocks=self.blocks or [], media=Media( thumbnail=media_store.to_media_url(self.thumbnail), @@ -145,7 +148,7 @@ class ArtifactRow(Base): thumbnail: Mapped[str | None] = mapped_column(Text, nullable=True) source_card_ids: Mapped[list] = mapped_column(JSON, default=list) # Only saved rows show in the catalog tab; unsaved rows are card references only. - saved: Mapped[bool] = mapped_column(Boolean, default=False, index=True) + saved: Mapped[bool] = mapped_column(Boolean, default=True, index=True) # On-demand LLM detail ("what is this"), filled via the Fetch info action. description: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) @@ -168,29 +171,6 @@ class ArtifactRow(Base): ) -class CollectionRow(Base): - """A user-created group of cards (docs/09). Membership is a card_ids JSON list, - matching the JSON-on-row pattern used for blocks / source_card_ids.""" - - __tablename__ = "collections" - - id: Mapped[str] = mapped_column(String, primary_key=True, default=_new_uuid) - name: Mapped[str] = mapped_column(Text) - card_ids: Mapped[list] = mapped_column(JSON, default=list) - created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow) - updated_at: Mapped[datetime] = mapped_column( - DateTime, default=_utcnow, onupdate=_utcnow - ) - - def to_collection(self) -> Collection: - return Collection( - id=self.id, - name=self.name, - card_ids=list(self.card_ids or []), - created_at=(self.created_at or _utcnow()).isoformat(), - ) - - class JobRow(Base): __tablename__ = "jobs" @@ -231,10 +211,15 @@ async def init_db() -> None: conn, "artifacts", { - "saved": "BOOLEAN DEFAULT 0", + "saved": "BOOLEAN DEFAULT 1", "description": "TEXT", }, ) + await _add_missing_columns( + conn, + "cards", + {"action_items": "JSON"}, # docs/13 — added in schema 1.3 + ) async def _add_missing_columns(conn, table: str, columns: dict[str, str]) -> None: @@ -306,6 +291,7 @@ async def upsert_artifact( year=year, thumbnail=thumbnail, source_card_ids=[card_id], + saved=True, ) db.add(row) else: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 344b58224b8555d7bf35179f1390d484e94713b1..25294d337ad6b04332fce528f5a695896638d7b1 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -18,7 +18,7 @@ async def client(database): async def test_health(client): r = await client.get("/health") assert r.status_code == 200 - assert r.json()["schema_version"] == "1.2" + assert r.json()["schema_version"] == "1.3" async def test_create_returns_id_and_queued(client): @@ -55,6 +55,24 @@ async def test_get_list_patch_delete(client): assert p.status_code == 200 assert p.json()["blocks"][0]["items"][0]["checked"] is True + # action_items (docs/13): follow toggle + per-item done round-trips via PATCH. + actions = { + "followed": True, + "items": [{"id": "a1", "text": "Drink water", "done": True}], + } + pa = await client.patch(f"/cards/{card_id}", json={"action_items": actions}) + assert pa.status_code == 200 + body = pa.json()["action_items"] + assert body["followed"] is True + assert body["items"][0]["done"] is True + # default card is unfollowed with an empty list + fresh = await client.post( + "/cards", json={"url": "https://instagram.com/reel/actions-default"} + ) + fid = fresh.json()["card_id"] + fa = (await client.get(f"/cards/{fid}")).json()["action_items"] + assert fa == {"followed": False, "items": []} + d = await client.delete(f"/cards/{card_id}") assert d.status_code == 200 assert (await client.get(f"/cards/{card_id}")).status_code == 404 @@ -183,65 +201,6 @@ async def test_catalog_detail_and_delete(client, database): assert (await client.get(f"/catalog/{artifact_id}")).status_code == 404 -# --------------------------------------------------------------------------- -# Collections CRUD -# --------------------------------------------------------------------------- - -async def test_collections_create_and_list(client): - r = await client.post("/collections", json={"name": "My reads"}) - assert r.status_code == 200 - col = r.json() - assert col["name"] == "My reads" - assert col["card_ids"] == [] - - lst = await client.get("/collections") - assert any(c["id"] == col["id"] for c in lst.json()) - - -async def test_collections_create_blank_name_422(client): - r = await client.post("/collections", json={"name": " "}) - assert r.status_code == 422 - - -async def test_collections_add_remove_card(client): - col_r = await client.post("/collections", json={"name": "Cooking"}) - col_id = col_r.json()["id"] - - card_r = await client.post("/cards", json={"url": "https://instagram.com/reel/coll1"}) - card_id = card_r.json()["card_id"] - - # add - add = await client.post(f"/collections/{col_id}/cards", json={"card_id": card_id}) - assert add.status_code == 200 - assert card_id in add.json()["card_ids"] - - # idempotent second add - add2 = await client.post(f"/collections/{col_id}/cards", json={"card_id": card_id}) - assert add2.json()["card_ids"].count(card_id) == 1 - - # detail resolves cards - detail = await client.get(f"/collections/{col_id}") - assert detail.status_code == 200 - assert any(c["card_id"] == card_id for c in detail.json()["cards"]) - - # remove - rm = await client.delete(f"/collections/{col_id}/cards/{card_id}") - assert rm.status_code == 200 - assert card_id not in rm.json()["card_ids"] - - -async def test_collections_delete(client): - r = await client.post("/collections", json={"name": "Temp"}) - col_id = r.json()["id"] - d = await client.delete(f"/collections/{col_id}") - assert d.status_code == 200 - assert (await client.get(f"/collections/{col_id}")).status_code == 404 - - -async def test_collections_missing_404(client): - assert (await client.get("/collections/no-such-id")).status_code == 404 - - # --------------------------------------------------------------------------- # /catalog?card_id= filter # --------------------------------------------------------------------------- diff --git a/backend/tests/test_article.py b/backend/tests/test_article.py index 2b51a6e78f31392c299420f990444ad2605885ab..13bfb4b67b4de9db847d6da77ede1b84d0b85117 100644 --- a/backend/tests/test_article.py +++ b/backend/tests/test_article.py @@ -63,7 +63,7 @@ def test_fetch_article_handles_no_extract(monkeypatch): @pytest.mark.parametrize("url,is_video", [ ("https://www.instagram.com/reel/abc", True), ("https://youtu.be/abc", True), - ("https://www.tiktok.com/@x/video/1", True), + ("https://www.youtube.com/shorts/abc", True), ("https://www.reddit.com/r/x/comments/1/title", False), ("https://en.wikipedia.org/wiki/Sleep", False), ("https://someblog.com/post", False), diff --git a/code/downloader.py b/code/downloader.py deleted file mode 100644 index 0cca8b1a92ef6c03ee391e8f61d9c390e1127d6b..0000000000000000000000000000000000000000 --- a/code/downloader.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -downloader.py — ingestion layer for the reel-to-knowledge pipeline. - -Wraps the keyless resolver cascade (your existing file, kept as `resolvers.py`) -behind a clean, async-friendly interface for the FastAPI backend. - -Design intent: -- Resolver internals in resolvers.py are intentionally LEFT UNTOUCHED. They are - fragile by nature (exact headers, magic strings, HTML scraping) and the cascade - exists precisely so that when one breaks, the next is tried. -- This module only adds the things the app needs around them: structured results, - logging, per-job isolation, a single ordered cascade, and an async entry point - so the blocking work never stalls the event loop. - -To wire up: -1. Rename your current downloader file to `resolvers.py` (keep it as-is). -2. Remove its old `download_content` and `download_video` functions — this module - replaces them. The `_download_*` resolver functions stay exactly as they are. -3. (Optional) swap the `print(...)` calls in resolvers.py for a module logger. -""" - -from __future__ import annotations - -import asyncio -import logging -import os -import uuid -from dataclasses import dataclass, field -from typing import Callable, Literal, Optional, Union - -import resolvers # your existing resolver functions, unchanged - -log = logging.getLogger("ingestion.downloader") - -MediaType = Literal["video", "images"] - - -# --------------------------------------------------------------------------- # -# Config & result types -# --------------------------------------------------------------------------- # - -@dataclass(frozen=True) -class DownloaderConfig: - """Per-deployment settings. rapidapi_key is optional and read from env.""" - output_dir: str = "downloads" - cookies_path: Optional[str] = None - rapidapi_key: str = field( - default_factory=lambda: os.getenv("RAPIDAPI_KEY", "").strip() - ) - - -@dataclass -class DownloadResult: - """What the pipeline's extraction stage consumes next.""" - media_type: MediaType # "video" or "images" - data: Union[str, list[str]] # path to .mp4, OR list of image paths - caption: str # may be empty; OCR/transcript compensates - resolver: str # which strategy succeeded (for observability) - - -class DownloadError(RuntimeError): - """Raised when every resolver in the cascade fails — an expected outcome.""" - - -# --------------------------------------------------------------------------- # -# Cascade definition -# --------------------------------------------------------------------------- # - -def _keyless_resolvers() -> list[tuple[str, Callable]]: - """ - Free, keyless resolvers, tried in order. Each is (name, fn) where fn has the - signature (url, output_path) -> (path, caption) | None. Order preserved from - the original cascade. - """ - return [ - ("vidssave", resolvers._download_vidssave), - ("savethevideo", resolvers._download_savethevideo), - ("saveig", resolvers._download_saveig), - ("downloadgram", resolvers._download_downloadgram), - ("anyvidsave", resolvers._download_anyvidsave), - ("igreelsdl", resolvers._download_igreelsdl), - ] - - -# --------------------------------------------------------------------------- # -# Orchestration (sync core) -# --------------------------------------------------------------------------- # - -def download_content(url: str, config: DownloaderConfig | None = None) -> DownloadResult: - """ - Run the full cascade for a single URL and return a structured result. - - Each call gets an isolated job directory so concurrent workers don't clobber - each other's media files. - - Raises DownloadError if nothing in the cascade succeeds. - """ - config = config or DownloaderConfig() - - # Per-job isolation: unique dir per call so concurrent downloads never collide. - job_dir = os.path.join(config.output_dir, uuid.uuid4().hex) - os.makedirs(job_dir, exist_ok=True) - output_path = os.path.join(job_dir, "video.mp4") - - # 1) Keyless resolvers — free, fragile, ordered. A raised exception in one - # must not abort the cascade; it just moves to the next. - for name, fn in _keyless_resolvers(): - log.debug("trying keyless resolver: %s", name) - res = _safe_call(name, lambda: fn(url, output_path)) - if res: - path, caption = res - log.info("download ok via %s", name) - return DownloadResult("video", path, caption, name) - - # 2) RapidAPI — optional, only if a key is configured. - if config.rapidapi_key: - log.debug("trying rapidapi") - res = _safe_call( - "rapidapi", - lambda: resolvers._download_rapidapi(url, output_path, config.rapidapi_key), - ) - if res: - path, caption = res - log.info("download ok via rapidapi") - return DownloadResult("video", path, caption, "rapidapi") - - # 3) yt-dlp — local fallback for videos/reels. - log.debug("trying yt-dlp") - res = _safe_call( - "yt-dlp", - lambda: resolvers._download_yt_dlp(url, output_path, config.cookies_path), - ) - if res: - path, caption = res - log.info("download ok via yt-dlp") - return DownloadResult("video", path, caption, "yt-dlp") - - # 4) Instaloader — local fallback for Instagram carousels/images. - # NOTE: resolvers._download_instaloader writes to a shared "temp_images" - # dir and is NOT concurrency-safe. Fine for single-worker dev; flagged - # for a later fix before running parallel workers. - if "instagram.com" in url: - log.debug("trying instaloader") - res = _safe_call("instaloader", lambda: resolvers._download_instaloader(url)) - if res: - paths, caption = res - log.info("download ok via instaloader (%d images)", len(paths)) - return DownloadResult("images", paths, caption, "instaloader") - - raise DownloadError(f"all resolvers failed for {url}") - - -def _safe_call(name: str, fn: Callable): - """Run a resolver, swallow + log any exception so the cascade continues.""" - try: - return fn() - except Exception: - log.exception("resolver %s raised; continuing cascade", name) - return None - - -# --------------------------------------------------------------------------- # -# Async entry point (what the FastAPI worker calls) -# --------------------------------------------------------------------------- # - -async def download_content_async( - url: str, config: DownloaderConfig | None = None -) -> DownloadResult: - """ - Async-safe entry point. The cascade is blocking (requests + yt-dlp + - instaloader), so it runs in a worker thread to avoid stalling the event loop. - """ - return await asyncio.to_thread(download_content, url, config) diff --git a/docs/00-overview.md b/docs/00-overview.md deleted file mode 100644 index fd383e2bf74f1a4b37d5f554ee8ebc26a046499f..0000000000000000000000000000000000000000 --- a/docs/00-overview.md +++ /dev/null @@ -1,66 +0,0 @@ -# 00 — Overview - -## One-line - -Share any short-form video to the app; it extracts everything meaningful inside -it (spoken audio, on-screen text, visual context, caption) and reshapes that into -a **structured card** the user can read, act on, and keep — so watching feels -productive instead of disposable. - -## The problem - -Short-form video is engaging but disposable. People save reels they'll never -revisit and consume content they gain nothing lasting from. The value inside a -reel — a recipe, a workout, a tip, a place — is locked in a format that's hard -to act on and impossible to search. - -## The product - -The app ingests a shared video, runs extraction, and produces a card that is: - -- **Readable at multiple depths** — a one-liner you grasp in 2 seconds, takeaways - you skim in 20, full detail when you want it. -- **Never plain text** — its elements *do things* (checkable steps, mappable - places, schedulable actions) and it's heavily visual (keyframes, maps, charts). -- **Actionable** — every card surfaces the one thing the reel is asking you to do. -- **Retrievable** — searchable, taggable, and queryable as a personal knowledge base. - -## Two usage modes (one output) - -These share the same card and differ only in *when* attention arrives. - -### Transparent Capture -User shares reels while scrolling, which immediately opens the app to show the pipeline process. The user sees exactly what the app is doing (downloading, extracting, structuring) so they aren't left waiting blindly. They can watch the card build in real-time. - -### Read-now -User shares, then immediately opens the app to read the card as a faster -replacement for rewatching. Here the card streams top-down: the one-liner and -TL;DR render first while deeper blocks fill in beneath. Perceived speed matters -more than total completion time. - -## Design principles (the non-negotiables) - -1. **Transparent capture.** The share path opens the app and visually shows the pipeline's progress. No blind waiting. -2. **Hierarchy over completeness.** A faithful full transcript is boring and feels - like homework. Lead with the point; bury the depth. Readable in 2 seconds and - in 2 minutes. -3. **Functional richness, not decoration.** "Not plain text" means elements *do - things*, not that they have emojis and colors. -4. **Visual-first, with a hierarchy.** Heavily graphical, but in two tiers that - don't compete: **content-visuals** (keyframes, maps, charts — the video's own - substance) are the star; **design-visuals** (color, motion, icons) are the - frame and stay calm where a content-visual is carrying meaning. Rich, not busy. -5. **One next action.** Every card makes clear the single thing the reel asks the - user to do — and lets them do or schedule it. -6. **Constrained vocabulary, flexible arrangement.** A fixed set of renderable - block types; content type decides which blocks appear and in what order. New - content types should rarely require new UI code. -7. **Always produce something.** Even when type detection or extraction partially - fails, a usable base card (one-liner + TL;DR + source) is always rendered. - -## What "productive" actually means here - -Productivity isn't a feeling the formatting creates — it's the feeling of having -done the thing the reel was asking of you. A workout reel's job is done when -you've worked out, not when you've read a nice card. The structured text is the -*understanding*; the action is the *productivity*. The card carries both. diff --git a/docs/01-architecture.md b/docs/01-architecture.md deleted file mode 100644 index 85ce2f47e99ff2d1b47106bd27a31afd14b50a29..0000000000000000000000000000000000000000 --- a/docs/01-architecture.md +++ /dev/null @@ -1,92 +0,0 @@ -# 01 — Architecture - -## High-level shape - -Three pieces: - -- **Mobile client (Flutter)** — share extension, library, card reader, actions, - search. Holds no heavy processing. -- **Backend service (Python / FastAPI)** — job queue, ingestion, extraction, - structuring, storage, search. -- **Async job pipeline** — the phone shares, which opens the app to stream pipeline updates (downloading, extracting, structuring) until the result is ready. - -``` -┌──────────────┐ share / paste ┌──────────────────────────┐ -│ Flutter app │ ────────────────────────▶│ FastAPI: POST /cards │ -│ - share ext │ │ enqueue job, return id │ -│ - library │◀──── card (poll/push) ───│ │ -│ - reader │ └────────────┬─────────────┘ -└──────────────┘ │ - ▼ - ┌──────────────────────────┐ - │ Worker (job queue) │ - │ download → extract → │ - │ structure → persist │ - └────────────┬─────────────┘ - │ - ┌────────────────────┼───────────────────┐ - ▼ ▼ ▼ - ingestion extraction structuring - (resolvers) (ffmpeg/Whisper) (HF/Groq LLM) -``` - -## The pipeline (share → card) - -``` -Share / paste - → Opens app - → POST /cards : create card (state = QUEUED), enqueue job - → Client subscribes to SSE stream at GET /cards/{id}/stream - → Worker picks up job (state = PROCESSING) - 1. ingestion: download media + caption (02-ingestion.md) - 2. extraction: ffmpeg → audio + keyframes - Whisper → transcript - (OCR via Tesseract, scene via HF VLM) (03-extraction.md) - 3. structuring: text-only LLM → validated block list (04-...-schema.md) - 4. persist: save card + blocks + media refs - → state = READY (or FAILED with a reason) - → Stream pushes updates to client at each step so user sees progress. -``` - -## Card state machine - -``` -QUEUED ──▶ PROCESSING ──▶ READY - │ │ - └────────────┴──▶ FAILED (reason: unavailable | no_content | unsupported | timeout) -``` - -- **QUEUED** — accepted, not yet started. Shows as a pending card in the library. -- **PROCESSING** — worker is running it. In read-now, partial content may stream. -- **READY** — card complete and renderable. -- **FAILED** — surfaced gently via the streaming progress UI. Always carries a reason. - -## Progressive rendering (read-now) - -The card persists incrementally so the reader can stream it: - -1. `base` (one-liner + TL;DR + source) is written as soon as structuring returns - the head of its output. -2. `blocks` fill in beneath. -3. `media` (thumbnail/keyframes) attaches when extraction finishes. - -The client renders whatever is present and shows lightweight placeholders for -what's still arriving — never one blocking spinner over the whole card. - -## Concurrency - -Multiple jobs run in parallel. Two consequences baked into the design: - -- Each download gets an **isolated job directory** (see `02-ingestion.md`) so - media files never collide. -- The job queue + worker pool size is a deployment knob; Whisper is the heaviest - step and effectively sets per-worker memory/CPU needs. - -## Reliability requirements - -- Retry transient extraction failures; dead-letter jobs that can't complete. -- No silent drops — every job ends in READY or FAILED with a recorded reason. -- Cached cards readable offline on the client; shares queued locally if the - backend is unreachable and synced when it returns. -- Observability: success rate per resolver, processing time per stage, failure - reasons. (Which resolver succeeded is recorded on every card.) diff --git a/docs/02-ingestion.md b/docs/02-ingestion.md deleted file mode 100644 index d05dddaaf2238b44cef5ea78b46264faa705a3b2..0000000000000000000000000000000000000000 --- a/docs/02-ingestion.md +++ /dev/null @@ -1,116 +0,0 @@ -# 02 — Ingestion - -## Strategy - -There is no clean official API for reel media, so ingestion uses a **cascade of -keyless third-party resolver services**, with local tools as fallback. These -services are fragile by nature — they get rate-limited, change endpoints, and -quietly die — so the cascade is built to treat **total failure as a normal, -expected outcome**, not a crash. - -This is a deliberate, accepted tradeoff (it runs against the source platforms' -terms of service). It is appropriate for a personal/early-stage tool; revisit -before any wide public distribution. - -## The cascade (order matters) - -Tried in sequence; first success wins: - -1. **Keyless resolvers** (free): vidssave → savethevideo → saveig → downloadgram - → anyvidsave → igreelsdl -2. **RapidAPI** (optional, only if `RAPIDAPI_KEY` is set) — kept as a paid escape - hatch; the app does not depend on it. -3. **yt-dlp** (local) — videos/reels fallback. -4. **instaloader** (local) — Instagram carousels/images fallback. - -## Module layout - -``` -ingestion/ - resolvers.py # the working resolver functions — LEFT UNTOUCHED - downloader.py # clean orchestration + async wrapper (the integration layer) -``` - -`resolvers.py` is the original working code. Its internals (exact headers, magic -strings, HTML scraping) are intentionally **not refactored** — that's the -fragile part that "just works," and rewriting it risks breaking it. - -`downloader.py` is the thin layer the app actually calls. It provides: - -- `DownloaderConfig` — output dir, cookies path, optional RapidAPI key (from env). -- `DownloadResult(media_type, data, caption, resolver)` — structured result; - records *which* resolver succeeded for observability. -- `download_content(url, config)` — sync cascade; isolates each job in a unique - directory; catches per-resolver exceptions so one failure doesn't abort the chain. -- `download_content_async(url, config)` — the FastAPI entry point. The cascade is - blocking (requests + yt-dlp + instaloader), so it runs in a worker thread via - `asyncio.to_thread` to avoid stalling the event loop. - -## How the worker calls it - -```python -from ingestion.downloader import download_content_async, DownloadError - -async def ingest(job): - job.state = "PROCESSING" - try: - result = await download_content_async(job.url) - except DownloadError: - job.state = "FAILED"; job.reason = "unavailable" - return None - # result.media_type -> "video" | "images" - # result.data -> path to .mp4 | list of image paths - # result.caption -> may be empty; OCR/transcript compensates - # result.resolver -> which strategy won (logged for metrics) - return result -``` - -## Known issues to fix before scaling - -- **instaloader is not concurrency-safe.** It writes to a shared `temp_images` - dir and `rmtree`s it; parallel jobs will fight. Fine for single-worker dev; - give it a per-job dir before running parallel workers. -- **Captions are often empty** from several resolvers. Downstream extraction - (transcript + OCR) is the real content source; never depend on caption alone. -- **Resolver rot.** Expect individual resolvers to break over time. Monitor - per-resolver success rate (it's recorded on every card) and prune/replace dead ones. - -## Article path (non-video sources) - -Not every shared link is short-form video. Reddit, Wikipedia, LinkedIn, -Substack, news, and blogs are **text** — there is no media to download, the -content *is* the article. These take a parallel path instead of the resolver -cascade. - -**Routing (domain classify).** `downloader._is_video_url` checks the host: -Instagram / TikTok / YouTube go to the fragile resolver cascade (unchanged); -**everything else is treated as an article**. yt-dlp stays a safety net — if -article extraction yields nothing usable, a non-video host is retried through -yt-dlp in case the page is actually a video it supports. - -**Extraction.** `ingestion/article.py` (new orchestration the app owns — *not* -title, readable body, author, and a remote lead-image URL. Best-effort: a thin -body (paywall/login wall) or any error → `None` → normal ingestion failure (a -LinkedIn post behind auth simply fails gracefully, same model as a dead -resolver). - -``` -DownloadResult(media_type="article", data="", caption=title, resolver="article", - text=…, title=…, author=…, image_url=…) -``` - -**Pipeline fit.** The extraction stage branches on `media_type == "article"`: -it skips ffmpeg / Whisper / OCR entirely, builds the labeled text bundle from -title + body, and uses the remote `image_url` directly as the thumbnail -(nothing is downloaded — fits the ephemeral free tier). Structuring (docs/04) is -**unchanged**: article text flows in as the bundle exactly like a transcript -does. No block-schema change. - -**Platform label.** `ingestion/source.py::platform_for_url` maps a URL to a -label (reddit / wikipedia / linkedin / substack / medium / known video -platforms), falling back to the bare host — shown in the card's source line. - -## Caching - -Cache by source URL. Re-sharing the same reel returns the existing card instead -of re-running the cascade — saves resolver load, extraction compute, and LLM quota. diff --git a/docs/03-extraction.md b/docs/03-extraction.md deleted file mode 100644 index 6b66cfddb9bd26d0851e8ce8708d4a2963cedb84..0000000000000000000000000000000000000000 --- a/docs/03-extraction.md +++ /dev/null @@ -1,126 +0,0 @@ -# 03 — Extraction - -Turns downloaded media into the raw signal the structuring step consumes. Goal: -an all-free stack that **keeps the structuring LLM doing as little as possible**, so -the free tier survives bursts. Heavy lifting (transcription, OCR) runs off the LLM — -on free hosted Whisper and on local compute — and the LLM receives pre-digested text. - -## Pipeline - -``` -media (video.mp4 or images) - → ffmpeg: extract audio + sample candidate frames (scene-change based) - → frame dedup: drop near-identical frames → keep distinct frames - → Groq Whisper: audio → transcript (off the LLM) - → Tesseract OCR: distinct frames → on-screen text (local) (off the LLM) - → [conditional] scene description: ONLY if transcript+OCR thin → HF Inference VLM - → aggregate: labeled text bundle - → Structuring LLM: text-only call → validated blocks (see 04) -``` - -Per card, the structuring LLM does **one text-only call** — always. Every vision -task (OCR via local Tesseract, scene description via a hosted open VLM) runs off the -LLM, so the structuring model (text-only by design) only ever sees lightweight text. - -### 1. ffmpeg (free, local) -- Extract the audio track for transcription. -- Sample candidate frames on scene changes (not a fixed 1/sec — adjacent frames - in a reel are near-identical). -- Pick the card thumbnail from the frames. -- For image carousels, the images themselves are the frames. - -### 2. Frame dedup (free, local) -Drop near-identical frames before OCR so Tesseract runs on a handful of *distinct* -frames, not 30 copies of the same overlay. Cheap perceptual-hash comparison. - -### 3. Transcription — Groq Whisper (free tier, off the LLM) -Audio → transcript via Groq's hosted Whisper (free, fast, off-device — keeps the -HF Space light). `faster-whisper` (local) is the fallback if you move to a GPU -Space. Empty/music-only audio is valid; the card leans on OCR + caption instead. - -### 4. OCR — Tesseract (local, free, off the LLM) -On-screen text overlays often carry the real information. Run Tesseract locally on -the distinct frames. No API, no quota — unlimited local compute. - -> **Quality trade-off (test early):** Tesseract reads stylized social-media text -> (fancy fonts, text over busy backgrounds, emoji) noticeably worse than a hosted -> vision model. For a project demo the quota safety usually wins, but test on a real -> reel — if Tesseract mangles the overlay text, route that frame to the conditional -> HF Inference VLM (step 5) for a vision OCR pass. Measure, then decide. - -### 5. Scene description — conditional, HF Inference VLM (rare) -Scene description is a vision task and can't run on the free HF Space CPU (a local -captioning model is too slow/RAM-heavy there). Instead, route it to a **hosted -open VLM via HF Inference** — keeping it off both the Space CPU *and* the structuring -LLM: - -- **Model:** `HuggingFaceM4/idefics2-8b` (strong open VLM for captioning/VQA), or - its lighter successor **SmolVLM** / Idefics3. Prefer whichever is currently live - and best-supported on HF Inference. -- **Trigger:** only when transcript AND OCR both come back empty/thin (a purely - visual reel). Most cards skip this entirely. -- **Free-tier fit:** free serverless inference allows a few hundred requests/hour - for sub-~10B models (idefics2-8b qualifies), with 10–30s cold starts on less - popular models. Because this step is *rare*, neither the rate limit nor the cold - start meaningfully bites. - -> **Verify before depending on it:** the old "Serverless Inference API" is now -> *Inference Providers*, and Idefics2 runs on TGI (maintenance mode as of 2026). -> Confirm the chosen model actually responds on HF Inference today before wiring -> it in; have a fallback (SmolVLM, or skip scene description) if it's gone cold. - -For maximum simplicity in v1, scene description may be omitted altogether. - -### 6. Aggregate (free, local) -Combine the signals into one labeled text bundle for the LLM. **Loose aggregation -is enough for v1** — no timestamp-level syncing needed: - -``` -CAPTION: -TRANSCRIPT: -ON-SCREEN TEXT: -[SCENE: ] -SOURCE: platform / creator / duration -``` - -Timestamp-aligning OCR-vs-transcript-vs-scene is a real technique but a phase-2 -refinement with marginal payoff for structured cards. Don't build it now. - -### 7. Structuring — text-only LLM call (see 04) -The aggregated text bundle goes to a single **text-only** LLM call that returns the -validated block list. Default backend is **HuggingFace Inference Providers** -(`Qwen/Qwen2.5-72B-Instruct`); **Groq** (`llama-3.1-70b-versatile`) is a selectable -fallback. Both are free-tier, both text-only — no images in this call. - -## The free stack, summarized - -| Stage | Tool | Free path | On the LLM? | -|-------|------|-----------|-------------| -| Audio + frame extract | ffmpeg | free, local | no | -| Frame dedup | perceptual hash | free, local | no | -| Transcription | Groq Whisper | hosted free tier | no | -| OCR | Tesseract | free, local | no | -| Scene description | HF Inference VLM (Idefics2 / SmolVLM) | hosted free tier, **conditional/rare** | no | -| Structuring | HF Inference (Qwen2.5-72B) / Groq (llama-3.1-70b) | free tier | yes — **text-only, always** | - -## Quota reality - -The biggest levers against the free-tier ceiling are **call count** (one text-only -call per card) and **duplicate caching** (re-shared reels never re-call the model). -This pipeline adds a third: the structuring call carries **no images**, which keeps -each call light on tokens and leaves any vision work to the rare conditional VLM pass. - -At project scale (tens–low hundreds of reels/day) the free tier likely survives -regardless; this design is the defensive version, strongest against burst-sharing. - -Verify current HF Inference / Groq free-tier limits before budgeting around them — -numbers shift. The structuring backend is selectable via `LLM_BACKEND` (see 05). - -## Inputs handed to structuring - -- `aggregated_text` (caption + transcript + OCR [+ scene], labeled) -- `keyframes` (used only by the conditional HF Inference VLM pass, never the LLM) -- `source` metadata (platform, creator, url, duration, resolver) - -Must produce a usable card even if transcript *and* OCR *and* caption are all thin, -falling back to the conditional scene pass or a minimal base card. diff --git a/docs/04-structuring-and-schema.md b/docs/04-structuring-and-schema.md deleted file mode 100644 index f0310d2e52b5cf7b8077fd34e674fed90b1ab405..0000000000000000000000000000000000000000 --- a/docs/04-structuring-and-schema.md +++ /dev/null @@ -1,194 +0,0 @@ -# 04 — Structuring & the Block Schema (keystone) - -This is the contract between backend and frontend. The backend produces cards in -exactly this shape; the frontend renders exactly these block types. Getting this -right is what prevents the schema-mismatch drift that plagues backend↔frontend -work. **Treat this file as the source of truth; both sides conform to it.** - -## Principle - -A fixed, small **vocabulary** of renderable blocks. The content type does not -select a rigid template — it shapes the *arrangement* of these shared primitives. -The model emits an ordered list of blocks; the renderer only ever needs to know -how to draw the vocabulary. Adding a new content type is usually a prompt change, -not new UI code. - -## Card object - -```json -{ - "schema_version": "1.2", - "card_id": "uuid", - "state": "queued | processing | ready | failed", - "failure_reason": null, - - "source": { - "url": "https://...", - "platform": "instagram | tiktok | youtube", - "creator": "@handle", - "caption": "", - "duration_seconds": 30, - "resolver": "vidssave" - }, - - "base": { - "one_liner": "3 ways to cut your AWS bill", - "tldr": "Short standalone summary the card can be understood by alone.", - "content_type": "recipe | workout | tutorial | tip | product_list | travel | news_explainer | other", - "type_confidence": 0.0, - "tags": ["aws", "cost-optimization"] - }, - - "primary_action": { - "kind": "shopping_list | schedule | save_place | reminder | export | none", - "label": "Add to shopping list", - "payload": {} - }, - - "blocks": [], - - "media": { - "thumbnail": "url_or_path", - "keyframes": ["url_or_path"] - }, - - "meta": { - "created_at": "ISO-8601", - "extraction": { "transcript": true, "ocr": true, "visual": true } - } -} -``` - -### Unconditional base layer -`base.one_liner`, `base.tldr`, and `source` are **always present**, regardless of -type-detection success. A card that confuses the type logic still renders usefully. - -- `one_liner` describes what the reel *gives you*, not what it's *about*. - Good: "3 ways to cut your AWS bill." Bad: "A video discussing cloud costs." -- `tldr` must let the card stand alone later, when the user has forgotten the reel. -- `tags` (schema 1.2) is a short list of lowercase topical keywords for browsing - and grouping the library. Validated like everything else (lowercased, deduped, - capped at 6); an empty list is normal and never blocks a card. - -## Block vocabulary - -Every block has `type` and `id`. Type-specific fields below. Phase-2 blocks marked. - -```json -// heading -{ "type": "heading", "id": "b1", "text": "Ingredients", "level": 2 } - -// paragraph -{ "type": "paragraph", "id": "b2", "text": "Plain prose." } - -// bullet_list -{ "type": "bullet_list", "id": "b3", "items": ["point one", "point two"] } - -// step_list (ordered; steps are individually checkable in the UI) -{ "type": "step_list", "id": "b4", - "steps": [ { "text": "Preheat oven to 200C", "checkable": true } ] } - -// key_value (sets/reps, prep time, price, specs) -{ "type": "key_value", "id": "b5", - "pairs": [ { "key": "Prep time", "value": "15 min" }, - { "key": "Serves", "value": "4" } ] } - -// checklist (shopping, packing — persists checked state) -{ "type": "checklist", "id": "b6", - "items": [ { "text": "Olive oil", "checked": false } ] } - -// callout (caveats, warnings, source notes; carries a confidence signal) -{ "type": "callout", "id": "b7", - "variant": "info | warning | caveat | source", - "text": "Creatine claims are widely supported.", - "confidence": "high | medium | low | unverified", - "source_url": null } - -// link -{ "type": "link", "id": "b8", "url": "https://...", "label": "Original study" } - -// map (PHASE 2) -{ "type": "map", "id": "b9", - "places": [ { "name": "Cafe X", "lat": 12.97, "lng": 77.59, "note": "" } ] } - -// table (PHASE 2) -{ "type": "table", "id": "b10", - "headers": ["Item", "Price"], "rows": [["A", "$5"], ["B", "$9"]] } -``` - -## How content types map to blocks (illustrative, not enforced) - -| content_type | typical block arrangement | -|--------------|---------------------------| -| recipe | heading → key_value(time/serves) → checklist(ingredients) → step_list(steps) | -| workout | heading → key_value(sets/reps) → step_list(exercises) | -| tutorial | heading → step_list → callout(tip) | -| tip | paragraph(tldr) → bullet_list(takeaways) → callout(caveat) | -| product_list | bullet_list or table → link(s) | -| travel | heading → map → bullet_list | -| news_explainer | paragraph → bullet_list(key points) → callout(source) | -| other | paragraph + bullet_list (the safe fallback) | - -## Primary action mapping - -`primary_action.kind` is derived from content type: - -| content_type | primary action | -|--------------|----------------| -| recipe | shopping_list (from the ingredients checklist) | -| workout | schedule (a session) | -| travel | save_place (places → map) | -| tutorial / tip | reminder (or export) | -| product_list | export | -| other | none | - -## The LLM output contract - -The structuring call is a **single text-only LLM call** — always. The default -backend is **HuggingFace Inference Providers** (`Qwen/Qwen2.5-72B-Instruct`), with -**Groq** (`llama-3.1-70b-versatile`) as a selectable fallback; both are text-only. -All vision (OCR, scene description) is handled upstream (see `03`), so the -structuring call never carries images. It receives the aggregated text bundle and -returns **only** a JSON card object matching this schema — no prose, no markdown -fences. The prompt: - -1. Provides the aggregated text (caption + transcript + on-screen OCR [+ scene]). -2. Lists the exact block vocabulary and forbids any type outside it. -3. Requires `base.one_liner` and `base.tldr` always be filled. -4. Asks it to choose `content_type` and arrange blocks accordingly. -5. Requires strict JSON output. - -## Validation (server-side, before persisting) - -Never trust raw model output. After parsing: - -- Strip code fences if present; parse JSON; on parse failure → **paragraph fallback** - (wrap transcript/caption into a single `paragraph` block + a generated `tldr`). -- Drop any block whose `type` is outside the vocabulary (or coerce to `paragraph`). -- Drop blocks missing required fields for their type. -- Ensure `base.one_liner` and `base.tldr` are non-empty; synthesize from transcript - if the model omitted them. -- Coerce `base.tags` to lowercase deduped strings, capped at 6 (drop non-strings). -- Assign stable `id`s if missing. - -The guarantee: **a card always renders something sane**, even on a degraded -model response. This validation step is the difference between "occasionally -renders garbage" and "always usable." - -## Artifacts (catalog) — separate from blocks - -The same single structuring call also emits an `artifacts` list — concrete, -named things the video *references* (books, movies, podcasts, products, places). -These are **not blocks** and never render inside the card; they feed the global -catalog. Validated separately (title required, dedupe, drop malformed). See -`@docs/12-catalog-and-artifacts.md`. Their addition to the structuring output is -what took `schema_version` to `1.1`. - -## Versioning - -`schema_version` is on every card. When the vocabulary changes, bump it; the -client renders known versions and degrades gracefully on unknown future blocks -(render their `text`/`items` if present, else skip). **1.1** added the -`artifacts` list to the structuring output (block vocabulary unchanged). **1.2** -added `base.tags` (auto-tags) to the card object; the block vocabulary is still -unchanged, so older clients simply ignore the new field. diff --git a/docs/05-backend.md b/docs/05-backend.md deleted file mode 100644 index af0f7b561292d934f691617ffeb4a78407597d3f..0000000000000000000000000000000000000000 --- a/docs/05-backend.md +++ /dev/null @@ -1,116 +0,0 @@ -# 05 — Backend - -Python / FastAPI. Async job pipeline. Designed to run on a small host with a -free-tier-friendly footprint. - -## Project layout - -``` -backend/ - app/ - main.py # FastAPI app, router registration - config.py # settings (env-driven) - api/ - cards.py # card endpoints - search.py # search + chat endpoints (phase 2/3) - pipeline/ - worker.py # job runner: ingest → extract → structure → persist - ingestion/ - resolvers.py # working resolver functions (untouched) - downloader.py # async orchestration layer - extraction.py # ffmpeg + Whisper + keyframes - structuring.py # text-only LLM call (HF/Groq) + schema validation - models/ - card.py # Card / Block ORM + Pydantic schemas - job.py # Job + state machine - store/ - db.py # DB session - media.py # media file storage (local / object store) - services/ - cache.py # url → existing card - notify.py # push / badge - tests/ - pyproject.toml -``` - -## Endpoints (MVP) - -| Method | Path | Purpose | -|--------|------|---------| -| `POST` | `/cards` | Accept a shared URL. Create card (QUEUED), enqueue job, return `card_id` immediately. Returns existing card if URL is cached. | -| `GET` | `/cards/{id}/stream` | SSE endpoint to stream pipeline state changes to the client in real-time. | -| `GET` | `/cards/{id}` | Fetch a card (any state). Used for fetching full card once stream indicates READY. | -| `GET` | `/cards` | List cards (paginated, filter by state/collection/tag). | -| `DELETE` | `/cards/{id}` | Delete a card and its derived data + media. | -| `PATCH` | `/cards/{id}` | Update user-mutable state (checked items, collection, tags). | - -Phase 2+: `POST /search` (semantic), `POST /chat` (query over library), -`POST /cards/{id}/export`, collection endpoints. - -`POST /cards` returns the `card_id` immediately, and the client instantly connects to `GET /cards/{id}/stream` to show the user the pipeline's progress step-by-step. - -## Job queue - -The queue is the spine. Two viable free setups: - -- **MVP / simplest:** a DB-backed queue (a `jobs` table polled by a worker - process). No extra infra. Good enough to ship Phase 1. -- **When scaling:** Redis + a worker pool (RQ or Celery). Add when one worker - can't keep up. - -Worker loop per job: set PROCESSING → `download_content_async` → extraction → -structuring + validation → persist (writing `base` first for progressive render) -→ READY, or FAILED with a reason. Retries on transient errors; dead-letter after N. - -## Storage - -- **Database:** SQLite for dev, PostgreSQL for production. Schema in `08-data-model.md`. -- **Media:** downloaded video/keyframes/thumbnail. Local disk for MVP; an - S3-compatible object store (e.g. Cloudflare R2, which has a free tier) when you - need durability/scale. Thumbnails and keyframes are kept; the full source video - can be discarded after extraction to save space (decision flagged in `11`). - -## Config (env) - -``` -DATABASE_URL= -MEDIA_DIR=./downloads # or object-store creds -LLM_BACKEND=huggingface|groq|none # structuring backend (default: huggingface) -HF_API_KEY= # if LLM_BACKEND=huggingface -HF_MODEL=Qwen/Qwen2.5-72B-Instruct -RAPIDAPI_KEY= # optional -WHISPER_BACKEND=none|groq -GROQ_API_KEY= # transcription, and structuring if LLM_BACKEND=groq -GROQ_WHISPER_MODEL=whisper-large-v3-turbo -PUSH_BACKEND=none|fcm # FCM free tier -``` - -## Deployment (Hugging Face Spaces) - -Target: **Hugging Face Spaces** (free tier), as a **Docker Space** running the -FastAPI app + worker. The app architecture is unchanged; HF runs FastAPI cleanly -— just expose the port. Two HF-specific constraints to design around: - -- **Ephemeral filesystem.** The free-tier disk resets on every rebuild/restart, - and Spaces sleep when idle. Local disk and a SQLite file do **not** persist - across restarts. - - For a demo/project: ephemeral is acceptable if you only need cards to live - within a running session. - - For persistence: use a free external Postgres (Neon / Supabase free tier) for - the DB and an S3-compatible store (Cloudflare R2 free tier) for media, or HF - persistent storage (paid). -- **CPU-only, limited RAM (free tier).** Local Whisper is slow/heavy here. Prefer - **Groq hosted Whisper** (free tier) so the Space stays light and the demo is - snappy. Local `faster-whisper` remains an option if you later move to a GPU - Space or a different host. - -Worker note: on a single free Space, run the worker in-process (background task) -or as a second process in the same container — a DB-backed queue is the simplest -fit (no Redis to host). - -## Observability - -- Per-resolver success rate (recorded on each card via `source.resolver`). -- Per-stage processing time (ingest / extract / structure). -- Failure counts by reason. -- LLM call count (watch against the HF Inference / Groq free-tier ceiling). diff --git a/docs/06-frontend.md b/docs/06-frontend.md deleted file mode 100644 index 1094015d3dd1475b3cca5f5d97e49e0873aead79..0000000000000000000000000000000000000000 --- a/docs/06-frontend.md +++ /dev/null @@ -1,88 +0,0 @@ -# 06 — Frontend - -Flutter. The client is share-in, library, reader, and actions. It holds no heavy -processing — all extraction/structuring is backend. Its hardest jobs are the -**share extension** (must be invisible) and the **block renderer** (must draw the -schema vocabulary and stream progressively). - -## Project layout - -``` -app/lib/ - main.dart - core/ - api_client.dart # talks to FastAPI - models/ # Card, Block (mirror 04-...-schema.md exactly) - block.dart # sealed Block type + fromJson dispatch - features/ - share/ # share-extension receiver + enqueue - library/ # visual grid of cards (with states) - reader/ # card view + progressive render - blocks/ # one widget per block type (the renderer) - actions/ # shopping list, schedule, reminder, export - search/ # search + chat (phase 2/3) - design/ - theme.dart # color system, type scale, motion tokens - components/ # shared visual components -``` - -## Screens - -### Share receiver (Visible Pipeline) -Registered as a share target for Instagram/TikTok/YouTube. On receive: -The OS opens the app immediately. The app POSTs the URL to `/cards`, gets a `card_id`, and subscribes to the pipeline stream. It displays the progression (Downloading → Extracting → Structuring) so the user sees the work happening instead of waiting blindly. - -### Library (visual grid, not a list) -Cards as a grid of thumbnails — each card has a real face (keyframe), never a row -of text. Shows state badges (queued / processing / ready / failed). This is the -first place the "heavily visual" principle shows up: a browsable wall of images, -not a feed of text. Sort/filter by recency, type, collection, tag. - -### Reader (progressive) -Opens directly on the shared card (even mid-processing in read-now). Renders -top-down as content arrives: - -1. one_liner + tldr render first, -2. blocks fill in beneath, -3. thumbnail/keyframes attach. - -Multi-depth layout: instant layer (one_liner) always visible; skim layer -(takeaways/steps) below; depth layer collapsed/expandable. The `primary_action` -is the visually dominant control. Lightweight placeholders for not-yet-arrived -content — never one blocking spinner. - -## The block renderer (core) - -A sealed `Block` type with one widget per vocabulary entry. The renderer maps a -block list to a column of widgets. It only ever needs to know the fixed -vocabulary from `04`: - -| block | widget behavior | -|-------|-----------------| -| heading | section header at `level` | -| paragraph | prose | -| bullet_list | bulleted items | -| step_list | ordered; each step a checkable row | -| key_value | label/value pairs (compact grid) | -| checklist | checkable items, **persist checked state** (PATCH card) | -| callout | tinted box by `variant`; shows `confidence` + optional source link | -| link | tappable link chip | -| map (P2) | embedded map with pins | -| table (P2) | simple table | - -Unknown/future block types degrade gracefully: render `text`/`items` if present, -else skip. Never crash on an unrecognized block. - -## State & data flow - -- Local cache of cards for **offline reading** (cached cards readable with no network). -- Shares made offline are **queued locally** and synced when the backend returns. -- While a card is `queued`/`processing`, the reader polls `GET /cards/{id}`; - fire-and-forget relies on push/badge instead. -- Checked items and collection/tag edits are optimistic-local then PATCHed. - -## Visual system (see 07) - -The renderer is where content-visuals (keyframes, maps, charts) and -design-visuals (color, motion, depth) meet. Discipline: where a content-visual is -present, the surrounding chrome stays calm so the visual carries attention. diff --git a/docs/07-visual-design.md b/docs/07-visual-design.md deleted file mode 100644 index 972d4913ca7407724aaa5bf60a12502340e20372..0000000000000000000000000000000000000000 --- a/docs/07-visual-design.md +++ /dev/null @@ -1,56 +0,0 @@ -# 07 — Visual Design - -The app is heavily graphical on every axis — but visual richness is organized so -it stays **rich, not busy**. The whole system rests on one rule: two tiers of -visuals that must not compete. - -## The two tiers - -### Content-visuals (the star) -Sourced from the video's own substance. These raise information-per-second, which -is what actually fights boredom — they deliver meaning faster than text could. - -- **Keyframe / thumbnail** — every card has a real face pulled from the video. - The library is a grid of these, not text rows. -- **Result frames** — the finished dish, the setup shot, etc., inline. -- **Maps** — travel/place content as an actual map with pins (P2). -- **Charts** — comparisons and numbers as real charts (P2). -- **Product thumbnails** — product-list items with real images (P2). -- **Visual step strips** — a frame per step where useful (P2). - -### Design-visuals (the frame) -The styled UI around the content. Stays **calmer wherever a content-visual is -carrying meaning.** - -- **Color system** — coherent palette; optional per-content-type accent (recipe / - workout / travel) so types are recognizable at a glance without an icon zoo. -- **Motion with meaning** — progressive top-down render as the card builds; smooth - expand/collapse between skim and depth layers; shared-element transition from - library thumbnail into the reader. -- **Depth & layering** — cards, elevation, spacing for a tactile, browsable feel. -- **Iconography** — restrained, consistent, tied to block types and actions. - Never one-icon-per-line clutter. -- **Theming** — light/dark, respects system. - -## The discipline (so "maximally visual" stays rich) - -- Where a content-visual is present, design-chrome dials down. A keyframe or map - carries attention — not a gradient behind it. -- One primary action is visually dominant per card; everything else is secondary. -- Animation is fast and purposeful. Nothing the user waits through. No decorative - loops, no confetti-for-its-own-sake. - -## Why not "just add graphics" - -Boredom isn't a visual problem — a reel is engaging because it's fast and gives -the point in seconds, not because it's colorful. Heavy decoration (gradients on -everything, an icon per block, animated backgrounds) reads fresh for a week and -becomes noise forever, and it competes with the content that is the actual value. -The richness that lasts is **functional and content-sourced**, not applied on top. - -## Motion tokens (starting point) - -- Card build-in: fast top-down stagger as blocks arrive. -- Expand/collapse: quick ease, no bounce. -- Library → reader: shared-element on the thumbnail. -- Save/checked: subtle, immediate, reversible — no celebration animation. diff --git a/docs/08-data-model.md b/docs/08-data-model.md deleted file mode 100644 index 820d41203f8a6d781b93759ff74c402fc2b9676a..0000000000000000000000000000000000000000 --- a/docs/08-data-model.md +++ /dev/null @@ -1,85 +0,0 @@ -# 08 — Data Model - -Relational. SQLite (dev) / PostgreSQL (prod). Blocks are stored as structured -JSON on the card rather than a table-per-block-type — the vocabulary is fixed and -always read/written together, so one JSON column is simpler and avoids joins. - -## Tables - -### cards -| column | type | notes | -|--------|------|-------| -| id | uuid PK | | -| state | enum | queued / processing / ready / failed | -| failure_reason | text null | | -| source_url | text | indexed; used for dedup cache | -| platform | text | instagram / tiktok / youtube | -| creator | text null | | -| caption | text null | | -| duration_seconds | int null | | -| resolver | text null | which resolver succeeded (metrics) | -| content_type | text null | recipe / workout / ... | -| type_confidence | float null | | -| one_liner | text null | base layer | -| tldr | text null | base layer | -| primary_action | jsonb null | { kind, label, payload } | -| blocks | jsonb | ordered block list (schema in 04) | -| thumbnail | text null | media ref | -| keyframes | jsonb null | list of media refs | -| schema_version | text | e.g. "1.0" | -| created_at | timestamp | | -| updated_at | timestamp | | -| owner_id | uuid null | null until accounts exist (see 11) | - -Indexes: `source_url` (dedup), `state`, `content_type`, `created_at`. - -### jobs -| column | type | notes | -|--------|------|-------| -| id | uuid PK | | -| card_id | uuid FK → cards | | -| state | enum | queued / processing / done / failed / dead | -| attempts | int | for retry/dead-letter | -| last_error | text null | | -| created_at | timestamp | | -| started_at | timestamp null | | -| finished_at | timestamp null | | - -(If using a DB-backed queue, this table *is* the queue. If using Redis, this is -just an audit record.) - -### collections -| column | type | notes | -|--------|------|-------| -| id | uuid PK | | -| name | text | | -| owner_id | uuid null | | - -### card_collections (join) -| card_id | uuid FK | -| collection_id | uuid FK | - -### tags / card_tags -Auto-tagging populates tags; `card_tags` joins them to cards. (Phase 2.) - -### users (Phase 2) -Added when accounts arrive. Until then cards are device-scoped (see `11`). - -## User-mutable state - -Stored on the card and updated via `PATCH /cards/{id}`: -- checklist `checked` flags (lives inside `blocks` JSON) -- collection membership, tags - -## Media lifecycle - -- Keyframes + thumbnail: kept (they're the card's content-visuals). -- Source video: may be discarded after extraction to save space (decision in `11`). -- On `DELETE /cards/{id}`: remove card row, derived data, and all media refs. - -## Search (Phase 2/3) - -- Full-text over one_liner + tldr + block text. -- Semantic: an embedding per card (over tldr + block text) in a vector index for - semantic search and chat-over-library. Embedding model: a free/local option to - keep with the no-cost stack (flagged in `11`). diff --git a/docs/09-features.md b/docs/09-features.md deleted file mode 100644 index 3e965c77e1c27659d08649c04792354416363532..0000000000000000000000000000000000000000 --- a/docs/09-features.md +++ /dev/null @@ -1,78 +0,0 @@ -# 09 — Features - -Full feature list by area. Phase tags: **[P1]** MVP, **[P2]**, **[P3]**. - -## Ingestion -- Share-sheet integration (Instagram first) **[P1]**, TikTok + YouTube Shorts **[P2]** -- Link paste fallback **[P1]** -- Article/text sources — Reddit, Wikipedia, LinkedIn, Substack, blogs, news (docs/02) **[done]** -- Domain-classified routing (video cascade vs. keyless article extraction) **[done]** -- Batch share — multiple reels queue independently without blocking **[P1]** -- Duplicate detection — re-share returns existing card **[P1]** -- Graceful late-surfaced failure for unsupported/private/unavailable **[P1]** - -## Extraction -- Audio transcription (Whisper) **[P1]** -- On-screen text OCR (via local Tesseract) **[P1]** -- Visual context understanding (conditional HF Inference VLM) **[P1]** -- Metadata capture (caption, creator, url, duration) **[P1]** - -## Structuring -- Content-type detection **[P1]** -- Single adaptive structuring pass (text-only HF/Groq LLM) **[P1]** -- Block vocabulary: heading, paragraph, bullet_list, step_list, key_value, - checklist, callout, link **[P1]**; map, table **[P2]** -- Strict schema validation + paragraph fallback **[P1]** -- Unconditional base layer (one_liner, tldr, source) **[P1]** - -## Reading experience -- Multi-depth hierarchy (instant / skim / depth) **[P1]** -- Progressive top-down rendering **[P1]** -- Checkable steps **[P1]** -- Confidence/source signal on claims **[P1]** -- Lookup-able products — catalog/reference tap opens a store/web search **[done]** -- Visual step strips for multi-step cards **[done]** -- Product/reference thumbnails on the card ("References" strip) **[done]** - -## Action layer (docs/13) -- One primary action per card **[P1]** (kind derived from content type) -- Content-aware action set — actions unlocked by the blocks a card contains **[done]** -- Common actions on every card: Copy, Share, Open original **[done]** -- Shopping/checklist generation (share as checkable list) **[done]** -- Reminders / calendar events (native add-event) **[done]** -- Export to Notion / Obsidian / Apple Notes / markdown (share markdown) **[done]** -- Open place in Maps; open card links **[done]** -- Grounded chat — Ask questions about a single card **[done]** - -## Visual -- Keyframe thumbnails + visual library grid **[P1]** -- Color/motion/depth system, progressive-render animation **[P1]** -- Visual step strips + product/reference thumbnails **[done]**; maps-with-pins, charts (deferred) - -## Catalog (artifacts) -- Artifact extraction from referenced things (book/movie/podcast/product/place…) **[P1]** -- Global deduplicated catalog space, grouped by type **[P1]** -- Free keyless thumbnail retrieval (iTunes / Open Library / Wikipedia) **[P1]** -- Source-card backreferences per artifact **[P1]** - -## Library & retrieval -- Card states surfaced (queued/processing/ready/failed) **[P1]** -- Basic search (full-text) **[P1]** -- Collections (manual card groups) + auto-tagging (LLM `base.tags`, schema 1.2) **[done]** -- Semantic search — HF embeddings + cosine, full-text fallback **[done]** -- Chat-with-your-library — cross-card grounded Q&A with source cards **[done]** - -## Resurfacing -- Weekly digest **[P3]** -- Spaced resurfacing (opt-in) **[P3]** - -## Trust & safety -- Claim-quality signals on health/finance/medical content **[P3]** -- Clear "AI-generated, may contain errors" labeling **[P1]** - -## Cross-cutting (production) -- Offline reading of cached cards **[P1]** -- Offline share queueing + sync **[P1]** -- Retry + dead-letter on failed jobs **[P1]** -- Observability (resolver success, stage timing, failure reasons, LLM call count) **[P1]** -- Card + media deletion (data ownership) **[P1]** diff --git a/docs/10-phasing-and-roadmap.md b/docs/10-phasing-and-roadmap.md deleted file mode 100644 index 6ae8685f51d00896545b0066ac38ada0f19c608c..0000000000000000000000000000000000000000 --- a/docs/10-phasing-and-roadmap.md +++ /dev/null @@ -1,57 +0,0 @@ -# 10 — Phasing & Roadmap - -Build order front-loads a production-quality *core* rather than spreading thin -across half-finished features. Each phase is shippable. - -## Phase 1 — MVP (production-quality core) - -**Goal:** one platform, end-to-end, genuinely solid. Share a reel → get a good -card → read it → find it later. - -- Share-sheet ingestion (Instagram), link paste, batch + dedup -- Async pipeline: queue + worker + state machine -- Ingestion cascade integrated (resolvers + downloader async layer) -- Extraction: ffmpeg + Whisper + Tesseract OCR (conditional HF VLM for visual) -- Single adaptive structuring pass + schema validation + fallback -- Base layer + core block vocabulary (8 blocks) -- Library with card states (visual grid + keyframe thumbnails) -- Reader with progressive render + multi-depth hierarchy -- One primary action per card -- Basic full-text search -- Core visual layer (color/motion/depth, thumbnails) -- Offline reading + offline share queue -- Retry/dead-letter, observability, delete -- "AI-generated" labeling - -**Exit criteria:** a reel shared while scrolling reliably becomes a useful card -without the user waiting; a reel shared then opened streams readable content fast; -failures surface gently; nothing silently drops. - -## Phase 2 — Breadth & action - -- TikTok + YouTube Shorts ingestion -- Action layer: shopping list, reminders/calendar, export (Notion/Obsidian/Notes) -- Richer content-visuals: maps with pins, charts, product thumbnails, step strips -- Map + table blocks -- Collections + auto-tagging -- Semantic search -- Accounts (if chosen — see `11`) - -## Phase 3 — Intelligence & retention - -- Chat-with-your-library -- Spaced resurfacing engine -- Weekly digest -- Trust/claim-quality signals on health/finance/medical content - -## Sequencing logic - -- **Schema first.** `04` is the contract; both backend and frontend build against - it from day one. Highest-leverage thing to lock before code. -- **Pipeline before polish.** A working async share→card→read loop is the spine; - visuals and actions layer onto it. -- **Keyframe extraction is the cheapest high-impact visual** — it turns the library - from text rows into something alive to browse, and you're already processing the - video. Do it in P1. -- **Single-pass structuring stays** until measured quality demands splitting — - don't pay double on verticals the single pass already handles. diff --git a/docs/11-assumptions-and-open-decisions.md b/docs/11-assumptions-and-open-decisions.md deleted file mode 100644 index c0be83237705f06cb0cbb45c6306af86f98d698a..0000000000000000000000000000000000000000 --- a/docs/11-assumptions-and-open-decisions.md +++ /dev/null @@ -1,63 +0,0 @@ -# 11 — Assumptions & Open Decisions - -These docs make a few decisions on your behalf so the set is complete and -buildable. All are **reversible** (this is documentation) and none block Phase 1. -Confirm or correct each; I've marked the default I assumed. - -## Deployment (decided) - -**Backend deploys to Hugging Face Spaces (free tier), as a Docker Space.** This is -a final-year project, so free-tier constraints are accepted. Consequences folded -into the docs: - -- Transcription default flipped to **Groq hosted Whisper** (free HF tier is - CPU-only; local Whisper is too slow there). -- Filesystem is **ephemeral** — cards/media don't survive restarts on free tier. - Acceptable for demo. For persistence, attach free external Postgres (Neon/ - Supabase) + R2, or HF persistent storage (paid). See `05-backend.md`. -- Worker runs in-process / same container; DB-backed queue (no Redis to host). - -## Assumed (correct me if wrong) - -1. **Frontend = Flutter, backend = Python/FastAPI.** (Stated by you.) -2. **Transcription = Groq hosted Whisper** (free tier), given HF free CPU. -3. **Structuring = text-only LLM** via HuggingFace Inference Providers - (`Qwen/Qwen2.5-72B-Instruct`, default) or Groq (`llama-3.1-70b-versatile`, - fallback). OCR runs locally on Tesseract; visual/scene description is a rare - conditional HF Inference VLM call. No single multimodal call — vision is kept - off the structuring LLM. -4. **Database = SQLite** for now (ephemeral on HF, fine for a project demo); - external Postgres only if you want persistence. -5. **Job queue = DB-backed**, worker in-process on the Space. -6. **Media storage = local/ephemeral** for now; R2 only if persistence is needed. -7. **Accounts = none; cards are device/session-scoped.** (`owner_id` nullable in - schema, ready if you add accounts later.) -8. **Push = local notifications**; FCM only if you want real push later. - -## Genuinely open — your call - -Most decisions are now settled by the HF / project-scope choice. What remains: - -1. **Persistence vs. ephemeral.** Free HF wipes data on restart. Fine for a live - demo; if you need cards to survive between sessions (e.g. an examiner tries it - the next day), wire up free external Postgres + R2. Default assumed: **ephemeral**. - -2. **Source video retention.** Keep the full downloaded video, or discard after - extraction and keep only keyframes + thumbnail? On ephemeral HF this barely - matters; default assumed: **discard**. - -3. **Embedding model for semantic search (P2/P3).** Free/local keeps the no-cost - stack. Decide when you reach P2. - -## Verify before building around them - -- **Current HF Inference & Groq free-tier limits** — these shift; check live numbers - before sizing your quota strategy. Single-pass + caching keeps you under - whatever the ceiling is. - -## Resolved - -- ~~Budget ceiling~~ → free tier; final-year project scope. -- ~~Hosting~~ → Hugging Face Spaces (Docker). -- ~~Accounts / cross-device sync~~ → none for now; device/session-scoped. -- ~~Distribution intent~~ → project/personal; resolver cascade is acceptable. diff --git a/docs/12-catalog-and-artifacts.md b/docs/12-catalog-and-artifacts.md deleted file mode 100644 index 2461eb5d281d183637b553f23d6c778efce7b604..0000000000000000000000000000000000000000 --- a/docs/12-catalog-and-artifacts.md +++ /dev/null @@ -1,90 +0,0 @@ -# 12 — Catalog & Artifacts - -A second top-level space alongside the card Library: a **catalog** of the -real-world things videos *reference* — books, movies, podcasts, music, products, -places — deduplicated across every card and shown as a wall of covers. - -This is a **parallel surface to the block schema, not a new block type**. The -block vocabulary (docs/04) is unchanged. Structuring's output simply grows an -`artifacts` list, which the worker aggregates into a global catalog. - -## What an artifact is - -A concrete, named thing the video mentions, distinct from the how-to content of -the card itself. A booktuber's video *is* a `tip`/`product_list` card **and** -contributes its named books to the catalog. - -```json -// artifact (as emitted by structuring, per card) -{ "type": "book|movie|tv_show|podcast|music|product|place|app|other", - "title": "Atomic Habits", - "creator": "James Clear", // author / director / artist / host / brand (nullable) - "year": 2018 } // nullable -``` - -## Pipeline fit (one LLM call, still) - -Artifact extraction piggybacks on the **existing single text-only structuring -call** (docs/04) — the prompt gains an `artifacts` field in its JSON output. No - -``` -structuring LLM → { base, blocks, artifacts } - → validate blocks (docs/04, unchanged) - → validate artifacts (drop title-less / malformed; dedupe within the card) - → worker: for each artifact - → resolve thumbnail (free image API, best-effort) - → upsert into the global catalog (dedupe by type + normalized title) -``` - -Artifacts are validated like blocks: never trusted raw. A malformed entry is -dropped; an empty list is normal. Artifact failure never affects the card — -the card still reaches READY. - -## Thumbnails (free, keyless, hotlinked) - -`services/artifact_images.py` routes by type to a public, no-key API and returns -a remote `https` URL (nothing is downloaded or stored — fits the ephemeral -free tier). Best-effort: any miss/timeout/error → `None` → typed placeholder. - -| Artifact type | Source | Key? | -|---|---|---| -| book | Open Library (cover by id/isbn), Wikipedia fallback | no | -| movie / tv_show / podcast / music / app | iTunes Search API (`artworkUrl`) | no | -| product / place / other | Wikipedia REST summary thumbnail | no | - -## Aggregation & dedupe - -A global `artifacts` table (docs/08 style: one row per catalog item). Dedupe key -is `(type, normalized_title)`. Re-seeing the same artifact in another card appends -that `card_id` to `source_card_ids` and backfills any missing thumbnail/creator/ -year rather than creating a duplicate. - -## API - -``` -GET /catalog list entries (optional ?type=, paginated) -GET /catalog/{id} one entry + its source_card_ids -DELETE /catalog/{id} remove an entry (data ownership) -``` - -`CatalogEntry`: - -```json -{ "id": "a_xxxxxxxx", "type": "book", "title": "Atomic Habits", - "creator": "James Clear", "year": 2018, - "thumbnail": "https://covers.openlibrary.org/...", - "source_card_ids": ["uuid", "uuid"], "created_at": "ISO-8601" } -``` - -## Frontend - -A second tab in the root `NavigationBar` (Library | Catalog). The Catalog screen -shows entries grouped by type, each a cover tile with title + creator·year. Covers -load via `Image.network` and degrade to a typed icon placeholder on error — never - -## Schema version - -Adding `artifacts` to the structuring/card contract bumps `SCHEMA_VERSION` -**1.0 → 1.1**. The block vocabulary is unchanged, so existing cards and the -renderer are unaffected; the client tolerates the new version (it never hard-fails -on a version it doesn't recognize). diff --git a/docs/13-actions-and-chat.md b/docs/13-actions-and-chat.md deleted file mode 100644 index 1740d0bd18b7579185f110bd078fd0b7f476b78a..0000000000000000000000000000000000000000 --- a/docs/13-actions-and-chat.md +++ /dev/null @@ -1,67 +0,0 @@ -# 13 — Actions & Chat - -How a card becomes *useful*, not just readable. Two surfaces: a **content-aware -action layer** and **grounded chat (Ask)**. Both are additive — no block-schema -change (docs/04 is unchanged); everything is derived from a card's existing -content. - -## Action layer - -The backend still derives one dominant `primary_action` per card (docs/04). The -client now goes further: it inspects the card's **blocks** and offers every -action that fits, plus a common set on every card. All payloads are built -client-side from blocks, so the wire `payload` stays empty. - -| Action | Shown when | Handler (free, on-device) | -|---|---|---| -| Ask | always | opens grounded chat (below) | -| Copy | always | card → markdown → clipboard | -| Share | always | card → markdown → OS share sheet (`share_plus`) | -| Add to calendar | always | native add-event sheet (`add_2_calendar`) | -| Open original | has source url | launches the reel (`url_launcher`) | -| Open in Maps | has a `map`/place block | Maps search/coords (`url_launcher`) | -| Shopping list | has `checklist`/`bullet_list` | items → checkable list → share | -| Open links | has a `link` block | launches the first link | - -The dominant `primary_action` is rendered as the big button; the rest live -behind a "more" menu. The reader shows the bar whenever the card is READY (Ask -is available even when there is no primary action). - -Frontend: `ui/features/reader/services/card_actions.dart` (`available()`, -`primaryType()`, `perform()`), surfaced by `views/primary_action_bar.dart`. - -### Android config - -- `url_launcher` https + `add_2_calendar` insert intents are declared in - `android/app/src/main/AndroidManifest.xml` `` (Android 11+ package - visibility). - -## Chat (Ask) - -Grounded Q&A over a single card. The model answers using **only that card's -structured content** as context; if the answer isn't there, it says so. - -``` -POST /cards/{card_id}/chat - body: { "messages": [ { "role": "user|assistant", "content": str }, ... ] } - → 200 { "reply": str } - → 409 card is not READY - → 422 last message is not from the user - → 503 no LLM backend configured -``` - -**Stateless.** Nothing is persisted server-side: the client holds the -conversation and replays the full history each turn (capped to the last -`_MAX_TURNS` for cost). Grounding context is the card's `base` (one_liner, tldr) -plus a flattened plain-text view of its blocks. - -**Free-first.** Reuses the same selectable backend as structuring -(`llm_backend = huggingface | groq | none`) via `services/llm_chat.py`. With no -key, the endpoint returns 503 and the UI shows an "unavailable" message — the -card still reads and every other action still works. - -**Trust.** The chat header carries the standard "AI-generated · may contain -errors" note (docs/09 trust & safety). - -Frontend: `ui/features/reader/view_models/chat_view_model.dart` + -`views/chat_screen.dart`, opened from the **Ask** action. diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index e6e2b9986428b64d8916d41c19d266ae1708c3ec..0000000000000000000000000000000000000000 --- a/docs/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# Reel-to-Knowledge App — Documentation Set - -This folder is the complete design documentation for the app: a mobile tool that -turns short-form videos (Instagram Reels, TikTok, YouTube Shorts) shared to it -into structured, readable, actionable knowledge cards — so watching feels -productive instead of disposable. - -## How to read these docs - -Start at `00-overview.md`, then read in order. The keystone document is -`04-structuring-and-schema.md` — the block schema is the contract between -backend and frontend, and most other docs depend on it. - -| File | What it covers | -|------|----------------| -| `00-overview.md` | Product summary, the two usage modes, design principles | -| `01-architecture.md` | System architecture, components, the share→card pipeline, state machine | -| `02-ingestion.md` | The resolver cascade, how downloading works, integration | -| `03-extraction.md` | ffmpeg + Whisper + Tesseract; turning media into raw signal | -| `04-structuring-and-schema.md` | **The block schema contract** (the keystone) | -| `05-backend.md` | FastAPI structure, endpoints, job queue, storage, deployment | -| `06-frontend.md` | Flutter structure, screens, share extension, card renderer | -| `07-visual-design.md` | The two-tier visual system (content-visuals vs design-visuals) | -| `08-data-model.md` | Database schema: cards, blocks, jobs, collections | -| `09-features.md` | Full feature list by area | -| `10-phasing-and-roadmap.md` | Phase 1 → 3 build order | -| `11-assumptions-and-open-decisions.md` | What was assumed + what you still must decide | - -## Status - -Draft v1. The product/design decisions are settled enough to begin Phase 1. -Open items (hosting/budget, accounts model, push strategy) are defaulted and -flagged in `11-assumptions-and-open-decisions.md` for your confirmation. - - -`@docs/05-backend.md` (or `06-frontend.md`) when working a specific phase.