13 Commits
Author SHA1 Message Date
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
davmar 00ab05eefc attempt to fix test txs 2026-09-10 23:59:00 +02:00
davmar f8e293f823 admin user able to create test txs 2026-09-10 23:18:24 +02:00
davmar c4f0ca785e user admin capability 2026-09-10 22:58:52 +02:00
davmar 6cc0de8945 fixed some issues and added label and, or capability 2026-09-10 22:45:25 +02:00
120 changed files with 5523 additions and 687 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
+8
View File
@@ -95,9 +95,17 @@ Migrations live in [`pocketbase/pb_migrations/`](pocketbase/pb_migrations/). The
| `1740000003` | `overview_favourites` | | `1740000003` | `overview_favourites` |
| `1740000004`–`0006` | `home_filter_widgets` (+ half width, budget fields) | | `1740000004`–`0006` | `home_filter_widgets` (+ half width, budget fields) |
| `1740000007` | `app_prefs` (Home section toggles + Look ahead years) | | `1740000007` | `app_prefs` (Home section toggles + Look ahead years) |
| `1740000008` | `home_filter_widgets.label_match` (AND/OR) |
| `1740000009` | `usersapp.is_admin` (admin accounts) |
Built-in labels stay in the app; only **custom** labels sync. Rows are scoped per user (`user = @request.auth.id`). Built-in labels stay in the app; only **custom** labels sync. Rows are scoped per user (`user = @request.auth.id`).
### Admin accounts
Migration `1740000009` adds `is_admin` (bool) on **`usersapp`**. In PocketBase Admin → **Users** → edit a user → enable **is_admin**.
Admins see an **Admin** badge on Account and can **Create test transaction** (saved locally and synced with bank transactions). Normal users do not get that action. Re-sign-in after flipping the flag so the app picks it up.
### Apply migrations (typical Linux host) ### Apply migrations (typical Linux host)
```bash ```bash
+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.
}
}
+51
View File
@@ -88,6 +88,7 @@ class BankTransaction {
required this.amount, required this.amount,
required this.currency, required this.currency,
required this.pending, required this.pending,
this.isManual = false,
}); });
final String date; final String date;
@@ -96,6 +97,9 @@ class BankTransaction {
final String currency; final String currency;
final bool pending; final bool pending;
/// Admin-created test row (editable / deletable); not from bank feed or CSV.
final bool isManual;
/// Stable local key used for per-occurrence annotations. /// Stable local key used for per-occurrence annotations.
String get id => '$date|$currency|$amount|$description'; String get id => '$date|$currency|$amount|$description';
@@ -109,6 +113,50 @@ class BankTransaction {
/// Exact match for copying notes (same description + amount + currency). /// Exact match for copying notes (same description + amount + currency).
String get exactKey => '$descriptionKey|$amount|$currency'; String get exactKey => '$descriptionKey|$amount|$currency';
static String stripManualStamp(String description) =>
description.replaceFirst(RegExp(r' ·#\d+$'), '');
/// True when [other] is the same row for delete/tombstone matching.
///
/// Uses id first, then date + cents + currency + description (stamp-insensitive),
/// so PocketBase float/amount rehydration cannot bypass a local delete.
bool matchesForDelete(BankTransaction other) {
if (id == other.id) return true;
if (date != other.date) return false;
if (currency.toUpperCase() != other.currency.toUpperCase()) return false;
if ((amount * 100).round() != (other.amount * 100).round()) return false;
if (descriptionKey == other.descriptionKey) return true;
return stripManualStamp(description).trim().toLowerCase() ==
stripManualStamp(other.description).trim().toLowerCase();
}
BankTransaction copyWith({
String? date,
String? description,
double? amount,
String? currency,
bool? pending,
bool? isManual,
}) {
return BankTransaction(
date: date ?? this.date,
description: description ?? this.description,
amount: amount ?? this.amount,
currency: currency ?? this.currency,
pending: pending ?? this.pending,
isManual: isManual ?? this.isManual,
);
}
Map<String, dynamic> toJson() => {
'date': date,
'description': description,
'amount': amount,
'currency': currency,
'pending': pending,
if (isManual) 'isManual': true,
};
factory BankTransaction.fromJson(Map<String, dynamic> json) { factory BankTransaction.fromJson(Map<String, dynamic> json) {
final rawAmount = json['amount']; final rawAmount = json['amount'];
final amount = switch (rawAmount) { final amount = switch (rawAmount) {
@@ -122,6 +170,9 @@ class BankTransaction {
amount: amount, amount: amount,
currency: json['currency'] as String? ?? 'EUR', currency: json['currency'] as String? ?? 'EUR',
pending: json['pending'] as bool? ?? false, pending: json['pending'] as bool? ?? false,
isManual: json['isManual'] as bool? ??
json['is_manual'] as bool? ??
false,
); );
} }
} }
+47 -5
View File
@@ -86,6 +86,7 @@ class HomeFilterWidget {
this.budgetFixedAmount = 0, this.budgetFixedAmount = 0,
this.budgetAverageMonths = 3, this.budgetAverageMonths = 3,
this.labelIds = const [], this.labelIds = const [],
this.labelMatch = LabelMatchMode.and,
this.flow = TxFlowFilter.all, this.flow = TxFlowFilter.all,
this.amountRange = const TxAmountRange(), this.amountRange = const TxAmountRange(),
this.query = '', this.query = '',
@@ -93,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;
@@ -115,6 +118,10 @@ class HomeFilterWidget {
final int budgetAverageMonths; final int budgetAverageMonths;
final List<String> labelIds; final List<String> labelIds;
/// AND vs OR when multiple [labelIds] are selected.
final LabelMatchMode labelMatch;
final TxFlowFilter flow; final TxFlowFilter flow;
final TxAmountRange amountRange; final TxAmountRange amountRange;
final String query; final String query;
@@ -124,10 +131,17 @@ 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(
labelIds: labelIds.toSet(), labelIds: labelIds.toSet(),
labelMatch: labelMatch,
flow: flow, flow: flow,
amountRange: amountRange, amountRange: amountRange,
query: query, query: query,
@@ -147,6 +161,7 @@ class HomeFilterWidget {
double? budgetFixedAmount, double? budgetFixedAmount,
int? budgetAverageMonths, int? budgetAverageMonths,
List<String>? labelIds, List<String>? labelIds,
LabelMatchMode? labelMatch,
TxFlowFilter? flow, TxFlowFilter? flow,
TxAmountRange? amountRange, TxAmountRange? amountRange,
String? query, String? query,
@@ -154,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,
@@ -168,6 +186,7 @@ class HomeFilterWidget {
budgetFixedAmount: budgetFixedAmount ?? this.budgetFixedAmount, budgetFixedAmount: budgetFixedAmount ?? this.budgetFixedAmount,
budgetAverageMonths: budgetAverageMonths ?? this.budgetAverageMonths, budgetAverageMonths: budgetAverageMonths ?? this.budgetAverageMonths,
labelIds: labelIds ?? this.labelIds, labelIds: labelIds ?? this.labelIds,
labelMatch: labelMatch ?? this.labelMatch,
flow: flow ?? this.flow, flow: flow ?? this.flow,
amountRange: amountRange ?? this.amountRange, amountRange: amountRange ?? this.amountRange,
query: query ?? this.query, query: query ?? this.query,
@@ -175,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,
); );
} }
@@ -192,6 +215,7 @@ class HomeFilterWidget {
display: display, display: display,
sortOrder: sortOrder, sortOrder: sortOrder,
labelIds: filter.labelIds.toList(growable: false), labelIds: filter.labelIds.toList(growable: false),
labelMatch: filter.labelMatch,
flow: filter.flow, flow: filter.flow,
amountRange: filter.amountRange, amountRange: filter.amountRange,
query: filter.query, query: filter.query,
@@ -199,6 +223,7 @@ class HomeFilterWidget {
showAmountIn: showIn, showAmountIn: showIn,
showAmountOut: showOut, showAmountOut: showOut,
showCount: true, showCount: true,
updatedAt: DateTime.now().millisecondsSinceEpoch,
); );
} }
@@ -215,6 +240,7 @@ class HomeFilterWidget {
'budgetFixedAmount': budgetFixedAmount, 'budgetFixedAmount': budgetFixedAmount,
'budgetAverageMonths': budgetAverageMonths, 'budgetAverageMonths': budgetAverageMonths,
'labelIds': labelIds, 'labelIds': labelIds,
'labelMatch': labelMatch.name,
'flow': flow.name, 'flow': flow.name,
'amountRange': { 'amountRange': {
if (amountRange.exact != null) 'exact': amountRange.exact, if (amountRange.exact != null) 'exact': amountRange.exact,
@@ -226,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) {
@@ -274,6 +302,10 @@ class HomeFilterWidget {
for (final id in (json['labelIds'] as List? ?? const [])) for (final id in (json['labelIds'] as List? ?? const []))
if (id is String && id.isNotEmpty) id, if (id is String && id.isNotEmpty) id,
], ],
labelMatch: LabelMatchMode.values.firstWhere(
(e) => e.name == json['labelMatch'],
orElse: () => LabelMatchMode.and,
),
flow: TxFlowFilter.values.firstWhere( flow: TxFlowFilter.values.firstWhere(
(e) => e.name == json['flow'], (e) => e.name == json['flow'],
orElse: () => TxFlowFilter.all, orElse: () => TxFlowFilter.all,
@@ -288,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);
}
+9
View File
@@ -134,6 +134,15 @@ int labelVisibleDepth(
/// Filter-only id for txs with no labels (not an assignable category). /// Filter-only id for txs with no labels (not an assignable category).
const String kUncategorizedFilterId = '__uncategorized__'; const String kUncategorizedFilterId = '__uncategorized__';
/// How multiple selected labels combine in a transaction filter.
enum LabelMatchMode {
/// Tx must include every selected label.
and,
/// Tx must include at least one selected label.
or,
}
/// Chip presentation for [kUncategorizedFilterId] refine filters. /// Chip presentation for [kUncategorizedFilterId] refine filters.
const TxLabel kUncategorizedFilterLabel = TxLabel( const TxLabel kUncategorizedFilterLabel = TxLabel(
id: kUncategorizedFilterId, id: kUncategorizedFilterId,
+18
View File
@@ -26,6 +26,13 @@ class AuthController extends ChangeNotifier {
RecordModel? get user => _client?.authStore.record; RecordModel? get user => _client?.authStore.record;
/// True when PocketBase `usersapp.is_admin` is set for this account.
bool get isAdmin {
final record = user;
if (record == null) return false;
return record.getBoolValue('is_admin');
}
String? get email { String? get email {
final value = user?.getStringValue('email'); final value = user?.getStringValue('email');
return value == null || value.isEmpty ? null : value; return value == null || value.isEmpty ? null : value;
@@ -80,6 +87,17 @@ class AuthController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Refresh the auth record (e.g. after `is_admin` was toggled in Admin UI).
Future<void> refreshSession() async {
if (!isLoggedIn) return;
try {
await client.collection(kPocketBaseUsersCollection).authRefresh();
} catch (error, stackTrace) {
debugPrint('AuthController.refreshSession failed: $error\n$stackTrace');
}
notifyListeners();
}
Future<void> logout() async { Future<void> logout() async {
client.authStore.clear(); client.authStore.clear();
notifyListeners(); notifyListeners();
+287 -3
View File
@@ -1,6 +1,8 @@
/// [ChangeNotifier] for bank link status, balance, and transactions. /// [ChangeNotifier] for bank link status, balance, and transactions.
library; library;
import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/bank.dart'; import '../models/bank.dart';
@@ -30,9 +32,34 @@ class BankController extends ChangeNotifier {
_remoteTransactions = cached.transactions; _remoteTransactions = cached.transactions;
} }
_importedTransactions = _storage?.loadImportedTransactions() ?? const []; _importedTransactions = _storage?.loadImportedTransactions() ?? const [];
_importedTransactions = [
for (final tx in _importedTransactions) _upgradeLegacyManual(tx),
];
_remoteTransactions = [
for (final tx in _remoteTransactions) _upgradeLegacyManual(tx),
];
_pendingRemoteDeletes = _storage?.loadPendingTxDeletes() ?? const [];
// Drop anything still queued for remote delete from the local caches.
if (_pendingRemoteDeletes.isNotEmpty) {
_remoteTransactions = [
for (final tx in _remoteTransactions)
if (!_isTombstoned(tx)) tx,
];
_importedTransactions = [
for (final tx in _importedTransactions)
if (!_isTombstoned(tx)) tx,
];
}
_invalidateMerged(); _invalidateMerged();
} }
/// Older admin test rows used a ·#stamp suffix before [isManual] existed.
static BankTransaction _upgradeLegacyManual(BankTransaction tx) {
if (tx.isManual) return tx;
if (!RegExp(r' ·#\d+$').hasMatch(tx.description)) return tx;
return tx.copyWith(isManual: true);
}
final IngtrakApi _api; final IngtrakApi _api;
final StorageService? _storage; final StorageService? _storage;
@@ -42,10 +69,116 @@ class BankController extends ChangeNotifier {
/// cannot leave the device behind the cloud (or wipe cloud-only history). /// cannot leave the device behind the cloud (or wipe cloud-only history).
VoidCallback? onAfterSuccessfulLoad; VoidCallback? onAfterSuccessfulLoad;
/// Fired when local admin txs are removed/replaced and need a remote delete.
VoidCallback? onPendingRemoteDeletesChanged;
ConnectStatus _status = ConnectStatus.disconnected; ConnectStatus _status = ConnectStatus.disconnected;
BankBalance? _balance; BankBalance? _balance;
List<BankTransaction> _remoteTransactions = const []; List<BankTransaction> _remoteTransactions = const [];
List<BankTransaction> _importedTransactions = const []; List<BankTransaction> _importedTransactions = const [];
List<BankTransaction> _pendingRemoteDeletes = const [];
/// Tombstoned txs waiting to be removed from PocketBase (also persisted).
List<BankTransaction> get pendingRemoteDeletes =>
List<BankTransaction>.unmodifiable(_pendingRemoteDeletes);
bool get hasPendingRemoteDeletes => _pendingRemoteDeletes.isNotEmpty;
bool _isTombstoned(BankTransaction tx) {
for (final tomb in _pendingRemoteDeletes) {
if (tx.matchesForDelete(tomb)) return true;
}
return false;
}
void requeueRemoteDeletes(Iterable<BankTransaction> txs) {
for (final tx in txs) {
_queueRemoteDelete(tx, notify: false);
}
unawaited(_persistPendingDeletes());
}
void _queueRemoteDelete(BankTransaction tx, {bool notify = true}) {
if (tx.id.isEmpty) return;
if (_isTombstoned(tx)) return;
_pendingRemoteDeletes = [
..._pendingRemoteDeletes,
tx.copyWith(isManual: true),
];
unawaited(_persistPendingDeletes());
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 {
final storage = _storage;
if (storage == null) return;
await storage.savePendingTxDeletes(_pendingRemoteDeletes);
}
/// Keep [isManual] when a synced/API row replaces a matching imported test tx.
void _reapplyManualFlags(Set<String> manualIds) {
if (manualIds.isEmpty) return;
BankTransaction stamp(BankTransaction tx) =>
manualIds.contains(tx.id) && !tx.isManual
? tx.copyWith(isManual: true)
: tx;
_remoteTransactions = [for (final tx in _remoteTransactions) stamp(tx)];
_importedTransactions = [
for (final tx in _importedTransactions) stamp(tx),
];
}
Set<String> _collectManualIds([
Iterable<BankTransaction> extra = const [],
]) =>
{
for (final tx in [
..._remoteTransactions,
..._importedTransactions,
...extra,
])
if (tx.isManual) tx.id,
};
List<BankTransaction>? _mergedTransactions; List<BankTransaction>? _mergedTransactions;
bool _isLoading = false; bool _isLoading = false;
@@ -161,6 +294,117 @@ class BankController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Insert a locally created transaction (admin testing). Persisted like CSV
/// imports and included in PocketBase `transactions` sync.
Future<BankTransaction> addManualTransaction({
required DateTime date,
required String description,
required double amount,
String currency = 'EUR',
bool pending = false,
}) async {
final desc =
description.trim().isEmpty ? 'Test transaction' : description.trim();
// Include a short unique suffix so identical drafts do not collide on id.
final stamp = DateTime.now().millisecondsSinceEpoch % 100000;
final tx = BankTransaction(
date:
'${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}',
description: '$desc ·#$stamp',
amount: amount,
currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(),
pending: pending,
isManual: true,
);
_importedTransactions = mergeTransactions(_importedTransactions, [tx]);
_importedTransactions = filterNewTransactions(
_remoteTransactions,
_importedTransactions,
);
_invalidateMerged();
await _persistImported();
notifyListeners();
return tx;
}
/// Replace an admin test transaction. Returns the updated row (id may change).
Future<BankTransaction> updateManualTransaction({
required BankTransaction previous,
required DateTime date,
required String description,
required double amount,
String currency = 'EUR',
bool pending = false,
}) async {
if (!previous.isManual) {
throw StateError('Only admin test transactions can be edited');
}
final desc =
description.trim().isEmpty ? 'Test transaction' : description.trim();
final stamped = () {
if (RegExp(r' ·#\d+$').hasMatch(desc)) return desc;
final prevStamp = RegExp(r' ·#(\d+)$').firstMatch(previous.description);
final prevBase =
previous.description.replaceFirst(RegExp(r' ·#\d+$'), '');
if (prevStamp != null && prevBase == desc) {
return '$desc ·#${prevStamp.group(1)}';
}
return '$desc ·#${DateTime.now().millisecondsSinceEpoch % 100000}';
}();
final updated = BankTransaction(
date:
'${date.year.toString().padLeft(4, '0')}-'
'${date.month.toString().padLeft(2, '0')}-'
'${date.day.toString().padLeft(2, '0')}',
description: stamped,
amount: amount,
currency: currency.trim().isEmpty ? 'EUR' : currency.trim().toUpperCase(),
pending: pending,
isManual: true,
);
_importedTransactions = [
for (final tx in _importedTransactions)
if (tx.id != previous.id) tx,
];
_remoteTransactions = [
for (final tx in _remoteTransactions)
if (tx.id != previous.id) tx,
];
if (previous.id != updated.id) {
_queueRemoteDelete(previous);
}
_importedTransactions = mergeTransactions(_importedTransactions, [updated]);
_invalidateMerged();
await _persistImported();
await _persistCache();
notifyListeners();
return updated;
}
/// Remove an admin test transaction from local caches.
Future<void> deleteManualTransaction(BankTransaction tx) async {
if (!tx.isManual) {
throw StateError('Only admin test transactions can be deleted');
}
_importedTransactions = [
for (final row in _importedTransactions)
if (!row.matchesForDelete(tx)) row,
];
_remoteTransactions = [
for (final row in _remoteTransactions)
if (!row.matchesForDelete(tx)) row,
];
_queueRemoteDelete(tx);
_invalidateMerged();
await _persistImported();
await _persistCache();
notifyListeners();
}
/// Union cloud txs into the local bank cache without dropping either side. /// Union cloud txs into the local bank cache without dropping either side.
/// ///
/// Rows land in the API cache; CSV imports that match are deduped away. /// Rows land in the API cache; CSV imports that match are deduped away.
@@ -168,13 +412,39 @@ class BankController extends ChangeNotifier {
List<BankTransaction> fromCloud, { List<BankTransaction> fromCloud, {
bool notify = true, bool notify = true,
}) async { }) async {
if (fromCloud.isEmpty) return; if (fromCloud.isEmpty && _pendingRemoteDeletes.isEmpty) return;
_remoteTransactions = mergeTransactions(_remoteTransactions, fromCloud); // Never resurrect rows the user already deleted locally.
final incoming = [
for (final tx in fromCloud)
if (!_isTombstoned(tx)) _upgradeLegacyManual(tx),
];
if (incoming.isEmpty && _pendingRemoteDeletes.isEmpty) return;
final manuals = _collectManualIds(incoming);
if (incoming.isNotEmpty) {
_remoteTransactions = dropSettledPendingDuplicates(
mergeTransactions(_remoteTransactions, incoming),
);
}
// Strip any local copies that match a pending delete (defensive).
if (_pendingRemoteDeletes.isNotEmpty) {
_remoteTransactions = [
for (final tx in _remoteTransactions)
if (!_isTombstoned(tx)) tx,
];
}
_importedTransactions = filterNewTransactions( _importedTransactions = filterNewTransactions(
_remoteTransactions, _remoteTransactions,
_importedTransactions, _importedTransactions,
); );
if (_pendingRemoteDeletes.isNotEmpty) {
_importedTransactions = [
for (final tx in _importedTransactions)
if (!_isTombstoned(tx)) tx,
];
}
_reapplyManualFlags(manuals);
_invalidateMerged(); _invalidateMerged();
if (!_status.linked && _remoteTransactions.isNotEmpty) { if (!_status.linked && _remoteTransactions.isNotEmpty) {
@@ -254,7 +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.
_remoteTransactions = mergeTransactions(apiTxs, _remoteTransactions); // Pending rows are not kept from cache — they must still be on the feed.
final manuals = _collectManualIds();
_remoteTransactions =
mergeApiTransactionsWithCache(apiTxs, _remoteTransactions);
if (!_status.linked) { if (!_status.linked) {
_status = ConnectStatus( _status = ConnectStatus(
status: 'linked', status: 'linked',
@@ -269,6 +542,17 @@ class BankController extends ChangeNotifier {
_remoteTransactions, _remoteTransactions,
_importedTransactions, _importedTransactions,
); );
if (_pendingRemoteDeletes.isNotEmpty) {
_remoteTransactions = [
for (final tx in _remoteTransactions)
if (!_isTombstoned(tx)) tx,
];
_importedTransactions = [
for (final tx in _importedTransactions)
if (!_isTombstoned(tx)) tx,
];
}
_reapplyManualFlags(manuals);
_invalidateMerged(); _invalidateMerged();
await _persistImported(); await _persistImported();
_error = null; _error = null;
+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();
} }
+101 -12
View File
@@ -6,6 +6,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../models/app_cloud_prefs.dart'; import '../models/app_cloud_prefs.dart';
import '../models/bank.dart';
import '../models/home_filter_widget.dart'; import '../models/home_filter_widget.dart';
import '../models/overview_favourite.dart'; import '../models/overview_favourite.dart';
import '../services/pb_sync_service.dart'; import '../services/pb_sync_service.dart';
@@ -46,6 +47,7 @@ class SyncController extends ChangeNotifier {
_prefs.onAppPrefsPersisted = _scheduleAppPrefsPush; _prefs.onAppPrefsPersisted = _scheduleAppPrefsPush;
_auth.addListener(_onAuthChanged); _auth.addListener(_onAuthChanged);
_bank.onAfterSuccessfulLoad = _onBankDataLoaded; _bank.onAfterSuccessfulLoad = _onBankDataLoaded;
_bank.onPendingRemoteDeletesChanged = _scheduleBankTxPush;
if (_auth.isLoggedIn) { if (_auth.isLoggedIn) {
unawaited(syncNow()); unawaited(syncNow());
} }
@@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier {
bool _pushFavouritesPending = false; bool _pushFavouritesPending = false;
bool _pushHomeWidgetsPending = false; bool _pushHomeWidgetsPending = false;
bool _pushAppPrefsPending = false; bool _pushAppPrefsPending = false;
bool _pushBankPending = false;
Timer? _pushDebounce; Timer? _pushDebounce;
Timer? _progressHold; Timer? _progressHold;
Timer? _uiNotifyThrottle; Timer? _uiNotifyThrottle;
@@ -198,6 +201,7 @@ class SyncController extends ChangeNotifier {
_pushFavouritesPending = false; _pushFavouritesPending = false;
_pushHomeWidgetsPending = false; _pushHomeWidgetsPending = false;
_pushAppPrefsPending = false; _pushAppPrefsPending = false;
_pushBankPending = false;
_lastError = null; _lastError = null;
_statusDetail = ''; _statusDetail = '';
_showProgressRing = false; _showProgressRing = false;
@@ -265,6 +269,47 @@ class SyncController extends ChangeNotifier {
_armPushDebounce(); _armPushDebounce();
} }
void _scheduleBankTxPush() {
_pushBankPending = true;
_armPushDebounce();
}
/// 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 {
final pending = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
if (pending.isEmpty) return;
_setStatus('Removing deleted bank txs…');
try {
await service.deleteTransactionsMatching(pending);
// Keep tombstones — they must remain until every device has seen them.
} catch (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) {
@@ -297,6 +342,9 @@ class SyncController extends ChangeNotifier {
try { try {
final service = PbSyncService(_auth.client); final service = PbSyncService(_auth.client);
// Learn deletes from other devices before merging bank txs / pushing.
await _pullAndApplyTxDeletes(service);
await _flushPendingTxDeletes(service);
final remoteMeta = await service.pull( final remoteMeta = await service.pull(
onProgress: (p) { onProgress: (p) {
_setProgress(p * 0.35); _setProgress(p * 0.35);
@@ -324,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 [];
@@ -338,7 +386,7 @@ class SyncController extends ChangeNotifier {
); );
} }
final mergedHomeWidgets = mergeHomeFilterWidgets( final mergedHomeWidgets = mergeHomeFilterWidgets(
_prefs.homeFilterWidgets, _prefs.homeFilterWidgetsForSync,
remoteHomeWidgets, remoteHomeWidgets,
); );
AppCloudPrefs? remoteAppPrefs; AppCloudPrefs? remoteAppPrefs;
@@ -408,8 +456,14 @@ class SyncController extends ChangeNotifier {
} catch (error) { } catch (error) {
debugPrint('SyncController: app_prefs push skipped ($error)'); debugPrint('SyncController: app_prefs push skipped ($error)');
} }
// Retry deletes in case any were queued during the pull/merge window.
await _flushPendingTxDeletes(service);
await _pushTxDeletes(service);
_pushBankPending = false;
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
final bankSummary = await service.pushTransactions( final bankSummary = await service.pushTransactions(
_bank.transactions, _bank.transactions,
deleteTombs: tombs,
progressStart: 0.82, progressStart: 0.82,
progressEnd: 1.0, progressEnd: 1.0,
onProgress: _setProgress, onProgress: _setProgress,
@@ -454,11 +508,17 @@ class SyncController extends ChangeNotifier {
final pushFavourites = _pushFavouritesPending; final pushFavourites = _pushFavouritesPending;
final pushHomeWidgets = _pushHomeWidgetsPending; final pushHomeWidgets = _pushHomeWidgetsPending;
final pushAppPrefs = _pushAppPrefsPending; final pushAppPrefs = _pushAppPrefsPending;
final pushBank = _pushBankPending || _bank.pendingRemoteDeletes.isNotEmpty;
_pushMetaPending = false; _pushMetaPending = false;
_pushFavouritesPending = false; _pushFavouritesPending = false;
_pushHomeWidgetsPending = false; _pushHomeWidgetsPending = false;
_pushAppPrefsPending = false; _pushAppPrefsPending = false;
if (!pushMeta && !pushFavourites && !pushHomeWidgets && !pushAppPrefs) { _pushBankPending = false;
if (!pushMeta &&
!pushFavourites &&
!pushHomeWidgets &&
!pushAppPrefs &&
!pushBank) {
return; return;
} }
@@ -471,13 +531,15 @@ class SyncController extends ChangeNotifier {
pushFavourites, pushFavourites,
pushHomeWidgets, pushHomeWidgets,
pushAppPrefs, pushAppPrefs,
pushBank,
)) { )) {
(false, false, false, true) => 'App prefs', (false, false, false, false, true) => 'Bank txs',
(false, false, true, false) => 'Home widgets', (false, false, false, true, false) => 'App prefs',
(false, false, true, true) => 'Home layout', (false, false, true, false, false) => 'Home widgets',
(false, true, false, false) => 'Favourites', (false, false, true, true, _) => 'Home layout',
(false, true, true, _) => 'Home layout', (false, true, false, false, false) => 'Favourites',
(true, false, false, false) => 'Annotations', (false, true, true, _, _) => 'Home layout',
(true, false, false, false, false) => 'Annotations',
_ => 'Changes', _ => 'Changes',
}; };
_statusDetail = 'Pushing $label…'; _statusDetail = 'Pushing $label…';
@@ -491,7 +553,8 @@ class SyncController extends ChangeNotifier {
final total = (pushMeta ? 1 : 0) + final total = (pushMeta ? 1 : 0) +
(pushFavourites ? 1 : 0) + (pushFavourites ? 1 : 0) +
(pushHomeWidgets ? 1 : 0) + (pushHomeWidgets ? 1 : 0) +
(pushAppPrefs ? 1 : 0); (pushAppPrefs ? 1 : 0) +
(pushBank ? 1 : 0);
void bump() { void bump() {
done++; done++;
_setProgress(done / total); _setProgress(done / total);
@@ -513,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');
@@ -521,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();
} }
@@ -528,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');
@@ -536,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();
} }
@@ -555,6 +620,29 @@ class SyncController extends ChangeNotifier {
} }
bump(); bump();
} }
if (pushBank) {
_setStatus('Updating bank txs…');
// Other devices may have deleted test txs — learn that before push.
await _pullAndApplyTxDeletes(service);
await _flushPendingTxDeletes(service);
await _pushTxDeletes(service);
try {
final tombs = List<BankTransaction>.of(_bank.pendingRemoteDeletes);
final summary = await service.pushTransactions(
_bank.transactions,
deleteTombs: tombs,
progressStart: done / total,
progressEnd: (done + 1) / total,
onProgress: _setProgress,
onStatus: _setStatus,
);
parts.add(summary);
} catch (error) {
debugPrint('SyncController: bank tx push skipped ($error)');
_pushBankPending = true;
}
bump();
}
_setProgress(1.0); _setProgress(1.0);
_lastSyncedAt = DateTime.now(); _lastSyncedAt = DateTime.now();
@@ -588,6 +676,7 @@ class SyncController extends ChangeNotifier {
_uiNotifyThrottle?.cancel(); _uiNotifyThrottle?.cancel();
_auth.removeListener(_onAuthChanged); _auth.removeListener(_onAuthChanged);
_bank.onAfterSuccessfulLoad = null; _bank.onAfterSuccessfulLoad = null;
_bank.onPendingRemoteDeletesChanged = null;
_meta.onPersisted = null; _meta.onPersisted = null;
_prefs.onFavouritesPersisted = null; _prefs.onFavouritesPersisted = null;
_prefs.onHomeWidgetsPersisted = null; _prefs.onHomeWidgetsPersisted = null;
+51 -9
View File
@@ -722,6 +722,25 @@ class TxMetaController extends ChangeNotifier {
_schedulePersist(invalidateLabels: false); _schedulePersist(invalidateLabels: false);
} }
/// Move per-tx annotations when an admin test transaction's id changes.
Future<void> rekeyTransaction(String fromId, String toId) async {
if (fromId == toId || fromId.isEmpty || toId.isEmpty) return;
final stored = _byId.remove(fromId);
if (stored != null) {
_byId[toId] = stored;
}
_labelIdsByTxCache = null;
_schedulePersist(invalidateLabels: true);
}
/// Drop per-tx annotations for a deleted admin test transaction.
Future<void> clearTransactionMeta(String txId) async {
if (txId.isEmpty) return;
if (_byId.remove(txId) == null) return;
_labelIdsByTxCache = null;
_schedulePersist(invalidateLabels: true);
}
Future<void> setNotes( Future<void> setNotes(
BankTransaction tx, BankTransaction tx,
String notes, { String notes, {
@@ -800,6 +819,7 @@ class TxMetaController extends ChangeNotifier {
List<BankTransaction> txs, List<BankTransaction> txs,
String query, { String query, {
Set<String> labelIds = const {}, Set<String> labelIds = const {},
LabelMatchMode labelMatch = LabelMatchMode.and,
}) { }) {
final needle = query.trim().toLowerCase(); final needle = query.trim().toLowerCase();
final hasText = needle.isNotEmpty; final hasText = needle.isNotEmpty;
@@ -809,7 +829,15 @@ class TxMetaController extends ChangeNotifier {
final labelIndex = _labelById; final labelIndex = _labelById;
return [ return [
for (final tx in txs) for (final tx in txs)
if (_matchesSearch(tx, needle, hasText, labelIds, hasLabels, labelIndex)) if (_matchesSearch(
tx,
needle,
hasText,
labelIds,
hasLabels,
labelMatch,
labelIndex,
))
tx, tx,
]; ];
} }
@@ -820,10 +848,11 @@ class TxMetaController extends ChangeNotifier {
bool hasText, bool hasText,
Set<String> labelIds, Set<String> labelIds,
bool hasLabels, bool hasLabels,
LabelMatchMode labelMatch,
Map<String, TxLabel> labelIndex, Map<String, TxLabel> labelIndex,
) { ) {
final meta = metaFor(tx); final meta = metaFor(tx);
if (hasLabels && !_matchesLabelFilter(meta, labelIds)) { if (hasLabels && !_matchesLabelFilter(meta, labelIds, labelMatch)) {
return false; return false;
} }
if (!hasText) return true; if (!hasText) return true;
@@ -838,20 +867,33 @@ class TxMetaController extends ChangeNotifier {
return false; return false;
} }
/// AND filter: every selected label must be on the tx. /// AND: every selected label must be on the tx.
/// OR: at least one selected label (or unlabeled, if that filter is set).
/// ///
/// Uncategorized alone matches unlabeled txs. Uncategorized combined with /// Uncategorized alone matches unlabeled txs. Uncategorized + others with
/// other labels matches nothing (a tx cannot be both). /// AND matches nothing; with OR matches unlabeled or any of the labels.
bool _matchesLabelFilter(TxMeta meta, Set<String> labelIds) { bool _matchesLabelFilter(
TxMeta meta,
Set<String> labelIds,
LabelMatchMode mode,
) {
final wantUncategorized = labelIds.contains(kUncategorizedFilterId); final wantUncategorized = labelIds.contains(kUncategorizedFilterId);
final required = <String>[ final required = <String>[
for (final id in labelIds) for (final id in labelIds)
if (id != kUncategorizedFilterId) id, if (id != kUncategorizedFilterId) id,
]; ];
if (wantUncategorized) { if (required.isEmpty) {
if (required.isNotEmpty) return false; if (wantUncategorized) return !meta.hasLabel;
return !meta.hasLabel; return true;
} }
if (mode == LabelMatchMode.or) {
if (wantUncategorized && !meta.hasLabel) return true;
for (final id in required) {
if (meta.labelIds.contains(id)) return true;
}
return false;
}
if (wantUncategorized) return false;
for (final id in required) { for (final id in required) {
if (!meta.labelIds.contains(id)) return false; if (!meta.labelIds.contains(id)) return false;
} }
+89 -1
View File
@@ -1,6 +1,8 @@
/// Signed-in account details, sync status, bank link, and sign-out. /// Signed-in account details, sync status, bank link, and sign-out.
library; library;
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -10,11 +12,26 @@ import '../providers/sync_provider.dart';
import '../providers/tx_meta_provider.dart'; import '../providers/tx_meta_provider.dart';
import '../utils/connect_bank.dart'; import '../utils/connect_bank.dart';
import '../utils/constants.dart'; import '../utils/constants.dart';
import '../widgets/admin_create_transaction_dialog.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
class AccountScreen extends StatelessWidget { class AccountScreen extends StatefulWidget {
const AccountScreen({super.key}); const AccountScreen({super.key});
@override
State<AccountScreen> createState() => _AccountScreenState();
}
class _AccountScreenState extends State<AccountScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(context.read<AuthController>().refreshSession());
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final auth = context.watch<AuthController>(); final auth = context.watch<AuthController>();
@@ -62,6 +79,24 @@ class AccountScreen extends StatelessWidget {
), ),
), ),
), ),
if (auth.isAdmin)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Chip(
avatar: Icon(
Icons.shield_outlined,
size: 16,
color: colors.onSecondaryContainer,
),
label: const Text('Admin'),
visualDensity: VisualDensity.compact,
backgroundColor: colors.secondaryContainer,
labelStyle: theme.textTheme.labelMedium?.copyWith(
color: colors.onSecondaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 24), const SizedBox(height: 24),
Card( Card(
child: Column( child: Column(
@@ -91,6 +126,59 @@ class AccountScreen extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Card(
child: ListTile(
leading: Icon(
Icons.add_card_outlined,
color: auth.isAdmin ? null : colors.onSurfaceVariant,
),
title: const Text('Create test transaction'),
subtitle: Text(
auth.isAdmin
? 'Admin · saved locally and synced to PocketBase'
: 'Admin only · enable is_admin on your usersapp user in PocketBase, then sign out and back in',
),
trailing: auth.isAdmin
? const Icon(Icons.chevron_right)
: Icon(
Icons.lock_outline,
color: colors.onSurfaceVariant,
),
onTap: () async {
if (!auth.isAdmin) {
await auth.refreshSession();
if (!context.mounted) return;
if (!context.read<AuthController>().isAdmin) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Not an admin yet. In PocketBase Admin → '
'usersapp → your user → set is_admin = true, '
'then sign out and sign in again.',
),
),
);
return;
}
}
final tx = await showAdminCreateTransactionDialog(context);
if (tx == null || !context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Created “${tx.description}” · '
'${tx.amount.toStringAsFixed(2)} ${tx.currency}',
),
),
);
final sync = context.read<SyncController>();
if (sync.canSync && !sync.isSyncing) {
unawaited(sync.syncNow());
}
},
),
),
const SizedBox(height: 16),
Card( Card(
child: ListTile( child: ListTile(
leading: Icon( leading: Icon(
+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],
),
), ),
), ),
); );
+145 -37
View File
@@ -1,6 +1,8 @@
/// 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:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -9,13 +11,14 @@ import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart'; 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 '../screens/transaction_detail_screen.dart'; import '../screens/transactions_screen.dart';
import '../utils/app_theme.dart'; import '../utils/app_theme.dart';
import '../utils/helpers.dart'; import '../utils/helpers.dart';
import '../utils/look_ahead.dart'; 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 +26,13 @@ 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 LookAheadScreen extends StatefulWidget { class LookAheadScreen extends StatefulWidget {
const LookAheadScreen({super.key}); const LookAheadScreen({super.key});
@@ -35,8 +45,9 @@ 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;
@override @override
void initState() { void initState() {
@@ -129,9 +140,78 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
}); });
} }
int _itemsCacheKey(TxMetaController meta, BankController bank) =>
Object.hash(meta.storeRevision, identityHashCode(bank.transactions));
/// 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 +245,29 @@ 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: 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,
@@ -337,17 +424,6 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
), ),
], ],
), ),
const SizedBox(height: 4),
Text(
items.isEmpty
? 'Create a spending from a transaction, or tap New.'
: '${items.length} spending${items.length == 1 ? '' : 's'} · '
'projected income ${formatMoney(income, currency)} · '
'expenses ${formatMoney(expenses, currency)}.',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 10), const SizedBox(height: 10),
Row( Row(
children: [ children: [
@@ -622,6 +698,46 @@ class _TemplateTile extends StatelessWidget {
return null; return null;
} }
void _openTransactions(BuildContext context) {
final labelIds = item.labelIds.toSet();
SimilarMatchCriteria? similar;
TxFlowFilter? flow;
String? query;
if (labelIds.isEmpty) {
final tx = _sourceTx;
if (tx != null) {
// Same matching as Look ahead / apply-to-similar for this source tx.
similar = SimilarMatchCriteria(
pattern: defaultSimilarPattern(tx.description),
flow: tx.amount < 0
? TxFlowFilter.expense
: tx.amount > 0
? TxFlowFilter.income
: TxFlowFilter.all,
);
flow = similar.flow;
} else if (template != null && template!.merchantKey.isNotEmpty) {
query = template!.merchantKey;
} else {
query = item.description;
}
}
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
initialLabelIds: labelIds,
initialLabelMatch: LabelMatchMode.or,
initialQuery: query,
initialFlow: flow,
initialSimilarMatch: similar,
initialSearchInMonthOnly: false,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -658,15 +774,7 @@ class _TemplateTile extends StatelessWidget {
color: income ? AppTheme.accent : AppTheme.expense, color: income ? AppTheme.accent : AppTheme.expense,
), ),
), ),
onTap: () { onTap: () => _openTransactions(context),
final tx = _sourceTx;
if (tx == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionDetailScreen(tx: tx),
),
);
},
); );
} }
} }
+344 -107
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) ...[
@@ -628,11 +836,11 @@ class _OverviewScreenState extends State<OverviewScreen> {
currency, currency,
), ),
label: switch (_labelFlow) { label: switch (_labelFlow) {
TxFlowFilter.income => 'Labeled income · $_periodTitle', TxFlowFilter.income => 'Income · $_periodTitle',
TxFlowFilter.expense => 'Labeled spend · $_periodTitle', TxFlowFilter.expense => 'Spend · $_periodTitle',
TxFlowFilter.all => _labelInvertChart TxFlowFilter.all => _labelInvertChart
? 'Labeled net (expense view) · $_periodTitle' ? 'Net (expense view) · $_periodTitle'
: 'Labeled net · $_periodTitle', : 'Net · $_periodTitle',
}, },
), ),
], ],
@@ -739,22 +947,57 @@ 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 ??
{
// 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,
});
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionsScreen(
initialMonth: initial,
initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or,
initialFlow: flow,
initialSearchInMonthOnly: !yearly && resolvedLabelIds.isNotEmpty,
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),
initialSearchInMonthOnly: !yearly && labelIds.isNotEmpty, initialYear: yearly ? null : _cursor.year,
initialYear: yearly ? period.year : null, initialLabelIds: resolvedLabelIds,
initialLabelMatch: LabelMatchMode.or,
initialFlow: flow,
initialSearchInMonthOnly: false,
), ),
), ),
); );
@@ -1158,6 +1401,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,
}); });
@@ -1181,7 +1425,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;
@@ -1276,7 +1525,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(
@@ -1285,7 +1534,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',
@@ -1358,13 +1607,18 @@ 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),
), // Whole card opens the chart filter; nested buttons/legend keep their own taps.
child: InkWell(
onTap: onChartTap,
borderRadius: radius,
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
compact ? 12 : 14, compact ? 12 : 14,
@@ -1528,6 +1782,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,
@@ -1538,11 +1794,13 @@ class _ChartCard extends StatelessWidget {
barStyle: labelBarStyle, barStyle: labelBarStyle,
invertChart: invertChart, invertChart: invertChart,
onMonthTap: onMonthTap, onMonthTap: onMonthTap,
onChartTap: onChartTap,
), ),
), ),
], ],
), ),
), ),
),
); );
} }
} }
@@ -1553,12 +1811,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) {
@@ -1577,7 +1843,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'
@@ -1585,31 +1854,24 @@ class _FlowBars extends StatelessWidget {
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
),
); );
} }
return BarChart( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: IgnorePointer(
// fl_chart otherwise owns the gesture arena and empty taps go nowhere.
// Tap opens this chart's filters; legend / history still open columns.
child: BarChart(
BarChartData( BarChartData(
maxY: safeMax, maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData( barTouchData: const BarTouchData(enabled: false),
touchTooltipData: BarTouchTooltipData(
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final month = months[group.x.toInt()];
final bucket = monthly[_periodKey(month, period)]!;
final isIncome = rodIndex == 1;
final value = isIncome ? bucket.income : bucket.expense;
return BarTooltipItem(
'${_periodAxisLabel(month, period)}\n'
'${isIncome ? 'Income' : 'Expense'}\n'
'${formatMoney(value, currency)}',
TextStyle(color: colors.onInverseSurface),
);
},
),
),
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(
@@ -1692,6 +1954,8 @@ class _FlowBars extends StatelessWidget {
], ],
), ),
duration: Duration.zero, duration: Duration.zero,
),
),
); );
} }
} }
@@ -1706,9 +1970,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;
@@ -1717,7 +1982,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;
@@ -1726,12 +1996,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,
), ),
),
); );
} }
@@ -1788,12 +2062,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();
@@ -1804,12 +2082,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 =
@@ -1887,59 +2165,16 @@ class _LabelMonthBars extends StatelessWidget {
final axisInterval = ((safeMax - safeMin) / 3).abs(); final axisInterval = ((safeMax - safeMin) / 3).abs();
final interval = axisInterval <= 0 ? 1.0 : axisInterval; final interval = axisInterval <= 0 ? 1.0 : axisInterval;
return BarChart( return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onChartTap,
child: IgnorePointer(
child: BarChart(
BarChartData( BarChartData(
minY: safeMin, minY: safeMin,
maxY: safeMax, maxY: safeMax,
groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10, groupsSpace: period == OverviewPeriodMode.yearly ? 16 : 10,
barTouchData: BarTouchData( barTouchData: const BarTouchData(enabled: false),
touchCallback: (event, response) {
if (event is! FlTapUpEvent) return;
final index = response?.spot?.touchedBarGroupIndex;
if (index == null || index < 0 || index >= months.length) return;
onMonthTap(months[index]);
},
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)),
rightTitles: rightTitles:
@@ -2012,6 +2247,8 @@ class _LabelMonthBars extends StatelessWidget {
], ],
), ),
duration: Duration.zero, duration: Duration.zero,
),
),
); );
} }
} }
+109 -9
View File
@@ -6,10 +6,12 @@ import 'package:provider/provider.dart';
import '../models/bank.dart'; import '../models/bank.dart';
import '../models/transaction_meta.dart'; import '../models/transaction_meta.dart';
import '../providers/auth_provider.dart';
import '../providers/bank_provider.dart'; import '../providers/bank_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/helpers.dart'; import '../utils/helpers.dart';
import '../widgets/admin_create_transaction_dialog.dart';
import '../widgets/label_parent_picker.dart'; import '../widgets/label_parent_picker.dart';
import '../widgets/recurring_template_sheet.dart'; import '../widgets/recurring_template_sheet.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
@@ -26,6 +28,7 @@ class TransactionDetailScreen extends StatefulWidget {
} }
class _TransactionDetailScreenState extends State<TransactionDetailScreen> { class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
late BankTransaction _tx;
late final TextEditingController _notesController; late final TextEditingController _notesController;
late final TextEditingController _labelSearchController; late final TextEditingController _labelSearchController;
late SimilarMatchCriteria _matchCriteria; late SimilarMatchCriteria _matchCriteria;
@@ -41,11 +44,12 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tx = widget.tx;
final meta = context.read<TxMetaController>(); final meta = context.read<TxMetaController>();
final notes = meta.metaFor(widget.tx).notes; final notes = meta.metaFor(_tx).notes;
_notesController = TextEditingController(text: notes); _notesController = TextEditingController(text: notes);
_labelSearchController = TextEditingController(); _labelSearchController = TextEditingController();
final tx = widget.tx; final tx = _tx;
_matchCriteria = SimilarMatchCriteria( _matchCriteria = SimilarMatchCriteria(
pattern: defaultSimilarPattern(tx.description), pattern: defaultSimilarPattern(tx.description),
flow: tx.amount < 0 flow: tx.amount < 0
@@ -63,7 +67,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
if (!mounted) return; if (!mounted) return;
final all = context.read<BankController>().transactions; final all = context.read<BankController>().transactions;
final count = context.read<TxMetaController>().countSimilar( final count = context.read<TxMetaController>().countSimilar(
widget.tx, _tx,
all, all,
criteria: _matchCriteria, criteria: _matchCriteria,
); );
@@ -88,14 +92,14 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
List<BankTransaction> _similarScope(TxMetaController meta) { List<BankTransaction> _similarScope(TxMetaController meta) {
final all = context.read<BankController>().transactions; final all = context.read<BankController>().transactions;
if (!_applyToSimilar) return const []; if (!_applyToSimilar) return const [];
return meta.similarTo(widget.tx, all, criteria: _matchCriteria); return meta.similarTo(_tx, all, criteria: _matchCriteria);
} }
Future<void> _editMatchCriteria() async { Future<void> _editMatchCriteria() async {
final all = context.read<BankController>().transactions; final all = context.read<BankController>().transactions;
final result = await showSimilarMatchDialog( final result = await showSimilarMatchDialog(
context, context,
prototype: widget.tx, prototype: _tx,
all: all, all: all,
initial: _matchCriteria, initial: _matchCriteria,
); );
@@ -138,7 +142,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
final similar = _similarScope(meta); final similar = _similarScope(meta);
final count = similar.isEmpty ? 1 : similar.length; final count = similar.isEmpty ? 1 : similar.length;
await meta.setNotes( await meta.setNotes(
widget.tx, _tx,
_notesController.text, _notesController.text,
applyToSimilar: similar, applyToSimilar: similar,
matchPattern: _pattern, matchPattern: _pattern,
@@ -180,7 +184,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
() async { () async {
final meta = context.read<TxMetaController>(); final meta = context.read<TxMetaController>();
final similar = toMatched ? _similarScope(meta) : const <BankTransaction>[]; final similar = toMatched ? _similarScope(meta) : const <BankTransaction>[];
final targets = similar.isEmpty ? [widget.tx] : similar; final targets = similar.isEmpty ? [_tx] : similar;
await meta.setLabelsForMany( await meta.setLabelsForMany(
targets, targets,
_draftLabelIds.toList(growable: false), _draftLabelIds.toList(growable: false),
@@ -188,7 +192,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_labelsDirty = false; _labelsDirty = false;
_draftLabelIds = meta.metaFor(widget.tx).labelIds.toSet(); _draftLabelIds = meta.metaFor(_tx).labelIds.toSet();
}); });
final count = targets.length; final count = targets.length;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -291,9 +295,79 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
await context.read<TxMetaController>().removeLabel(label.id); await context.read<TxMetaController>().removeLabel(label.id);
} }
Future<void> _editManualTransaction() async {
final updated = await showAdminCreateTransactionDialog(
context,
existing: _tx,
);
if (updated == null || !mounted) return;
final meta = context.read<TxMetaController>();
if (updated.id != _tx.id) {
await meta.rekeyTransaction(_tx.id, updated.id);
}
setState(() {
_tx = updated;
_matchCriteria = SimilarMatchCriteria(
pattern: defaultSimilarPattern(updated.description),
flow: updated.amount < 0
? TxFlowFilter.expense
: updated.amount > 0
? TxFlowFilter.income
: TxFlowFilter.all,
);
_draftLabelIds = meta.metaFor(updated).labelIds.toSet();
_labelsDirty = false;
_similarReady = false;
_similarCount = 0;
});
_refreshSimilarCount();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Test transaction updated')),
);
}
Future<void> _deleteManualTransaction() async {
final ok = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete test transaction?'),
content: const Text(
'This removes the transaction from this device. The remote copy is '
'deleted on the next PocketBase sync.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Delete'),
),
],
);
},
);
if (ok != true || !mounted) return;
final id = _tx.id;
final bank = context.read<BankController>();
final meta = context.read<TxMetaController>();
final messenger = ScaffoldMessenger.of(context);
final navigator = Navigator.of(context);
await bank.deleteManualTransaction(_tx);
await meta.clearTransactionMeta(id);
if (!mounted) return;
navigator.pop();
messenger.showSnackBar(
const SnackBar(content: Text('Test transaction deleted')),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tx = widget.tx; final tx = _tx;
final metaController = context.watch<TxMetaController>(); final metaController = context.watch<TxMetaController>();
final meta = metaController.metaFor(tx); final meta = metaController.metaFor(tx);
final similarCount = _similarCount; final similarCount = _similarCount;
@@ -317,11 +391,37 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
if (keep.contains(entry.label.id)) entry, if (keep.contains(entry.label.id)) entry,
]; ];
}(); }();
final canEditManual =
context.watch<AuthController>().isAdmin && tx.isManual;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Transaction'), title: const Text('Transaction'),
actions: [ actions: [
if (canEditManual)
PopupMenuButton<String>(
enabled: !_busy,
onSelected: (value) {
if (value == 'edit') {
_editManualTransaction();
} else if (value == 'delete') {
_deleteManualTransaction();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'edit',
child: Text('Edit test transaction'),
),
PopupMenuItem(
value: 'delete',
child: Text(
'Delete',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
),
if (_dirty) if (_dirty)
TextButton( TextButton(
onPressed: _busy ? null : _saveNotes, onPressed: _busy ? null : _saveNotes,
+42 -32
View File
@@ -9,7 +9,6 @@ import 'package:provider/provider.dart';
import '../models/bank.dart'; import '../models/bank.dart';
import '../models/transaction_meta.dart'; import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart'; import '../providers/bank_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/connect_bank.dart'; import '../utils/connect_bank.dart';
@@ -17,7 +16,6 @@ import '../utils/helpers.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/home_widgets_sheet.dart';
import '../widgets/label_filter_dialog.dart'; import '../widgets/label_filter_dialog.dart';
import '../widgets/loading_view.dart'; import '../widgets/loading_view.dart';
import '../widgets/responsive_body.dart'; import '../widgets/responsive_body.dart';
@@ -30,11 +28,13 @@ class TransactionsScreen extends StatefulWidget {
super.key, super.key,
this.initialMonth, this.initialMonth,
this.initialLabelIds = const {}, this.initialLabelIds = const {},
this.initialLabelMatch = LabelMatchMode.and,
this.initialSearchInMonthOnly, this.initialSearchInMonthOnly,
this.initialYear, this.initialYear,
this.initialQuery, this.initialQuery,
this.initialFlow, this.initialFlow,
this.initialAmountRange, this.initialAmountRange,
this.initialSimilarMatch,
}); });
/// When set, opens on this calendar month instead of the current month. /// When set, opens on this calendar month instead of the current month.
@@ -43,6 +43,9 @@ class TransactionsScreen extends StatefulWidget {
/// Pre-select these label filters (e.g. from overview history). /// Pre-select these label filters (e.g. from overview history).
final Set<String> initialLabelIds; final Set<String> initialLabelIds;
/// AND vs OR for [initialLabelIds] (and later filter edits).
final LabelMatchMode initialLabelMatch;
/// When set, overrides the default "search in month" checkbox. /// When set, overrides the default "search in month" checkbox.
final bool? initialSearchInMonthOnly; final bool? initialSearchInMonthOnly;
@@ -55,6 +58,9 @@ class TransactionsScreen extends StatefulWidget {
final TxFlowFilter? initialFlow; final TxFlowFilter? initialFlow;
final TxAmountRange? initialAmountRange; final TxAmountRange? initialAmountRange;
/// Match txs the same way as “apply to similar” / Look ahead transaction source.
final SimilarMatchCriteria? initialSimilarMatch;
@override @override
State<TransactionsScreen> createState() => _TransactionsScreenState(); State<TransactionsScreen> createState() => _TransactionsScreenState();
} }
@@ -68,6 +74,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
DateTime? _selectedDay; DateTime? _selectedDay;
late bool _searchInMonthOnly; late bool _searchInMonthOnly;
late final Set<String> _labelFilters; late final Set<String> _labelFilters;
late LabelMatchMode _labelMatch;
SimilarMatchCriteria? _similarMatch;
int? _yearFilter; int? _yearFilter;
var _showCalendar = false; var _showCalendar = false;
var _flowFilter = TxFlowFilter.all; var _flowFilter = TxFlowFilter.all;
@@ -89,6 +97,8 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
_month = start.isAfter(now) ? now : start; _month = start.isAfter(now) ? now : start;
} }
_labelFilters = Set<String>.of(widget.initialLabelIds); _labelFilters = Set<String>.of(widget.initialLabelIds);
_labelMatch = widget.initialLabelMatch;
_similarMatch = widget.initialSimilarMatch;
// Default to current month so the list, navigator, and filter popup agree. // Default to current month so the list, navigator, and filter popup agree.
_searchInMonthOnly = widget.initialSearchInMonthOnly ?? true; _searchInMonthOnly = widget.initialSearchInMonthOnly ?? true;
_yearFilter = widget.initialYear; _yearFilter = widget.initialYear;
@@ -98,6 +108,11 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
if (initialQuery.isNotEmpty) { if (initialQuery.isNotEmpty) {
_searchController.text = initialQuery; _searchController.text = initialQuery;
_activeQuery = initialQuery; _activeQuery = initialQuery;
} else if (_similarMatch != null &&
_similarMatch!.pattern.trim().isNotEmpty) {
// Show the similar pattern in the search field for context; filtering
// uses [txMatchesSimilarCriteria], not plain text contains.
_searchController.text = _similarMatch!.pattern;
} }
} }
@@ -109,16 +124,23 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
} }
bool get _isSearching => bool get _isSearching =>
_activeQuery.trim().isNotEmpty || _labelFilters.isNotEmpty; _activeQuery.trim().isNotEmpty ||
_labelFilters.isNotEmpty ||
_similarMatch != null;
bool get _hasListFilters => bool get _hasListFilters =>
_labelFilters.isNotEmpty || _labelFilters.isNotEmpty ||
_flowFilter != TxFlowFilter.all || _flowFilter != TxFlowFilter.all ||
_amountRange.isActive; _amountRange.isActive ||
_similarMatch != null;
bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) { bool _matchesExtraFilters(BankTransaction tx, TxMetaController meta) {
if (!txMatchesFlow(tx, _flowFilter)) return false; if (!txMatchesFlow(tx, _flowFilter)) return false;
if (!txMatchesAmountRange(tx, _amountRange)) return false; if (!txMatchesAmountRange(tx, _amountRange)) return false;
final similar = _similarMatch;
if (similar != null && !txMatchesSimilarCriteria(tx, similar)) {
return false;
}
return true; return true;
} }
@@ -147,6 +169,11 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
final trimmed = value.trim(); final trimmed = value.trim();
_searchDebounceTimer?.cancel(); _searchDebounceTimer?.cancel();
// Editing the box leaves “similar match” mode and uses plain text search.
if (_similarMatch != null) {
setState(() => _similarMatch = null);
}
if (trimmed.length >= _minLiveSearchChars) { if (trimmed.length >= _minLiveSearchChars) {
// TextField updates via the controller; only rebuild the list after debounce. // TextField updates via the controller; only rebuild the list after debounce.
_searchDebounceTimer = Timer(_searchDebounce, () { _searchDebounceTimer = Timer(_searchDebounce, () {
@@ -165,13 +192,19 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
void _onSearchSubmitted(String value) { void _onSearchSubmitted(String value) {
_searchDebounceTimer?.cancel(); _searchDebounceTimer?.cancel();
setState(() => _activeQuery = value.trim()); setState(() {
_similarMatch = null;
_activeQuery = value.trim();
});
} }
void _clearSearch() { void _clearSearch() {
_searchDebounceTimer?.cancel(); _searchDebounceTimer?.cancel();
_searchController.clear(); _searchController.clear();
setState(() => _activeQuery = ''); setState(() {
_activeQuery = '';
_similarMatch = null;
});
} }
Future<void> _pickFilters(List<TxLabel> available) async { Future<void> _pickFilters(List<TxLabel> available) async {
@@ -183,6 +216,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
currentMonth: _month, currentMonth: _month,
initial: TransactionFilterSelection( initial: TransactionFilterSelection(
labelIds: Set<String>.of(_labelFilters), labelIds: Set<String>.of(_labelFilters),
labelMatch: _labelMatch,
flow: _flowFilter, flow: _flowFilter,
amountRange: _amountRange, amountRange: _amountRange,
query: _activeQuery, query: _activeQuery,
@@ -195,6 +229,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
_labelFilters _labelFilters
..clear() ..clear()
..addAll(result.labelIds); ..addAll(result.labelIds);
_labelMatch = result.labelMatch;
_flowFilter = result.flow; _flowFilter = result.flow;
_amountRange = result.amountRange; _amountRange = result.amountRange;
_searchInMonthOnly = result.monthOnly; _searchInMonthOnly = result.monthOnly;
@@ -240,27 +275,6 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
); );
} }
Future<void> _saveAsHomeWidget() async {
final preferences = context.read<PreferencesController>();
final created = await composeHomeFilterWidget(
context,
initialFilter: TransactionFilterSelection(
labelIds: Set<String>.of(_labelFilters),
flow: _flowFilter,
amountRange: _amountRange,
query: _activeQuery,
monthOnly: _searchInMonthOnly,
),
skipFilterDialog: true,
);
if (created == null) return;
await preferences.addHomeFilterWidget(created);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added “${created.name}” to Home')),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bank = context.watch<BankController>(); final bank = context.watch<BankController>();
@@ -269,11 +283,6 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
appBar: AppBar( appBar: AppBar(
title: const Text('Transactions'), title: const Text('Transactions'),
actions: [ actions: [
IconButton(
tooltip: 'Save as Home widget',
onPressed: _saveAsHomeWidget,
icon: const Icon(Icons.dashboard_customize_outlined),
),
...bankAppBarActions( ...bankAppBarActions(
context: context, context: context,
onRefresh: bank.refresh, onRefresh: bank.refresh,
@@ -386,6 +395,7 @@ class _TransactionsScreenState extends State<TransactionsScreen> {
searchPool, searchPool,
_activeQuery, _activeQuery,
labelIds: _labelFilters, labelIds: _labelFilters,
labelMatch: _labelMatch,
)) ))
if (_matchesExtraFilters(tx, metaController)) tx, if (_matchesExtraFilters(tx, metaController)) tx,
]; ];

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