12 Commits
Author SHA1 Message Date
davmar 22a535a6fd pass suggestions 2026-09-17 15:45:11 +02:00
davmar bd3bc5357e projected balance chart 2026-09-17 01:23:07 +02:00
davmar a23739d717 bar tap correct 2026-09-17 01:00:11 +02:00
davmar b62164dde5 spinner feedback to ui 2 2026-09-17 00:50:15 +02:00
davmar 5cd9a88682 spinner feedback to ui 2026-09-17 00:50:07 +02:00
davmar e1881acc18 fix pending transaction 2026-09-16 23:59:03 +02:00
davmar f4b914a2ad ios added 2026-09-11 13:27:24 +02:00
davmar 34ef156538 fix tap on chart 2 2026-09-11 13:20:48 +02:00
davmar cfb551328b fix tap on chart 2026-09-11 12:45:50 +02:00
davmar 40366f0708 some issues fixed 2026-09-11 12:31:07 +02:00
davmar 2e40786bcd attempt to fix test txs 2.1 2026-09-11 00:11:00 +02:00
davmar 38bcf01ae3 attempt to fix test txs 2 2026-09-11 00:10:15 +02:00
103 changed files with 4747 additions and 629 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ migration:
- platform: root - platform: root
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- platform: android - platform: ios
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
+1
View File
@@ -13,6 +13,7 @@ analyzer:
exclude: exclude:
- build/** - build/**
- android/** - android/**
- ios/**
linter: linter:
# The lint rules applied to this project can be customized in the # The lint rules applied to this project can be customized in the
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+662
View File
@@ -0,0 +1,662 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 60;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2620;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 37Z4F445LJ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.davmar.ingtrakapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 37Z4F445LJ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.davmar.ingtrakapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 37Z4F445LJ;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.davmar.ingtrakapp;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,59 @@
{
"pins" : [
{
"identity" : "dkcamera",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKCamera",
"state" : {
"branch" : "master",
"revision" : "5c691d11014b910aff69f960475d70e65d9dcc96"
}
},
{
"identity" : "dkimagepickercontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKImagePickerController",
"state" : {
"branch" : "4.3.9",
"revision" : "0bdfeacefa308545adde07bef86e349186335915"
}
},
{
"identity" : "dkphotogallery",
"kind" : "remoteSourceControl",
"location" : "https://github.com/zhangao0086/DKPhotoGallery",
"state" : {
"branch" : "master",
"revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d"
}
},
{
"identity" : "sdwebimage",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/SDWebImage",
"state" : {
"revision" : "2de3a496eaf6df9a1312862adcfd54acd73c39c0",
"version" : "5.21.7"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif.git",
"state" : {
"revision" : "4430cbc148baa3907651d40562d96325426f409a",
"version" : "5.4.5"
}
},
{
"identity" : "tocropviewcontroller",
"kind" : "remoteSourceControl",
"location" : "https://github.com/TimOliver/TOCropViewController",
"state" : {
"revision" : "d4a6d8100f4b886fdbc8ae399bf144ff3e9afb7e",
"version" : "2.8.0"
}
}
],
"version" : 2
}
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "[email protected]",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24506" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24504"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="-101" y="12"/>
</scene>
</scenes>
</document>
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Myapp</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>myapp</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+34
View File
@@ -0,0 +1,34 @@
**/dgph
*.mode1v3
*.mode2v3
*.moved-aside
*.pbxuser
*.perspectivev3
**/*sync/
.sconsign.dblite
.tags*
**/.vagrant/
**/DerivedData/
Icon?
**/Pods/
**/.symlinks/
profile
xcuserdata
**/.generated/
Flutter/App.framework
Flutter/Flutter.framework
Flutter/Flutter.podspec
Flutter/Generated.xcconfig
Flutter/ephemeral/
Flutter/app.flx
Flutter/app.zip
Flutter/flutter_assets/
Flutter/flutter_export_environment.sh
ServiceDefinitions.json
Runner/GeneratedPluginRegistrant.*
# Exceptions to above rules.
!default.mode1v3
!default.mode2v3
!default.pbxuser
!default.perspectivev3
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>App</string>
<key>CFBundleIdentifier</key>
<string>io.flutter.flutter.app</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>App</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
</dict>
</plist>
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+1
View File
@@ -0,0 +1 @@
#include "Generated.xcconfig"
+647
View File
@@ -0,0 +1,647 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
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 = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
97C146E61CF9000F007C117D /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1510;
ORGANIZATIONNAME = "";
TargetAttributes = {
331C8080294A63A400263BE5 = {
CreatedOnToolsVersion = 14.0;
TestTargetID = 97C146ED1CF9000F007C117D;
};
97C146ED1CF9000F007C117D = {
CreatedOnToolsVersion = 7.3.1;
LastSwiftMigration = 1100;
};
};
};
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
compatibilityVersion = "Xcode 9.3";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
97C146ED1CF9000F007C117D /* Runner */,
331C8080294A63A400263BE5 /* RunnerTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
331C807F294A63A400263BE5 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EC1CF9000F007C117D /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 97C146ED1CF9000F007C117D /* Runner */;
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin PBXVariantGroup section */
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C146FB1CF9000F007C117D /* Base */,
);
name = Main.storyboard;
sourceTree = "<group>";
};
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
isa = PBXVariantGroup;
children = (
97C147001CF9000F007C117D /* Base */,
);
name = LaunchScreen.storyboard;
sourceTree = "<group>";
};
/* End PBXVariantGroup section */
/* Begin XCBuildConfiguration section */
249021D3217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
};
249021D4217E4FDB00AE95B9 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
97C147061CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.ingtrak.myapp;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
331C8088294A63A400263BE5 /* Debug */,
331C8089294A63A400263BE5 /* Release */,
331C808A294A63A400263BE5 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147031CF9000F007C117D /* Debug */,
97C147041CF9000F007C117D /* Release */,
249021D3217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
isa = XCConfigurationList;
buildConfigurations = (
97C147061CF9000F007C117D /* Debug */,
97C147071CF9000F007C117D /* Release */,
249021D4217E4FDB00AE95B9 /* Profile */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
@@ -0,0 +1,119 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Prepare Flutter Framework Script"
scriptText = "/bin/sh &quot;$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh&quot; prepare&#10;">
<EnvironmentBuildable>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</EnvironmentBuildable>
</ActionContent>
</ExecutionAction>
</PreActions>
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</MacroExpansion>
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "331C8080294A63A400263BE5"
BuildableName = "RunnerTests.xctest"
BlueprintName = "RunnerTests"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Profile"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>
+7
View File
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
</Workspace>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PreviewsEnabled</key>
<false/>
</dict>
</plist>
+16
View File
@@ -0,0 +1,16 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
}
@@ -0,0 +1,122 @@
{
"images" : [
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "20x20",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "60x60",
"idiom" : "iphone",
"filename" : "[email protected]",
"scale" : "3x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "20x20",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "29x29",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "40x40",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "1x"
},
{
"size" : "76x76",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "83.5x83.5",
"idiom" : "ipad",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"size" : "1024x1024",
"idiom" : "ios-marketing",
"filename" : "[email protected]",
"scale" : "1x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "[email protected]",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "[email protected]",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

@@ -0,0 +1,5 @@
# Launch Screen Assets
You can customize the launch screen with your own desired assets by replacing the image files in this directory.
You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="12121" systemVersion="16G29" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" colorMatched="YES" initialViewController="01J-lp-oVM">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="12089"/>
</dependencies>
<scenes>
<!--View Controller-->
<scene sceneID="EHf-IW-A2E">
<objects>
<viewController id="01J-lp-oVM" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="Ydg-fD-yQy"/>
<viewControllerLayoutGuide type="bottom" id="xbc-2k-c8Z"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="53" y="375"/>
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
</resources>
</document>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
<scene sceneID="tne-QT-ifu">
<objects>
<viewController id="BYZ-38-t0r" customClass="FlutterViewController" sceneMemberID="viewController">
<layoutGuides>
<viewControllerLayoutGuide type="top" id="y3c-jy-aDJ"/>
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>
+70
View File
@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Myapp</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>myapp</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"
+6
View File
@@ -0,0 +1,6 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
+12
View File
@@ -0,0 +1,12 @@
import Flutter
import UIKit
import XCTest
class RunnerTests: XCTestCase {
func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
+33 -5
View File
@@ -94,6 +94,8 @@ class HomeFilterWidget {
this.showAmountIn = true, this.showAmountIn = true,
this.showAmountOut = true, this.showAmountOut = true,
this.showCount = true, this.showCount = true,
this.updatedAt = 0,
this.deleted = false,
}); });
final String id; final String id;
@@ -129,6 +131,12 @@ class HomeFilterWidget {
final bool showAmountOut; final bool showAmountOut;
final bool showCount; final bool showCount;
/// Epoch ms; used for last-write-wins sync across devices.
final int updatedAt;
/// Soft-delete so removals propagate via sync instead of being recreated.
final bool deleted;
IconData get icon => HomeWidgetIcons.iconFor(iconId); IconData get icon => HomeWidgetIcons.iconFor(iconId);
TransactionFilterSelection get filter => TransactionFilterSelection( TransactionFilterSelection get filter => TransactionFilterSelection(
@@ -161,6 +169,9 @@ class HomeFilterWidget {
bool? showAmountIn, bool? showAmountIn,
bool? showAmountOut, bool? showAmountOut,
bool? showCount, bool? showCount,
int? updatedAt,
bool? deleted,
bool touchUpdatedAt = false,
}) { }) {
return HomeFilterWidget( return HomeFilterWidget(
id: id ?? this.id, id: id ?? this.id,
@@ -183,6 +194,10 @@ class HomeFilterWidget {
showAmountIn: showAmountIn ?? this.showAmountIn, showAmountIn: showAmountIn ?? this.showAmountIn,
showAmountOut: showAmountOut ?? this.showAmountOut, showAmountOut: showAmountOut ?? this.showAmountOut,
showCount: showCount ?? this.showCount, showCount: showCount ?? this.showCount,
updatedAt: touchUpdatedAt
? DateTime.now().millisecondsSinceEpoch
: (updatedAt ?? this.updatedAt),
deleted: deleted ?? this.deleted,
); );
} }
@@ -208,6 +223,7 @@ class HomeFilterWidget {
showAmountIn: showIn, showAmountIn: showIn,
showAmountOut: showOut, showAmountOut: showOut,
showCount: true, showCount: true,
updatedAt: DateTime.now().millisecondsSinceEpoch,
); );
} }
@@ -236,6 +252,8 @@ class HomeFilterWidget {
'showAmountIn': showAmountIn, 'showAmountIn': showAmountIn,
'showAmountOut': showAmountOut, 'showAmountOut': showAmountOut,
'showCount': showCount, 'showCount': showCount,
'updatedAt': updatedAt,
'deleted': deleted,
}; };
factory HomeFilterWidget.fromJson(Map<String, dynamic> json) { factory HomeFilterWidget.fromJson(Map<String, dynamic> json) {
@@ -302,21 +320,31 @@ class HomeFilterWidget {
showAmountIn: json['showAmountIn'] as bool? ?? true, showAmountIn: json['showAmountIn'] as bool? ?? true,
showAmountOut: json['showAmountOut'] as bool? ?? true, showAmountOut: json['showAmountOut'] as bool? ?? true,
showCount: json['showCount'] as bool? ?? true, showCount: json['showCount'] as bool? ?? true,
updatedAt: asInt(json['updatedAt'] ?? json['updated_at'], 0),
deleted: json['deleted'] as bool? ?? false,
); );
} }
} }
/// Union home widgets by id (local wins on conflict). /// Union home widgets by id with last-write-wins on [HomeFilterWidget.updatedAt].
List<HomeFilterWidget> mergeHomeFilterWidgets( List<HomeFilterWidget> mergeHomeFilterWidgets(
List<HomeFilterWidget> local, List<HomeFilterWidget> local,
List<HomeFilterWidget> remote, List<HomeFilterWidget> remote,
) { ) {
final byId = <String, HomeFilterWidget>{ final byId = <String, HomeFilterWidget>{};
for (final w in remote) w.id: w, void put(HomeFilterWidget w) {
}; final existing = byId[w.id];
for (final w in local) { if (existing == null || w.updatedAt >= existing.updatedAt) {
byId[w.id] = w; byId[w.id] = w;
} }
}
for (final w in remote) {
put(w);
}
for (final w in local) {
put(w);
}
final merged = byId.values.toList() final merged = byId.values.toList()
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); ..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
return merged; return merged;
+48
View File
@@ -16,6 +16,8 @@ class OverviewFavourite {
this.flow = TxFlowFilter.expense, this.flow = TxFlowFilter.expense,
this.barStyle = LabelChartBarStyle.parallel, this.barStyle = LabelChartBarStyle.parallel,
this.invertExpensePerspective = false, this.invertExpensePerspective = false,
this.updatedAt = 0,
this.deleted = false,
}); });
final String id; final String id;
@@ -34,6 +36,12 @@ class OverviewFavourite {
final LabelChartBarStyle barStyle; final LabelChartBarStyle barStyle;
final bool invertExpensePerspective; final bool invertExpensePerspective;
/// Epoch ms; used for last-write-wins sync across devices.
final int updatedAt;
/// Soft-delete so removals propagate via sync instead of being recreated.
final bool deleted;
OverviewFavourite copyWith({ OverviewFavourite copyWith({
String? id, String? id,
String? name, String? name,
@@ -44,6 +52,9 @@ class OverviewFavourite {
TxFlowFilter? flow, TxFlowFilter? flow,
LabelChartBarStyle? barStyle, LabelChartBarStyle? barStyle,
bool? invertExpensePerspective, bool? invertExpensePerspective,
int? updatedAt,
bool? deleted,
bool touchUpdatedAt = false,
}) { }) {
return OverviewFavourite( return OverviewFavourite(
id: id ?? this.id, id: id ?? this.id,
@@ -56,6 +67,10 @@ class OverviewFavourite {
barStyle: barStyle ?? this.barStyle, barStyle: barStyle ?? this.barStyle,
invertExpensePerspective: invertExpensePerspective:
invertExpensePerspective ?? this.invertExpensePerspective, invertExpensePerspective ?? this.invertExpensePerspective,
updatedAt: touchUpdatedAt
? DateTime.now().millisecondsSinceEpoch
: (updatedAt ?? this.updatedAt),
deleted: deleted ?? this.deleted,
); );
} }
@@ -69,9 +84,18 @@ class OverviewFavourite {
'flow': flow.name, 'flow': flow.name,
'barStyle': barStyle.name, 'barStyle': barStyle.name,
'invertExpensePerspective': invertExpensePerspective, 'invertExpensePerspective': invertExpensePerspective,
'updatedAt': updatedAt,
'deleted': deleted,
}; };
factory OverviewFavourite.fromJson(Map<String, dynamic> json) { factory OverviewFavourite.fromJson(Map<String, dynamic> json) {
final rawUpdated = json['updatedAt'] ?? json['updated_at'];
final updatedAt = switch (rawUpdated) {
int n => n,
num n => n.round(),
String s => int.tryParse(s) ?? 0,
_ => 0,
};
return OverviewFavourite( return OverviewFavourite(
id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}', id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}',
name: (json['name'] as String?)?.trim().isNotEmpty == true name: (json['name'] as String?)?.trim().isNotEmpty == true
@@ -100,6 +124,8 @@ class OverviewFavourite {
), ),
invertExpensePerspective: invertExpensePerspective:
json['invertExpensePerspective'] as bool? ?? false, json['invertExpensePerspective'] as bool? ?? false,
updatedAt: updatedAt,
deleted: json['deleted'] as bool? ?? false,
); );
} }
} }
@@ -107,3 +133,25 @@ class OverviewFavourite {
enum OverviewMetricMode { flow, labels } enum OverviewMetricMode { flow, labels }
enum OverviewPeriodMode { monthly, yearly } enum OverviewPeriodMode { monthly, yearly }
/// Union favourites by id with last-write-wins on [OverviewFavourite.updatedAt].
List<OverviewFavourite> mergeOverviewFavourites(
List<OverviewFavourite> local,
List<OverviewFavourite> remote,
) {
final byId = <String, OverviewFavourite>{};
void put(OverviewFavourite fav) {
final existing = byId[fav.id];
if (existing == null || fav.updatedAt >= existing.updatedAt) {
byId[fav.id] = fav;
}
}
for (final fav in remote) {
put(fav);
}
for (final fav in local) {
put(fav);
}
return byId.values.toList(growable: false);
}
+50 -22
View File
@@ -91,25 +91,6 @@ class BankController extends ChangeNotifier {
return false; return false;
} }
/// Drop tombstones that match any of [txs] (fingerprint-aware).
void clearRemoteDeletesMatching(Iterable<BankTransaction> txs) {
final list = txs.toList(growable: false);
if (list.isEmpty) return;
final next = [
for (final tomb in _pendingRemoteDeletes)
if (!list.any(tomb.matchesForDelete)) tomb,
];
if (next.length == _pendingRemoteDeletes.length) return;
_pendingRemoteDeletes = next;
unawaited(_persistPendingDeletes());
}
void clearAllPendingRemoteDeletes() {
if (_pendingRemoteDeletes.isEmpty) return;
_pendingRemoteDeletes = const [];
unawaited(_persistPendingDeletes());
}
void requeueRemoteDeletes(Iterable<BankTransaction> txs) { void requeueRemoteDeletes(Iterable<BankTransaction> txs) {
for (final tx in txs) { for (final tx in txs) {
_queueRemoteDelete(tx, notify: false); _queueRemoteDelete(tx, notify: false);
@@ -120,11 +101,54 @@ class BankController extends ChangeNotifier {
void _queueRemoteDelete(BankTransaction tx, {bool notify = true}) { void _queueRemoteDelete(BankTransaction tx, {bool notify = true}) {
if (tx.id.isEmpty) return; if (tx.id.isEmpty) return;
if (_isTombstoned(tx)) return; if (_isTombstoned(tx)) return;
_pendingRemoteDeletes = [..._pendingRemoteDeletes, tx]; _pendingRemoteDeletes = [
..._pendingRemoteDeletes,
tx.copyWith(isManual: true),
];
unawaited(_persistPendingDeletes()); unawaited(_persistPendingDeletes());
if (notify) onPendingRemoteDeletesChanged?.call(); if (notify) onPendingRemoteDeletesChanged?.call();
} }
/// Merge delete tombstones from another device and strip matching local rows.
Future<void> applyRemoteDeletes(
List<BankTransaction> remote, {
bool notify = true,
}) async {
var tombsChanged = false;
for (final tomb in remote) {
if (_isTombstoned(tomb)) continue;
_pendingRemoteDeletes = [
..._pendingRemoteDeletes,
tomb.copyWith(isManual: true),
];
tombsChanged = true;
}
if (_pendingRemoteDeletes.isEmpty) return;
final beforeRemote = _remoteTransactions.length;
final beforeImported = _importedTransactions.length;
_remoteTransactions = [
for (final tx in _remoteTransactions)
if (!_isTombstoned(tx)) tx,
];
_importedTransactions = [
for (final tx in _importedTransactions)
if (!_isTombstoned(tx)) tx,
];
final stripped = beforeRemote != _remoteTransactions.length ||
beforeImported != _importedTransactions.length;
if (tombsChanged || stripped) {
_invalidateMerged();
if (tombsChanged) await _persistPendingDeletes();
if (stripped) {
await _persistCache();
await _persistImported();
}
if (notify) notifyListeners();
}
}
Future<void> _persistPendingDeletes() async { Future<void> _persistPendingDeletes() async {
final storage = _storage; final storage = _storage;
if (storage == null) return; if (storage == null) return;
@@ -399,7 +423,9 @@ class BankController extends ChangeNotifier {
final manuals = _collectManualIds(incoming); final manuals = _collectManualIds(incoming);
if (incoming.isNotEmpty) { if (incoming.isNotEmpty) {
_remoteTransactions = mergeTransactions(_remoteTransactions, incoming); _remoteTransactions = dropSettledPendingDuplicates(
mergeTransactions(_remoteTransactions, incoming),
);
} }
// Strip any local copies that match a pending delete (defensive). // Strip any local copies that match a pending delete (defensive).
if (_pendingRemoteDeletes.isNotEmpty) { if (_pendingRemoteDeletes.isNotEmpty) {
@@ -498,8 +524,10 @@ class BankController extends ChangeNotifier {
final apiTxs = results[1] as List<BankTransaction>; final apiTxs = results[1] as List<BankTransaction>;
// Bank API is ~89 days. Prefer API rows for overlaps, but keep any // Bank API is ~89 days. Prefer API rows for overlaps, but keep any
// cloud/CSV history that the API window does not include. // cloud/CSV history that the API window does not include.
// Pending rows are not kept from cache — they must still be on the feed.
final manuals = _collectManualIds(); final manuals = _collectManualIds();
_remoteTransactions = mergeTransactions(apiTxs, _remoteTransactions); _remoteTransactions =
mergeApiTransactionsWithCache(apiTxs, _remoteTransactions);
if (!_status.linked) { if (!_status.linked) {
_status = ConnectStatus( _status = ConnectStatus(
status: 'linked', status: 'linked',
+66 -30
View File
@@ -42,26 +42,40 @@ class PreferencesController extends ChangeNotifier {
); );
List<OverviewFavourite> get overviewFavourites => List<OverviewFavourite> get overviewFavourites =>
List.unmodifiable([
for (final fav in _overviewFavourites)
if (!fav.deleted) fav,
]);
/// Full list including soft-deleted rows (for PocketBase push).
List<OverviewFavourite> get overviewFavouritesForSync =>
List.unmodifiable(_overviewFavourites); List.unmodifiable(_overviewFavourites);
List<OverviewFavourite> get displayedOverviewFavourites => [ List<OverviewFavourite> get displayedOverviewFavourites => [
for (final fav in _overviewFavourites) for (final fav in _overviewFavourites)
if (fav.display) fav, if (!fav.deleted && fav.display) fav,
]; ];
bool get hasDisplayedOverviewFavourites => bool get hasDisplayedOverviewFavourites =>
_overviewFavourites.any((f) => f.display); _overviewFavourites.any((f) => !f.deleted && f.display);
List<HomeFilterWidget> get homeFilterWidgets => List<HomeFilterWidget> get homeFilterWidgets =>
List.unmodifiable([
for (final w in _homeFilterWidgets)
if (!w.deleted) w,
]);
/// Full list including soft-deleted rows (for PocketBase push).
List<HomeFilterWidget> get homeFilterWidgetsForSync =>
List.unmodifiable(_homeFilterWidgets); List.unmodifiable(_homeFilterWidgets);
List<HomeFilterWidget> get displayedHomeFilterWidgets => [ List<HomeFilterWidget> get displayedHomeFilterWidgets => [
for (final w in _homeFilterWidgets) for (final w in _homeFilterWidgets)
if (w.display) w, if (!w.deleted && w.display) w,
]; ];
bool get hasDisplayedHomeFilterWidgets => bool get hasDisplayedHomeFilterWidgets =>
_homeFilterWidgets.any((w) => w.display); _homeFilterWidgets.any((w) => !w.deleted && w.display);
HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections; HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections;
@@ -118,7 +132,10 @@ class PreferencesController extends ChangeNotifier {
} }
Future<void> addOverviewFavourite(OverviewFavourite favourite) async { Future<void> addOverviewFavourite(OverviewFavourite favourite) async {
_overviewFavourites = [..._overviewFavourites, favourite]; final stamped = favourite.updatedAt == 0
? favourite.copyWith(touchUpdatedAt: true)
: favourite;
_overviewFavourites = [..._overviewFavourites, stamped];
notifyListeners(); notifyListeners();
await _persistFavourites(); await _persistFavourites();
} }
@@ -127,18 +144,19 @@ class PreferencesController extends ChangeNotifier {
final index = _overviewFavourites.indexWhere((f) => f.id == favourite.id); final index = _overviewFavourites.indexWhere((f) => f.id == favourite.id);
if (index < 0) return; if (index < 0) return;
final next = [..._overviewFavourites]; final next = [..._overviewFavourites];
next[index] = favourite; next[index] = favourite.copyWith(touchUpdatedAt: true);
_overviewFavourites = next; _overviewFavourites = next;
notifyListeners(); notifyListeners();
await _persistFavourites(); await _persistFavourites();
} }
Future<void> removeOverviewFavourite(String id) async { Future<void> removeOverviewFavourite(String id) async {
final next = [ final index = _overviewFavourites.indexWhere((f) => f.id == id);
for (final fav in _overviewFavourites) if (index < 0) return;
if (fav.id != id) fav, final current = _overviewFavourites[index];
]; if (current.deleted) return;
if (next.length == _overviewFavourites.length) return; final next = [..._overviewFavourites];
next[index] = current.copyWith(deleted: true, touchUpdatedAt: true);
_overviewFavourites = next; _overviewFavourites = next;
notifyListeners(); notifyListeners();
await _persistFavourites(); await _persistFavourites();
@@ -148,7 +166,7 @@ class PreferencesController extends ChangeNotifier {
final index = _overviewFavourites.indexWhere((f) => f.id == id); final index = _overviewFavourites.indexWhere((f) => f.id == id);
if (index < 0) return; if (index < 0) return;
final current = _overviewFavourites[index]; final current = _overviewFavourites[index];
if (current.display == display) return; if (current.deleted || current.display == display) return;
await updateOverviewFavourite(current.copyWith(display: display)); await updateOverviewFavourite(current.copyWith(display: display));
} }
@@ -164,16 +182,20 @@ class PreferencesController extends ChangeNotifier {
} }
Future<void> addHomeFilterWidget(HomeFilterWidget widget) async { Future<void> addHomeFilterWidget(HomeFilterWidget widget) async {
final order = _homeFilterWidgets.isEmpty final order = _homeFilterWidgets.where((w) => !w.deleted).isEmpty
? 0 ? 0
: _homeFilterWidgets : _homeFilterWidgets
.where((w) => !w.deleted)
.map((w) => w.sortOrder) .map((w) => w.sortOrder)
.reduce((a, b) => a > b ? a : b) + .reduce((a, b) => a > b ? a : b) +
1; 1;
_homeFilterWidgets = [ final stamped = (widget.updatedAt == 0
..._homeFilterWidgets, ? widget.copyWith(touchUpdatedAt: true)
widget.copyWith(sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder), : widget)
]; .copyWith(
sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder,
);
_homeFilterWidgets = [..._homeFilterWidgets, stamped];
notifyListeners(); notifyListeners();
await _persistHomeWidgets(); await _persistHomeWidgets();
} }
@@ -182,7 +204,7 @@ class PreferencesController extends ChangeNotifier {
final index = _homeFilterWidgets.indexWhere((w) => w.id == widget.id); final index = _homeFilterWidgets.indexWhere((w) => w.id == widget.id);
if (index < 0) return; if (index < 0) return;
final next = [..._homeFilterWidgets]; final next = [..._homeFilterWidgets];
next[index] = widget; next[index] = widget.copyWith(touchUpdatedAt: true);
next.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); next.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
_homeFilterWidgets = next; _homeFilterWidgets = next;
notifyListeners(); notifyListeners();
@@ -190,11 +212,12 @@ class PreferencesController extends ChangeNotifier {
} }
Future<void> removeHomeFilterWidget(String id) async { Future<void> removeHomeFilterWidget(String id) async {
final next = [ final index = _homeFilterWidgets.indexWhere((w) => w.id == id);
for (final w in _homeFilterWidgets) if (index < 0) return;
if (w.id != id) w, final current = _homeFilterWidgets[index];
]; if (current.deleted) return;
if (next.length == _homeFilterWidgets.length) return; final next = [..._homeFilterWidgets];
next[index] = current.copyWith(deleted: true, touchUpdatedAt: true);
_homeFilterWidgets = next; _homeFilterWidgets = next;
notifyListeners(); notifyListeners();
await _persistHomeWidgets(); await _persistHomeWidgets();
@@ -204,19 +227,32 @@ class PreferencesController extends ChangeNotifier {
final index = _homeFilterWidgets.indexWhere((w) => w.id == id); final index = _homeFilterWidgets.indexWhere((w) => w.id == id);
if (index < 0) return; if (index < 0) return;
final current = _homeFilterWidgets[index]; final current = _homeFilterWidgets[index];
if (current.display == display) return; if (current.deleted || current.display == display) return;
await updateHomeFilterWidget(current.copyWith(display: display)); await updateHomeFilterWidget(current.copyWith(display: display));
} }
Future<void> reorderHomeFilterWidgets(int oldIndex, int newIndex) async { Future<void> reorderHomeFilterWidgets(int oldIndex, int newIndex) async {
if (oldIndex == newIndex) return; if (oldIndex == newIndex) return;
final next = [..._homeFilterWidgets]; final visible = [
final item = next.removeAt(oldIndex); for (final w in _homeFilterWidgets)
final target = newIndex.clamp(0, next.length); if (!w.deleted) w,
next.insert(target, item);
_homeFilterWidgets = [
for (var i = 0; i < next.length; i++) next[i].copyWith(sortOrder: i),
]; ];
if (oldIndex < 0 || oldIndex >= visible.length) return;
final nextVisible = [...visible];
final item = nextVisible.removeAt(oldIndex);
final target = newIndex.clamp(0, nextVisible.length);
nextVisible.insert(target, item);
final orderById = <String, int>{
for (var i = 0; i < nextVisible.length; i++) nextVisible[i].id: i,
};
final now = DateTime.now().millisecondsSinceEpoch;
_homeFilterWidgets = [
for (final w in _homeFilterWidgets)
if (w.deleted)
w
else
w.copyWith(sortOrder: orderById[w.id] ?? w.sortOrder, updatedAt: now),
]..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
notifyListeners(); notifyListeners();
await _persistHomeWidgets(); await _persistHomeWidgets();
} }
+38 -15
View File
@@ -274,23 +274,42 @@ class SyncController extends ChangeNotifier {
_armPushDebounce(); _armPushDebounce();
} }
/// Removes tombstoned bank txs from PocketBase before pull/push can resurrect them. /// Pull shared delete tombstones, then remove matching local bank rows.
Future<void> _pullAndApplyTxDeletes(PbSyncService service) async {
try {
final remote = await service.pullTxDeletes(onStatus: _setStatus);
await _bank.applyRemoteDeletes(remote, notify: false);
} catch (error) {
debugPrint('SyncController: tx_deletes pull skipped ($error)');
}
}
/// Removes tombstoned bank txs from the transactions collection.
///
/// Tombstones themselves stay local + synced via `tx_deletes` so other
/// devices learn about the delete and do not push the row back.
Future<void> _flushPendingTxDeletes(PbSyncService service) async { Future<void> _flushPendingTxDeletes(PbSyncService service) async {
final pending = List<BankTransaction>.of(_bank.pendingRemoteDeletes); final pending = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
if (pending.isEmpty) return; if (pending.isEmpty) return;
_setStatus('Removing deleted bank txs…'); _setStatus('Removing deleted bank txs…');
try { try {
final matched = await service.deleteTransactionsMatching(pending); await service.deleteTransactionsMatching(pending);
if (matched.isNotEmpty) { // Keep tombstones — they must remain until every device has seen them.
_bank.clearRemoteDeletesMatching(matched);
}
// Do NOT clear unmatched tombstones — they still block pull resurrection
// until pushTransactions GC removes the remote rows.
} catch (error) { } catch (error) {
debugPrint('SyncController: tx deletes failed ($error)'); debugPrint('SyncController: tx deletes failed ($error)');
} }
} }
Future<void> _pushTxDeletes(PbSyncService service) async {
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
if (tombs.isEmpty) return;
try {
await service.pushTxDeletes(tombs, onStatus: _setStatus);
} catch (error) {
debugPrint('SyncController: tx_deletes push skipped ($error)');
}
}
void _armPushDebounce() { void _armPushDebounce() {
if (!_auth.isLoggedIn) return; if (!_auth.isLoggedIn) return;
if (_syncing) { if (_syncing) {
@@ -323,7 +342,8 @@ class SyncController extends ChangeNotifier {
try { try {
final service = PbSyncService(_auth.client); final service = PbSyncService(_auth.client);
// Delete first — otherwise pull re-merges the row and push recreates it. // Learn deletes from other devices before merging bank txs / pushing.
await _pullAndApplyTxDeletes(service);
await _flushPendingTxDeletes(service); await _flushPendingTxDeletes(service);
final remoteMeta = await service.pull( final remoteMeta = await service.pull(
onProgress: (p) { onProgress: (p) {
@@ -352,7 +372,7 @@ class SyncController extends ChangeNotifier {
); );
} }
final mergedFavourites = mergeOverviewFavourites( final mergedFavourites = mergeOverviewFavourites(
_prefs.overviewFavourites, _prefs.overviewFavouritesForSync,
remoteFavourites, remoteFavourites,
); );
List<HomeFilterWidget> remoteHomeWidgets = const []; List<HomeFilterWidget> remoteHomeWidgets = const [];
@@ -366,7 +386,7 @@ class SyncController extends ChangeNotifier {
); );
} }
final mergedHomeWidgets = mergeHomeFilterWidgets( final mergedHomeWidgets = mergeHomeFilterWidgets(
_prefs.homeFilterWidgets, _prefs.homeFilterWidgetsForSync,
remoteHomeWidgets, remoteHomeWidgets,
); );
AppCloudPrefs? remoteAppPrefs; AppCloudPrefs? remoteAppPrefs;
@@ -438,6 +458,7 @@ class SyncController extends ChangeNotifier {
} }
// Retry deletes in case any were queued during the pull/merge window. // Retry deletes in case any were queued during the pull/merge window.
await _flushPendingTxDeletes(service); await _flushPendingTxDeletes(service);
await _pushTxDeletes(service);
_pushBankPending = false; _pushBankPending = false;
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes); final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
final bankSummary = await service.pushTransactions( final bankSummary = await service.pushTransactions(
@@ -448,8 +469,6 @@ class SyncController extends ChangeNotifier {
onProgress: _setProgress, onProgress: _setProgress,
onStatus: _setStatus, onStatus: _setStatus,
); );
// Push GC removes orphan admin test rows; tombstones are done.
_bank.clearAllPendingRemoteDeletes();
_lastSyncedAt = DateTime.now(); _lastSyncedAt = DateTime.now();
_lastError = null; _lastError = null;
_lastSyncSummary = '$metaSummary\n$bankSummary'; _lastSyncSummary = '$metaSummary\n$bankSummary';
@@ -557,7 +576,7 @@ class SyncController extends ChangeNotifier {
_setStatus('Pushing favourites…'); _setStatus('Pushing favourites…');
try { try {
await service.pushOverviewFavourites( await service.pushOverviewFavourites(
_prefs.overviewFavourites, _prefs.overviewFavouritesForSync,
onStatus: _setStatus, onStatus: _setStatus,
); );
parts.add('Favourites updated'); parts.add('Favourites updated');
@@ -565,6 +584,7 @@ class SyncController extends ChangeNotifier {
debugPrint( debugPrint(
'SyncController: overview_favourites push skipped ($error)', 'SyncController: overview_favourites push skipped ($error)',
); );
_pushFavouritesPending = true;
} }
bump(); bump();
} }
@@ -572,7 +592,7 @@ class SyncController extends ChangeNotifier {
_setStatus('Pushing home widgets…'); _setStatus('Pushing home widgets…');
try { try {
await service.pushHomeFilterWidgets( await service.pushHomeFilterWidgets(
_prefs.homeFilterWidgets, _prefs.homeFilterWidgetsForSync,
onStatus: _setStatus, onStatus: _setStatus,
); );
parts.add('Home widgets updated'); parts.add('Home widgets updated');
@@ -580,6 +600,7 @@ class SyncController extends ChangeNotifier {
debugPrint( debugPrint(
'SyncController: home_filter_widgets push skipped ($error)', 'SyncController: home_filter_widgets push skipped ($error)',
); );
_pushHomeWidgetsPending = true;
} }
bump(); bump();
} }
@@ -601,7 +622,10 @@ class SyncController extends ChangeNotifier {
} }
if (pushBank) { if (pushBank) {
_setStatus('Updating bank txs…'); _setStatus('Updating bank txs…');
// Other devices may have deleted test txs — learn that before push.
await _pullAndApplyTxDeletes(service);
await _flushPendingTxDeletes(service); await _flushPendingTxDeletes(service);
await _pushTxDeletes(service);
try { try {
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes); final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
final summary = await service.pushTransactions( final summary = await service.pushTransactions(
@@ -612,7 +636,6 @@ class SyncController extends ChangeNotifier {
onProgress: _setProgress, onProgress: _setProgress,
onStatus: _setStatus, onStatus: _setStatus,
); );
_bank.clearAllPendingRemoteDeletes();
parts.add(summary); parts.add(summary);
} catch (error) { } catch (error) {
debugPrint('SyncController: bank tx push skipped ($error)'); debugPrint('SyncController: bank tx push skipped ($error)');
+136 -9
View File
@@ -10,8 +10,10 @@ import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart'; import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart'; import '../utils/app_theme.dart';
import '../utils/connect_bank.dart'; import '../utils/connect_bank.dart';
import '../utils/deferred_tab_work.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../utils/tx_filter_metrics.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/home_filter_widget_card.dart'; import '../widgets/home_filter_widget_card.dart';
import '../widgets/home_widgets_sheet.dart'; import '../widgets/home_widgets_sheet.dart';
@@ -22,6 +24,18 @@ import '../widgets/skip_build_when_covered.dart';
import 'transaction_detail_screen.dart'; import 'transaction_detail_screen.dart';
import 'transactions_screen.dart'; import 'transactions_screen.dart';
class _HomeSnapshot {
const _HomeSnapshot({
required this.categorized,
required this.statsById,
required this.budgetById,
});
final int categorized;
final Map<String, TxFilterMetrics> statsById;
final Map<String, HomeBudgetMetrics> budgetById;
}
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
const HomeScreen({super.key}); const HomeScreen({super.key});
@@ -33,16 +47,93 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
var _editingWidgets = false; var _editingWidgets = false;
final _work = DeferredTabWork<_HomeSnapshot>();
int _homeCacheKey({
required TxMetaController meta,
required BankController bank,
required List<HomeFilterWidget> widgets,
}) {
return Object.hash(
meta.storeRevision,
identityHashCode(bank.transactions),
Object.hashAll(widgets.map((w) => w.id)),
Object.hashAll(widgets.map((w) => w.updatedAt)),
_editingWidgets,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Inactive IndexedStack tabs must not watch providers — otherwise every // Inactive IndexedStack tabs must not watch providers — otherwise every
// annotation save rebuilds Home while the user is on another tab. // annotation save rebuilds Home while the user is on another tab.
if (ShellTabScope.of(context) != HomeScreen.tabIndex) { final active = ShellTabScope.of(context) == HomeScreen.tabIndex;
if (!active) {
if (_work.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) == HomeScreen.tabIndex) return;
_work.cancel();
});
}
return const SizedBox.expand(); return const SizedBox.expand();
} }
final bank = context.watch<BankController>(); final bank = context.watch<BankController>();
final meta = context.watch<TxMetaController>();
final prefs = context.watch<PreferencesController>();
final widgets = _editingWidgets
? prefs.homeFilterWidgets
: prefs.displayedHomeFilterWidgets;
final needsData = bank.isLinked || bank.hasData;
final emptyLoading = bank.isLoading && bank.balance == null && !bank.hasData;
final cacheKey = _homeCacheKey(meta: meta, bank: bank, widgets: widgets);
if (needsData && !emptyLoading && !_work.readyFor(cacheKey)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) != HomeScreen.tabIndex) return;
_work.ensure(
key: cacheKey,
mounted: () => mounted,
onUpdate: () {
if (mounted) setState(() {});
},
compute: (yieldUi) async {
final categorized =
meta.labeledTransactionCount(bank.transactions);
await yieldUi();
final stats = <String, TxFilterMetrics>{};
final budgets = <String, HomeBudgetMetrics>{};
for (var i = 0; i < widgets.length; i++) {
final w = widgets[i];
if (w.type == HomeWidgetType.budget) {
budgets[w.id] = computeHomeBudgetMetrics(
all: bank.transactions,
meta: meta,
widget: w,
);
} else {
stats[w.id] = computeTxFilterMetrics(
all: bank.transactions,
meta: meta,
filter: w.filter,
);
}
if (i % 2 == 1) await yieldUi();
}
return _HomeSnapshot(
categorized: categorized,
statsById: stats,
budgetById: budgets,
);
},
);
});
}
final showSpinner =
needsData && !emptyLoading && !_work.readyFor(cacheKey);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -54,7 +145,9 @@ class _HomeScreenState extends State<HomeScreen> {
onPressed: () => onPressed: () =>
setState(() => _editingWidgets = !_editingWidgets), setState(() => _editingWidgets = !_editingWidgets),
icon: Icon( icon: Icon(
_editingWidgets ? Icons.check : Icons.dashboard_customize_outlined, _editingWidgets
? Icons.check
: Icons.dashboard_customize_outlined,
), ),
), ),
...bankAppBarActions( ...bankAppBarActions(
@@ -67,9 +160,18 @@ class _HomeScreenState extends State<HomeScreen> {
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: bank.refresh, onRefresh: bank.refresh,
child: ResponsiveBody( child: ResponsiveBody(
child: _HomeBody( child: showSpinner
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
LoadingView(message: 'Loading home…'),
],
)
: _HomeBody(
bank: bank, bank: bank,
editingWidgets: _editingWidgets, editingWidgets: _editingWidgets,
snapshot: _work.value,
), ),
), ),
), ),
@@ -81,10 +183,12 @@ class _HomeBody extends StatelessWidget {
const _HomeBody({ const _HomeBody({
required this.bank, required this.bank,
required this.editingWidgets, required this.editingWidgets,
required this.snapshot,
}); });
final BankController bank; final BankController bank;
final bool editingWidgets; final bool editingWidgets;
final _HomeSnapshot? snapshot;
void _openTransactions(BuildContext context) { void _openTransactions(BuildContext context) {
Navigator.of(context).push( Navigator.of(context).push(
@@ -141,7 +245,7 @@ class _HomeBody extends StatelessWidget {
final colors = theme.colorScheme; final colors = theme.colorScheme;
final meta = context.watch<TxMetaController>(); final meta = context.watch<TxMetaController>();
final recent = bank.transactions.take(5).toList(growable: false); final recent = bank.transactions.take(5).toList(growable: false);
final categorized = final categorized = snapshot?.categorized ??
meta.labeledTransactionCount(bank.transactions); meta.labeledTransactionCount(bank.transactions);
final widgets = editingWidgets final widgets = editingWidgets
? prefs.homeFilterWidgets ? prefs.homeFilterWidgets
@@ -319,6 +423,9 @@ class _HomeBody extends StatelessWidget {
child: HomeFilterWidgetCard( child: HomeFilterWidgetCard(
widgetConfig: widgetConfig, widgetConfig: widgetConfig,
editing: true, editing: true,
precomputedStats: snapshot?.statsById[widgetConfig.id],
precomputedBudget:
snapshot?.budgetById[widgetConfig.id],
onToggleDisplay: (display) => onToggleDisplay: (display) =>
prefs.setHomeFilterWidgetDisplay( prefs.setHomeFilterWidgetDisplay(
widgetConfig.id, widgetConfig.id,
@@ -347,7 +454,11 @@ class _HomeBody extends StatelessWidget {
}, },
) )
else else
..._packedHomeWidgetRows(widgets), ..._packedHomeWidgetRows(
widgets,
statsById: snapshot?.statsById,
budgetById: snapshot?.budgetById,
),
], ],
if (_showBuiltIn( if (_showBuiltIn(
editing: editingWidgets, editing: editingWidgets,
@@ -553,7 +664,11 @@ class _HomeWidgetSlot extends StatelessWidget {
} }
} }
List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) { List<Widget> _packedHomeWidgetRows(
List<HomeFilterWidget> widgets, {
Map<String, TxFilterMetrics>? statsById,
Map<String, HomeBudgetMetrics>? budgetById,
}) {
final rows = <Widget>[]; final rows = <Widget>[];
var i = 0; var i = 0;
while (i < widgets.length) { while (i < widgets.length) {
@@ -571,11 +686,19 @@ List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( Expanded(
child: HomeFilterWidgetCard(widgetConfig: a), child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: HomeFilterWidgetCard(widgetConfig: b), child: HomeFilterWidgetCard(
widgetConfig: b,
precomputedStats: statsById?[b.id],
precomputedBudget: budgetById?[b.id],
),
), ),
], ],
), ),
@@ -590,7 +713,11 @@ List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) {
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
child: _HomeWidgetSlot( child: _HomeWidgetSlot(
halfWidth: a.halfWidth, halfWidth: a.halfWidth,
child: HomeFilterWidgetCard(widgetConfig: a), child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
), ),
), ),
); );
+13 -1
View File
@@ -2,6 +2,7 @@
library; library;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -46,6 +47,8 @@ class _LoginScreenState extends State<LoginScreen> {
email: _emailController.text, email: _emailController.text,
password: _passwordController.text, password: _passwordController.text,
); );
// Lets iOS/Android password managers offer to save / confirm the login.
TextInput.finishAutofillContext();
if (!mounted) return; if (!mounted) return;
if (!widget.asRoot) { if (!widget.asRoot) {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
@@ -96,7 +99,8 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Form( AutofillGroup(
child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
children: [ children: [
@@ -106,6 +110,10 @@ class _LoginScreenState extends State<LoginScreen> {
keyboardType: TextInputType.emailAddress, keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
autofocus: true, autofocus: true,
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
],
validator: (value) { validator: (value) {
final text = value?.trim() ?? ''; final text = value?.trim() ?? '';
if (text.isEmpty) return 'Enter your email'; if (text.isEmpty) return 'Enter your email';
@@ -118,6 +126,9 @@ class _LoginScreenState extends State<LoginScreen> {
controller: _passwordController, controller: _passwordController,
obscureText: _obscurePassword, obscureText: _obscurePassword,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
enableSuggestions: false,
autocorrect: false,
onFieldSubmitted: (_) => _submit(), onFieldSubmitted: (_) => _submit(),
validator: (value) { validator: (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {
@@ -160,6 +171,7 @@ class _LoginScreenState extends State<LoginScreen> {
], ],
), ),
), ),
),
], ],
), ),
), ),
+567 -41
View File
@@ -1,6 +1,9 @@
/// Look ahead: projected balance from recurring templates through a date. /// Look ahead: projected balance from recurring templates through a date.
library; library;
import 'dart:async';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -16,6 +19,7 @@ import '../utils/look_ahead.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../utils/tx_calendar.dart'; import '../utils/tx_calendar.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/loading_view.dart';
import '../widgets/recurring_template_sheet.dart'; import '../widgets/recurring_template_sheet.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
import '../widgets/shell_tab_scope.dart'; import '../widgets/shell_tab_scope.dart';
@@ -23,6 +27,25 @@ import '../widgets/skip_build_when_covered.dart';
enum _PeriodMode { monthly, yearly } enum _PeriodMode { monthly, yearly }
class _ItemsSnapshot {
const _ItemsSnapshot({required this.key, required this.items});
final int key;
final List<RecurringItem> items;
}
class _BalancePoint {
const _BalancePoint({
required this.key,
required this.label,
required this.balance,
});
final DateTime key;
final String label;
final double balance;
}
class LookAheadScreen extends StatefulWidget { class LookAheadScreen extends StatefulWidget {
const LookAheadScreen({super.key}); const LookAheadScreen({super.key});
@@ -35,8 +58,10 @@ class LookAheadScreen extends StatefulWidget {
class _LookAheadScreenState extends State<LookAheadScreen> { class _LookAheadScreenState extends State<LookAheadScreen> {
var _period = _PeriodMode.monthly; var _period = _PeriodMode.monthly;
late DateTime _cursor; late DateTime _cursor;
List<RecurringItem>? _cachedItems; _ItemsSnapshot? _snapshot;
int? _itemsCacheKey; int? _loadingKey;
int _computeGen = 0;
var _showChart = false;
@override @override
void initState() { void initState() {
@@ -52,14 +77,15 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
/// Inclusive end date for the selected period. /// Inclusive end date for the selected period.
DateTime get _through { DateTime get _through {
if (_period == _PeriodMode.yearly) { // Chart view (and yearly list) always project through year-end.
if (_showChart || _period == _PeriodMode.yearly) {
return DateTime(_cursor.year, 12, 31); return DateTime(_cursor.year, 12, 31);
} }
return DateTime(_cursor.year, _cursor.month + 1, 0); return DateTime(_cursor.year, _cursor.month + 1, 0);
} }
String get _periodTitle { String get _periodTitle {
if (_period == _PeriodMode.yearly) return '${_cursor.year}'; if (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}';
return formatMonthYear(_cursor); return formatMonthYear(_cursor);
} }
@@ -68,28 +94,32 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return DateTime(t.year + maxYears, t.month); return DateTime(t.year + maxYears, t.month);
} }
int _maxYear(int maxYears) => _today.year + maxYears;
bool get _navByYear => _showChart || _period == _PeriodMode.yearly;
bool _canGoBack() { bool _canGoBack() {
if (_period == _PeriodMode.yearly) { if (_navByYear) return _cursor.year > _today.year;
return _cursor.year > _today.year;
}
return monthStart(_cursor).isAfter(_minMonth); return monthStart(_cursor).isAfter(_minMonth);
} }
bool _canGoForward(int maxYears) { bool _canGoForward(int maxYears) {
if (_period == _PeriodMode.yearly) { if (_navByYear) return _cursor.year < _maxYear(maxYears);
return _cursor.year < _today.year + maxYears;
}
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears)); return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
} }
void _clampCursor(int maxYears) { void _clampCursor(int maxYears) {
if (_period == _PeriodMode.yearly) { if (_navByYear) {
if (_cursor.year > _today.year + maxYears) { if (_cursor.year > _maxYear(maxYears)) {
_cursor = DateTime(_today.year + maxYears); _cursor = DateTime(_maxYear(maxYears));
}
if (_cursor.year < _today.year) {
_cursor = DateTime(_today.year);
} }
} else { } else {
final maxMonth = _maxMonthFor(maxYears); final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth; if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
if (_cursor.isBefore(_minMonth)) _cursor = _minMonth;
} }
} }
@@ -97,26 +127,22 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
if (mode == _period) return; if (mode == _period) return;
setState(() { setState(() {
_period = mode; _period = mode;
if (mode == _PeriodMode.yearly) { if (mode == _PeriodMode.yearly || _showChart) {
_cursor = DateTime(_cursor.year); _cursor = DateTime(_cursor.year);
if (_cursor.year < _today.year) _cursor = DateTime(_today.year); _clampCursor(maxYears);
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
}
} else { } else {
final month = DateTime(_cursor.year, _today.month); final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month); _cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
final maxMonth = _maxMonthFor(maxYears); _clampCursor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
} }
}); });
} }
void _go(int delta, int maxYears) { void _go(int delta, int maxYears) {
setState(() { setState(() {
if (_period == _PeriodMode.yearly) { if (_navByYear) {
final next = DateTime(_cursor.year + delta); final next = DateTime(_cursor.year + delta);
if (next.year < _today.year || next.year > _today.year + maxYears) { if (next.year < _today.year || next.year > _maxYear(maxYears)) {
return; return;
} }
_cursor = next; _cursor = next;
@@ -129,9 +155,149 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
}); });
} }
void _toggleChart(int maxYears) {
setState(() {
_showChart = !_showChart;
if (_showChart) {
_cursor = DateTime(_cursor.year);
} else if (_period == _PeriodMode.monthly) {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
}
_clampCursor(maxYears);
});
}
int _itemsCacheKey(TxMetaController meta, BankController bank) =>
Object.hash(meta.storeRevision, identityHashCode(bank.transactions));
/// Projected account balance at each month-end / year-end.
List<_BalancePoint> _balanceSeries({
required List<RecurringItem> items,
required double currentBalance,
required int maxYears,
}) {
final today = _today;
if (_period == _PeriodMode.monthly) {
final year = _cursor.year;
const months = [
'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D',
];
final out = <_BalancePoint>[];
for (var m = 1; m <= 12; m++) {
final end = DateTime(year, m + 1, 0);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(year, m),
label: months[m - 1],
balance: balance,
),
);
}
return out;
}
final startYear = _today.year;
final endYear = _maxYear(maxYears);
final out = <_BalancePoint>[];
for (var y = startYear; y <= endYear; y++) {
final end = DateTime(y, 12, 31);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(y),
label: '$y',
balance: balance,
),
);
}
return out;
}
/// Resolve recurring amounts off the build path, yielding so nav stays live.
void _ensureItemsLoaded({
required int cacheKey,
required TxMetaController meta,
required BankController bank,
}) {
if (_snapshot?.key == cacheKey || _loadingKey == cacheKey) return;
_loadingKey = cacheKey;
final gen = ++_computeGen;
final templates = List<RecurringTemplate>.of(meta.recurringTemplates);
final transactions = bank.transactions;
unawaited(() async {
// Let the spinner / tab switch paint before heavy work.
await Future<void>.delayed(Duration.zero);
if (!mounted || gen != _computeGen) return;
final items = <RecurringItem>[];
for (var i = 0; i < templates.length; i++) {
final tpl = templates[i];
items.add(
RecurringItem(
templateId: tpl.id,
description: tpl.name,
amount: resolveRecurringAmount(
template: tpl,
transactions: transactions,
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer,
labelIdsFor: meta.labelIdsFor,
),
currency: tpl.currency,
dayOfMonth: tpl.dayOfMonth.clamp(1, 28),
ruleSummary: recurringRuleSummary(
template: tpl,
labelName: (id) => meta.labelById(id)?.name ?? 'label',
),
sourceTxId: tpl.sourceTxId,
labelIds: [
if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!,
],
),
);
// Yield often so bottom-nav taps are not starved.
if (i % 2 == 1) {
await Future<void>.delayed(Duration.zero);
if (!mounted || gen != _computeGen) return;
}
}
if (!mounted || gen != _computeGen) return;
setState(() {
_snapshot = _ItemsSnapshot(key: cacheKey, items: items);
if (_loadingKey == cacheKey) _loadingKey = null;
});
}());
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) { final active = ShellTabScope.of(context) == LookAheadScreen.tabIndex;
if (!active) {
if (_loadingKey != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) == LookAheadScreen.tabIndex) return;
_computeGen++;
_loadingKey = null;
});
}
return const SizedBox.expand(); return const SizedBox.expand();
} }
return SkipBuildWhenCovered( return SkipBuildWhenCovered(
@@ -165,22 +331,38 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
final currentAmount = balance?.amount ?? 0; final currentAmount = balance?.amount ?? 0;
final today = _today; final today = _today;
final through = _through; final through = _through;
final cacheKey = Object.hash( final cacheKey = _itemsCacheKey(meta, bank);
meta.storeRevision, if (_snapshot?.key != cacheKey && _loadingKey != cacheKey) {
identityHashCode(bank.transactions), WidgetsBinding.instance.addPostFrameCallback((_) {
); if (!mounted) return;
if (_cachedItems == null || _itemsCacheKey != cacheKey) { if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) return;
_itemsCacheKey = cacheKey; _ensureItemsLoaded(cacheKey: cacheKey, meta: meta, bank: bank);
_cachedItems = recurringItemsFromTemplates( });
templates: meta.recurringTemplates, }
transactions: bank.transactions,
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer, final snapshot = _snapshot;
labelIdsFor: meta.labelIdsFor, final ready = snapshot != null && snapshot.key == cacheKey;
labelName: (id) => meta.labelById(id)?.name ?? 'label',
if (!ready) {
return Scaffold(
appBar: AppBar(
title: const Text('Look ahead'),
actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
),
body: const LoadingView(message: 'Calculating look ahead…'),
); );
} }
final items = _cachedItems!;
final items = snapshot.items;
final projected = projectedOccurrences( final projected = projectedOccurrences(
items: items, items: items,
from: today, from: today,
@@ -216,7 +398,16 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Look ahead'), title: const Text('Look ahead'),
actions: bankAppBarActions(context: context), actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
), ),
body: ResponsiveBody( body: ResponsiveBody(
child: ListView( child: ListView(
@@ -234,10 +425,8 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
canGoForward: _canGoForward(maxYears), canGoForward: _canGoForward(maxYears),
onBack: () => _go(-1, maxYears), onBack: () => _go(-1, maxYears),
onForward: () => _go(1, maxYears), onForward: () => _go(1, maxYears),
backTooltip: backTooltip: _navByYear ? 'Previous year' : 'Previous month',
_period == _PeriodMode.yearly ? 'Previous year' : 'Previous month', forwardTooltip: _navByYear ? 'Next year' : 'Next month',
forwardTooltip:
_period == _PeriodMode.yearly ? 'Next year' : 'Next month',
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Material( Material(
@@ -321,6 +510,28 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
), ),
], ],
), ),
if (_showChart) ...[
const SizedBox(height: 16),
_LookAheadBalanceChart(
points: _balanceSeries(
items: items,
currentBalance: currentAmount,
maxYears: maxYears,
),
period: _period,
currency: currency,
),
const SizedBox(height: 8),
Text(
_period == _PeriodMode.yearly
? 'Projected account balance by year'
: 'Projected account balance by month',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
] else ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
children: [ children: [
@@ -362,6 +573,11 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
_SavingsCard(
monthlySavings: monthlyIncome - monthlyExpense,
currency: currency,
),
const SizedBox(height: 10),
if (items.isEmpty) if (items.isEmpty)
const EmptyState( const EmptyState(
icon: Icons.event_repeat_outlined, icon: Icons.event_repeat_outlined,
@@ -398,12 +614,322 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
), ),
), ),
], ],
],
), ),
), ),
); );
} }
} }
class _LookAheadBalanceChart extends StatelessWidget {
const _LookAheadBalanceChart({
required this.points,
required this.period,
required this.currency,
});
final List<_BalancePoint> points;
final _PeriodMode period;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
if (points.isEmpty) {
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No projected balance for this period',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
var minY = points.first.balance;
var maxY = points.first.balance;
for (final p in points) {
if (p.balance < minY) minY = p.balance;
if (p.balance > maxY) maxY = p.balance;
}
if (minY == maxY) {
minY -= 1;
maxY += 1;
}
final pad = (maxY - minY).abs() * 0.12;
final safeMin = minY - pad;
final safeMax = maxY + pad;
final interval = ((safeMax - safeMin) / 3).abs();
final safeInterval = interval <= 0 ? 1.0 : interval;
final spots = [
for (var i = 0; i < points.length; i++)
FlSpot(i.toDouble(), points[i].balance),
];
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Projected balance',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
minX: 0,
maxX: (points.length - 1).toDouble(),
minY: safeMin,
maxY: safeMax,
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touched) {
return [
for (final spot in touched)
if (spot.x.round() >= 0 &&
spot.x.round() < points.length)
LineTooltipItem(
'${points[spot.x.round()].label}\n'
'${formatMoney(points[spot.x.round()].balance, currency)}',
theme.textTheme.labelMedium!.copyWith(
color: colors.onInverseSurface,
fontWeight: FontWeight.w600,
),
),
];
},
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: safeInterval,
getTitlesWidget: (value, meta) {
if ((value - safeMax).abs() < 0.01 ||
(value - safeMin).abs() < 0.01) {
return const SizedBox.shrink();
}
return Text(
_compactMoney(value),
style: theme.textTheme.labelSmall,
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= points.length) {
return const SizedBox.shrink();
}
if (period == _PeriodMode.yearly &&
points.length > 8 &&
index % 2 == 1) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
points[index].label,
style: theme.textTheme.labelSmall,
),
);
},
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: safeInterval,
getDrawingHorizontalLine: (value) => FlLine(
color: colors.outlineVariant.withValues(alpha: 0.35),
strokeWidth: 1,
dashArray: const [4, 4],
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.18,
color: colors.primary,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
return FlDotCirclePainter(
radius: 3.5,
color: colors.primary,
strokeWidth: 0,
);
},
),
belowBarData: BarAreaData(
show: true,
color: colors.primary.withValues(alpha: 0.14),
),
),
],
),
duration: Duration.zero,
),
),
],
),
),
);
}
}
class _SavingsCard extends StatelessWidget {
const _SavingsCard({
required this.monthlySavings,
required this.currency,
});
final double monthlySavings;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final yearly = monthlySavings * 12;
final positive = monthlySavings >= 0;
final tone = positive ? AppTheme.accent : AppTheme.expense;
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.savings_outlined, color: tone, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Monthly savings',
amount: formatMoney(monthlySavings, currency),
color: tone,
),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Yearly savings',
amount: formatMoney(yearly, currency),
color: tone,
alignEnd: true,
),
),
],
),
),
);
}
}
class _SavingsMetric extends StatelessWidget {
const _SavingsMetric({
required this.label,
required this.amount,
required this.color,
this.alignEnd = false,
});
final String label;
final String amount;
final Color color;
final bool alignEnd;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Column(
crossAxisAlignment:
alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Text(
label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
amount,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
],
);
}
}
String _compactMoney(double value) {
final abs = value.abs();
if (abs >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (abs >= 1000) return '${(value / 1000).toStringAsFixed(1)}k';
if (abs >= 100) return value.toStringAsFixed(0);
return value.toStringAsFixed(abs < 10 && abs > 0 ? 1 : 0);
}
class _PeriodToolbar extends StatelessWidget { class _PeriodToolbar extends StatelessWidget {
const _PeriodToolbar({ const _PeriodToolbar({
required this.period, required this.period,
+385 -92
View File
@@ -12,6 +12,7 @@ import '../providers/bank_provider.dart';
import '../providers/preferences_provider.dart'; import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart'; import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart'; import '../utils/app_theme.dart';
import '../utils/deferred_tab_work.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../utils/navigation.dart'; import '../utils/navigation.dart';
import '../utils/tx_calendar.dart'; import '../utils/tx_calendar.dart';
@@ -28,6 +29,36 @@ import 'transactions_screen.dart';
const _kIncomeColor = AppTheme.accent; const _kIncomeColor = AppTheme.accent;
const _kExpenseColor = AppTheme.expense; const _kExpenseColor = AppTheme.expense;
class _FavouriteAgg {
const _FavouriteAgg({
required this.periods,
required this.monthly,
required this.labelSpend,
required this.labelAmountByPeriod,
});
final List<DateTime> periods;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByPeriod;
}
class _OverviewAggSnapshot {
const _OverviewAggSnapshot({
required this.periods,
required this.monthly,
required this.labelSpend,
required this.labelAmountByPeriod,
required this.favourites,
});
final List<DateTime> periods;
final Map<DateTime, _MonthBucket> monthly;
final List<_LabelSpend> labelSpend;
final Map<DateTime, Map<String, double>> labelAmountByPeriod;
final Map<String, _FavouriteAgg> favourites;
}
class OverviewScreen extends StatefulWidget { class OverviewScreen extends StatefulWidget {
const OverviewScreen({super.key}); const OverviewScreen({super.key});
@@ -59,6 +90,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
/// Favourites charts view (vs full overview with history). /// Favourites charts view (vs full overview with history).
bool _showFavouritesView = false; bool _showFavouritesView = false;
var _favouritesDefaultApplied = false; var _favouritesDefaultApplied = false;
final _work = DeferredTabWork<_OverviewAggSnapshot>();
@override @override
void initState() { void initState() {
@@ -247,7 +279,15 @@ class _OverviewScreenState extends State<OverviewScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (ShellTabScope.of(context) != OverviewScreen.tabIndex) { final active = ShellTabScope.of(context) == OverviewScreen.tabIndex;
if (!active) {
if (_work.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) == OverviewScreen.tabIndex) return;
_work.cancel();
});
}
return const SizedBox.expand(); return const SizedBox.expand();
} }
@@ -268,6 +308,41 @@ class _OverviewScreenState extends State<OverviewScreen> {
}); });
} }
final emptyLoading =
bank.isLoading && bank.transactions.isEmpty && bank.balance == null;
final needsData = bank.isLinked || bank.hasData;
final displayedFavs = prefs.displayedOverviewFavourites;
final cacheKey = Object.hash(
meta.storeRevision,
identityHashCode(bank.transactions),
_showFavouritesView,
_period,
_metric,
_cursor.year,
Object.hashAll(_includedLabelIds),
_labelFlow,
_labelBarStyle,
_labelInvertChart,
Object.hashAll(displayedFavs.map((f) => f.id)),
Object.hashAll(displayedFavs.map((f) => f.updatedAt)),
);
if (needsData && !emptyLoading && !_work.readyFor(cacheKey)) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) != OverviewScreen.tabIndex) return;
_scheduleOverviewCompute(
key: cacheKey,
bank: bank,
meta: meta,
displayedFavs: displayedFavs,
);
});
}
final showSpinner =
needsData && !emptyLoading && !_work.readyFor(cacheKey);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(_showFavouritesView ? 'Favourites' : 'Overview'), title: Text(_showFavouritesView ? 'Favourites' : 'Overview'),
@@ -288,7 +363,15 @@ class _OverviewScreenState extends State<OverviewScreen> {
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: bank.refresh, onRefresh: bank.refresh,
child: ResponsiveBody( child: ResponsiveBody(
child: SkipBuildWhenCovered( child: showSpinner
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
LoadingView(message: 'Loading overview…'),
],
)
: SkipBuildWhenCovered(
builder: (context) => _showFavouritesView builder: (context) => _showFavouritesView
? _buildFavouritesBody(context, bank, meta, prefs) ? _buildFavouritesBody(context, bank, meta, prefs)
: _buildBody(context, bank, meta, prefs), : _buildBody(context, bank, meta, prefs),
@@ -298,6 +381,89 @@ class _OverviewScreenState extends State<OverviewScreen> {
); );
} }
void _scheduleOverviewCompute({
required Object key,
required BankController bank,
required TxMetaController meta,
required List<OverviewFavourite> displayedFavs,
}) {
_work.ensure(
key: key,
mounted: () => mounted,
onUpdate: () {
if (mounted) setState(() {});
},
compute: (yieldUi) async {
final txs = bank.transactions;
final periods = _chartPeriods(txs);
await yieldUi();
Map<DateTime, _MonthBucket> monthly = const {};
var labelSpend = const <_LabelSpend>[];
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {};
if (!_showFavouritesView) {
if (_metric == OverviewMetricMode.flow) {
monthly = _flowTotals(txs, meta, periods, _period);
} else {
final labels = _labelAggregates(
txs,
meta,
periods,
_period,
includedLabelIds: _includedLabelIds,
flow: _labelFlow,
);
labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod;
}
await yieldUi();
}
final favourites = <String, _FavouriteAgg>{};
if (_showFavouritesView) {
for (var i = 0; i < displayedFavs.length; i++) {
final favourite = displayedFavs[i];
final favPeriods = _chartPeriodsFor(favourite.period, txs);
Map<DateTime, _MonthBucket> favMonthly = const {};
var favLabels = const <_LabelSpend>[];
Map<DateTime, Map<String, double>> favByPeriod = const {};
if (favourite.metric == OverviewMetricMode.flow) {
favMonthly =
_flowTotals(txs, meta, favPeriods, favourite.period);
} else {
final labels = _labelAggregates(
txs,
meta,
favPeriods,
favourite.period,
includedLabelIds: favourite.labelIds.toSet(),
flow: favourite.flow,
);
favLabels = labels.spend;
favByPeriod = labels.amountByPeriod;
}
favourites[favourite.id] = _FavouriteAgg(
periods: favPeriods,
monthly: favMonthly,
labelSpend: favLabels,
labelAmountByPeriod: favByPeriod,
);
await yieldUi();
}
}
return _OverviewAggSnapshot(
periods: periods,
monthly: monthly,
labelSpend: labelSpend,
labelAmountByPeriod: labelAmountByPeriod,
favourites: favourites,
);
},
);
}
Widget _buildFavouritesBody( Widget _buildFavouritesBody(
BuildContext context, BuildContext context,
BankController bank, BankController bank,
@@ -329,6 +495,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
: 'EUR'); : 'EUR');
final txs = bank.transactions; final txs = bank.transactions;
final displayed = prefs.displayedOverviewFavourites; final displayed = prefs.displayedOverviewFavourites;
final agg = _work.value;
return ListView( return ListView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
@@ -399,6 +566,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
txs: txs, txs: txs,
meta: meta, meta: meta,
currency: currency, currency: currency,
precomputed: agg?.favourites[displayed[i].id],
), ),
], ],
], ],
@@ -412,12 +580,16 @@ class _OverviewScreenState extends State<OverviewScreen> {
required List<BankTransaction> txs, required List<BankTransaction> txs,
required TxMetaController meta, required TxMetaController meta,
required String currency, required String currency,
_FavouriteAgg? precomputed,
}) { }) {
final periods = _chartPeriodsFor(favourite.period, txs); final periods = precomputed?.periods ??
Map<DateTime, _MonthBucket> monthly = const {}; _chartPeriodsFor(favourite.period, txs);
var labelSpend = const <_LabelSpend>[]; Map<DateTime, _MonthBucket> monthly = precomputed?.monthly ?? const {};
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {}; var labelSpend = precomputed?.labelSpend ?? const <_LabelSpend>[];
Map<DateTime, Map<String, double>> labelAmountByPeriod =
precomputed?.labelAmountByPeriod ?? const {};
if (precomputed == null) {
if (favourite.metric == OverviewMetricMode.flow) { if (favourite.metric == OverviewMetricMode.flow) {
monthly = _flowTotals(txs, meta, periods, favourite.period); monthly = _flowTotals(txs, meta, periods, favourite.period);
} else { } else {
@@ -432,6 +604,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
labelSpend = labels.spend; labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod; labelAmountByPeriod = labels.amountByPeriod;
} }
}
final periodTitle = favourite.period == OverviewPeriodMode.monthly final periodTitle = favourite.period == OverviewPeriodMode.monthly
? '${_cursor.year}' ? '${_cursor.year}'
@@ -474,7 +647,29 @@ class _OverviewScreenState extends State<OverviewScreen> {
), ),
); );
}, },
onMonthTap: (month) => _openPeriod(context, month), onMonthTap: (month, {labelId, flow}) => _openPeriod(
context,
month,
labelId: labelId,
labelIds: {
for (final id in favourite.labelIds) id,
},
flow: flow ??
(favourite.metric == OverviewMetricMode.labels
? favourite.flow
: null),
periodMode: favourite.period,
),
onChartTap: () => _openChartFilter(
context,
labelIds: {
for (final id in favourite.labelIds) id,
},
flow: favourite.metric == OverviewMetricMode.labels
? favourite.flow
: null,
periodMode: favourite.period,
),
); );
} }
@@ -508,15 +703,17 @@ class _OverviewScreenState extends State<OverviewScreen> {
? bank.transactions.first.currency ? bank.transactions.first.currency
: 'EUR'); : 'EUR');
final txs = bank.transactions; final txs = bank.transactions;
final periods = _chartPeriods(txs); final agg = _work.value;
final periods = agg?.periods ?? _chartPeriods(txs);
final historyPeriods = [...periods.reversed]; final historyPeriods = [...periods.reversed];
// Only aggregate what the active metric needs — toggling was scanning // Prefer deferred aggregates; fall back only if snapshot is missing.
// all txs 3–4 times and resolving full meta each time. Map<DateTime, _MonthBucket> monthly = agg?.monthly ?? const {};
Map<DateTime, _MonthBucket> monthly = const {}; var labelSpend = agg?.labelSpend ?? const <_LabelSpend>[];
var labelSpend = const <_LabelSpend>[]; Map<DateTime, Map<String, double>> labelAmountByPeriod =
Map<DateTime, Map<String, double>> labelAmountByPeriod = const {}; agg?.labelAmountByPeriod ?? const {};
if (agg == null) {
if (_metric == OverviewMetricMode.flow) { if (_metric == OverviewMetricMode.flow) {
monthly = _flowTotals(txs, meta, periods, _period); monthly = _flowTotals(txs, meta, periods, _period);
} else { } else {
@@ -531,6 +728,7 @@ class _OverviewScreenState extends State<OverviewScreen> {
labelSpend = labels.spend; labelSpend = labels.spend;
labelAmountByPeriod = labels.amountByPeriod; labelAmountByPeriod = labels.amountByPeriod;
} }
}
final summaryFlow = _metric == OverviewMetricMode.flow final summaryFlow = _metric == OverviewMetricMode.flow
? _sumBuckets(monthly.values) ? _sumBuckets(monthly.values)
@@ -590,7 +788,17 @@ class _OverviewScreenState extends State<OverviewScreen> {
), ),
); );
}, },
onMonthTap: (month) => _openPeriod(context, month), onMonthTap: (month, {labelId, flow}) => _openPeriod(
context,
month,
labelId: labelId,
flow: flow ??
(_metric == OverviewMetricMode.labels ? _labelFlow : null),
),
onChartTap: () => _openChartFilter(
context,
flow: _metric == OverviewMetricMode.labels ? _labelFlow : null,
),
), ),
const SizedBox(height: 14), const SizedBox(height: 14),
if (_metric == OverviewMetricMode.flow) ...[ if (_metric == OverviewMetricMode.flow) ...[
@@ -739,23 +947,60 @@ class _OverviewScreenState extends State<OverviewScreen> {
BuildContext context, BuildContext context,
DateTime period, { DateTime period, {
String? labelId, String? labelId,
Set<String>? labelIds,
TxFlowFilter? flow,
OverviewPeriodMode? periodMode,
}) { }) {
final yearly = _period == OverviewPeriodMode.yearly; final mode = periodMode ?? _period;
final yearly = mode == OverviewPeriodMode.yearly;
final initial = yearly ? DateTime(period.year, 12) : period; final initial = yearly ? DateTime(period.year, 12) : period;
final labelIds = labelId != null final resolvedLabelIds = labelId != null
? {labelId} ? {labelId}
: { : (labelIds ??
(_metric == OverviewMetricMode.labels
? {
// Open with the same labels currently shown on the chart. // Open with the same labels currently shown on the chart.
for (final id in _includedLabelIds) id, for (final id in _includedLabelIds) id,
}
: <String>{}));
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
initialMonth: initial,
initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or,
initialFlow: flow,
// A specific bar/period should always scope to that month (or year).
initialSearchInMonthOnly: !yearly,
initialYear: yearly ? period.year : null,
),
),
);
}
/// Open Transactions with this chart's filters (no specific bar/column).
void _openChartFilter(
BuildContext context, {
Set<String>? labelIds,
TxFlowFilter? flow,
OverviewPeriodMode? periodMode,
}) {
final mode = periodMode ?? _period;
final yearly = mode == OverviewPeriodMode.yearly;
final resolvedLabelIds = labelIds ??
{
for (final id in _includedLabelIds) id,
}; };
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => TransactionsScreen( builder: (_) => TransactionsScreen(
initialMonth: initial, // Monthly charts show a year — open that whole year with the chart filter.
initialLabelIds: labelIds, initialMonth: yearly ? null : DateTime(_cursor.year, 12),
initialYear: yearly ? null : _cursor.year,
initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or, initialLabelMatch: LabelMatchMode.or,
initialSearchInMonthOnly: !yearly && labelIds.isNotEmpty, initialFlow: flow,
initialYear: yearly ? period.year : null, initialSearchInMonthOnly: false,
), ),
), ),
); );
@@ -1159,6 +1404,7 @@ class _ChartCard extends StatelessWidget {
required this.availableLabelCount, required this.availableLabelCount,
required this.onLabelTap, required this.onLabelTap,
required this.onMonthTap, required this.onMonthTap,
required this.onChartTap,
this.onPeriodModeChanged, this.onPeriodModeChanged,
this.onEdit, this.onEdit,
}); });
@@ -1182,7 +1428,12 @@ class _ChartCard extends StatelessWidget {
final int? includedLabelCount; final int? includedLabelCount;
final int availableLabelCount; final int availableLabelCount;
final void Function(TxLabel label) onLabelTap; final void Function(TxLabel label) onLabelTap;
final void Function(DateTime month) onMonthTap; final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
final ValueChanged<OverviewPeriodMode>? onPeriodModeChanged; final ValueChanged<OverviewPeriodMode>? onPeriodModeChanged;
final VoidCallback? onEdit; final VoidCallback? onEdit;
@@ -1277,7 +1528,7 @@ class _ChartCard extends StatelessWidget {
runSpacing: 2, runSpacing: 2,
alignment: WrapAlignment.end, alignment: WrapAlignment.end,
children: [ children: [
for (final row in labelSpend.take(compact ? 3 : 5)) for (final row in labelSpend.take(6))
GestureDetector( GestureDetector(
onTap: () => onLabelTap(row.label), onTap: () => onLabelTap(row.label),
child: _LegendDot( child: _LegendDot(
@@ -1286,7 +1537,7 @@ class _ChartCard extends StatelessWidget {
compact: compact, compact: compact,
), ),
), ),
if (labelSpend.length > (compact ? 3 : 5)) if (labelSpend.length > 6)
_LegendDot( _LegendDot(
color: colors.outline, color: colors.outline,
label: 'Other', label: 'Other',
@@ -1359,13 +1610,15 @@ class _ChartCard extends StatelessWidget {
final hasLegend = metric == OverviewMetricMode.flow || final hasLegend = metric == OverviewMetricMode.flow ||
labelSpend.isNotEmpty; labelSpend.isNotEmpty;
final radius = BorderRadius.circular(compact ? 16 : 20);
return Card( return Card(
color: colors.surfaceContainer, color: colors.surfaceContainer,
elevation: 0, elevation: 0,
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
shape: RoundedRectangleBorder( clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(compact ? 16 : 20), shape: RoundedRectangleBorder(borderRadius: radius),
), // Header/legend open the chart-wide filter; bars handle their own taps.
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
compact ? 12 : 14, compact ? 12 : 14,
@@ -1373,6 +1626,13 @@ class _ChartCard extends StatelessWidget {
compact ? 10 : 14, compact ? 10 : 14,
compact ? 12 : 16, compact ? 12 : 16,
), ),
child: Column(
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: onChartTap,
borderRadius: BorderRadius.circular(12),
child: Column( child: Column(
children: [ children: [
if (compact) ...[ if (compact) ...[
@@ -1521,6 +1781,10 @@ class _ChartCard extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
], ],
],
),
),
),
SizedBox( SizedBox(
height: chartHeight, height: chartHeight,
child: metric == OverviewMetricMode.flow child: metric == OverviewMetricMode.flow
@@ -1529,6 +1793,8 @@ class _ChartCard extends StatelessWidget {
monthly: monthly, monthly: monthly,
currency: currency, currency: currency,
period: period, period: period,
onMonthTap: onMonthTap,
onChartTap: onChartTap,
) )
: _LabelMonthBars( : _LabelMonthBars(
months: months, months: months,
@@ -1539,6 +1805,7 @@ class _ChartCard extends StatelessWidget {
barStyle: labelBarStyle, barStyle: labelBarStyle,
invertChart: invertChart, invertChart: invertChart,
onMonthTap: onMonthTap, onMonthTap: onMonthTap,
onChartTap: onChartTap,
), ),
), ),
], ],
@@ -1554,12 +1821,20 @@ class _FlowBars extends StatelessWidget {
required this.monthly, required this.monthly,
required this.currency, required this.currency,
required this.period, required this.period,
required this.onMonthTap,
required this.onChartTap,
}); });
final List<DateTime> months; final List<DateTime> months;
final Map<DateTime, _MonthBucket> monthly; final Map<DateTime, _MonthBucket> monthly;
final String currency; final String currency;
final OverviewPeriodMode period; final OverviewPeriodMode period;
final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -1578,7 +1853,10 @@ class _FlowBars extends StatelessWidget {
}); });
if (empty) { if (empty) {
return Center( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: Center(
child: Text( child: Text(
period == OverviewPeriodMode.yearly period == OverviewPeriodMode.yearly
? 'No income or expenses in these years' ? 'No income or expenses in these years'
@@ -1586,6 +1864,7 @@ class _FlowBars extends StatelessWidget {
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
); );
} }
@@ -1594,23 +1873,35 @@ class _FlowBars extends StatelessWidget {
maxY: safeMax, maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData( barTouchData: BarTouchData(
handleBuiltInTouches: false,
touchTooltipData: BarTouchTooltipData( touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) { getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
final month = months[group.x.toInt()]; ),
final bucket = monthly[_periodKey(month, period)]!; touchCallback: (event, response) {
final isIncome = rodIndex == 1; if (event is! FlTapUpEvent) return;
final value = isIncome ? bucket.income : bucket.expense; final spot = response?.spot;
return BarTooltipItem( if (spot == null) {
'${_periodAxisLabel(month, period)}\n' onChartTap();
'${isIncome ? 'Income' : 'Expense'}\n' return;
'${formatMoney(value, currency)}', }
TextStyle(color: colors.onInverseSurface), final index = spot.touchedBarGroupIndex;
); if (index < 0 || index >= months.length) {
onChartTap();
return;
}
final rod = spot.touchedRodDataIndex;
// Rod 0 = expense, rod 1 = income (see barGroups below).
final flow = rod == 0
? TxFlowFilter.expense
: rod == 1
? TxFlowFilter.income
: null;
onMonthTap(months[index], flow: flow);
}, },
), ),
),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)), const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles( leftTitles: AxisTitles(
@@ -1707,9 +1998,10 @@ class _LabelMonthBars extends StatelessWidget {
required this.barStyle, required this.barStyle,
required this.invertChart, required this.invertChart,
required this.onMonthTap, required this.onMonthTap,
required this.onChartTap,
}); });
static const _maxSeriesLabels = 5; static const _maxSeriesLabels = 6;
final List<DateTime> months; final List<DateTime> months;
final List<_LabelSpend> labelSpend; final List<_LabelSpend> labelSpend;
@@ -1718,7 +2010,12 @@ class _LabelMonthBars extends StatelessWidget {
final OverviewPeriodMode period; final OverviewPeriodMode period;
final LabelChartBarStyle barStyle; final LabelChartBarStyle barStyle;
final bool invertChart; final bool invertChart;
final void Function(DateTime month) onMonthTap; final void Function(
DateTime month, {
String? labelId,
TxFlowFilter? flow,
}) onMonthTap;
final VoidCallback onChartTap;
double _plot(double value) => invertChart ? -value : value; double _plot(double value) => invertChart ? -value : value;
@@ -1727,12 +2024,16 @@ class _LabelMonthBars extends StatelessWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
if (labelSpend.isEmpty) { if (labelSpend.isEmpty) {
return Center( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: Center(
child: Text( child: Text(
'No labeled activity in this period.\nTag transactions to see them here.', 'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
); );
} }
@@ -1789,12 +2090,16 @@ class _LabelMonthBars extends StatelessWidget {
} }
} }
if (maxY == 0 && minY == 0) { if (maxY == 0 && minY == 0) {
return Center( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: Center(
child: Text( child: Text(
'No labeled activity in this period.\nTag transactions to see them here.', 'No labeled activity in this period.\nTag transactions to see them here.',
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
); );
} }
final span = (maxY - minY).abs(); final span = (maxY - minY).abs();
@@ -1805,12 +2110,12 @@ class _LabelMonthBars extends StatelessWidget {
final rodWidth = stacked final rodWidth = stacked
? (period == OverviewPeriodMode.yearly ? 18.0 : 14.0) ? (period == OverviewPeriodMode.yearly ? 18.0 : 14.0)
: period == OverviewPeriodMode.yearly : period == OverviewPeriodMode.yearly
? (seriesCount <= 3 ? 12.0 : 8.0) ? (seriesCount <= 3 ? 12.0 : seriesCount <= 6 ? 8.0 : 6.0)
: (seriesCount <= 3 : (seriesCount <= 3
? 7.0 ? 7.0
: seriesCount <= 5 : seriesCount <= 6
? 5.0 ? 4.5
: 4.0); : 3.5);
List<BarChartRodData> rodsFor(DateTime month) { List<BarChartRodData> rodsFor(DateTime month) {
final bucket = final bucket =
@@ -1894,52 +2199,40 @@ class _LabelMonthBars extends StatelessWidget {
maxY: safeMax, maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData( barTouchData: BarTouchData(
handleBuiltInTouches: false,
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) => null,
),
touchCallback: (event, response) { touchCallback: (event, response) {
if (event is! FlTapUpEvent) return; if (event is! FlTapUpEvent) return;
final index = response?.spot?.touchedBarGroupIndex; final spot = response?.spot;
if (index == null || index < 0 || index >= months.length) return; if (spot == null) {
onMonthTap(months[index]); onChartTap();
return;
}
final index = spot.touchedBarGroupIndex;
if (index < 0 || index >= months.length) {
onChartTap();
return;
}
final month = months[index];
if (stacked) {
final stackIndex = spot.touchedStackItemIndex;
if (stackIndex >= 0 && stackIndex < topLabels.length) {
onMonthTap(month, labelId: topLabels[stackIndex].label.id);
return;
}
// "Other" segment or whole column → that period with chart labels.
onMonthTap(month);
return;
}
final rod = spot.touchedRodDataIndex;
if (rod >= 0 && rod < topLabels.length) {
onMonthTap(month, labelId: topLabels[rod].label.id);
return;
}
onMonthTap(month);
}, },
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final month = months[group.x.toInt()];
if (stacked && rod.rodStackItems.isNotEmpty) {
final bucket =
amountByMonth[_periodKey(month, period)] ?? const {};
final buffer = StringBuffer(
'${_periodAxisLabel(month, period)}\n',
);
var total = 0.0;
for (final row in topLabels) {
final amount = bucket[row.label.id] ?? 0;
if (amount == 0) continue;
total += amount;
buffer.writeln(
'${row.label.name}: ${formatMoney(amount, currency)}',
);
}
final other = otherFor(month);
if (other != 0) {
total += other;
buffer.writeln('Other: ${formatMoney(other, currency)}');
}
buffer.write('Total: ${formatMoney(total, currency)}');
return BarTooltipItem(
buffer.toString(),
TextStyle(color: colors.onInverseSurface),
);
}
final name = rodIndex < topLabels.length
? topLabels[rodIndex].label.name
: 'Other';
return BarTooltipItem(
'${_periodAxisLabel(month, period)}\n'
'$name\n'
'${formatMoney(invertChart ? -rod.toY : rod.toY, currency)}',
TextStyle(color: colors.onInverseSurface),
);
},
),
), ),
titlesData: FlTitlesData( titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
+76
View File
@@ -27,7 +27,31 @@ bool isSameTransaction(BankTransaction a, BankTransaction b) {
if (a.date != b.date) return false; if (a.date != b.date) return false;
if (a.currency.toUpperCase() != b.currency.toUpperCase()) return false; if (a.currency.toUpperCase() != b.currency.toUpperCase()) return false;
if (_amountCents(a.amount) != _amountCents(b.amount)) return false; if (_amountCents(a.amount) != _amountCents(b.amount)) return false;
return _descriptionsLikelyMatch(a, b);
}
/// True when a cached/API [pending] row is the unsettled form of [booked].
///
/// Banks often change booking date (and sometimes wording) when a payment
/// settles, so this allows a small date window unlike [isSameTransaction].
bool isPendingSettledBy(
BankTransaction pending,
BankTransaction booked, {
int maxDayGap = 10,
}) {
if (!pending.pending || booked.pending) return false;
if (pending.isManual || booked.isManual) return false;
if (pending.currency.toUpperCase() != booked.currency.toUpperCase()) {
return false;
}
if (_amountCents(pending.amount) != _amountCents(booked.amount)) return false;
if (!_descriptionsLikelyMatch(pending, booked)) return false;
final gap = _dateDiffDays(pending.date, booked.date);
if (gap == null) return false;
return gap.abs() <= maxDayGap;
}
bool _descriptionsLikelyMatch(BankTransaction a, BankTransaction b) {
if (a.descriptionKey == b.descriptionKey) return true; if (a.descriptionKey == b.descriptionKey) return true;
final ma = a.merchantKey; final ma = a.merchantKey;
@@ -47,10 +71,62 @@ bool isSameTransaction(BankTransaction a, BankTransaction b) {
int _amountCents(double amount) => (amount * 100).round(); int _amountCents(double amount) => (amount * 100).round();
int? _dateDiffDays(String a, String b) {
final da = DateTime.tryParse(a);
final db = DateTime.tryParse(b);
if (da == null || db == null) return null;
return da.difference(db).inDays;
}
/// Coarse bucket so fuzzy [isSameTransaction] only compares likely peers. /// Coarse bucket so fuzzy [isSameTransaction] only compares likely peers.
String _dedupeBucket(BankTransaction tx) => String _dedupeBucket(BankTransaction tx) =>
'${tx.date}|${tx.currency.toUpperCase()}|${_amountCents(tx.amount)}'; '${tx.date}|${tx.currency.toUpperCase()}|${_amountCents(tx.amount)}';
String _amountCurrencyBucket(BankTransaction tx) =>
'${tx.currency.toUpperCase()}|${_amountCents(tx.amount)}';
/// Drop pending rows that already have a booked twin (same money + merchant).
List<BankTransaction> dropSettledPendingDuplicates(
List<BankTransaction> txs,
) {
final booked = [for (final tx in txs) if (!tx.pending) tx];
if (booked.isEmpty) return txs;
final bookedBuckets = <String, List<BankTransaction>>{};
for (final tx in booked) {
bookedBuckets.putIfAbsent(_amountCurrencyBucket(tx), () => []).add(tx);
}
final out = <BankTransaction>[];
for (final tx in txs) {
if (!tx.pending || tx.isManual) {
out.add(tx);
continue;
}
final peers = bookedBuckets[_amountCurrencyBucket(tx)];
final settled =
peers != null && peers.any((b) => isPendingSettledBy(tx, b));
if (!settled) out.add(tx);
}
return out;
}
/// Merge a fresh bank API page with locally cached history.
///
/// Pending rows are ephemeral: only the current API list may keep them
/// (except admin manuals). That stops settled/cancelled pendings from
/// living forever in the on-device cache after the bank drops them.
List<BankTransaction> mergeApiTransactionsWithCache(
List<BankTransaction> apiTxs,
List<BankTransaction> cached,
) {
final history = [
for (final tx in cached)
if (!tx.pending || tx.isManual) tx,
];
return dropSettledPendingDuplicates(mergeTransactions(apiTxs, history));
}
/// Merge [extra] into [existing], skipping duplicates. Newest date first. /// Merge [extra] into [existing], skipping duplicates. Newest date first.
List<BankTransaction> mergeTransactions( List<BankTransaction> mergeTransactions(
List<BankTransaction> existing, List<BankTransaction> existing,
+98 -16
View File
@@ -25,20 +25,6 @@ List<BankTransaction> mergeBankTransactionLists(
return mergeTransactions(local, remote); return mergeTransactions(local, remote);
} }
/// Union Overview favourites by client id (local wins on conflict).
List<OverviewFavourite> mergeOverviewFavourites(
List<OverviewFavourite> local,
List<OverviewFavourite> remote,
) {
final byId = <String, OverviewFavourite>{
for (final fav in remote) fav.id: fav,
};
for (final fav in local) {
byId[fav.id] = fav;
}
return byId.values.toList(growable: false);
}
/// Union-merge so neither device loses annotations on first sync. /// Union-merge so neither device loses annotations on first sync.
TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) { TxMetaStoreData mergeTxMetaStores(TxMetaStoreData a, TxMetaStoreData b) {
final byId = <String, TxMeta>{...a.byId}; final byId = <String, TxMeta>{...a.byId};
@@ -485,6 +471,8 @@ class PbSyncService {
), ),
invertExpensePerspective: invertExpensePerspective:
r.getBoolValue('invert_expense_perspective'), r.getBoolValue('invert_expense_perspective'),
updatedAt: _recordUpdatedAtMs(r),
deleted: r.getBoolValue('deleted'),
), ),
); );
} }
@@ -544,6 +532,8 @@ class PbSyncService {
'showAmountIn': r.getBoolValue('show_amount_in', true), 'showAmountIn': r.getBoolValue('show_amount_in', true),
'showAmountOut': r.getBoolValue('show_amount_out', true), 'showAmountOut': r.getBoolValue('show_amount_out', true),
'showCount': r.getBoolValue('show_count', true), 'showCount': r.getBoolValue('show_count', true),
'updatedAt': _recordUpdatedAtMs(r),
'deleted': r.getBoolValue('deleted'),
}), }),
); );
} }
@@ -804,6 +794,70 @@ class PbSyncService {
return matchedTombs; return matchedTombs;
} }
/// Pull synced delete tombstones for admin test transactions.
Future<List<BankTransaction>> pullTxDeletes({
void Function(String message)? onStatus,
}) async {
final userId = _userId;
final filter = 'user="$userId"';
onStatus?.call('Pulling deleted test txs…');
final records = await _fullList('tx_deletes', filter);
final out = <BankTransaction>[];
for (final r in records) {
final tx = _bankTxFromRecord(r);
if (tx == null) continue;
out.add(tx.copyWith(isManual: true));
}
debugPrint('PbSyncService pull: tx_deletes=${out.length}');
return out;
}
/// Upsert local delete tombstones. Never removes remote-only rows (union).
Future<String> pushTxDeletes(
List<BankTransaction> tombs, {
void Function(String message)? onStatus,
}) async {
if (tombs.isEmpty) return 'Tx deletes: nothing to push';
final userId = _userId;
final filter = 'user="$userId"';
onStatus?.call('Syncing deleted test txs…');
final existing = {
for (final r in await _fullList('tx_deletes', filter))
r.getStringValue('tx_id'): r,
};
final ops = <_BatchOp>[];
var skipped = 0;
final seen = <String>{};
for (final tomb in tombs) {
final syncId = _clip(tomb.id, 2000);
if (syncId.isEmpty || !seen.add(syncId)) continue;
final body = <String, dynamic>{
'user': userId,
'tx_id': syncId,
'date': _clip(tomb.date, 32),
'description': _clip(
tomb.description.isEmpty ? 'Unknown' : tomb.description,
4000,
),
'amount': tomb.amount,
'currency': _clip(
tomb.currency.isEmpty ? 'EUR' : tomb.currency.toUpperCase(),
8,
),
};
final remote = existing[syncId];
if (remote == null) {
ops.add(_BatchOp.create(body));
} else {
skipped++;
}
}
await _runWriteOps('tx_deletes', ops, onStatus: onStatus);
return 'Tx deletes: local ${tombs.length} (+${ops.length} · $skipped exist)';
}
Future<void> _runOne(String collection, _BatchOp op) { Future<void> _runOne(String collection, _BatchOp op) {
final col = _pb.collection(collection); final col = _pb.collection(collection);
final Future<dynamic> future; final Future<dynamic> future;
@@ -968,6 +1022,12 @@ class PbSyncService {
filter = filter =
'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"'; 'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"';
} }
case 'tx_deletes':
final key = body['tx_id'];
if (userId != null && key is String) {
filter =
'user="$userId" && tx_id="${key.replaceAll('"', '\\"')}"';
}
} }
if (filter == null) { if (filter == null) {
throw StateError('Cannot upsert $collection without filter'); throw StateError('Cannot upsert $collection without filter');
@@ -1294,6 +1354,8 @@ class PbSyncService {
'flow': fav.flow.name, 'flow': fav.flow.name,
'bar_style': fav.barStyle.name, 'bar_style': fav.barStyle.name,
'invert_expense_perspective': fav.invertExpensePerspective, 'invert_expense_perspective': fav.invertExpensePerspective,
'updated_at': fav.updatedAt,
'deleted': fav.deleted,
}; };
final remote = existing[clientId]; final remote = existing[clientId];
if (remote == null) { if (remote == null) {
@@ -1358,6 +1420,8 @@ class PbSyncService {
'show_amount_in': w.showAmountIn, 'show_amount_in': w.showAmountIn,
'show_amount_out': w.showAmountOut, 'show_amount_out': w.showAmountOut,
'show_count': w.showCount, 'show_count': w.showCount,
'updated_at': w.updatedAt,
'deleted': w.deleted,
}; };
final remote = existing[clientId]; final remote = existing[clientId];
if (remote == null) { if (remote == null) {
@@ -1463,7 +1527,9 @@ class PbSyncService {
remote.getStringValue('flow') == fav.flow.name && remote.getStringValue('flow') == fav.flow.name &&
remote.getStringValue('bar_style') == fav.barStyle.name && remote.getStringValue('bar_style') == fav.barStyle.name &&
remote.getBoolValue('invert_expense_perspective') == remote.getBoolValue('invert_expense_perspective') ==
fav.invertExpensePerspective; fav.invertExpensePerspective &&
remote.getBoolValue('deleted') == fav.deleted &&
_recordUpdatedAtMs(remote) == fav.updatedAt;
} }
bool _homeFilterWidgetUnchanged(RecordModel remote, HomeFilterWidget w) { bool _homeFilterWidgetUnchanged(RecordModel remote, HomeFilterWidget w) {
@@ -1502,7 +1568,9 @@ class PbSyncService {
remote.getBoolValue('month_only') == w.monthOnly && remote.getBoolValue('month_only') == w.monthOnly &&
remote.getBoolValue('show_amount_in', true) == w.showAmountIn && remote.getBoolValue('show_amount_in', true) == w.showAmountIn &&
remote.getBoolValue('show_amount_out', true) == w.showAmountOut && remote.getBoolValue('show_amount_out', true) == w.showAmountOut &&
remote.getBoolValue('show_count', true) == w.showCount; remote.getBoolValue('show_count', true) == w.showCount &&
remote.getBoolValue('deleted') == w.deleted &&
_recordUpdatedAtMs(remote) == w.updatedAt;
} }
bool _appPrefsUnchanged(RecordModel remote, AppCloudPrefs prefs) { bool _appPrefsUnchanged(RecordModel remote, AppCloudPrefs prefs) {
@@ -1515,6 +1583,20 @@ class PbSyncService {
remote.getIntValue('look_ahead_max_years', 5) == remote.getIntValue('look_ahead_max_years', 5) ==
prefs.lookAheadMaxYears.clamp(1, 30); prefs.lookAheadMaxYears.clamp(1, 30);
} }
/// Prefer custom `updated_at`, else PocketBase system `updated`.
int _recordUpdatedAtMs(RecordModel r) {
final raw = r.get<dynamic>('updated_at');
final custom = switch (raw) {
int n => n,
num n => n.round(),
String s => int.tryParse(s) ?? 0,
_ => 0,
};
if (custom > 0) return custom;
final system = DateTime.tryParse(r.getStringValue('updated'));
return system?.millisecondsSinceEpoch ?? 0;
}
} }
RecurringTemplateSource _parseTemplateSource(String raw) { RecurringTemplateSource _parseTemplateSource(String raw) {
+50
View File
@@ -0,0 +1,50 @@
/// Schedule heavy tab work off the first paint, yielding so nav stays responsive.
library;
import 'dart:async';
/// Runs [compute] after a frame, cancellable via [cancel] / generation bumps.
class DeferredTabWork<T> {
int _gen = 0;
Object? _loadingKey;
T? value;
Object? valueKey;
bool readyFor(Object key) => value != null && valueKey == key;
bool get isLoading => _loadingKey != null;
void cancel() {
_gen++;
_loadingKey = null;
}
/// Starts work for [key] unless already ready or in flight for that key.
void ensure({
required Object key,
required Future<T> Function(Future<void> Function() yieldUi) compute,
required void Function() onUpdate,
required bool Function() mounted,
}) {
if (readyFor(key) || _loadingKey == key) return;
_loadingKey = key;
final gen = ++_gen;
unawaited(() async {
await Future<void>.delayed(Duration.zero);
if (!mounted() || gen != _gen) return;
Future<void> yieldUi() async {
await Future<void>.delayed(Duration.zero);
}
final result = await compute(yieldUi);
if (!mounted() || gen != _gen) return;
value = result;
valueKey = key;
if (_loadingKey == key) _loadingKey = null;
onUpdate();
}());
}
}
+12
View File
@@ -14,6 +14,10 @@ class AppTextField extends StatelessWidget {
this.keyboardType, this.keyboardType,
this.validator, this.validator,
this.autofocus = false, this.autofocus = false,
this.autofillHints,
this.obscureText = false,
this.enableSuggestions,
this.autocorrect,
}); });
final TextEditingController controller; final TextEditingController controller;
@@ -24,6 +28,10 @@ class AppTextField extends StatelessWidget {
final TextInputType? keyboardType; final TextInputType? keyboardType;
final String? Function(String?)? validator; final String? Function(String?)? validator;
final bool autofocus; final bool autofocus;
final Iterable<String>? autofillHints;
final bool obscureText;
final bool? enableSuggestions;
final bool? autocorrect;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -31,8 +39,12 @@ class AppTextField extends StatelessWidget {
controller: controller, controller: controller,
maxLines: maxLines, maxLines: maxLines,
autofocus: autofocus, autofocus: autofocus,
obscureText: obscureText,
textInputAction: textInputAction, textInputAction: textInputAction,
keyboardType: keyboardType, keyboardType: keyboardType,
autofillHints: autofillHints,
enableSuggestions: enableSuggestions ?? !obscureText,
autocorrect: autocorrect ?? !obscureText,
validator: validator, validator: validator,
decoration: InputDecoration( decoration: InputDecoration(
labelText: label, labelText: label,
+59 -16
View File
@@ -23,6 +23,8 @@ class HomeFilterWidgetCard extends StatelessWidget {
this.onToggleDisplay, this.onToggleDisplay,
this.onEdit, this.onEdit,
this.dragHandle, this.dragHandle,
this.precomputedStats,
this.precomputedBudget,
}); });
final HomeFilterWidget widgetConfig; final HomeFilterWidget widgetConfig;
@@ -31,6 +33,10 @@ class HomeFilterWidgetCard extends StatelessWidget {
final VoidCallback? onEdit; final VoidCallback? onEdit;
final Widget? dragHandle; final Widget? dragHandle;
/// When set, skips scanning all transactions in build.
final TxFilterMetrics? precomputedStats;
final HomeBudgetMetrics? precomputedBudget;
void _openTransactions(BuildContext context) { void _openTransactions(BuildContext context) {
final filter = widgetConfig.filter; final filter = widgetConfig.filter;
Navigator.of(context).push( Navigator.of(context).push(
@@ -95,12 +101,14 @@ class HomeFilterWidgetCard extends StatelessWidget {
currency: currency, currency: currency,
transactions: bank.transactions, transactions: bank.transactions,
meta: meta, meta: meta,
precomputed: precomputedBudget,
) )
: _StatsBody( : _StatsBody(
widgetConfig: widgetConfig, widgetConfig: widgetConfig,
currency: currency, currency: currency,
transactions: bank.transactions, transactions: bank.transactions,
meta: meta, meta: meta,
precomputed: precomputedStats,
), ),
), ),
if (editing && onToggleDisplay != null) if (editing && onToggleDisplay != null)
@@ -267,18 +275,21 @@ class _StatsBody extends StatelessWidget {
required this.currency, required this.currency,
required this.transactions, required this.transactions,
required this.meta, required this.meta,
this.precomputed,
}); });
final HomeFilterWidget widgetConfig; final HomeFilterWidget widgetConfig;
final String currency; final String currency;
final List<BankTransaction> transactions; final List<BankTransaction> transactions;
final TxMetaController meta; final TxMetaController meta;
final TxFilterMetrics? precomputed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final metrics = computeTxFilterMetrics( final metrics = precomputed ??
computeTxFilterMetrics(
all: transactions, all: transactions,
meta: meta, meta: meta,
filter: widgetConfig.filter, filter: widgetConfig.filter,
@@ -288,19 +299,14 @@ class _StatsBody extends StatelessWidget {
final spentOnly = widgetConfig.showAmountOut && final spentOnly = widgetConfig.showAmountOut &&
!widgetConfig.showAmountIn && !widgetConfig.showAmountIn &&
!widgetConfig.showCount; !widgetConfig.showCount;
final half = widgetConfig.halfWidth;
// Avoid LayoutBuilder/Spacer/Expanded here: half-width rows use Widget metricsBlock({required bool alignEnd}) {
// IntrinsicHeight, which cannot measure those widgets. if (spentOnly) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [ children: [
_CardHeader(
icon: widgetConfig.icon,
title: widgetConfig.name,
subtitle: _subtitleFor(widgetConfig),
),
if (spentOnly) ...[
const SizedBox(height: 32),
Text( Text(
'Spent', 'Spent',
style: theme.textTheme.labelMedium?.copyWith( style: theme.textTheme.labelMedium?.copyWith(
@@ -316,11 +322,13 @@ class _StatsBody extends StatelessWidget {
height: 1.15, height: 1.15,
), ),
), ),
] else ...[ ],
const SizedBox(height: 14), );
Wrap( }
return Wrap(
spacing: 16, spacing: 16,
runSpacing: 8, runSpacing: 8,
alignment: alignEnd ? WrapAlignment.end : WrapAlignment.start,
children: [ children: [
if (widgetConfig.showAmountIn) if (widgetConfig.showAmountIn)
_Metric( _Metric(
@@ -336,13 +344,45 @@ class _StatsBody extends StatelessWidget {
), ),
if (widgetConfig.showCount) if (widgetConfig.showCount)
_Metric( _Metric(
label: 'Txs', label: 'Transactions',
value: '${metrics.count}', value: '${metrics.count}',
color: colors.primary, color: colors.primary,
), ),
], ],
);
}
// Avoid LayoutBuilder/Spacer/Expanded here: half-width rows use
// IntrinsicHeight, which cannot measure those widgets.
if (!half) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _CardHeader(
icon: widgetConfig.icon,
title: widgetConfig.name,
subtitle: _subtitleFor(widgetConfig),
),
),
const SizedBox(width: 12),
Flexible(
child: metricsBlock(alignEnd: true),
), ),
], ],
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CardHeader(
icon: widgetConfig.icon,
title: widgetConfig.name,
subtitle: _subtitleFor(widgetConfig),
),
SizedBox(height: spentOnly ? 32 : 14),
metricsBlock(alignEnd: false),
], ],
); );
} }
@@ -354,18 +394,21 @@ class _BudgetBody extends StatelessWidget {
required this.currency, required this.currency,
required this.transactions, required this.transactions,
required this.meta, required this.meta,
this.precomputed,
}); });
final HomeFilterWidget widgetConfig; final HomeFilterWidget widgetConfig;
final String currency; final String currency;
final List<BankTransaction> transactions; final List<BankTransaction> transactions;
final TxMetaController meta; final TxMetaController meta;
final HomeBudgetMetrics? precomputed;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colors = theme.colorScheme; final colors = theme.colorScheme;
final budget = computeHomeBudgetMetrics( final budget = precomputed ??
computeHomeBudgetMetrics(
all: transactions, all: transactions,
meta: meta, meta: meta,
widget: widgetConfig, widget: widgetConfig,
@@ -0,0 +1,83 @@
/// PocketBase migration: `tx_deletes` — synced tombstones for deleted admin
/// test transactions so another device cannot push them back into `transactions`.
///
/// Apply on the server:
///
/// sudo cp this_file /opt/pocketbase/pb_migrations/
/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js
/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up
/// sudo systemctl restart pocketbase
migrate((app) => {
const users = app.findCollectionByNameOrId("usersapp");
try {
app.findCollectionByNameOrId("tx_deletes");
return;
} catch (_) {}
const ownerRules = {
listRule: "user = @request.auth.id",
viewRule: "user = @request.auth.id",
createRule: "@request.auth.id != '' && user = @request.auth.id",
updateRule: "user = @request.auth.id",
deleteRule: "user = @request.auth.id",
};
const txDeletes = new Collection({
type: "base",
name: "tx_deletes",
...ownerRules,
fields: [
{
type: "relation",
name: "user",
required: true,
collectionId: users.id,
cascadeDelete: true,
maxSelect: 1,
},
{
// Clipped BankTransaction.id
type: "text",
name: "tx_id",
required: true,
min: 1,
max: 2000,
},
{
type: "text",
name: "date",
required: true,
min: 1,
max: 32,
},
{
type: "text",
name: "description",
required: true,
min: 1,
max: 4000,
},
{
type: "number",
name: "amount",
required: true,
},
{
type: "text",
name: "currency",
required: true,
min: 1,
max: 8,
},
],
indexes: [
"CREATE UNIQUE INDEX idx_tx_deletes_user_tx ON tx_deletes (user, tx_id)",
],
});
app.save(txDeletes);
}, (app) => {
try {
app.delete(app.findCollectionByNameOrId("tx_deletes"));
} catch (_) {}
});
@@ -0,0 +1,48 @@
/// PocketBase migration: `updated_at` + `deleted` on overview favourites and
/// home filter widgets for cross-device last-write-wins sync.
///
/// Apply on the server:
///
/// sudo cp this_file /opt/pocketbase/pb_migrations/
/// sudo chown pocketbase:pocketbase /opt/pocketbase/pb_migrations/*.js
/// sudo -u pocketbase /opt/pocketbase/pocketbase migrate up
/// sudo systemctl restart pocketbase
migrate((app) => {
function addSyncFields(collectionName) {
const col = app.findCollectionByNameOrId(collectionName);
let changed = false;
if (!col.fields.getByName("updated_at")) {
col.fields.add(new Field({
type: "number",
name: "updated_at",
required: false,
}));
changed = true;
}
if (!col.fields.getByName("deleted")) {
col.fields.add(new Field({
type: "bool",
name: "deleted",
required: false,
}));
changed = true;
}
if (changed) app.save(col);
}
addSyncFields("overview_favourites");
addSyncFields("home_filter_widgets");
}, (app) => {
function removeSyncFields(collectionName) {
try {
const col = app.findCollectionByNameOrId(collectionName);
const updated = col.fields.getByName("updated_at");
if (updated) col.fields.removeById(updated.id);
const deleted = col.fields.getByName("deleted");
if (deleted) col.fields.removeById(deleted.id);
app.save(col);
} catch (_) {}
}
removeSyncFields("overview_favourites");
removeSyncFields("home_filter_widgets");
});

Some files were not shown because too many files have changed in this diff Show More