diff --git a/apps/api/src/food-search.test.ts b/apps/api/src/food-search.test.ts index f84c873..19c6001 100644 --- a/apps/api/src/food-search.test.ts +++ b/apps/api/src/food-search.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { pickRecentFoods, pickSearchResults } from "./food-search.js"; +import { catalogWhere, pickRecentFoods, pickSearchResults } from "./food-search.js"; const food = (id: string, barcode: string, name: string, ownerId: string, verified = false) => ({ id, @@ -61,3 +61,25 @@ test("recent foods are distinct, newest first, with the last amount", () => { { foodId: "b", lastAmount: 30 }, ]); }); + +test("catalog lists every published food, or only the viewer's own", () => { + assert.deepEqual(catalogWhere("me", { scope: "published", status: "all", q: "" }), { + AND: [{ published: true }], + }); + assert.deepEqual(catalogWhere("me", { scope: "mine", status: "all", q: "" }), { + AND: [{ ownerId: "me" }], + }); +}); + +test("catalog narrows by verification status and search text", () => { + assert.deepEqual(catalogWhere("me", { scope: "published", status: "unverified", q: "milk" }), { + AND: [ + { published: true }, + { verifiedAt: null }, + { OR: [{ name: { contains: "milk" } }, { barcode: { startsWith: "milk" } }] }, + ], + }); + assert.deepEqual(catalogWhere("me", { scope: "published", status: "verified", q: "" }).AND[1], { + verifiedAt: { not: null }, + }); +}); diff --git a/apps/api/src/food-search.ts b/apps/api/src/food-search.ts index 8bad281..34be944 100644 --- a/apps/api/src/food-search.ts +++ b/apps/api/src/food-search.ts @@ -44,3 +44,24 @@ export function pickRecentFoods( } return result; } + +export const CATALOG_PAGE_SIZE = 30; + +export type CatalogScope = "published" | "mine"; +export type CatalogStatus = "all" | "verified" | "unverified"; + +/** + * Filter for the food catalog: every published food, or only the viewer's own + * foods (published or not). Optionally narrowed to verified/unverified and to + * a name or barcode search. + */ +export function catalogWhere( + viewerId: string, + { scope, status, q }: { scope: CatalogScope; status: CatalogStatus; q: string }, +) { + const and: object[] = [scope === "mine" ? { ownerId: viewerId } : { published: true }]; + if (status === "verified") and.push({ verifiedAt: { not: null } }); + if (status === "unverified") and.push({ verifiedAt: null }); + if (q) and.push({ OR: [{ name: { contains: q } }, { barcode: { startsWith: q } }] }); + return { AND: and }; +} diff --git a/apps/api/src/routes/foods.ts b/apps/api/src/routes/foods.ts index 7203055..8999241 100644 --- a/apps/api/src/routes/foods.ts +++ b/apps/api/src/routes/foods.ts @@ -3,10 +3,10 @@ import { currentUser, requireUser } from "../auth.js"; import { prisma } from "../db.js"; import { resolveBarcode } from "../domain.js"; import { asyncRoute, HttpError } from "../http.js"; -import { pickRecentFoods, pickSearchResults } from "../food-search.js"; +import { CATALOG_PAGE_SIZE, catalogWhere, pickRecentFoods, pickSearchResults } from "../food-search.js"; import { num, presentFood } from "../present.js"; import { requirePermission } from "../rbac.js"; -import { foodSchema, foodUpdateSchema } from "../schemas.js"; +import { catalogQuerySchema, foodSchema, foodUpdateSchema } from "../schemas.js"; export const foodsRouter = Router(); foodsRouter.use(requireUser); @@ -35,6 +35,26 @@ foodsRouter.get( }), ); +// Must stay above "/:id" or "catalog" would be read as a food id. +foodsRouter.get( + "/catalog", + requirePermission("foods:read"), + asyncRoute(async (req, res) => { + const user = currentUser(res); + const query = catalogQuerySchema.parse(req.query); + const rows = await prisma.food.findMany({ + where: catalogWhere(user.id, query), + orderBy: [{ name: "asc" }, { id: "asc" }], + skip: query.offset, + take: CATALOG_PAGE_SIZE + 1, + }); + res.json({ + foods: rows.slice(0, CATALOG_PAGE_SIZE).map((food) => presentFood(food, user.id)), + hasMore: rows.length > CATALOG_PAGE_SIZE, + }); + }), +); + foodsRouter.get( "/recent", requirePermission("foods:read"), diff --git a/apps/api/src/schemas.ts b/apps/api/src/schemas.ts index 74baa93..dd6068a 100644 --- a/apps/api/src/schemas.ts +++ b/apps/api/src/schemas.ts @@ -153,3 +153,10 @@ export const fastingSchema = z.object({ .max(3) .default([0]), }); + +export const catalogQuerySchema = z.object({ + scope: z.enum(["published", "mine"]).default("published"), + status: z.enum(["all", "verified", "unverified"]).default("all"), + q: z.string().trim().max(64).default(""), + offset: z.coerce.number().int().min(0).max(100000).default(0), +}); diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml index 460c196..124da93 100644 --- a/apps/mobile/analysis_options.yaml +++ b/apps/mobile/analysis_options.yaml @@ -13,6 +13,8 @@ analyzer: exclude: - build/** - ios/** + # Vendored plugin (patched iOS code); not ours to lint. + - packages/** linter: # The lint rules applied to this project can be customized in the diff --git a/apps/mobile/assets/tessdata/eng.traineddata b/apps/mobile/assets/tessdata/eng.traineddata new file mode 100644 index 0000000..bbef467 Binary files /dev/null and b/apps/mobile/assets/tessdata/eng.traineddata differ diff --git a/apps/mobile/assets/tessdata/heb.traineddata b/apps/mobile/assets/tessdata/heb.traineddata new file mode 100644 index 0000000..7356caf Binary files /dev/null and b/apps/mobile/assets/tessdata/heb.traineddata differ diff --git a/apps/mobile/assets/tessdata_config.json b/apps/mobile/assets/tessdata_config.json new file mode 100644 index 0000000..e134fd4 --- /dev/null +++ b/apps/mobile/assets/tessdata_config.json @@ -0,0 +1,6 @@ +{ + "files": [ + "eng.traineddata", + "heb.traineddata" + ] +} diff --git a/apps/mobile/ios/Flutter/Debug.xcconfig b/apps/mobile/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/apps/mobile/ios/Flutter/Debug.xcconfig +++ b/apps/mobile/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/apps/mobile/ios/Flutter/Release.xcconfig b/apps/mobile/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/apps/mobile/ios/Flutter/Release.xcconfig +++ b/apps/mobile/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/apps/mobile/ios/Podfile b/apps/mobile/ios/Podfile new file mode 100644 index 0000000..9d7ef08 --- /dev/null +++ b/apps/mobile/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '15.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/apps/mobile/ios/Podfile.lock b/apps/mobile/ios/Podfile.lock new file mode 100644 index 0000000..31a1ca0 --- /dev/null +++ b/apps/mobile/ios/Podfile.lock @@ -0,0 +1,29 @@ +PODS: + - Flutter (1.0.0) + - flutter_tesseract_ocr (0.3.4): + - Flutter + - SwiftyTesseract + - SwiftyTesseract (3.1.3) + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_tesseract_ocr (from `.symlinks/plugins/flutter_tesseract_ocr/ios`) + +SPEC REPOS: + trunk: + - SwiftyTesseract + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_tesseract_ocr: + :path: ".symlinks/plugins/flutter_tesseract_ocr/ios" + +SPEC CHECKSUMS: + Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47 + flutter_tesseract_ocr: fffe5de044967e12d184feaded7fabfb546b6042 + SwiftyTesseract: 1f3d96668ae92dc2208d9842c8a59bea9fad2cbb + +PODFILE CHECKSUM: 3ba3a73b1b12adcf321a227a745625fe0888cc82 + +COCOAPODS: 1.17.0 diff --git a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj index 96d96d1..ebb608d 100644 --- a/apps/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -13,10 +13,12 @@ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 8B46958EDA16368FC19D3678 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AE298FB8FAB88F4AF511BA51 /* Pods_Runner.framework */; }; 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 */; }; A1B2C3D4E5F60718293A4B02 /* HealthSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B01 /* HealthSync.swift */; }; + E00B80397A5232A92AE589B3 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9AFA32EE7C99683FA6C23C8 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -43,16 +45,20 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 085707543C6302FC472E7E43 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 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 = ""; }; + 2261C44CFEEED0D6001D85EA /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; 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 = ""; }; + 7589C768B45D244E7DABA8D7 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 873E21F5ABFAF3C5B3468135 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.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; }; @@ -62,14 +68,27 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A1B2C3D4E5F60718293A4B01 /* HealthSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthSync.swift; sourceTree = ""; }; A1B2C3D4E5F60718293A4B03 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; + A9AFA32EE7C99683FA6C23C8 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + AE298FB8FAB88F4AF511BA51 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + B203297172C88EE5B59976ED /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + D64FF2D8902175D57A653C85 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 895DC9EF541F28F5D7BAA2FD /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + E00B80397A5232A92AE589B3 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 8B46958EDA16368FC19D3678 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,6 +122,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + A588A91D7DF2A82A63E12027 /* Pods */, + F66567ACF3C8EAE9DB410434 /* Frameworks */, ); sourceTree = ""; }; @@ -133,6 +154,29 @@ path = Runner; sourceTree = ""; }; + A588A91D7DF2A82A63E12027 /* Pods */ = { + isa = PBXGroup; + children = ( + 085707543C6302FC472E7E43 /* Pods-Runner.debug.xcconfig */, + D64FF2D8902175D57A653C85 /* Pods-Runner.release.xcconfig */, + 2261C44CFEEED0D6001D85EA /* Pods-Runner.profile.xcconfig */, + 873E21F5ABFAF3C5B3468135 /* Pods-RunnerTests.debug.xcconfig */, + 7589C768B45D244E7DABA8D7 /* Pods-RunnerTests.release.xcconfig */, + B203297172C88EE5B59976ED /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + F66567ACF3C8EAE9DB410434 /* Frameworks */ = { + isa = PBXGroup; + children = ( + AE298FB8FAB88F4AF511BA51 /* Pods_Runner.framework */, + A9AFA32EE7C99683FA6C23C8 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -140,8 +184,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 1A805F956D537ABA76E84246 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 895DC9EF541F28F5D7BAA2FD /* Frameworks */, ); buildRules = ( ); @@ -157,12 +203,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 89E2B79BBB42C1EFE5149514 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + A6BB47B2072DC4E50C5944EE /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -206,7 +254,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; @@ -240,6 +288,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 1A805F956D537ABA76E84246 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -256,6 +326,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 89E2B79BBB42C1EFE5149514 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -271,6 +363,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A6BB47B2072DC4E50C5944EE /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -401,6 +510,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 873E21F5ABFAF3C5B3468135 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -418,6 +528,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 7589C768B45D244E7DABA8D7 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -433,6 +544,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B203297172C88EE5B59976ED /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -642,7 +754,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/apps/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/apps/mobile/ios/Runner/Info.plist b/apps/mobile/ios/Runner/Info.plist index 4d7d2c4..b8b307d 100644 --- a/apps/mobile/ios/Runner/Info.plist +++ b/apps/mobile/ios/Runner/Info.plist @@ -50,7 +50,9 @@ NSHealthShareUsageDescription Drop It reads your weight, steps, exercise minutes, and active calories from Apple Health to show them next to your food log and count active calories toward your daily calorie goal. NSCameraUsageDescription - Drop It uses the camera to scan food barcodes. + Drop It uses the camera to scan food barcodes and nutrition labels. + NSPhotoLibraryUsageDescription + Drop It reads a photo you choose to extract the values from a nutrition label. UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/apps/mobile/lib/catalog.dart b/apps/mobile/lib/catalog.dart new file mode 100644 index 0000000..9c7676e --- /dev/null +++ b/apps/mobile/lib/catalog.dart @@ -0,0 +1,384 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'api.dart'; +import 'copy.dart'; +import 'theme.dart'; + +String _plain(num value) => + value == value.roundToDouble() ? value.round().toString() : value.toString(); + +/// Every published food, or your own. Admins (foods:verify) can verify entries. +class CatalogScreen extends StatefulWidget { + const CatalogScreen({ + super.key, + required this.api, + required this.t, + required this.canVerify, + required this.onCreateFood, + }); + + final Api api; + final Copy t; + final bool canVerify; + + /// Opens the "new food" form; resolves to the created food, or null. + final Future?> Function() onCreateFood; + + @override + State createState() => _CatalogScreenState(); +} + +class _CatalogScreenState extends State { + String scope = 'published'; + String status = 'all'; + final search = TextEditingController(); + Timer? debounce; + List>? foods; + bool hasMore = false; + bool loadingMore = false; + String error = ''; + int request = 0; + + Copy get t => widget.t; + + @override + void initState() { + super.initState(); + load(); + } + + @override + void dispose() { + debounce?.cancel(); + search.dispose(); + super.dispose(); + } + + String path(int offset) { + final query = Uri( + queryParameters: { + 'scope': scope, + 'status': status, + 'offset': '$offset', + if (search.text.trim().isNotEmpty) 'q': search.text.trim(), + }, + ).query; + return '/api/foods/catalog?$query'; + } + + Future load() async { + final id = ++request; + setState(() => error = ''); + try { + final res = await widget.api.send('GET', path(0)); + if (!mounted || id != request) return; // a newer filter already answered + setState(() { + foods = (res['foods'] as List).cast().map((f) => f.cast()).toList(); + hasMore = res['hasMore'] == true; + }); + } on ApiException catch (err) { + if (mounted && id == request) setState(() => error = t.error(err.code)); + } + } + + Future loadMore() async { + final current = foods; + if (current == null || loadingMore) return; + final id = request; + setState(() => loadingMore = true); + try { + final res = await widget.api.send('GET', path(current.length)); + if (!mounted || id != request) return; + setState(() { + foods = [ + ...current, + ...(res['foods'] as List).cast().map((f) => f.cast()), + ]; + hasMore = res['hasMore'] == true; + }); + } on ApiException catch (err) { + if (mounted) setState(() => error = t.error(err.code)); + } finally { + if (mounted) setState(() => loadingMore = false); + } + } + + void onSearchChanged(String _) { + debounce?.cancel(); + debounce = Timer(const Duration(milliseconds: 250), load); + } + + /// Only one verified food per barcode: the server un-verifies the others. + void applyVerified(Map verified) { + setState(() { + foods = [ + for (final food in foods ?? >[]) + if (food['id'] == verified['id']) + verified + else if (verified['barcode'] != null && food['barcode'] == verified['barcode']) + {...food, 'verified': false} + else + food, + ]; + }); + } + + Future showDetail(Map food) async { + final verified = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (_) => _FoodDetail( + api: widget.api, + t: t, + food: food, + canVerify: widget.canVerify, + ), + ); + if (verified != null) applyVerified(verified); + } + + @override + Widget build(BuildContext context) { + final apple = context.apple; + final list = foods; + return ListView( + padding: const EdgeInsets.all(Gaps.lg), + children: [ + AppleSegmented( + value: scope, + options: [ + AppleMenuOption('published', t.catalogPublished), + AppleMenuOption('mine', t.catalogMine), + ], + onChanged: (value) { + setState(() => scope = value); + load(); + }, + ), + const SizedBox(height: Gaps.md), + TextField( + controller: search, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: t.catalogSearch, + prefixIcon: const Icon(Icons.search), + ), + onChanged: onSearchChanged, + ), + const SizedBox(height: Gaps.md), + AppleSegmented( + value: status, + options: [ + AppleMenuOption('all', t.catalogAll), + AppleMenuOption('verified', t.verified), + AppleMenuOption('unverified', t.catalogUnverified), + ], + onChanged: (value) { + setState(() => status = value); + load(); + }, + ), + if (scope == 'mine') ...[ + const SizedBox(height: Gaps.md), + SizedBox( + height: Gaps.hit, + child: FilledButton( + onPressed: () async { + final created = await widget.onCreateFood(); + if (created != null) load(); + }, + child: Text(t.createFood), + ), + ), + ], + const SizedBox(height: Gaps.md), + if (error.isNotEmpty) ErrorText(error), + if (list == null && error.isEmpty) + const Padding( + padding: EdgeInsets.all(Gaps.xl), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + if (list != null && list.isEmpty) + Padding( + padding: const EdgeInsets.all(Gaps.lg), + child: Text( + scope == 'mine' && search.text.trim().isEmpty && status == 'all' + ? t.catalogEmptyMine + : t.catalogEmpty, + style: TextStyle(color: apple.labelSecondary), + ), + ), + if (list != null && list.isNotEmpty) + Container( + decoration: BoxDecoration( + color: apple.cardBg, + borderRadius: BorderRadius.circular(Radii.card), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var i = 0; i < list.length; i++) ...[ + if (i > 0) + Divider(height: 0.5, thickness: 0.5, color: apple.separator), + _row(list[i], apple), + ], + ], + ), + ), + if (hasMore) + Padding( + padding: const EdgeInsets.only(top: Gaps.md), + child: SizedBox( + height: Gaps.hit, + child: OutlinedButton( + onPressed: loadingMore ? null : loadMore, + child: Text(t.catalogLoadMore), + ), + ), + ), + ], + ); + } + + Widget _row(Map food, AppleColors apple) { + final unit = food['basis'] == 'solid' ? t.grams : t.milliliters; + final badges = [ + if (food['verified'] == true) t.verified, + if (food['owned'] == true && food['published'] != true) t.catalogPrivate, + ]; + return ListTile( + minTileHeight: Gaps.hit, + contentPadding: const EdgeInsets.symmetric(horizontal: Gaps.lg), + title: Text(food['name'] as String, overflow: TextOverflow.ellipsis), + subtitle: Text( + '${_plain((food['calories'] as num?) ?? 0)} ${t.kcal} / 100 $unit', + style: TextStyle(color: apple.labelSecondary), + ), + trailing: badges.isEmpty + ? null + : Text( + badges.join(' · '), + style: TextStyle( + color: food['verified'] == true + ? apple.accentPrimary + : apple.labelSecondary, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + onTap: () => showDetail(food), + ); + } +} + +class _FoodDetail extends StatefulWidget { + const _FoodDetail({ + required this.api, + required this.t, + required this.food, + required this.canVerify, + }); + final Api api; + final Copy t; + final Map food; + final bool canVerify; + + @override + State<_FoodDetail> createState() => _FoodDetailState(); +} + +class _FoodDetailState extends State<_FoodDetail> { + late Map food = widget.food; + String error = ''; + bool busy = false; + + @override + Widget build(BuildContext context) { + final t = widget.t; + final apple = context.apple; + final unit = food['basis'] == 'solid' ? t.grams : t.milliliters; + final rows = [ + (t.calories, food['calories']), + (t.protein, food['protein']), + (t.carbohydrates, food['carbohydrates']), + (t.fat, food['fat']), + (t.fiber, food['fiber']), + (t.sugars, food['sugars']), + (t.sodium, food['sodium']), + ]; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(Gaps.lg, 0, Gaps.lg, Gaps.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + spacing: Gaps.sm, + children: [ + Text( + food['name'] as String, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + [ + '/ 100 $unit', + if (food['verified'] == true) t.verified, + if (food['barcode'] != null) '${t.barcode}: ${food['barcode']}', + ].join(' · '), + style: TextStyle(color: apple.labelSecondary), + ), + for (final (label, value) in rows) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + Text( + _plain((value as num?) ?? 0), + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + if (error.isNotEmpty) ErrorText(error), + if (widget.canVerify && + food['published'] == true && + food['verified'] != true) + SizedBox( + height: Gaps.hit, + child: FilledButton( + onPressed: busy + ? null + : () async { + setState(() { + busy = true; + error = ''; + }); + try { + final res = await widget.api.send( + 'POST', + '/api/foods/${food['id']}/verify', + ); + final verified = + (res['food'] as Map).cast(); + if (context.mounted) Navigator.pop(context, verified); + } on ApiException catch (err) { + if (mounted) { + setState(() { + busy = false; + error = t.error(err.code); + }); + } + } + }, + child: Text(t.verify), + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/lib/copy.dart b/apps/mobile/lib/copy.dart index c2f3e3e..2f1f7cf 100644 --- a/apps/mobile/lib/copy.dart +++ b/apps/mobile/lib/copy.dart @@ -125,6 +125,34 @@ class Copy { String get noMatches => pick('No matching foods.', 'לא נמצא מזון תואם.'); String get measuredAs => pick('Measured as', 'נמדד לפי'); String get optional => pick('Optional', 'רשות'); + String get scanLabel => pick('Scan nutrition label', 'סריקת טבלת ערכים תזונתיים'); + String get scanLabelHint => pick( + 'Take a clear, well-lit photo of the nutrition table. English and Hebrew labels are supported.', + 'צלמו תמונה ברורה ומוארת של טבלת הערכים התזונתיים. תוויות בעברית ובאנגלית נתמכות.', + ); + String get takePhoto => pick('Take photo', 'צילום'); + String get retakePhoto => pick('Retake photo', 'צילום מחדש'); + String get chooseFromLibrary => pick('Choose from library', 'בחירה מהגלריה'); + String get scanReading => pick('Reading label…', 'קורא את התווית…'); + String get scanNothingFound => pick( + 'Could not find nutrition values. Try again with better light, or enter them manually.', + 'לא נמצאו ערכים תזונתיים. נסו שוב עם יותר אור, או הזינו ידנית.', + ); + String get scanFailed => pick( + 'Could not read the photo. Try again or enter the values manually.', + 'לא ניתן לקרוא את התמונה. נסו שוב או הזינו ידנית.', + ); + String get cameraUnavailable => pick( + 'The camera is unavailable. Check camera access in Settings.', + 'המצלמה אינה זמינה. בדקו את הרשאת המצלמה בהגדרות.', + ); + String get showScannedText => pick('Show recognized text', 'הצגת הטקסט שזוהה'); + String get close => pick('Close', 'סגירה'); + String get scannedCheck => pick('Scanned – please check', 'נסרק – נא לבדוק'); + String get scannedFromServing => pick( + 'The label only listed per-serving values, so they were converted to per 100. Please check them.', + 'בתווית מופיעים ערכים למנה בלבד, ולכן הומרו ל-100. נא לבדוק אותם.', + ); String get cancel => pick('Cancel', 'ביטול'); String get undo => pick('Undo', 'ביטול פעולה'); String get kg => pick('kg', 'ק״ג'); @@ -185,6 +213,18 @@ class Copy { String get unpublish => pick('Stop sharing', 'הפסקת שיתוף'); String get verify => pick('Verify', 'אימות'); String get verified => pick('Verified', 'מאומת'); + String get foodCatalog => pick('Food catalog', 'קטלוג מזון'); + String get catalogTab => pick('Foods', 'מזונות'); + String get catalogPublished => pick('Published', 'מפורסמים'); + String get catalogMine => pick('My foods', 'המזונות שלי'); + String get catalogSearch => pick('Search by name or barcode', 'חיפוש לפי שם או ברקוד'); + String get catalogEmpty => pick('No foods found.', 'לא נמצאו מזונות.'); + String get catalogEmptyMine => + pick("You haven't added any foods yet.", 'עוד לא הוספתם מזונות.'); + String get catalogAll => pick('All', 'הכול'); + String get catalogUnverified => pick('Unverified', 'לא מאומתים'); + String get catalogLoadMore => pick('Load more', 'טען עוד'); + String get catalogPrivate => pick('Private', 'פרטי'); String get chooseFood => pick('Choose a published food', 'בחירת מזון שפורסם'); String get noFood => pick( 'This barcode is not in the database yet.', diff --git a/apps/mobile/lib/main.dart b/apps/mobile/lib/main.dart index 319d5be..e75772f 100644 --- a/apps/mobile/lib/main.dart +++ b/apps/mobile/lib/main.dart @@ -12,8 +12,11 @@ import 'package:timezone/timezone.dart' as tz; import 'api.dart'; import 'calorie_goal.dart'; +import 'catalog.dart'; import 'copy.dart'; import 'health.dart'; +import 'nutrition_parser.dart'; +import 'nutrition_scan.dart'; import 'reminders.dart'; import 'theme.dart'; @@ -894,6 +897,22 @@ class _HomeScreenState extends State { CupertinoIcons.timer_fill, FastingScreen(api: widget.api, t: t, sync: widget.sync), ), + ( + t.catalogTab, + CupertinoIcons.cube_box, + CupertinoIcons.cube_box_fill, + CatalogScreen( + api: widget.api, + t: t, + canVerify: canVerifyFoods, + onCreateFood: () => Navigator.push>( + context, + MaterialPageRoute( + builder: (_) => FoodScreen(api: widget.api, t: t), + ), + ), + ), + ), ( t.weight, CupertinoIcons.gauge, @@ -4036,6 +4055,49 @@ class _FoodScreenState extends State { final sugars = TextEditingController(); final sodium = TextEditingController(); String error = ''; + final scanned = {}; + bool scannedFromServing = false; + String scannedText = ''; + + Future scanLabel() async { + final scan = await Navigator.push( + context, + MaterialPageRoute(builder: (_) => NutritionScanScreen(t: widget.t)), + ); + if (scan == null) return; + String text(num? v) => v == null ? '' : _plainNumber(v); + setState(() { + scanned.clear(); + for (final (controller, value) in [ + (calories, scan.calories), + (protein, scan.protein), + (carbohydrates, scan.carbohydrates), + (fat, scan.fat), + (fiber, scan.fiber), + (sugars, scan.sugars), + (sodium, scan.sodium), + ]) { + if (value == null) continue; + controller.text = text(value); + scanned.add(controller); + } + basis = scan.liquid ? 'liquid' : 'solid'; + scannedFromServing = scan.fromServing; + scannedText = scan.raw; + }); + } + + Widget scannedField(TextEditingController controller, String label, String hint) { + return decimalField( + controller, + label, + hint: hint, + helper: scanned.contains(controller) ? widget.t.scannedCheck : null, + onChanged: (_) { + if (scanned.remove(controller)) setState(() {}); + }, + ); + } @override Widget build(BuildContext context) { @@ -4045,6 +4107,43 @@ class _FoodScreenState extends State { body: AppList( gap: Gaps.md, children: [ + SizedBox( + height: Gaps.hit, + child: OutlinedButton.icon( + onPressed: scanLabel, + icon: const Icon(CupertinoIcons.camera_viewfinder), + label: Text(t.scanLabel), + ), + ), + if (scannedText.isNotEmpty) + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton( + onPressed: () => showDialog( + context: context, + builder: (_) => AlertDialog( + content: SingleChildScrollView( + child: SelectableText( + scannedText, + textDirection: TextDirection.ltr, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(t.close), + ), + ], + ), + ), + child: Text(t.showScannedText), + ), + ), + if (scannedFromServing) + Text( + t.scannedFromServing, + style: TextStyle(color: context.apple.labelSecondary), + ), if (widget.barcode != null) Text( '${t.barcode}: ${widget.barcode}', @@ -4072,7 +4171,7 @@ class _FoodScreenState extends State { ], onChanged: (value) => setState(() => basis = value), ), - decimalField(calories, t.calories, hint: '0'), + scannedField(calories, t.calories, '0'), for (final pair in [ [(protein, t.protein), (carbohydrates, t.carbohydrates)], [(fat, t.fat), (fiber, t.fiber)], @@ -4083,7 +4182,7 @@ class _FoodScreenState extends State { children: [ for (final (controller, label) in pair) Expanded( - child: decimalField(controller, label, hint: t.optional), + child: scannedField(controller, label, t.optional), ), ], ), diff --git a/apps/mobile/lib/nutrition_parser.dart b/apps/mobile/lib/nutrition_parser.dart new file mode 100644 index 0000000..56a3092 --- /dev/null +++ b/apps/mobile/lib/nutrition_parser.dart @@ -0,0 +1,525 @@ +/// Turns raw OCR text from a nutrition facts table (English, Hebrew or both) +/// into per-100 g / per-100 ml values. Pure Dart so it can be unit-tested +/// without a camera. +/// +/// Labels list a per-serving column (30 g, 60 g, 80 g...) next to a per-100 +/// column. We always take the per-100 value, and we use the ratio between the +/// two columns (value100 * serving / 100 == value per serving) both to find +/// the right column and to catch OCR misreads such as a dropped decimal point. +library; + +class NutritionScan { + const NutritionScan({ + this.calories, + this.protein, + this.carbohydrates, + this.fat, + this.fiber, + this.sugars, + this.sodium, + this.liquid = false, + this.fromServing = false, + this.raw = '', + }); + + final num? calories; + final num? protein; + final num? carbohydrates; + final num? fat; + final num? fiber; + final num? sugars; + + /// Milligrams. + final num? sodium; + + /// The table is per 100 ml rather than per 100 g. + final bool liquid; + + /// No per-100 column was found, so values were scaled from a serving. + final bool fromServing; + + /// The OCR text this was parsed from (for the "show text" debug view). + final String raw; + + int get found => [ + calories, + protein, + carbohydrates, + fat, + fiber, + sugars, + sodium, + ].where((v) => v != null).length; + + /// Enough to be worth prefilling the form with. + bool get usable => found >= 2; + + NutritionScan withRaw(String text) => NutritionScan( + calories: calories, + protein: protein, + carbohydrates: carbohydrates, + fat: fat, + fiber: fiber, + sugars: sugars, + sodium: sodium, + liquid: liquid, + fromServing: fromServing, + raw: text, + ); +} + +enum _Field { calories, protein, carbs, fat, fiber, sugars, sodium, salt } + +const _keywords = <_Field, List>{ + _Field.calories: [ + 'energy', 'calories', 'calorie', 'kcal', + 'אנרגיה', 'קלוריות', 'קלוריה', 'קקל', + ], + _Field.protein: ['protein', 'proteins', 'חלבון', 'חלבונים'], + _Field.carbs: [ + 'carbohydrate', 'carbohydrates', 'carbs', 'פחמימות', 'פחמימה', + ], + _Field.sugars: ['sugars', 'sugar', 'סוכרים', 'סוכר'], + _Field.fat: ['fat', 'fats', 'שומן', 'שומנים'], + _Field.fiber: ['fiber', 'fibre', 'fibres', 'סיבים', 'סיב'], + _Field.sodium: ['sodium', 'נתרן'], + _Field.salt: ['salt', 'מלח'], +}; + +/// Lines about these are sub-nutrients we don't track. +const _skipEnglish = [ + 'saturated', 'saturates', 'trans', 'mono', 'poly', + 'monounsaturated', 'polyunsaturated', 'cholesterol', +]; +const _skipHebrew = [ + 'רווי', 'רוויים', 'רוויות', 'טראנס', 'טרנס', 'כולסטרול', 'חד', 'רב', +]; + +/// "Sugars (teaspoons)" and friends repeat a nutrient in another unit. +const _skipAnywhere = [ + 'teaspoons', 'teaspoon', 'tsp', 'tablespoon', 'tablespoons', 'כפיות', 'כפית', +]; + +final _niqqud = RegExp('[֑-ׇ]'); +final _quoteMarks = RegExp('["״\'׳`“”]'); +final _hebrewLetter = RegExp('[א-ת]'); +final _word = RegExp('[a-zא-ת]+'); +final _qty = RegExp( + r'(<|פחות מ)?\s*(\d+(?:[.,]\d+)?)\s*(%|[a-zא-ת]+)?', +); +final _massUnit = RegExp(r'^(?:g|gr|ml|גרם|מל)$'); + +String _normalize(String text) { + const finals = {'ך': 'כ', 'ם': 'מ', 'ן': 'נ', 'ף': 'פ', 'ץ': 'צ'}; + var out = text + .toLowerCase() + .replaceAll(_niqqud, '') + .replaceAll(_quoteMarks, ''); + finals.forEach((from, to) => out = out.replaceAll(from, to)); + // Tesseract reads digits inside Hebrew lines backwards ("ל-100" -> "001", + // "30 גרם" -> "03"). A leading zero never appears in real serving sizes, so + // flip those back. + return out + .split('\n') + .map((line) { + if (!_hebrewLetter.hasMatch(line)) return line; + return line.replaceAllMapped( + RegExp(r'(? m.group(0)!.split('').reversed.join(), + ); + }) + .join('\n'); +} + +int _distance(String a, String b) { + if (a == b) return 0; + var prev = List.generate(b.length + 1, (i) => i); + for (var i = 1; i <= a.length; i++) { + final cur = List.filled(b.length + 1, 0)..[0] = i; + for (var j = 1; j <= b.length; j++) { + final cost = a[i - 1] == b[j - 1] ? 0 : 1; + cur[j] = [prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost].reduce( + (x, y) => x < y ? x : y, + ); + } + prev = cur; + } + return prev[b.length]; +} + +bool _matches(String word, String keyword) { + final key = _normalize(keyword); + // Hebrew attaches ה/ו/ב/ל/מ to the front of words. + final candidates = {word}; + if (word.length > 3 && 'הובלמ'.contains(word[0])) { + candidates.add(word.substring(1)); + } + for (final w in candidates) { + if (w == key) return true; + if (key.length >= 5 && + (w.length - key.length).abs() <= 1 && + _distance(w, key) <= 1) { + return true; + } + } + return false; +} + +/// Language of a keyword: Hebrew if it has any Hebrew letter. +bool _isHebrewWord(String w) => _hebrewLetter.hasMatch(w); + +/// [language] is 'he', 'en', or null for both. A bilingual label repeats every +/// row in two languages; reading one language avoids mixing them up. +({_Field field, int index})? _detect(List words, [String? language]) { + int? skipEnglish; + var skipHebrew = false; + for (var i = 0; i < words.length; i++) { + final w = words[i]; + if (_skipAnywhere.contains(w)) return null; + if (_skipEnglish.contains(w)) skipEnglish ??= i; + if (_skipHebrew.contains(w)) skipHebrew = true; + } + for (var i = 0; i < words.length; i++) { + if (language == 'he' && !_isHebrewWord(words[i])) continue; + if (language == 'en' && _isHebrewWord(words[i])) continue; + for (final entry in _keywords.entries) { + if (entry.value.any((k) => _matches(words[i], k))) { + if (skipHebrew) return null; + if (skipEnglish != null && skipEnglish < i) return null; + return (field: entry.key, index: i); + } + } + } + return null; +} + +class _Qty { + _Qty(this.value, this.unit, this.hasDecimal, this.start); + double value; + final String unit; + + /// Offset of the number in its line, to tell what sits before or after a label. + final int start; + + /// False when the OCR text had no decimal point, which is where lost + /// decimals ("1.8" read as "18") can hide. + final bool hasDecimal; +} + +List<_Qty> _quantities(String line) { + final all = <_Qty>[]; + for (final m in _qty.allMatches(line)) { + final unit = m.group(3) ?? ''; + if (unit == '%') continue; + final text = m.group(2)!; + final value = double.tryParse(text.replaceAll(',', '.')); + if (value == null) continue; + // "(0)" / ")9(" is OCR junk for a unit like "(g)", never a value. + final from = m.start + m.group(0)!.indexOf(text); + final to = from + text.length; + if (from > 0 && + '()'.contains(line[from - 1]) && + to < line.length && + '()'.contains(line[to])) { + continue; + } + all.add(_Qty(value, unit, RegExp(r'[.,]').hasMatch(text), from)); + } + // "per 100 g" in the label itself isn't a value. + final real = all + .where((q) => !(q.value == 100 && _massUnit.hasMatch(q.unit))) + .toList(); + return real.isEmpty ? all : real; +} + +bool _isKcal(String unit) => + const {'kcal', 'cal', 'קקל', 'קלוריות'}.contains(unit); + +bool _isKj(String unit) => const {'kj', 'קג'}.contains(unit); + +/// Fallback when the serving size is unknown: which column of a two-column +/// table holds the per-100 values, judged from header order. +int _per100Column(List lines) { + final per100 = RegExp(r'100\s*(g|gr|ml|גרם|מל)(?![a-zא-ת])'); + final other = RegExp( + r'(serving|portion|per\s+(?:pack|unit)|מנה|יחידה|ליחידה|מנת)|(? lines) { + final amount = RegExp(r'(\d+(?:[.,]\d+)?)\s*(?:g|gr|ml|גרם|מל)(?![a-zא-ת])'); + for (final line in lines) { + final words = _word.allMatches(line).map((m) => m.group(0)!).toList(); + // Only header lines: data rows name a nutrient. + if (_detect(words) != null) continue; + if (!RegExp(r'per|serving|portion|מנה|למנה|יחידה|ל-|^\s*\d') + .hasMatch(line)) { + continue; + } + for (final m in amount.allMatches(line)) { + final v = double.tryParse(m.group(1)!.replaceAll(',', '.')); + if (v != null && v > 0 && v != 100) return v; + } + } + return null; +} + +bool _close(double actual, double expected) => + (actual - expected).abs() <= 0.15 || + (actual - expected).abs() <= expected.abs() * 0.08; + +class _Pick { + _Pick(this.value, this.hasDecimal, {this.verified = false}); + double value; + bool hasDecimal; + + /// Confirmed by the serving column (value * serving / 100 matched). + bool verified; +} + +/// Chooses the per-100 number from a row of columns. With the serving size +/// known, the per-100 value is the one whose scaled twin appears in the row; +/// OCR often drops decimal points, so tenth-scaled variants are tried too. +_Pick _pick(List<_Qty> qtys, double? serving, int column) { + if (qtys.length == 1) return _Pick(qtys[0].value, qtys[0].hasDecimal); + if (serving != null) { + _Pick? best; + var bestRepairs = 99; + for (var i = 0; i < qtys.length; i++) { + for (var j = 0; j < qtys.length; j++) { + if (i == j) continue; + for (final ri in [false, true]) { + for (final rj in [false, true]) { + final a = qtys[i]; + final b = qtys[j]; + if ((ri && (a.hasDecimal || a.value < 10)) || + (rj && (b.hasDecimal || b.value < 10))) { + continue; + } + final va = ri ? a.value / 10 : a.value; + final vb = rj ? b.value / 10 : b.value; + if (!_close(vb, va * serving / 100)) continue; + // Zeros match every ratio and prove nothing. + final repairs = (ri ? 1 : 0) + (rj ? 1 : 0); + if (repairs < bestRepairs) { + bestRepairs = repairs; + best = _Pick(va, a.hasDecimal || ri, verified: va != 0); + } + } + } + } + } + if (best != null) return best; + } + final q = qtys[column < qtys.length ? column : qtys.length - 1]; + return _Pick(q.value, q.hasDecimal); +} + +num? _round(double? v) => v == null ? null : (v * 100).round() / 100; + +/// [preferHebrew] decides which language wins when a bilingual label reads +/// equally well in both. +NutritionScan parseNutritionText(String raw, {bool preferHebrew = false}) { + final he = _parse(raw, 'he'); + final en = _parse(raw, 'en'); + if (he.found != en.found) return he.found > en.found ? he : en; + return preferHebrew ? he : en; +} + +NutritionScan _parse(String raw, String language) { + final text = _normalize(raw); + final lines = text + .split(RegExp(r'[\r\n]+')) + .map((l) => l.trim()) + .where((l) => l.isNotEmpty) + .toList(); + + final liquid = RegExp(r'100\s*(ml|מל)').hasMatch(text); + final hasPer100 = RegExp( + r'100\s*(g|gr|ml|גרם|מל)(?![a-z\u05D0-\u05EA])', + ).hasMatch(text); + final serving = _servingSize(lines); + final scale = (!hasPer100 && serving != null) ? 100 / serving : 1.0; + final column = _per100Column(lines); + + // Only trust a serving column if at least one row actually adds up; + // otherwise this is a single-column table (and a stray "5 g" in the header + // must not make us discard single numbers). + var result = _extract(lines, language, hasPer100 ? serving : null, column); + if (result.verified == 0 && hasPer100 && serving != null) { + result = _extract(lines, language, null, column); + } + final found = result.found; + _reconcile(found); + + double? at(_Field f) { + final p = found[f]; + return p == null ? null : p.value * scale; + } + + var calories = at(_Field.calories); + if (calories == null && result.kj != null) { + calories = (result.kj! / 4.184) * scale; + } + final sodium = at(_Field.sodium) ?? at(_Field.salt); + + return NutritionScan( + calories: calories == null ? null : calories.round(), + protein: _round(at(_Field.protein)), + carbohydrates: _round(at(_Field.carbs)), + fat: _round(at(_Field.fat)), + fiber: _round(at(_Field.fiber)), + sugars: _round(at(_Field.sugars)), + sodium: sodium == null ? null : sodium.round(), + liquid: liquid, + fromServing: scale != 1.0, + raw: raw, + ); +} + +({Map<_Field, _Pick> found, double? kj, int verified}) _extract( + List lines, + String language, + double? ratioServing, + int column, +) { + final found = <_Field, _Pick>{}; + double? kjFallback; + var verified = 0; + + for (final line in lines) { + final matches = _word.allMatches(line).toList(); + final words = matches.map((m) => m.group(0)!).toList(); + final hit = _detect(words, language); + if (hit == null) continue; + final keywordEnd = matches[hit.index].end; + var qtys = _quantities(line); + + // A value follows its label; a number before it is usually noise from + // outside the table. Fall back to the closest number before the label + // when the English label comes after its value on a bilingual line. + final after = qtys.where((q) => q.start >= keywordEnd).toList(); + if (after.isNotEmpty) { + qtys = after; + } else { + final before = qtys.where((q) => q.start < keywordEnd).toList(); + qtys = before.isEmpty ? <_Qty>[] : [before.last]; + } + if (qtys.isEmpty) continue; + + if (hit.field == _Field.calories) { + final kcal = qtys.where((q) => _isKcal(q.unit)).toList(); + if (kcal.isEmpty) { + final kj = qtys.where((q) => _isKj(q.unit)).toList(); + if (kj.isNotEmpty && kjFallback == null) { + kjFallback = kj[column < kj.length ? column : kj.length - 1].value; + } + if (qtys.every((q) => _isKj(q.unit))) continue; + qtys = qtys.where((q) => !_isKj(q.unit)).toList(); + } else { + qtys = kcal; + } + } + + if (found.containsKey(hit.field) && hit.field != _Field.calories) continue; + // One number in a two-column table could be either column: leave it empty + // rather than guess. + if (ratioServing != null && qtys.length == 1) continue; + final pick = _pick(qtys, ratioServing, column); + if (pick.verified) verified++; + final unit = qtys.first.unit; + if (hit.field == _Field.sodium && (unit == 'g' || unit == 'גרם')) { + pick.value *= 1000; + } + if (hit.field == _Field.salt) { + // Salt is ~40% sodium. + pick.value = (unit == 'mg' || unit == 'מג') + ? pick.value * 0.4 + : pick.value * 400; + } + if (hit.field == _Field.calories && + found.containsKey(_Field.calories) && + !qtys.any((q) => _isKcal(q.unit))) { + continue; + } + found[hit.field] = pick; + } + return (found: found, kj: kjFallback, verified: verified); +} + +/// Cross-checks the numbers against each other, repairing lost decimals where +/// that makes the label add up and dropping values that are impossible, so we +/// leave a field empty rather than invent a number. +void _reconcile(Map<_Field, _Pick> found) { + final macros = [_Field.protein, _Field.carbs, _Field.fat]; + final kcal = found[_Field.calories]?.value; + + // Calories should be ~ 4*protein + 4*carbs + 9*fat (+ a little for fibre + // and alcohol). Try tenth-scaled variants of unverified, decimal-less values. + if (kcal != null && kcal > 0 && macros.every(found.containsKey)) { + double error(Map<_Field, double> v) { + final est = 4 * v[_Field.protein]! + + 4 * v[_Field.carbs]! + + 9 * v[_Field.fat]!; + final fiber = found[_Field.fiber]?.value ?? 0; + final low = est; + final high = est + 2 * fiber + 0.1 * est; + if (kcal >= low * 0.8 && kcal <= high * 1.1) return 0; + return kcal < low ? (low - kcal) / kcal : (kcal - high) / kcal; + } + + final original = {for (final f in macros) f: found[f]!.value}; + if (error(original) > 0) { + final fixable = [ + for (final f in macros) + if (!found[f]!.hasDecimal && found[f]!.value >= 10) + f, + ]; + Map<_Field, double>? best; + var bestError = error(original); + for (var mask = 1; mask < (1 << fixable.length); mask++) { + final trial = {...original}; + for (var i = 0; i < fixable.length; i++) { + if (mask & (1 << i) != 0) trial[fixable[i]] = trial[fixable[i]]! / 10; + } + final e = error(trial); + if (e < bestError) { + bestError = e; + best = trial; + } + } + if (best != null && bestError == 0) { + best.forEach((f, v) => found[f]!.value = v); + } + } + } + + // Sugars are part of the carbohydrates; fibre can't exceed them either. + final carbs = found[_Field.carbs]?.value; + for (final f in [_Field.sugars, _Field.fiber]) { + final p = found[f]; + if (p == null || carbs == null) continue; + if (p.value > carbs + 0.5) found.remove(f); + } + + // Impossible per-100 values. + for (final f in [ + _Field.protein, _Field.carbs, _Field.fat, _Field.fiber, _Field.sugars, + ]) { + final p = found[f]; + if (p != null && (p.value > 100 || p.value < 0)) found.remove(f); + } + final cal = found[_Field.calories]; + if (cal != null && cal.value > 900) found.remove(_Field.calories); +} diff --git a/apps/mobile/lib/nutrition_scan.dart b/apps/mobile/lib/nutrition_scan.dart new file mode 100644 index 0000000..f3a4e75 --- /dev/null +++ b/apps/mobile/lib/nutrition_scan.dart @@ -0,0 +1,225 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_tesseract_ocr/flutter_tesseract_ocr.dart'; +import 'package:image/image.dart' as img; +import 'package:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'copy.dart'; +import 'nutrition_parser.dart'; +import 'theme.dart'; + +/// Fixes EXIF orientation and downscales a phone photo. Deliberately no +/// grayscale/contrast tweaks: on real labels (white text on a green panel) +/// they made Tesseract lose decimals and whole rows, and nearest-neighbour +/// resizing (the `image` package default) erases decimal points, so use +/// averaging. Runs off the UI isolate. +Future _prepare(String path) async { + final dir = (await getTemporaryDirectory()).path; + final out = '$dir/nutrition_base.png'; + await compute(_preprocess, [path, out]); + return out; +} + +void _preprocess(List args) { + final decoded = img.decodeImage(File(args[0]).readAsBytesSync()); + if (decoded == null) throw const FormatException('bad image'); + var image = img.bakeOrientation(decoded); + image = _fit(image, 2400); + File(args[1]).writeAsBytesSync(img.encodePng(image)); +} + +img.Image _fit(img.Image image, int longSide) { + if (image.width >= image.height) { + return image.width <= longSide + ? image + : img.copyResize(image, + width: longSide, interpolation: img.Interpolation.average); + } + return image.height <= longSide + ? image + : img.copyResize(image, + height: longSide, interpolation: img.Interpolation.average); +} + +/// [base] rotated by [degrees] and fitted into [longSide] pixels. +Future _variant(String base, int degrees, int longSide) async { + final dir = (await getTemporaryDirectory()).path; + final out = '$dir/nutrition_${degrees}_$longSide.png'; + await compute(_rotateResize, [base, out, '$degrees', '$longSide']); + return out; +} + +void _rotateResize(List args) { + var image = img.decodePng(File(args[0]).readAsBytesSync())!; + final degrees = int.parse(args[2]); + final longSide = int.parse(args[3]); + if (degrees != 0) image = img.copyRotate(image, angle: degrees); + image = _fit(image, longSide); + File(args[1]).writeAsBytesSync(img.encodePng(image)); +} + +Future _read(String file, String psm, bool preferHebrew) async { + final text = await FlutterTesseractOcr.extractText( + file, + language: 'heb+eng', + args: {'psm': psm}, + ); + return parseNutritionText(text, preferHebrew: preferHebrew).withRaw(text); +} + +/// Reads a photo of a nutrition table. The photo may be sideways or upside +/// down, so a quick low-resolution pass is tried at each rotation first and +/// the rotation where the most values can be read wins. On a label that has +/// both Hebrew and English, [preferHebrew] picks the language. +Future scanNutritionImage( + String path, { + bool preferHebrew = false, +}) async { + final base = await _prepare(path); + + var rotation = 0; + var bestFound = -1; + for (final degrees in [0, 90, 270, 180]) { + final small = await _variant(base, degrees, 1000); + final scan = await _read(small, '6', preferHebrew); + if (scan.found > bestFound) { + bestFound = scan.found; + rotation = degrees; + } + if (scan.found >= 4) break; + } + + // Block modes keep each table row (label + its numbers) on one line. Mode 6 + // suits a tidy table; 4 copes better with uneven text sizes, so try it when + // the first pass comes back thin and keep whichever read more. + final full = await _variant(base, rotation, 2400); + NutritionScan? best; + for (final psm in ['6', '4']) { + final scan = await _read(full, psm, preferHebrew); + if (best == null || scan.found > best.found) best = scan; + if (best.found >= 5) break; + } + return best!; +} + +/// Asks for a photo of a nutrition table, reads it, and pops a +/// [NutritionScan] (or nothing if the user backs out). +class NutritionScanScreen extends StatefulWidget { + const NutritionScanScreen({super.key, required this.t}); + final Copy t; + + @override + State createState() => _NutritionScanScreenState(); +} + +class _NutritionScanScreenState extends State { + final picker = ImagePicker(); + bool busy = false; + String error = ''; + String? photo; + String rawText = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => pick(ImageSource.camera)); + } + + Future pick(ImageSource source) async { + setState(() => error = ''); + XFile? file; + try { + file = await picker.pickImage(source: source, imageQuality: 95); + } catch (_) { + if (mounted) setState(() => error = widget.t.cameraUnavailable); + return; + } + if (file == null || !mounted) return; + setState(() { + busy = true; + photo = file!.path; + }); + try { + final scan = await scanNutritionImage( + file.path, + preferHebrew: widget.t.he, + ); + if (!mounted) return; + if (scan.usable) { + Navigator.pop(context, scan); + return; + } + setState(() { + busy = false; + rawText = scan.raw; + error = widget.t.scanNothingFound; + }); + } catch (_) { + if (mounted) { + setState(() { + busy = false; + error = widget.t.scanFailed; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final t = widget.t; + final apple = context.apple; + return Scaffold( + appBar: AppBar(title: Text(t.scanLabel)), + body: AppList( + gap: Gaps.md, + children: [ + Text(t.scanLabelHint, style: TextStyle(color: apple.labelSecondary)), + if (photo != null) + ClipRRect( + borderRadius: BorderRadius.circular(Radii.card), + child: Image.file(File(photo!), height: 240, fit: BoxFit.cover), + ), + if (busy) + Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: Gaps.md, + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + Text(t.scanReading), + ], + ), + if (error.isNotEmpty) ErrorText(error), + if (rawText.trim().isNotEmpty) + Text( + rawText, + style: TextStyle(fontSize: 12, color: apple.labelSecondary), + textDirection: TextDirection.ltr, + ), + if (!busy) ...[ + SizedBox( + height: Gaps.hit, + child: FilledButton( + onPressed: () => pick(ImageSource.camera), + child: Text(photo == null ? t.takePhoto : t.retakePhoto), + ), + ), + SizedBox( + height: Gaps.hit, + child: OutlinedButton( + onPressed: () => pick(ImageSource.gallery), + child: Text(t.chooseFromLibrary), + ), + ), + ], + ], + ), + ); + } +} diff --git a/apps/mobile/packages/flutter_tesseract_ocr/CHANGELOG.md b/apps/mobile/packages/flutter_tesseract_ocr/CHANGELOG.md new file mode 100644 index 0000000..f390c64 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/CHANGELOG.md @@ -0,0 +1,147 @@ +## 0.4.31 +- feat(ios): Run Tesseract initialization and OCR on background thread to prevent UI freezing + +## 0.4.30 +- 16KB Page Size Compatibility Warning on Google Play Console (due to native libraries) + +## 0.4.29 + -Wasm support #55 + +## 0.4.27 +- move to flutter gradle's declarative plugins + +## 0.4.26 +- Fixing Compile Issue error + +## 0.4.23 + +- Update flutter_tesseract_ocr.podspec + +## 0.4.22 + +- Build with latest version of Tesseract4Android +- Change to latest version of web tesseract + +## 0.4.20 + +- ios version up to 12.0 + +## 0.4.19 + +- Fixing Calling multiple async calls crashing bug + +## 0.4.17 + +- Migrating to Android embedding V2 for flutter + +## 0.4.16 + +- edit readme + +## 0.4.13 + +- rollback, remove liveocr +- fixes I had this problem when I called the menthod again, the first call was successful +- fixes Web: No Such Method Exception +- add try-catch fixes #13 + +## 0.4.12 + +- fixes I had this problem when I called the menthod again, the first call was successful + +## 0.4.12 + +- fixes Web: No Such Method Exception + +## 0.4.10 + +- fixes TessBaseAPI was not terminated using recycle() + +## 0.4.9 + +- fixes web error (Error: Method not found: 'FlutterTesseractOcr.extractTextLive') + +## 0.4.8 + +- add try-catch / edit readme + +## 0.4.7 + +- add try-catch fixes #13 + +## 0.4.6 + +- add live ocr (IOS) + +## 0.4.4 + +- add gif + +## 0.4.2 + +- working on IOS (https://github.com/khjde1207/tesseract_ocr/issues/5) + +## 0.4.2 + +- fixes error NoSuchMethodError: The method 'containsKey' was called on null + +## 0.4.0 + +- Performance improvement (0.2 seconds faster per page than before) + +## 0.3.8 + +- support web + +## 0.3.7 + +- Support latest gradle + +## 0.3.4 + +- TesseractOcr -> FlutterTesseractOcr + +## 0.3.0 + +- added language support you can pass the language as an optional parameter. TesseractOcr.extractText(imagePath, language: "financial"); + if not set it will default to "eng". language must match the name of .traineddata file + +## 0.1.0 + +- Extract Text from images, Especially helpful when you are extracting single character images. + +## 0.1.1 + +- fixed path references + +## 0.1.2 + +- added description + +## 0.1.2+1 + +- fixed typo + +## 0.1.2+2 + +- fixed description + +## 0.1.2+3 + +- added LICENSE + +## 0.1.3 + +- updated swift version to 4.2 + +## 0.2.0 + +- fixed dependecies + +## 0.2.1 + +- fixed swift version + +## 0.2.2 + +- set target swift version to 4.2 diff --git a/apps/mobile/packages/flutter_tesseract_ocr/LICENSE b/apps/mobile/packages/flutter_tesseract_ocr/LICENSE new file mode 100644 index 0000000..aff9945 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2019, Ahmet tok +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/apps/mobile/packages/flutter_tesseract_ocr/README.md b/apps/mobile/packages/flutter_tesseract_ocr/README.md new file mode 100644 index 0000000..9829115 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/README.md @@ -0,0 +1,133 @@ +# Tesseract OCR for Flutter + +Tesseract OCR 4.0 for flutter +This plugin is based on Tesseract OCR 4 +This plugin uses Tesseract4Android and SwiftyTesseract. + +[pub.dev link](https://pub.dev/packages/flutter_tesseract_ocr) + +--- + +https://pub.dev/packages/google_ml_kit + +Tesseract is slower than ml_kit. + +Consider whether you should use Tesseract + +--- + +# example + +

+ +

+ +## install + +``` + +dev_dependencies: + ... + flutter_tesseract_ocr: + +``` + +## web + +./web/index.html + +use https://www.npmjs.com/package/tesseract.js/v/v4.0.2 + +``` + + + + ... + .. + . + +``` + +--- + +## Getting Started (Android / Ios) + +You must add trained data and trained data config file to your assets directory. +You can find additional language trained data files here Trained language files + +add tessdata folder under assets folder, add tessdata_config.json file under assets folder: + +``` +{ + "files": [ + "eng.traineddata", + ".traineddata" + ] +} +``` + +Plugin assumes you have tessdata folder in your assets directory and defined in your pubspec.yaml + +Check the contents of example/assets folder and example/pubspec.yaml + +--- + +## IOS issues + +[Initialization of SwiftyTesseract has failed](https://github.com/khjde1207/tesseract_ocr/issues/16) + +Just drag tessdata folder from asset to place under Runner folder in xcode add as a reference then it will work. + +[Reference](https://github.com/arrrrny/tesseract_ocr/issues/31) + +--- + +## Usage + +Using is very simple: + +``` +//args support android / Web , i don't have a mac +String text = await FlutterTesseractOcr.extractText('/path/to/image', language: 'kor+eng', + args: { + "psm": "4", + "preserve_interword_spaces": "1", + }); + +``` + +You can leave `language` empty, it will default to `'eng'. + +``` +//---- dynamic add Tessdata (Android)---- ▼ +// https://github.com/tesseract-ocr/tessdata/raw/main/dan_frak.traineddata + +HttpClient httpClient = new HttpClient(); + +HttpClientRequest request = await httpClient.getUrl(Uri.parse( + 'https://github.com/tesseract-ocr/tessdata/raw/main/${langName}.traineddata')); + +HttpClientResponse response = await request.close(); +Uint8List bytes =await consolidateHttpClientResponseBytes(response); +String dir = await FlutterTesseractOcr.getTessdataPath(); + +print('$dir/${langName}.traineddata'); +File file = new File('$dir/${langName}.traineddata'); +await file.writeAsBytes(bytes); +//---- dynamic add Tessdata ---- ▲ + +``` diff --git a/apps/mobile/packages/flutter_tesseract_ocr/android/build.gradle b/apps/mobile/packages/flutter_tesseract_ocr/android/build.gradle new file mode 100644 index 0000000..38efc3e --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/android/build.gradle @@ -0,0 +1,53 @@ +group 'io.paratoner.flutter_tesseract_ocr' +version '1.0' + +buildscript { + repositories { + google() + jcenter() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.1.2' + } + +} + +rootProject.allprojects { + repositories { + flatDir { + dirs project(':flutter_tesseract_ocr').file('libs') + } + + } +} + +apply plugin: 'com.android.library' + +android { + namespace "io.paratoner.flutter_tesseract_ocr" + compileSdkVersion 34 + + + defaultConfig { + minSdkVersion 21 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + + } + lintOptions { + disable 'InvalidPackage' + } + // packagingOptions{ + // doNotStrip '*/mips/*.so' + // doNotStrip '*/mips64/*.so' + // } +} +// repositories { +// flatDir { +// dirs 'libs' +// dirs project(':flutter_tesseract_ocr').file('libs') +// } +// } +dependencies { + api(name: 'tesseract4android-release', ext: 'aar') +} diff --git a/apps/mobile/packages/flutter_tesseract_ocr/android/gradle.properties b/apps/mobile/packages/flutter_tesseract_ocr/android/gradle.properties new file mode 100644 index 0000000..86f2d21 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/android/gradle.properties @@ -0,0 +1,6 @@ +org.gradle.jvmargs=-Xmx1536M +# android.enableDexingArtifactTransform=false +android.useAndroidX=true +android.enableJetifier=true + + diff --git a/apps/mobile/packages/flutter_tesseract_ocr/android/gradle/wrapper/gradle-wrapper.properties b/apps/mobile/packages/flutter_tesseract_ocr/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f338a88 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip diff --git a/apps/mobile/packages/flutter_tesseract_ocr/android/libs/tesseract4android-release.aar b/apps/mobile/packages/flutter_tesseract_ocr/android/libs/tesseract4android-release.aar new file mode 100644 index 0000000..85596ac Binary files /dev/null and b/apps/mobile/packages/flutter_tesseract_ocr/android/libs/tesseract4android-release.aar differ diff --git a/apps/mobile/packages/flutter_tesseract_ocr/android/settings.gradle b/apps/mobile/packages/flutter_tesseract_ocr/android/settings.gradle new file mode 100644 index 0000000..fc44c11 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'flutter_tesseract_ocr' diff --git a/apps/mobile/packages/flutter_tesseract_ocr/android/src/main/AndroidManifest.xml b/apps/mobile/packages/flutter_tesseract_ocr/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a2f47b6 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/apps/mobile/packages/flutter_tesseract_ocr/android/src/main/java/io/paratoner/flutter_tesseract_ocr/FlutterTesseractOcrPlugin.java b/apps/mobile/packages/flutter_tesseract_ocr/android/src/main/java/io/paratoner/flutter_tesseract_ocr/FlutterTesseractOcrPlugin.java new file mode 100644 index 0000000..3555a97 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/android/src/main/java/io/paratoner/flutter_tesseract_ocr/FlutterTesseractOcrPlugin.java @@ -0,0 +1,118 @@ +package io.paratoner.flutter_tesseract_ocr; + +import com.googlecode.tesseract.android.TessBaseAPI; + +import android.os.AsyncTask; +import androidx.annotation.NonNull; + +import java.io.File; +import java.util.Map.*; +import java.util.Map; + +import io.flutter.embedding.engine.plugins.FlutterPlugin; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.MethodCall; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugin.common.MethodChannel.MethodCallHandler; +import io.flutter.plugin.common.MethodChannel.Result; + +public class FlutterTesseractOcrPlugin implements FlutterPlugin, MethodCallHandler { + private static final int DEFAULT_PAGE_SEG_MODE = TessBaseAPI.PageSegMode.PSM_AUTO_OSD; + private TessBaseAPI baseApi = null; + private String lastLanguage = ""; + + private MethodChannel channel; + + @Override + public void onAttachedToEngine(@NonNull FlutterPluginBinding flutterPluginBinding) { + BinaryMessenger messenger = flutterPluginBinding.getBinaryMessenger(); + channel = new MethodChannel(messenger, "flutter_tesseract_ocr"); + channel.setMethodCallHandler(this); + } + + @Override + public void onDetachedFromEngine(@NonNull FlutterPluginBinding binding) { + if (channel != null) { + channel.setMethodCallHandler(null); + channel = null; + } + if (baseApi != null) { + baseApi.recycle(); + baseApi = null; + } + } + + @Override + public void onMethodCall(final MethodCall call, final Result result) { + switch (call.method) { + case "extractText": + case "extractHocr": + final String tessDataPath = call.argument("tessData"); + final String imagePath = call.argument("imagePath"); + final Map args = call.argument("args"); + String DEFAULT_LANGUAGE = "eng"; + if (call.argument("language") != null) { + DEFAULT_LANGUAGE = call.argument("language"); + } + + if (baseApi == null || !lastLanguage.equals(DEFAULT_LANGUAGE)) { + baseApi = new TessBaseAPI(); + baseApi.init(tessDataPath, DEFAULT_LANGUAGE); + lastLanguage = DEFAULT_LANGUAGE; + } + + int psm = DEFAULT_PAGE_SEG_MODE; + if (args != null) { + for (Map.Entry entry : args.entrySet()) { + if (!entry.getKey().equals("psm")) { + baseApi.setVariable(entry.getKey(), entry.getValue()); + } else { + psm = Integer.parseInt(entry.getValue()); + } + } + } + + baseApi.setPageSegMode(psm); + new OcrAsyncTask(baseApi, new File(imagePath), result, call.method.equals("extractHocr")).execute(); + break; + + default: + result.notImplemented(); + } + } + + private static class OcrAsyncTask extends AsyncTask { + private final TessBaseAPI baseApi; + private final File imageFile; + private final Result result; + private final boolean extractHocr; + + OcrAsyncTask(TessBaseAPI baseApi, File imageFile, Result result, boolean extractHocr) { + this.baseApi = baseApi; + this.imageFile = imageFile; + this.result = result; + this.extractHocr = extractHocr; + } + + @Override + protected String doInBackground(Void... voids) { + + this.baseApi.setImage(imageFile); + String recognizedText; + if (extractHocr) { + recognizedText = this.baseApi.getHOCRText(0); + } else { + recognizedText = this.baseApi.getUTF8Text(); + } + this.baseApi.stop(); + return recognizedText; + + + } + + @Override + protected void onPostExecute(String recognizedText) { + result.success(recognizedText); + } + } +} diff --git a/apps/mobile/packages/flutter_tesseract_ocr/check_elf_alignment.sh b/apps/mobile/packages/flutter_tesseract_ocr/check_elf_alignment.sh new file mode 100644 index 0000000..0cd8f92 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/check_elf_alignment.sh @@ -0,0 +1,113 @@ +#!/bin/bash +progname="${0##*/}" +progname="${progname%.sh}" + +# usage: check_elf_alignment.sh [path to *.so files|path to *.apk] + +cleanup_trap() { + if [ -n "${tmp}" -a -d "${tmp}" ]; then + rm -rf ${tmp} + fi + exit $1 +} + +usage() { + echo "Host side script to check the ELF alignment of shared libraries." + echo "Shared libraries are reported ALIGNED when their ELF regions are" + echo "16 KB or 64 KB aligned. Otherwise they are reported as UNALIGNED." + echo + echo "Usage: ${progname} [input-path|input-APK|input-APEX]" +} + +if [ ${#} -ne 1 ]; then + usage + exit +fi + +case ${1} in + --help | -h | -\?) + usage + exit + ;; + + *) + dir="${1}" + ;; +esac + +if ! [ -f "${dir}" -o -d "${dir}" ]; then + echo "Invalid file: ${dir}" >&2 + exit 1 +fi + +if [[ "${dir}" == *.apk ]]; then + trap 'cleanup_trap' EXIT + + echo + echo "Recursively analyzing $dir" + echo + + if { zipalign --help 2>&1 | grep -q "\-P "; }; then + echo "=== APK zip-alignment ===" + zipalign -v -c -P 16 4 "${dir}" | egrep 'lib/arm64-v8a|lib/x86_64|Verification' + echo "=========================" + else + echo "NOTICE: Zip alignment check requires build-tools version 35.0.0-rc3 or higher." + echo " You can install the latest build-tools by running the below command" + echo " and updating your \$PATH:" + echo + echo " sdkmanager \"build-tools;35.0.0-rc3\"" + fi + + dir_filename=$(basename "${dir}") + tmp=$(mktemp -d -t "${dir_filename%.apk}_out_XXXXX") + unzip "${dir}" lib/* -d "${tmp}" >/dev/null 2>&1 + dir="${tmp}" +fi + +if [[ "${dir}" == *.apex ]]; then + trap 'cleanup_trap' EXIT + + echo + echo "Recursively analyzing $dir" + echo + + dir_filename=$(basename "${dir}") + tmp=$(mktemp -d -t "${dir_filename%.apex}_out_XXXXX") + deapexer extract "${dir}" "${tmp}" || { echo "Failed to deapex." && exit 1; } + dir="${tmp}" +fi + +RED="\e[31m" +GREEN="\e[32m" +ENDCOLOR="\e[0m" + +unaligned_libs=() + +echo +echo "=== ELF alignment ===" + +matches="$(find "${dir}" -type f)" +IFS=$'\n' +for match in $matches; do + # We could recursively call this script or rewrite it to though. + [[ "${match}" == *".apk" ]] && echo "WARNING: doesn't recursively inspect .apk file: ${match}" + [[ "${match}" == *".apex" ]] && echo "WARNING: doesn't recursively inspect .apex file: ${match}" + + [[ $(file "${match}") == *"ELF"* ]] || continue + + res="$(objdump -p "${match}" | grep LOAD | awk '{ print $NF }' | head -1)" + if [[ $res =~ 2\*\*(1[4-9]|[2-9][0-9]|[1-9][0-9]{2,}) ]]; then + echo -e "${match}: ${GREEN}ALIGNED${ENDCOLOR} ($res)" + else + echo -e "${match}: ${RED}UNALIGNED${ENDCOLOR} ($res)" + unaligned_libs+=("${match}") + fi +done + +if [ ${#unaligned_libs[@]} -gt 0 ]; then + echo -e "${RED}Found ${#unaligned_libs[@]} unaligned libs (only arm64-v8a/x86_64 libs need to be aligned).${ENDCOLOR}" +elif [ -n "${dir_filename}" ]; then + echo -e "ELF Verification Successful" +fi +echo "=====================" \ No newline at end of file diff --git a/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/FlutterTesseractOcrPlugin.h b/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/FlutterTesseractOcrPlugin.h new file mode 100644 index 0000000..ddc7df8 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/FlutterTesseractOcrPlugin.h @@ -0,0 +1,4 @@ +#import + +@interface FlutterTesseractOcrPlugin : NSObject +@end diff --git a/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/FlutterTesseractOcrPlugin.m b/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/FlutterTesseractOcrPlugin.m new file mode 100644 index 0000000..c995d55 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/FlutterTesseractOcrPlugin.m @@ -0,0 +1,8 @@ +#import "FlutterTesseractOcrPlugin.h" +#import + +@implementation FlutterTesseractOcrPlugin ++ (void)registerWithRegistrar:(NSObject*)registrar { + [SwiftFlutterTesseractOcrPlugin registerWithRegistrar:registrar]; +} +@end diff --git a/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/SwiftFlutterTesseractOcrPlugin.swift b/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/SwiftFlutterTesseractOcrPlugin.swift new file mode 100644 index 0000000..e858736 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/ios/Classes/SwiftFlutterTesseractOcrPlugin.swift @@ -0,0 +1,97 @@ +import Flutter +import UIKit +import libtesseract + +/// Drives libtesseract directly (instead of SwiftyTesseract's wrapper) so we +/// can choose the page segmentation mode. Without a block mode Tesseract +/// splits table columns away from their row labels. Language data is read +/// from the app's Documents/tessdata folder, which the Dart side fills; the +/// original plugin symlinked into the read-only app bundle and crashed. +public class SwiftFlutterTesseractOcrPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let channel = FlutterMethodChannel(name: "flutter_tesseract_ocr", binaryMessenger: registrar.messenger()) + let instance = SwiftFlutterTesseractOcrPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + guard call.method == "extractText" else { + result(FlutterMethodNotImplemented) + return + } + guard let params = call.arguments as? [String: Any], + let imagePath = params["imagePath"] as? String, + let image = UIImage(contentsOfFile: imagePath), + let cgImage = image.cgImage else { + result(FlutterError(code: "bad_args", message: "Could not read the image", details: nil)) + return + } + let language = (params["language"] as? String) ?? "eng" + let options = params["args"] as? [String: Any] ?? [:] + let psm = Int(options["psm"] as? String ?? "") ?? 6 + + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { + result(FlutterError(code: "no_documents", message: "No documents directory", details: nil)) + return + } + let tessdata = documents.appendingPathComponent("tessdata").path + for code in language.split(separator: "+") { + if !FileManager.default.fileExists(atPath: "\(tessdata)/\(code).traineddata") { + result(FlutterError(code: "missing_language", message: "Missing \(code).traineddata", details: nil)) + return + } + } + + DispatchQueue.global(qos: .userInitiated).async { + let outcome = SwiftFlutterTesseractOcrPlugin.recognize( + cgImage, tessdata: tessdata, language: language, psm: psm) + DispatchQueue.main.async { + switch outcome { + case .success(let text): result(text) + case .failure(let message): + result(FlutterError(code: "ocr_failed", message: message, details: nil)) + } + } + } + } + + private enum Outcome { + case success(String) + case failure(String) + } + + private static func recognize(_ image: CGImage, tessdata: String, language: String, psm: Int) -> Outcome { + let width = image.width + let height = image.height + // Hand Tesseract the colour pixels: its own thresholding reads + // coloured table panels far better than a plain grayscale conversion. + let bytesPerRow = width * 4 + var pixels = [UInt8](repeating: 255, count: bytesPerRow * height) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, width: width, height: height, + bitsPerComponent: 8, bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return false } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + if !drawn { return .failure("Could not prepare the image") } + + guard let api = TessBaseAPICreate() else { return .failure("Could not start Tesseract") } + defer { TessBaseAPIDelete(api) } + + if TessBaseAPIInit2(api, tessdata, language, TessOcrEngineMode(rawValue: 1)) != 0 { + return .failure("Could not load the language data") + } + TessBaseAPISetPageSegMode(api, TessPageSegMode(rawValue: UInt32(psm))) + TessBaseAPISetVariable(api, "preserve_interword_spaces", "1") + pixels.withUnsafeBufferPointer { buffer in + TessBaseAPISetImage(api, buffer.baseAddress, Int32(width), Int32(height), 4, Int32(bytesPerRow)) + } + TessBaseAPISetSourceResolution(api, 300) + guard let cString = TessBaseAPIGetUTF8Text(api) else { return .failure("No text returned") } + defer { TessDeleteText(cString) } + return .success(String(cString: cString)) + } +} diff --git a/apps/mobile/packages/flutter_tesseract_ocr/ios/flutter_tesseract_ocr.podspec b/apps/mobile/packages/flutter_tesseract_ocr/ios/flutter_tesseract_ocr.podspec new file mode 100644 index 0000000..a5fb75b --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/ios/flutter_tesseract_ocr.podspec @@ -0,0 +1,24 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html +# +Pod::Spec.new do |s| + s.name = 'flutter_tesseract_ocr' + s.version = '0.3.4' + s.summary = 'Tesseract OCR 4 Flutter' + s.description = <<-DESC +Tesseract 4 adds a new neural net (LSTM) based OCR engine which is focused on line recognition. It has unicode (UTF-8) support, and can recognize more than 100 languages. + DESC + s.homepage = 'https://paratoner.io' + s.license = { :file => '../LICENSE',:type => 'BSD' } + s.author = { 'Ahmet Tok' => 'arny@paratoner.io' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.public_header_files = 'Classes/**/*.h' + s.dependency 'Flutter' + s.dependency 'SwiftyTesseract' + # s.ios.deployment_target = '12.0' + # s.swift_version = '4.2' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + # s.pod_target_xcconfig = { 'SWIFT_VERSION' => '4.2' } +end + diff --git a/apps/mobile/packages/flutter_tesseract_ocr/lib/android_ios.dart b/apps/mobile/packages/flutter_tesseract_ocr/lib/android_ios.dart new file mode 100644 index 0000000..9ff2150 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/lib/android_ios.dart @@ -0,0 +1,91 @@ +// part of flutter_tesseract_ocr; +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart'; + +class FlutterTesseractOcr { + static const String TESS_DATA_CONFIG = 'assets/tessdata_config.json'; + static const String TESS_DATA_PATH = 'assets/tessdata'; + static const MethodChannel _channel = + const MethodChannel('flutter_tesseract_ocr'); + + /// image to text + ///``` + /// String _ocrText = await FlutterTesseractOcr.extractText(url, language: langs, args: { + /// "preserve_interword_spaces": "1",}); + ///``` + static Future extractText(String imagePath, + {String? language, Map? args}) async { + assert(await File(imagePath).exists(), true); + final String tessData = await _loadTessData(); + final String extractText = + await _channel.invokeMethod('extractText', { + 'imagePath': imagePath, + 'tessData': tessData, + 'language': language, + 'args': args, + }); + return extractText; + } + + /// image to html text(hocr) + ///``` + /// String _ocrHocr = await FlutterTesseractOcr.extractText(url, language: langs, args: { + /// "preserve_interword_spaces": "1",}); + ///``` + static Future extractHocr(String imagePath, + {String? language, Map? args}) async { + assert(await File(imagePath).exists(), true); + final String tessData = await _loadTessData(); + final String extractText = + await _channel.invokeMethod('extractHocr', { + 'imagePath': imagePath, + 'tessData': tessData, + 'language': language, + 'args': args, + }); + return extractText; + } + + /// getTessdataPath + ///``` + /// print(await FlutterTesseractOcr.getTessdataPath()) + ///``` + static Future getTessdataPath() async { + final Directory appDirectory = await getApplicationDocumentsDirectory(); + final String tessdataDirectory = join(appDirectory.path, 'tessdata'); + return tessdataDirectory; + } + + static Future _loadTessData() async { + final Directory appDirectory = await getApplicationDocumentsDirectory(); + final String tessdataDirectory = join(appDirectory.path, 'tessdata'); + + if (!await Directory(tessdataDirectory).exists()) { + await Directory(tessdataDirectory).create(); + } + await _copyTessDataToAppDocumentsDirectory(tessdataDirectory); + return appDirectory.path; + } + + static Future _copyTessDataToAppDocumentsDirectory( + String tessdataDirectory) async { + final String config = await rootBundle.loadString(TESS_DATA_CONFIG); + Map files = jsonDecode(config); + for (var file in files["files"]) { + if (!await File('$tessdataDirectory/$file').exists()) { + final ByteData data = await rootBundle.load('$TESS_DATA_PATH/$file'); + final Uint8List bytes = data.buffer.asUint8List( + data.offsetInBytes, + data.lengthInBytes, + ); + await File('$tessdataDirectory/$file').writeAsBytes(bytes); + } + } + } +} diff --git a/apps/mobile/packages/flutter_tesseract_ocr/lib/flutter_tesseract_ocr.dart b/apps/mobile/packages/flutter_tesseract_ocr/lib/flutter_tesseract_ocr.dart new file mode 100644 index 0000000..3e488c7 --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/lib/flutter_tesseract_ocr.dart @@ -0,0 +1,4 @@ +library flutter_tesseract_ocr; + +export 'android_ios.dart' // Stub implementation + if (dart.library.js_interop) 'web.dart'; // dart:js_intertop implementation diff --git a/apps/mobile/packages/flutter_tesseract_ocr/lib/web.dart b/apps/mobile/packages/flutter_tesseract_ocr/lib/web.dart new file mode 100644 index 0000000..2a8838b --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/lib/web.dart @@ -0,0 +1,61 @@ +import 'dart:async'; +import 'dart:js_interop'; + +@JS('_extractText') +external JSPromise _extractText(JSAny imagePath, JSAny args); + +// FlutterTesseractOcr Class +class FlutterTesseractOcr { + /// image to text + ///``` + /// String _ocrText = await FlutterTesseractOcr.extractText(url, language: langs, args: { + /// "preserve_interword_spaces": "1",}); + ///``` + static Future extractText(String imagePath, + {String? language, Map? args}) async { + final data = { + "language": language, + // To avoid error when we pass null to args + "args": args ?? {}, + }; + + var promiseData = _extractText(imagePath.toJS, (data).jsify()!); + + return promiseData.toDart.then((v) => v.dartify() as String); + } + + /// image to html text(hocr) + ///``` + /// String _ocrHocr = await FlutterTesseractOcr.extractText(url, language: langs, args: { + /// "preserve_interword_spaces": "1",}); + ///``` + static Future extractHocr(String imagePath, + {String? language, Map? args}) async { + final data = { + "language": language, + // To avoid error when we pass null to args + "args": {...args ?? {}, "tessjs_create_hocr": "1"} + }; + + var promiseData = _extractText( + imagePath.toJS, + data.jsify()!, + ); + + return promiseData.toDart.then((v) => v.dartify() as String); + } + + //web not support + static Future getTessdataPath() async { + return ""; + } + + //web not support + static Future _loadTessData() async { + return ""; + } + + //web not support + static Future _copyTessDataToAppDocumentsDirectory( + String tessdataDirectory) async {} +} diff --git a/apps/mobile/packages/flutter_tesseract_ocr/pubspec.yaml b/apps/mobile/packages/flutter_tesseract_ocr/pubspec.yaml new file mode 100644 index 0000000..ecdb9ec --- /dev/null +++ b/apps/mobile/packages/flutter_tesseract_ocr/pubspec.yaml @@ -0,0 +1,69 @@ +name: flutter_tesseract_ocr +description: Tesseract 4 adds a new neural net (LSTM) based OCR engine which is focused on line recognition. It has unicode (UTF-8) support, and can recognize more than 100 languages. + +version: 0.4.31 +# author: khjde1207 +homepage: https://github.com/khjde1207/tesseract_ocr + +analyzer: + enable-experiment: + - non-nullable + +environment: + sdk: ">=3.0.0 <4.0.0" + flutter: ">=1.12.0" +dependencies: + flutter: + sdk: flutter + path_provider: ^2.1.4 + path: ^1.9.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # This section identifies this Flutter project as a plugin project. + # The androidPackage and pluginClass identifiers should not ordinarily + # be modified. They are used by the tooling to maintain consistency when + # adding or updating assets for this project. + + plugin: + platforms: + android: + package: io.paratoner.flutter_tesseract_ocr + pluginClass: FlutterTesseractOcrPlugin + ios: + pluginClass: FlutterTesseractOcrPlugin + web: + default_package: FlutterTesseractOcrPlugin + + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # To add custom fonts to your plugin package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index b975393..daa4e15 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -1,6 +1,14 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + archive: + dependency: transitive + description: + name: archive + sha256: de5ac590b8155e4e676088d714bd41ea620449a6adc0f7950c5073874b287ddb + url: "https://pub.dev" + source: hosted + version: "4.6.0" args: dependency: transitive description: @@ -57,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: "direct main" description: @@ -105,6 +121,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d785431f1910aac8a50e5c492cae912320158030a88703c67b9ef7f1f96fd7ba + url: "https://pub.dev" + source: hosted + version: "0.9.5+2" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" flutter: dependency: "direct main" description: flutter @@ -158,6 +206,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_secure_storage: dependency: "direct main" description: @@ -206,6 +262,13 @@ packages: url: "https://pub.dev" source: hosted version: "4.2.2" + flutter_tesseract_ocr: + dependency: "direct main" + description: + path: "packages/flutter_tesseract_ocr" + relative: true + source: path + version: "0.4.31" flutter_test: dependency: "direct dev" description: flutter @@ -240,6 +303,78 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image: + dependency: "direct main" + description: + name: image + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" + url: "https://pub.dev" + source: hosted + version: "4.12.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "8a7823d5d3e884f7780829d85d92110d072bdcd9ed48e2be158d2abe91a912d4" + url: "https://pub.dev" + source: hosted + version: "0.8.13+25" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: f4e5650a81b268af3886bc789216ff6d56bdfa79934f93f1a5a8a303b8f60f77 + url: "https://pub.dev" + source: hosted + version: "0.8.13+15" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042" + url: "https://pub.dev" + source: hosted + version: "2.11.2" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" jni: dependency: transitive description: @@ -328,6 +463,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.3" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" mobile_scanner: dependency: "direct main" description: @@ -432,6 +575,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + posix: + dependency: transitive + description: + name: posix + sha256: "613646d076944f3acc125830af935b48d4b8a57a388691547bbfd25b434f4d63" + url: "https://pub.dev" + source: hosted + version: "6.5.4" pub_semver: dependency: transitive description: @@ -599,4 +750,4 @@ packages: version: "3.1.4" sdks: dart: ">=3.13.5 <4.0.0" - flutter: ">=3.44.0" + flutter: ">=3.47.0" diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 04c5595..539371f 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -42,6 +42,10 @@ dependencies: timezone: ^0.11.1 sign_in_with_apple: ^8.2.0 crypto: ^3.0.8 + flutter_tesseract_ocr: + path: packages/flutter_tesseract_ocr + image_picker: ^1.2.4 + image: ^4.12.0 dev_dependencies: flutter_test: @@ -65,6 +69,10 @@ flutter: # the material Icons class. uses-material-design: true + assets: + - assets/tessdata_config.json + - assets/tessdata/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg diff --git a/apps/mobile/push-iphone.sh b/apps/mobile/push-iphone.sh index 96e5179..45956ea 100755 --- a/apps/mobile/push-iphone.sh +++ b/apps/mobile/push-iphone.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# Build the latest release and install it on a paired iPhone. +# Build the latest release and update it on a paired iPhone. +# The app is upgraded in place (no uninstall), so its data and the "trust this +# developer" approval on the phone are kept. It does not open the app. # Usage: ./push-iphone.sh [device-id] set -euo pipefail @@ -56,19 +58,19 @@ if [[ -n "$device_name" ]]; then else echo "Installing ${bundle} on ${device_id}..." fi -flutter install --release -d "$device_id" --device-timeout 60 - -echo "Opening Drop It..." -launched=0 -for _ in 1 2 3; do - if xcrun devicectl device process launch --device "$device_id" "$bundle"; then - launched=1 +# The first attempt can hit a sleeping or locked phone ("Connection reset by +# peer"); it wakes the connection, so retry a few times. +installed=0 +for attempt in 1 2 3; do + if xcrun devicectl device install app --device "$device_id" "$app"; then + installed=1 break fi - sleep 2 + echo "Install attempt ${attempt} failed. Keep the iPhone unlocked; retrying..." >&2 + sleep 3 done -if [[ "$launched" -ne 1 ]]; then - echo "The update is installed, but the iPhone will not open it until this Mac's developer certificate is trusted." >&2 - echo "On the iPhone: Settings → General → VPN & Device Management → Apple Development: yarinleviq@gmail.com → Trust. Then open Drop It." >&2 +if [[ "$installed" -ne 1 ]]; then + echo "Could not reach the iPhone. Unlock it and connect by cable (or make sure it is on the same Wi-Fi), then run this again." >&2 exit 1 fi +echo "Updated. Open Drop It on the iPhone when you are ready." diff --git a/apps/mobile/test/nutrition_parser_test.dart b/apps/mobile/test/nutrition_parser_test.dart new file mode 100644 index 0000000..ac69d9d --- /dev/null +++ b/apps/mobile/test/nutrition_parser_test.dart @@ -0,0 +1,231 @@ +import 'package:drop_it/nutrition_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('English table with per 100 g and per serving columns', () { + final scan = parseNutritionText(''' +Nutrition Facts + Per 100 g Per serving (30 g) +Energy 1046 kJ / 250 kcal 314 kJ / 75 kcal +Fat 10.5 g 3.2 g + of which saturates 4 g 1.2 g +Carbohydrate 30 g 9 g + of which sugars 12.5 g 3.8 g +Fibre 2,5 g 0.8 g +Protein 6 g 1.8 g +Salt 1.2 g 0.4 g +'''); + expect(scan.calories, 250); + expect(scan.fat, 10.5); + expect(scan.carbohydrates, 30); + expect(scan.sugars, 12.5); + expect(scan.fiber, 2.5); + expect(scan.protein, 6); + expect(scan.sodium, 480); + expect(scan.liquid, false); + }); + + test('Hebrew table, per 100 g first', () { + final scan = parseNutritionText(''' +ערכים תזונתיים + ל-100 גרם למנה (30 גרם) +אנרגיה (קק"ל) 250 75 +חלבונים (גרם) 6.0 1.8 +פחמימות (גרם) 30 9 +מתוכם סוכרים 12.5 3.8 +שומנים (גרם) 10.5 3.2 +מתוכם שומן רווי 4 1.2 +סיבים תזונתיים 2.5 0.8 +נתרן (מ"ג) 480 144 +'''); + expect(scan.calories, 250); + expect(scan.protein, 6); + expect(scan.carbohydrates, 30); + expect(scan.sugars, 12.5); + expect(scan.fat, 10.5); + expect(scan.fiber, 2.5); + expect(scan.sodium, 480); + }); + + test('picks the per 100 column when it comes second', () { + final scan = parseNutritionText(''' +Per serving (30 g) Per 100 g +Energy 75 kcal 250 kcal +Protein 1.8 g 6 g +Fat 3.2 g 10.5 g +'''); + expect(scan.calories, 250); + expect(scan.protein, 6); + expect(scan.fat, 10.5); + }); + + test('per 100 ml marks the food as liquid', () { + final scan = parseNutritionText(''' +Per 100 ml +Energy 42 kcal +Carbohydrate 10.6 g +Sugars 10.6 g +Protein 0 g +'''); + expect(scan.liquid, true); + expect(scan.calories, 42); + expect(scan.protein, 0); + }); + + test('per-serving-only table is scaled to 100 g', () { + final scan = parseNutritionText(''' +Serving size 25 g +Calories 100 +Protein 5 g +Total Fat 2 g +Sodium 150 mg +'''); + expect(scan.fromServing, true); + expect(scan.calories, 400); + expect(scan.protein, 20); + expect(scan.fat, 8); + expect(scan.sodium, 600); + }); + + test('tolerates OCR typos and percent columns', () { + final scan = parseNutritionText(''' +Energv 120 kcal +Protein 3 g 6% +Carbohydrates 20 g 7% +פחמימוח 20 +'''); + expect(scan.calories, 120); + expect(scan.protein, 3); + expect(scan.carbohydrates, 20); + }); + + test('converts kJ-only energy and handles less-than values', () { + final scan = parseNutritionText(''' +Energy 418 kJ +Fat < 0.5 g +Protein 2 g +'''); + expect(scan.calories, 100); + expect(scan.fat, 0.5); + }); + + test('garbage text yields nothing usable', () { + expect(parseNutritionText('hello world 123').usable, false); + }); + + // Real Tesseract (heb+eng, psm 6) output from rendered labels. + test('Hebrew OCR: reversed header digits and lost decimals', () { + final scan = parseNutritionText(''' +ערכים תזונתיים | ל-001 גרם | למנה )03 גרם( +אנרגיה )קק"ל( 250 75 +חלבונים )גרם( 6.0 18 +פחמימות )גרם( 30 9 +מתוכם סוכרים )גרם( | 12.5 3.8 +שומנים )גרם( 10.5 2 +מתוכם שומן רווי )גרם( 4 12 +סיבים תזונתיים ‎ )pia(‏ 2.5 0.8 +נתרן )מ"ג( 480 144 +'''); + expect(scan.fromServing, false); + expect(scan.calories, 250); + expect(scan.protein, 6); + expect(scan.carbohydrates, 30); + expect(scan.sugars, 12.5); + expect(scan.fat, 10.5); + expect(scan.fiber, 2.5); + expect(scan.sodium, 480); + }); + + test('Hebrew OCR, 80 g serving: repairs dropped decimals', () { + final scan = parseNutritionText(''' +ל-001 גרם ל-08 גרם +אנרגיה )קק"ל( 62 50 +חלבון )גרם( 8.5 68 +פחמימות )גרם( 42 3.4 +שומן )גרם( 15 12 +נתרן )מ"ג( 95 76 +'''); + expect(scan.calories, 62); + expect(scan.protein, 8.5); + expect(scan.carbohydrates, 4.2); + expect(scan.fat, 1.5); + expect(scan.sodium, 95); + }); + + test('English OCR, per-100 column second, one unreadable row stays empty', () { + final scan = parseNutritionText(''' +Average values Per 80g Per 100g +Energy (kcal) 90 72 +Protein (g) onl 2.5 +Carbohydrate (g) 11.0 8.8 +Fat (g) 3.5 2.8 +'''); + expect(scan.calories, 90); + expect(scan.carbohydrates, 11); + expect(scan.fat, 3.5); + expect(scan.protein, isNull); // ambiguous single number: not guessed + }); + + test('values that do not add up are dropped, not invented', () { + final scan = parseNutritionText(''' +Per 100 g +Energy 120 kcal +Protein 8 g +Carbohydrate 20 g +of which sugars 55 g +Fat 3 g +'''); + expect(scan.sugars, isNull); + expect(scan.calories, 120); + }); + + // Real photo (Pringles bilingual label, single per-100 column) as read by + // Tesseract after rotating upright. Includes a stray "70" outside the table, + // a trans/saturated fat row and a "Sugars (teaspoons)" row. + const bilingual = r''' +יפס פרינגלס ₪ פרינגלס ירוק רמי לוי | פריו +, : > < סופר-פארם +‎EE A SSS SS‏ סה סנס בכ הננ +‎lergens - contains: Coconut, milk, 7 feu‏ +מידע על א : +מכיל: סויה, חלב, קוקוס, חיטה - גלוטן.\ - ‎heat (gluten) 8 soy‏ +‎Nutrition Information per 100 = = :‏ +ה סימון תזונתי ל-100 גרם מזו| . : +אנרגיה (קלוריות), קק"ל 523 ‎Energy (kcal):‏ - +] סך שומנים, גרם, מתוכם: 31 :(0) ‎Total Fat‏ +חומצות שומן רוויות, גרם 8.7 (0) ‎Saturated Fat‏ | | +חומצות שומן טראנס, גרם| פחות מ-0.5 ‎Trans Fat (g):‏ +₪ כולסטרול, מ"ג 1 ‎Cholesterol (mg):‏ +‎ie‏ נתרן, מ"ג 579 ‎Sodium (mg):‏ +] 70 הפחמימות, גרם, מתוכן: 57 ‎Carbohydrate (g):‏ +‎HN‏ סוכרים, גרם 2.8 ‎Sugars (g):‏ +‎Sugars (teaspoons):| 0.75 DION'S? Ae‏ +סיבים תזונתיים, גרם ‏ | 2.4 ‎Fibre (g):‏ +‎ee |‏ חלבונים, גרם 4 ‎OF Protein (a):‏ +'''; + + for (final hebrew in [false, true]) { + test('bilingual label read as ${hebrew ? 'Hebrew' : 'English'}', () { + final scan = parseNutritionText(bilingual, preferHebrew: hebrew); + expect(scan.calories, 523); + expect(scan.protein, 4); + expect(scan.carbohydrates, 57); + expect(scan.sugars, 2.8); // not the 0.75 teaspoons row + expect(scan.fat, 31); // not saturated 8.7 / trans + expect(scan.fiber, 2.4); + expect(scan.sodium, 579); + expect(scan.fromServing, false); + }); + } + + test('mirrored-parenthesis OCR junk is not a value', () { + final scan = parseNutritionText(''' +Nutrition Information per 100g +‎ay‏ סוכרים, גר 2.8 :)9( ‎Sugars‏ +‎ee‏ חלבונים, גרם 4 ‎Protein (g):‏ +פחמימות 57 Carbohydrate (g): +'''); + expect(scan.sugars, 2.8); + expect(scan.protein, 4); + }); +} diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index a0605f6..288aa3f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -138,6 +138,7 @@ function Shell({ me, setMe }: { me: Me; setMe: (me: Me | null) => void }) { } /> } /> : } /> + } /> } /> } /> @@ -148,6 +149,145 @@ function Shell({ me, setMe }: { me: Me; setMe: (me: Me | null) => void }) { ); } +type CatalogScope = "published" | "mine"; +type CatalogStatus = "all" | "verified" | "unverified"; +type CatalogResponse = { foods: Food[]; hasMore: boolean }; + +/** Every published food, or your own; admins (foods:verify) can verify entries here. */ +function CatalogPage({ canVerifyFoods }: { canVerifyFoods: boolean }) { + const { t } = useI18n(); + const [scope, setScope] = useState("published"); + const [status, setStatus] = useState("all"); + const [search, setSearch] = useState(""); + const [query, setQuery] = useState(""); + const [foods, setFoods] = useState(null); + const [hasMore, setHasMore] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [expanded, setExpanded] = useState(null); + const [error, setError] = useState(""); + const request = useRef(0); + + // Debounce typing so every keystroke isn't a request. + useEffect(() => { + const id = window.setTimeout(() => setQuery(search.trim()), 250); + return () => window.clearTimeout(id); + }, [search]); + + function url(offset: number) { + const params = new URLSearchParams({ scope, status, offset: String(offset) }); + if (query) params.set("q", query); + return `/api/foods/catalog?${params}`; + } + + useEffect(() => { + const id = ++request.current; + setError(""); + setExpanded(null); + api(url(0)) + .then((res) => { + if (id !== request.current) return; // a newer filter already answered + setFoods(res.foods); + setHasMore(res.hasMore); + }) + .catch((err) => { + if (id === request.current) setError(message(t, err)); + }); + }, [scope, status, query]); + + async function loadMore() { + if (!foods) return; + const id = request.current; + setLoadingMore(true); + try { + const res = await api(url(foods.length)); + if (id !== request.current) return; + setFoods([...foods, ...res.foods]); + setHasMore(res.hasMore); + } catch (err) { + setError(message(t, err)); + } finally { + setLoadingMore(false); + } + } + + async function verify(food: Food) { + try { + const res = await api<{ food: Food }>(`/api/foods/${food.id}/verify`, { method: "POST" }); + // Only one verified food per barcode: the server un-verifies the others. + setFoods((current) => + (current ?? []).map((item) => + item.id === food.id ? res.food : food.barcode && item.barcode === food.barcode ? { ...item, verified: false } : item, + ), + ); + } catch (err) { + setError(message(t, err)); + } + } + + return ( + <> +

{t.foodCatalog}

+
+ + +
+
+ setSearch(event.target.value)} placeholder={t.catalogSearch} aria-label={t.catalogSearch} /> +
+ + + +
+ {scope === "mine" && {t.createFood}} +
+ {error &&

{error}

} + {!foods && !error &&

…

} + {foods && foods.length === 0 &&

{scope === "mine" && !query && status === "all" ? t.catalogEmptyMine : t.catalogEmpty}

} + {foods && foods.length > 0 && ( +
+ {foods.map((food) => { + const open = expanded === food.id; + return ( +
+ + {open && ( +
+
+
{t.protein}
{food.protein}
+
{t.carbohydrates}
{food.carbohydrates}
+
{t.fat}
{food.fat}
+
{t.fiber}
{food.fiber}
+
{t.sugars}
{food.sugars}
+
{t.sodium}
{food.sodium}
+
+ {food.barcode &&

{t.barcode}: {food.barcode}

} +
+ {canVerifyFoods && food.published && !food.verified && ( + + )} + {food.owned && {t.edit}} +
+
+ )} +
+ ); + })} +
+ )} + {hasMore && } + + ); +} + /** Top-level destinations (HIG tab bar): bottom on phones, in the top bar on wide screens. */ function TabNav({ className }: { className: string }) { const { t } = useI18n(); @@ -155,6 +295,7 @@ function TabNav({ className }: { className: string }) { { to: "/", label: t.today, end: true, icon: <> }, { to: "/week", label: t.week, icon: }, { to: "/fasting", label: t.fastingTab, icon: <> }, + { to: "/catalog", label: t.catalogTab, icon: <> }, { to: "/weight", label: t.weight, icon: <> }, { to: "/settings", label: t.settings, icon: <> }, ]; diff --git a/apps/web/src/i18n.tsx b/apps/web/src/i18n.tsx index 1b1cc68..3cfe682 100644 --- a/apps/web/src/i18n.tsx +++ b/apps/web/src/i18n.tsx @@ -153,6 +153,18 @@ const en = { unpublish: "Stop sharing", verify: "Verify", verified: "Verified", + foodCatalog: "Food catalog", + catalogTab: "Foods", + catalogPublished: "Published", + catalogMine: "My foods", + catalogSearch: "Search by name or barcode", + catalogEmpty: "No foods found.", + catalogEmptyMine: "You haven't added any foods yet.", + catalogAll: "All", + catalogUnverified: "Unverified", + catalogLoadMore: "Load more", + catalogPrivate: "Private", + catalogFilter: "Verification", personal: "Your food", chooseFood: "Choose a published food", noFood: "This barcode is not in the database yet.", @@ -385,6 +397,18 @@ const he: typeof en = { unpublish: "הפסקת שיתוף", verify: "אימות", verified: "מאומת", + foodCatalog: "קטלוג מזון", + catalogTab: "מזונות", + catalogPublished: "מפורסמים", + catalogMine: "המזונות שלי", + catalogSearch: "חיפוש לפי שם או ברקוד", + catalogEmpty: "לא נמצאו מזונות.", + catalogEmptyMine: "עוד לא הוספתם מזונות.", + catalogAll: "הכול", + catalogUnverified: "לא מאומתים", + catalogLoadMore: "טען עוד", + catalogPrivate: "פרטי", + catalogFilter: "אימות", personal: "המזון שלך", chooseFood: "בחירת מזון שפורסם", noFood: "הברקוד הזה עדיין לא במאגר.", diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 696af3b..b12b52f 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1230,3 +1230,17 @@ button.energy, a.energy { background: var(--accent-energy-tint); color: var(--ac .month-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--gap-xs); align-content: start; } .month-grid .picker-option { justify-content: center; min-height: var(--hit); } + +/* Food catalog */ +.catalog-tools { display: grid; gap: var(--gap-md); margin: var(--gap-md) 0; } +.catalog-tools input[type="search"] { -webkit-appearance: none; appearance: none; } +.catalog-list .catalog-item + .catalog-item { border-top: var(--hairline) solid var(--separator); } +.catalog-list .food-row { border-top: 0; padding-inline: 0; } +.catalog-list .food-row:hover { background: transparent; } +.catalog-badges { display: flex; gap: var(--gap-xs); flex: none; } +.chip-verified { background: var(--accent-tint); color: var(--accent-primary); font-weight: 600; } +.catalog-detail { padding: 0 0 var(--gap-md); display: grid; gap: var(--gap-sm); } +.catalog-macros { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: var(--gap-sm); margin: 0; } +.catalog-macros div { background: var(--bg-tertiary); border-radius: var(--radius-sm); padding: var(--gap-sm) var(--gap-md); } +.catalog-macros dt { font-size: 0.75rem; color: var(--label-secondary); } +.catalog-macros dd { margin: 0; font-weight: 600; font-variant-numeric: tabular-nums; }