1.4.2 (build 48): OCR de screenshots en Share Extension
El extension acepta ahora imágenes además de texto: al compartir un screenshot del banco/broker, Vision reconoce el texto EN EL DISPOSITIVO (la imagen nunca sale del teléfono), extrae los importes candidatos y rellena el más prominente visualmente (en las UIs bancarias el balance es el número más grande). El resto de candidatos se ofrecen como chips de un toque. Percentajes y años se filtran. - ExtImageAmountScanner.swift: VNRecognizeTextRequest + ranking por altura de bounding box + dedup por valor - ShareViewController: carga UIImage/URL/Data del provider, estado de escaneo, sección de candidatos - Info.plist: NSExtensionActivationSupportsImageWithMaxCount=1 - Strings nuevas en los 7 idiomas (ext_scanning_image, ext_detected_amounts, ext_ocr_no_amounts) - PredictionEngine: troceado ternario que agotaba el type-checker en Debug - Bump a 1.4.2 build 48 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WoScpmHdVj1aUf4rAp6hbe
This commit is contained in:
@@ -541,7 +541,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournal/PortfolioJournalDebug.entitlements;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -559,7 +559,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -582,7 +582,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
PROVISIONING_PROFILE_SPECIFIER = "porfoliojournal";
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournal/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
@@ -600,7 +600,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@@ -739,7 +739,7 @@
|
|||||||
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground;
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalWidgetExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -752,7 +752,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -774,7 +774,7 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
PROVISIONING_PROFILE_SPECIFIER = "Portfolio Journalwidget";
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
DEVELOPMENT_ASSET_PATHS = PortfolioJournalWidget/Assets.xcassets;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -787,7 +787,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.PortfolioJournalWidget;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -805,7 +805,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -814,7 +814,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@@ -829,7 +829,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -838,7 +838,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@@ -852,7 +852,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -861,7 +861,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@@ -875,7 +875,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
|
||||||
@@ -884,7 +884,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@loader_path/Frameworks",
|
"@loader_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournalUITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
@@ -899,7 +899,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
INFOPLIST_FILE = PortfolioJournalQuickUpdate/Info.plist;
|
||||||
@@ -911,7 +911,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
@@ -927,7 +927,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = PortfolioJournalQuickUpdateExtension.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||||
CODE_SIGN_STYLE = Manual;
|
CODE_SIGN_STYLE = Manual;
|
||||||
CURRENT_PROJECT_VERSION = 47;
|
CURRENT_PROJECT_VERSION = 48;
|
||||||
DEVELOPMENT_TEAM = 2825Q76T7H;
|
DEVELOPMENT_TEAM = 2825Q76T7H;
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
PROVISIONING_PROFILE_SPECIFIER = "PortfolioJournal QuickUpdate AppStore";
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -940,7 +940,7 @@
|
|||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 1.4.2;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
PRODUCT_BUNDLE_IDENTIFIER = com.alexandrevazquez.PortfolioJournal.QuickUpdate;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
|||||||
@@ -115,10 +115,13 @@ class PredictionEngine {
|
|||||||
var smoothed = values[0]
|
var smoothed = values[0]
|
||||||
for i in 1..<values.count { smoothed = alpha * values[i] + (1 - alpha) * smoothed }
|
for i in 1..<values.count { smoothed = alpha * values[i] + (1 - alpha) * smoothed }
|
||||||
|
|
||||||
let trend: Double = values.count >= 2
|
var trend = 0.0
|
||||||
? (values.suffix(3).reduce(0, +) / Double(min(3, values.count)) -
|
if values.count >= 2 {
|
||||||
values.prefix(3).reduce(0, +) / Double(min(3, values.count))) / Double(values.count)
|
let window = Double(min(3, values.count))
|
||||||
: 0
|
let recentAvg: Double = values.suffix(3).reduce(0, +) / window
|
||||||
|
let earlyAvg: Double = values.prefix(3).reduce(0, +) / window
|
||||||
|
trend = (recentAvg - earlyAvg) / Double(values.count)
|
||||||
|
}
|
||||||
let stdDev = calculateStdDev(values: values)
|
let stdDev = calculateStdDev(values: values)
|
||||||
|
|
||||||
return (1...monthsAhead).compactMap { month in
|
return (1...monthsAhead).compactMap { month in
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
import Vision
|
||||||
|
|
||||||
|
// MARK: - Screenshot amount scanner (on-device OCR)
|
||||||
|
//
|
||||||
|
// When the user shares an image (a bank/broker screenshot), Vision runs text
|
||||||
|
// recognition entirely on-device — the image never leaves the phone. Every
|
||||||
|
// number that parses as a positive amount becomes a candidate, ranked by
|
||||||
|
// visual prominence: in banking UIs the account balance is almost always the
|
||||||
|
// biggest number on screen, so the tallest match is prefilled and the rest are
|
||||||
|
// offered as one-tap alternatives.
|
||||||
|
|
||||||
|
struct ExtAmountCandidate: Identifiable, Equatable {
|
||||||
|
let id: UUID
|
||||||
|
let display: String
|
||||||
|
let value: Decimal
|
||||||
|
let prominence: CGFloat
|
||||||
|
|
||||||
|
init(display: String, value: Decimal, prominence: CGFloat) {
|
||||||
|
self.id = UUID()
|
||||||
|
self.display = display
|
||||||
|
self.value = value
|
||||||
|
self.prominence = prominence
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: Self, rhs: Self) -> Bool { lhs.id == rhs.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ExtImageAmountScanner {
|
||||||
|
static let maxCandidates = 6
|
||||||
|
|
||||||
|
static func scan(_ image: UIImage, completion: @escaping ([ExtAmountCandidate]) -> Void) {
|
||||||
|
guard let cgImage = image.cgImage else {
|
||||||
|
completion([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let request = VNRecognizeTextRequest { request, _ in
|
||||||
|
let observations = (request.results as? [VNRecognizedTextObservation]) ?? []
|
||||||
|
completion(candidates(from: observations))
|
||||||
|
}
|
||||||
|
request.recognitionLevel = .accurate
|
||||||
|
// Amounts are digits and separators; language correction only "fixes" them into words.
|
||||||
|
request.usesLanguageCorrection = false
|
||||||
|
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async {
|
||||||
|
let handler = VNImageRequestHandler(cgImage: cgImage, orientation: cgOrientation(from: image.imageOrientation))
|
||||||
|
do {
|
||||||
|
try handler.perform([request])
|
||||||
|
} catch {
|
||||||
|
completion([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Candidate extraction
|
||||||
|
|
||||||
|
private static let numberPattern: NSRegularExpression = {
|
||||||
|
// Grouped amounts (12.345,67 / 1,234.56 / 12 345,67), plain decimals (1234,5) or integers.
|
||||||
|
// Non-raw string so \u{00A0} becomes a literal NBSP inside the character class.
|
||||||
|
let pattern = "\\d{1,3}(?:[.,\u{00A0} ]\\d{3})+(?:[.,]\\d{1,2})?|\\d+[.,]\\d{1,2}|\\d+"
|
||||||
|
return try! NSRegularExpression(pattern: pattern)
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static func candidates(from observations: [VNRecognizedTextObservation]) -> [ExtAmountCandidate] {
|
||||||
|
var found: [ExtAmountCandidate] = []
|
||||||
|
|
||||||
|
for observation in observations {
|
||||||
|
guard let recognized = observation.topCandidates(1).first else { continue }
|
||||||
|
let line = recognized.string
|
||||||
|
let range = NSRange(line.startIndex..., in: line)
|
||||||
|
|
||||||
|
for match in numberPattern.matches(in: line, range: range) {
|
||||||
|
guard let matchRange = Range(match.range, in: line) else { continue }
|
||||||
|
let display = String(line[matchRange])
|
||||||
|
|
||||||
|
guard !isPercentage(display, in: line, at: matchRange),
|
||||||
|
!looksLikeYear(display),
|
||||||
|
let value = ExtAmountParser.parse(display) else { continue }
|
||||||
|
|
||||||
|
found.append(ExtAmountCandidate(
|
||||||
|
display: display.replacingOccurrences(of: "\u{00A0}", with: " "),
|
||||||
|
value: value,
|
||||||
|
prominence: observation.boundingBox.height
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedup by value, keeping the most prominent occurrence.
|
||||||
|
var byValue: [Decimal: ExtAmountCandidate] = [:]
|
||||||
|
for candidate in found {
|
||||||
|
if let existing = byValue[candidate.value], existing.prominence >= candidate.prominence { continue }
|
||||||
|
byValue[candidate.value] = candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
return byValue.values
|
||||||
|
.sorted { $0.prominence > $1.prominence }
|
||||||
|
.prefix(maxCandidates)
|
||||||
|
.map { $0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "1,25 %" or "%1.25" — performance figures, not balances.
|
||||||
|
private static func isPercentage(_ display: String, in line: String, at range: Range<String.Index>) -> Bool {
|
||||||
|
let after = line[range.upperBound...].drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||||
|
if after.first == "%" { return true }
|
||||||
|
let before = line[..<range.lowerBound].reversed().drop(while: { $0 == " " || $0 == "\u{00A0}" })
|
||||||
|
return before.first == "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bare 4-digit integers in a plausible year range are dates, not amounts.
|
||||||
|
private static func looksLikeYear(_ display: String) -> Bool {
|
||||||
|
guard display.count == 4, display.allSatisfy(\.isNumber), let value = Int(display) else { return false }
|
||||||
|
return (1990...2099).contains(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cgOrientation(from orientation: UIImage.Orientation) -> CGImagePropertyOrientation {
|
||||||
|
switch orientation {
|
||||||
|
case .up: return .up
|
||||||
|
case .down: return .down
|
||||||
|
case .left: return .left
|
||||||
|
case .right: return .right
|
||||||
|
case .upMirrored: return .upMirrored
|
||||||
|
case .downMirrored: return .downMirrored
|
||||||
|
case .leftMirrored: return .leftMirrored
|
||||||
|
case .rightMirrored: return .rightMirrored
|
||||||
|
@unknown default: return .up
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>NSExtensionActivationSupportsText</key>
|
<key>NSExtensionActivationSupportsText</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSExtensionActivationSupportsImageWithMaxCount</key>
|
||||||
|
<integer>1</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSExtensionPointIdentifier</key>
|
<key>NSExtensionPointIdentifier</key>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import UniformTypeIdentifiers
|
|||||||
// MARK: - Quick Update share extension
|
// MARK: - Quick Update share extension
|
||||||
//
|
//
|
||||||
// Appears in the share sheet when the user shares/selects text in ANY app (their
|
// Appears in the share sheet when the user shares/selects text in ANY app (their
|
||||||
// bank, broker…). Shows a compact form floating over the host app: pick a source
|
// bank, broker…) or shares a screenshot where the balance is visible. Shows a
|
||||||
// (the next one pending this month is preselected), amount prefilled from the
|
// compact form floating over the host app: pick a source (the next one pending
|
||||||
// shared text, save — and the user never leaves the bank app. The main app turns
|
// this month is preselected), amount prefilled from the shared text — or OCR'd
|
||||||
// these captures into real snapshots on its next activation.
|
// on-device from the image — save, and the user never leaves the bank app. The
|
||||||
|
// main app turns these captures into real snapshots on its next activation.
|
||||||
|
|
||||||
@objc(ShareViewController)
|
@objc(ShareViewController)
|
||||||
final class ShareViewController: UIViewController {
|
final class ShareViewController: UIViewController {
|
||||||
@@ -16,33 +17,53 @@ final class ShareViewController: UIViewController {
|
|||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
view.backgroundColor = .clear
|
view.backgroundColor = .clear
|
||||||
extractSharedText { [weak self] text in
|
extractSharedContent { [weak self] text, image in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self?.presentForm(sharedText: text)
|
self?.presentForm(sharedText: text, sharedImage: image)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func extractSharedText(completion: @escaping (String?) -> Void) {
|
private func extractSharedContent(completion: @escaping (String?, UIImage?) -> Void) {
|
||||||
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
|
let providers = (extensionContext?.inputItems as? [NSExtensionItem])?
|
||||||
.compactMap { $0.attachments }
|
.compactMap { $0.attachments }
|
||||||
.flatMap { $0 } ?? []
|
.flatMap { $0 } ?? []
|
||||||
|
|
||||||
guard let provider = providers.first(where: {
|
if let provider = providers.first(where: {
|
||||||
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
|
$0.hasItemConformingToTypeIdentifier(UTType.plainText.identifier)
|
||||||
}) else {
|
}) {
|
||||||
completion(nil)
|
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
||||||
|
completion(item as? String, nil)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in
|
if let provider = providers.first(where: {
|
||||||
completion(item as? String)
|
$0.hasItemConformingToTypeIdentifier(UTType.image.identifier)
|
||||||
|
}) {
|
||||||
|
provider.loadItem(forTypeIdentifier: UTType.image.identifier, options: nil) { item, _ in
|
||||||
|
completion(nil, Self.image(from: item))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Share sheets deliver images as UIImage, file URL, or raw Data depending on the host app.
|
||||||
|
private static func image(from item: NSSecureCoding?) -> UIImage? {
|
||||||
|
switch item {
|
||||||
|
case let image as UIImage: return image
|
||||||
|
case let url as URL: return (try? Data(contentsOf: url)).flatMap(UIImage.init(data:))
|
||||||
|
case let data as Data: return UIImage(data: data)
|
||||||
|
default: return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentForm(sharedText: String?) {
|
private func presentForm(sharedText: String?, sharedImage: UIImage?) {
|
||||||
let form = QuickUpdateShareView(
|
let form = QuickUpdateShareView(
|
||||||
sharedText: sharedText,
|
sharedText: sharedText,
|
||||||
|
sharedImage: sharedImage,
|
||||||
onDone: { [weak self] in
|
onDone: { [weak self] in
|
||||||
self?.extensionContext?.completeRequest(returningItems: nil)
|
self?.extensionContext?.completeRequest(returningItems: nil)
|
||||||
},
|
},
|
||||||
@@ -68,6 +89,7 @@ final class ShareViewController: UIViewController {
|
|||||||
|
|
||||||
struct QuickUpdateShareView: View {
|
struct QuickUpdateShareView: View {
|
||||||
let sharedText: String?
|
let sharedText: String?
|
||||||
|
let sharedImage: UIImage?
|
||||||
let onDone: () -> Void
|
let onDone: () -> Void
|
||||||
let onCancel: () -> Void
|
let onCancel: () -> Void
|
||||||
|
|
||||||
@@ -75,6 +97,9 @@ struct QuickUpdateShareView: View {
|
|||||||
@State private var selectedSourceId: UUID?
|
@State private var selectedSourceId: UUID?
|
||||||
@State private var amountText: String = ""
|
@State private var amountText: String = ""
|
||||||
@State private var saved = false
|
@State private var saved = false
|
||||||
|
@State private var scanning = false
|
||||||
|
@State private var candidates: [ExtAmountCandidate] = []
|
||||||
|
@State private var scanFoundNothing = false
|
||||||
@FocusState private var amountFocused: Bool
|
@FocusState private var amountFocused: Bool
|
||||||
|
|
||||||
private var parsedAmount: Decimal? {
|
private var parsedAmount: Decimal? {
|
||||||
@@ -117,10 +142,59 @@ struct QuickUpdateShareView: View {
|
|||||||
private var form: some View {
|
private var form: some View {
|
||||||
Form {
|
Form {
|
||||||
Section(String(localized: "ext_amount_section")) {
|
Section(String(localized: "ext_amount_section")) {
|
||||||
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
|
if scanning {
|
||||||
.keyboardType(.decimalPad)
|
HStack(spacing: 10) {
|
||||||
.font(.title3.weight(.semibold))
|
ProgressView()
|
||||||
.focused($amountFocused)
|
Text(String(localized: "ext_scanning_image"))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
TextField(String(localized: "ext_amount_placeholder"), text: $amountText)
|
||||||
|
.keyboardType(.decimalPad)
|
||||||
|
.font(.title3.weight(.semibold))
|
||||||
|
.focused($amountFocused)
|
||||||
|
if scanFoundNothing {
|
||||||
|
Text(String(localized: "ext_ocr_no_amounts"))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidates.count > 1 {
|
||||||
|
Section(String(localized: "ext_detected_amounts")) {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ForEach(candidates) { candidate in
|
||||||
|
Button {
|
||||||
|
amountText = "\(candidate.value)"
|
||||||
|
} label: {
|
||||||
|
Text(candidate.display)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 7)
|
||||||
|
.background(
|
||||||
|
Capsule().fill(
|
||||||
|
amountText == "\(candidate.value)"
|
||||||
|
? Color.blue.opacity(0.18)
|
||||||
|
: Color(.tertiarySystemFill)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Capsule().strokeBorder(
|
||||||
|
amountText == "\(candidate.value)" ? Color.blue : .clear,
|
||||||
|
lineWidth: 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Section(String(localized: "ext_source_section")) {
|
Section(String(localized: "ext_source_section")) {
|
||||||
@@ -181,11 +255,30 @@ struct QuickUpdateShareView: View {
|
|||||||
|
|
||||||
if let sharedText, let parsed = ExtAmountParser.parse(sharedText) {
|
if let sharedText, let parsed = ExtAmountParser.parse(sharedText) {
|
||||||
amountText = "\(parsed)"
|
amountText = "\(parsed)"
|
||||||
|
} else if let sharedImage {
|
||||||
|
scanImage(sharedImage)
|
||||||
} else {
|
} else {
|
||||||
amountFocused = true
|
amountFocused = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func scanImage(_ image: UIImage) {
|
||||||
|
scanning = true
|
||||||
|
ExtImageAmountScanner.scan(image) { found in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
scanning = false
|
||||||
|
candidates = found
|
||||||
|
if let best = found.first {
|
||||||
|
// Most visually prominent number — in bank UIs, the balance.
|
||||||
|
amountText = "\(best.value)"
|
||||||
|
} else {
|
||||||
|
scanFoundNothing = true
|
||||||
|
amountFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
guard let amount = parsedAmount, let sourceId = selectedSourceId else { return }
|
guard let amount = parsedAmount, let sourceId = selectedSourceId else { return }
|
||||||
ExtSharedStore.appendPending(ExtPendingUpdate(
|
ExtSharedStore.appendPending(ExtPendingUpdate(
|
||||||
|
|||||||
@@ -9,3 +9,6 @@
|
|||||||
"ext_saved_body" = "Erscheint in Portfolio Journal beim nächsten Öffnen.";
|
"ext_saved_body" = "Erscheint in Portfolio Journal beim nächsten Öffnen.";
|
||||||
"ext_no_sources_title" = "Noch keine Quellen";
|
"ext_no_sources_title" = "Noch keine Quellen";
|
||||||
"ext_no_sources_body" = "Öffne Portfolio Journal einmal, damit deine Quellen hier erscheinen.";
|
"ext_no_sources_body" = "Öffne Portfolio Journal einmal, damit deine Quellen hier erscheinen.";
|
||||||
|
"ext_scanning_image" = "Bild wird gelesen…";
|
||||||
|
"ext_detected_amounts" = "Erkannte Beträge";
|
||||||
|
"ext_ocr_no_amounts" = "Keine Beträge erkannt — gib den Wert manuell ein.";
|
||||||
|
|||||||
@@ -9,3 +9,6 @@
|
|||||||
"ext_saved_body" = "It will appear in Portfolio Journal the next time you open it.";
|
"ext_saved_body" = "It will appear in Portfolio Journal the next time you open it.";
|
||||||
"ext_no_sources_title" = "No sources yet";
|
"ext_no_sources_title" = "No sources yet";
|
||||||
"ext_no_sources_body" = "Open Portfolio Journal once so your sources appear here.";
|
"ext_no_sources_body" = "Open Portfolio Journal once so your sources appear here.";
|
||||||
|
"ext_scanning_image" = "Reading image…";
|
||||||
|
"ext_detected_amounts" = "Detected amounts";
|
||||||
|
"ext_ocr_no_amounts" = "No amounts recognized — type the value manually.";
|
||||||
|
|||||||
@@ -9,3 +9,6 @@
|
|||||||
"ext_saved_body" = "Aparecerá en Portfolio Journal la próxima vez que la abras.";
|
"ext_saved_body" = "Aparecerá en Portfolio Journal la próxima vez que la abras.";
|
||||||
"ext_no_sources_title" = "Aún no hay fuentes";
|
"ext_no_sources_title" = "Aún no hay fuentes";
|
||||||
"ext_no_sources_body" = "Abre Portfolio Journal una vez para que tus fuentes aparezcan aquí.";
|
"ext_no_sources_body" = "Abre Portfolio Journal una vez para que tus fuentes aparezcan aquí.";
|
||||||
|
"ext_scanning_image" = "Leyendo imagen…";
|
||||||
|
"ext_detected_amounts" = "Importes detectados";
|
||||||
|
"ext_ocr_no_amounts" = "No se han reconocido importes — escribe el valor a mano.";
|
||||||
|
|||||||
@@ -9,3 +9,6 @@
|
|||||||
"ext_saved_body" = "Apparaîtra dans Portfolio Journal à la prochaine ouverture.";
|
"ext_saved_body" = "Apparaîtra dans Portfolio Journal à la prochaine ouverture.";
|
||||||
"ext_no_sources_title" = "Aucune source";
|
"ext_no_sources_title" = "Aucune source";
|
||||||
"ext_no_sources_body" = "Ouvrez Portfolio Journal une fois pour que vos sources apparaissent ici.";
|
"ext_no_sources_body" = "Ouvrez Portfolio Journal une fois pour que vos sources apparaissent ici.";
|
||||||
|
"ext_scanning_image" = "Lecture de l’image…";
|
||||||
|
"ext_detected_amounts" = "Montants détectés";
|
||||||
|
"ext_ocr_no_amounts" = "Aucun montant reconnu — saisissez la valeur manuellement.";
|
||||||
|
|||||||
@@ -9,3 +9,6 @@
|
|||||||
"ext_saved_body" = "Apparirà in Portfolio Journal alla prossima apertura.";
|
"ext_saved_body" = "Apparirà in Portfolio Journal alla prossima apertura.";
|
||||||
"ext_no_sources_title" = "Nessuna fonte";
|
"ext_no_sources_title" = "Nessuna fonte";
|
||||||
"ext_no_sources_body" = "Apri Portfolio Journal una volta perché le tue fonti appaiano qui.";
|
"ext_no_sources_body" = "Apri Portfolio Journal una volta perché le tue fonti appaiano qui.";
|
||||||
|
"ext_scanning_image" = "Lettura dell’immagine…";
|
||||||
|
"ext_detected_amounts" = "Importi rilevati";
|
||||||
|
"ext_ocr_no_amounts" = "Nessun importo riconosciuto — inserisci il valore manualmente.";
|
||||||
|
|||||||
@@ -9,3 +9,6 @@
|
|||||||
"ext_saved_body" = "次回Portfolio Journalを開いたときに反映されます。";
|
"ext_saved_body" = "次回Portfolio Journalを開いたときに反映されます。";
|
||||||
"ext_no_sources_title" = "投資先がありません";
|
"ext_no_sources_title" = "投資先がありません";
|
||||||
"ext_no_sources_body" = "一度Portfolio Journalを開くと、ここに投資先が表示されます。";
|
"ext_no_sources_body" = "一度Portfolio Journalを開くと、ここに投資先が表示されます。";
|
||||||
|
"ext_scanning_image" = "画像を読み取り中…";
|
||||||
|
"ext_detected_amounts" = "検出された金額";
|
||||||
|
"ext_ocr_no_amounts" = "金額を認識できませんでした。手動で入力してください。";
|
||||||
|
|||||||
@@ -9,3 +9,6 @@
|
|||||||
"ext_saved_body" = "Aparecerá no Portfolio Journal na próxima vez que você abrir.";
|
"ext_saved_body" = "Aparecerá no Portfolio Journal na próxima vez que você abrir.";
|
||||||
"ext_no_sources_title" = "Nenhuma fonte";
|
"ext_no_sources_title" = "Nenhuma fonte";
|
||||||
"ext_no_sources_body" = "Abra o Portfolio Journal uma vez para que suas fontes apareçam aqui.";
|
"ext_no_sources_body" = "Abra o Portfolio Journal uma vez para que suas fontes apareçam aqui.";
|
||||||
|
"ext_scanning_image" = "Lendo imagem…";
|
||||||
|
"ext_detected_amounts" = "Valores detectados";
|
||||||
|
"ext_ocr_no_amounts" = "Nenhum valor reconhecido — digite o valor manualmente.";
|
||||||
|
|||||||
Reference in New Issue
Block a user