17 Commits
Author SHA1 Message Date
davmar a93dbbe30d categorize multiple tags 2026-10-15 18:33:04 +02:00
davmar 22a535a6fd pass suggestions 2026-09-17 15:45:11 +02:00
davmar bd3bc5357e projected balance chart 2026-09-17 01:23:07 +02:00
davmar a23739d717 bar tap correct 2026-09-17 01:00:11 +02:00
davmar b62164dde5 spinner feedback to ui 2 2026-09-17 00:50:15 +02:00
davmar 5cd9a88682 spinner feedback to ui 2026-09-17 00:50:07 +02:00
davmar e1881acc18 fix pending transaction 2026-09-16 23:59:03 +02:00
davmar f4b914a2ad ios added 2026-09-11 13:27:24 +02:00
davmar 34ef156538 fix tap on chart 2 2026-09-11 13:20:48 +02:00
davmar cfb551328b fix tap on chart 2026-09-11 12:45:50 +02:00
davmar 40366f0708 some issues fixed 2026-09-11 12:31:07 +02:00
davmar 2e40786bcd attempt to fix test txs 2.1 2026-09-11 00:11:00 +02:00
davmar 38bcf01ae3 attempt to fix test txs 2 2026-09-11 00:10:15 +02:00
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
122 changed files with 6105 additions and 727 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ migration:
- platform: root
create_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
base_revision: d3b14c876900e553bc736ca19295fc09e3853e8e
- platform: android
- platform: ios
create_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` |
| `1740000004`–`0006` | `home_filter_widgets` (+ half width, budget fields) |
| `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`).
### 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)
```bash
+1
View File
@@ -13,6 +13,7 @@ analyzer:
exclude:
- build/**
- android/**
- ios/**
linter:
# 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.currency,
required this.pending,
this.isManual = false,
});
final String date;
@@ -96,6 +97,9 @@ class BankTransaction {
final String currency;
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.
String get id => '$date|$currency|$amount|$description';
@@ -109,6 +113,50 @@ class BankTransaction {
/// Exact match for copying notes (same description + 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) {
final rawAmount = json['amount'];
final amount = switch (rawAmount) {
@@ -122,6 +170,9 @@ class BankTransaction {
amount: amount,
currency: json['currency'] as String? ?? 'EUR',
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.budgetAverageMonths = 3,
this.labelIds = const [],
this.labelMatch = LabelMatchMode.and,
this.flow = TxFlowFilter.all,
this.amountRange = const TxAmountRange(),
this.query = '',
@@ -93,6 +94,8 @@ class HomeFilterWidget {
this.showAmountIn = true,
this.showAmountOut = true,
this.showCount = true,
this.updatedAt = 0,
this.deleted = false,
});
final String id;
@@ -115,6 +118,10 @@ class HomeFilterWidget {
final int budgetAverageMonths;
final List<String> labelIds;
/// AND vs OR when multiple [labelIds] are selected.
final LabelMatchMode labelMatch;
final TxFlowFilter flow;
final TxAmountRange amountRange;
final String query;
@@ -124,10 +131,17 @@ class HomeFilterWidget {
final bool showAmountOut;
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);
TransactionFilterSelection get filter => TransactionFilterSelection(
labelIds: labelIds.toSet(),
labelMatch: labelMatch,
flow: flow,
amountRange: amountRange,
query: query,
@@ -147,6 +161,7 @@ class HomeFilterWidget {
double? budgetFixedAmount,
int? budgetAverageMonths,
List<String>? labelIds,
LabelMatchMode? labelMatch,
TxFlowFilter? flow,
TxAmountRange? amountRange,
String? query,
@@ -154,6 +169,9 @@ class HomeFilterWidget {
bool? showAmountIn,
bool? showAmountOut,
bool? showCount,
int? updatedAt,
bool? deleted,
bool touchUpdatedAt = false,
}) {
return HomeFilterWidget(
id: id ?? this.id,
@@ -168,6 +186,7 @@ class HomeFilterWidget {
budgetFixedAmount: budgetFixedAmount ?? this.budgetFixedAmount,
budgetAverageMonths: budgetAverageMonths ?? this.budgetAverageMonths,
labelIds: labelIds ?? this.labelIds,
labelMatch: labelMatch ?? this.labelMatch,
flow: flow ?? this.flow,
amountRange: amountRange ?? this.amountRange,
query: query ?? this.query,
@@ -175,6 +194,10 @@ class HomeFilterWidget {
showAmountIn: showAmountIn ?? this.showAmountIn,
showAmountOut: showAmountOut ?? this.showAmountOut,
showCount: showCount ?? this.showCount,
updatedAt: touchUpdatedAt
? DateTime.now().millisecondsSinceEpoch
: (updatedAt ?? this.updatedAt),
deleted: deleted ?? this.deleted,
);
}
@@ -192,6 +215,7 @@ class HomeFilterWidget {
display: display,
sortOrder: sortOrder,
labelIds: filter.labelIds.toList(growable: false),
labelMatch: filter.labelMatch,
flow: filter.flow,
amountRange: filter.amountRange,
query: filter.query,
@@ -199,6 +223,7 @@ class HomeFilterWidget {
showAmountIn: showIn,
showAmountOut: showOut,
showCount: true,
updatedAt: DateTime.now().millisecondsSinceEpoch,
);
}
@@ -215,6 +240,7 @@ class HomeFilterWidget {
'budgetFixedAmount': budgetFixedAmount,
'budgetAverageMonths': budgetAverageMonths,
'labelIds': labelIds,
'labelMatch': labelMatch.name,
'flow': flow.name,
'amountRange': {
if (amountRange.exact != null) 'exact': amountRange.exact,
@@ -226,6 +252,8 @@ class HomeFilterWidget {
'showAmountIn': showAmountIn,
'showAmountOut': showAmountOut,
'showCount': showCount,
'updatedAt': updatedAt,
'deleted': deleted,
};
factory HomeFilterWidget.fromJson(Map<String, dynamic> json) {
@@ -274,6 +302,10 @@ class HomeFilterWidget {
for (final id in (json['labelIds'] as List? ?? const []))
if (id is String && id.isNotEmpty) id,
],
labelMatch: LabelMatchMode.values.firstWhere(
(e) => e.name == json['labelMatch'],
orElse: () => LabelMatchMode.and,
),
flow: TxFlowFilter.values.firstWhere(
(e) => e.name == json['flow'],
orElse: () => TxFlowFilter.all,
@@ -288,20 +320,30 @@ class HomeFilterWidget {
showAmountIn: json['showAmountIn'] as bool? ?? true,
showAmountOut: json['showAmountOut'] 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> local,
List<HomeFilterWidget> remote,
) {
final byId = <String, HomeFilterWidget>{
for (final w in remote) w.id: w,
};
final byId = <String, HomeFilterWidget>{};
void put(HomeFilterWidget w) {
final existing = byId[w.id];
if (existing == null || w.updatedAt >= existing.updatedAt) {
byId[w.id] = w;
}
}
for (final w in remote) {
put(w);
}
for (final w in local) {
byId[w.id] = w;
put(w);
}
final merged = byId.values.toList()
..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
+48
View File
@@ -16,6 +16,8 @@ class OverviewFavourite {
this.flow = TxFlowFilter.expense,
this.barStyle = LabelChartBarStyle.parallel,
this.invertExpensePerspective = false,
this.updatedAt = 0,
this.deleted = false,
});
final String id;
@@ -34,6 +36,12 @@ class OverviewFavourite {
final LabelChartBarStyle barStyle;
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({
String? id,
String? name,
@@ -44,6 +52,9 @@ class OverviewFavourite {
TxFlowFilter? flow,
LabelChartBarStyle? barStyle,
bool? invertExpensePerspective,
int? updatedAt,
bool? deleted,
bool touchUpdatedAt = false,
}) {
return OverviewFavourite(
id: id ?? this.id,
@@ -56,6 +67,10 @@ class OverviewFavourite {
barStyle: barStyle ?? this.barStyle,
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,
'barStyle': barStyle.name,
'invertExpensePerspective': invertExpensePerspective,
'updatedAt': updatedAt,
'deleted': deleted,
};
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(
id: json['id'] as String? ?? 'fav_${DateTime.now().millisecondsSinceEpoch}',
name: (json['name'] as String?)?.trim().isNotEmpty == true
@@ -100,6 +124,8 @@ class OverviewFavourite {
),
invertExpensePerspective:
json['invertExpensePerspective'] as bool? ?? false,
updatedAt: updatedAt,
deleted: json['deleted'] as bool? ?? false,
);
}
}
@@ -107,3 +133,25 @@ class OverviewFavourite {
enum OverviewMetricMode { flow, labels }
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).
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.
const TxLabel kUncategorizedFilterLabel = TxLabel(
id: kUncategorizedFilterId,
+18
View File
@@ -26,6 +26,13 @@ class AuthController extends ChangeNotifier {
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 {
final value = user?.getStringValue('email');
return value == null || value.isEmpty ? null : value;
@@ -80,6 +87,17 @@ class AuthController extends ChangeNotifier {
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 {
client.authStore.clear();
notifyListeners();
+287 -3
View File
@@ -1,6 +1,8 @@
/// [ChangeNotifier] for bank link status, balance, and transactions.
library;
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/bank.dart';
@@ -30,9 +32,34 @@ class BankController extends ChangeNotifier {
_remoteTransactions = cached.transactions;
}
_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();
}
/// 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 StorageService? _storage;
@@ -42,10 +69,116 @@ class BankController extends ChangeNotifier {
/// cannot leave the device behind the cloud (or wipe cloud-only history).
VoidCallback? onAfterSuccessfulLoad;
/// Fired when local admin txs are removed/replaced and need a remote delete.
VoidCallback? onPendingRemoteDeletesChanged;
ConnectStatus _status = ConnectStatus.disconnected;
BankBalance? _balance;
List<BankTransaction> _remoteTransactions = 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;
bool _isLoading = false;
@@ -161,6 +294,117 @@ class BankController extends ChangeNotifier {
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.
///
/// Rows land in the API cache; CSV imports that match are deduped away.
@@ -168,13 +412,39 @@ class BankController extends ChangeNotifier {
List<BankTransaction> fromCloud, {
bool notify = true,
}) 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(
_remoteTransactions,
_importedTransactions,
);
if (_pendingRemoteDeletes.isNotEmpty) {
_importedTransactions = [
for (final tx in _importedTransactions)
if (!_isTombstoned(tx)) tx,
];
}
_reapplyManualFlags(manuals);
_invalidateMerged();
if (!_status.linked && _remoteTransactions.isNotEmpty) {
@@ -254,7 +524,10 @@ class BankController extends ChangeNotifier {
final apiTxs = results[1] as List<BankTransaction>;
// Bank API is ~89 days. Prefer API rows for overlaps, but keep any
// 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) {
_status = ConnectStatus(
status: 'linked',
@@ -269,6 +542,17 @@ class BankController extends ChangeNotifier {
_remoteTransactions,
_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();
await _persistImported();
_error = null;
+66 -30
View File
@@ -42,26 +42,40 @@ class PreferencesController extends ChangeNotifier {
);
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<OverviewFavourite> get displayedOverviewFavourites => [
for (final fav in _overviewFavourites)
if (fav.display) fav,
if (!fav.deleted && fav.display) fav,
];
bool get hasDisplayedOverviewFavourites =>
_overviewFavourites.any((f) => f.display);
_overviewFavourites.any((f) => !f.deleted && f.display);
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<HomeFilterWidget> get displayedHomeFilterWidgets => [
for (final w in _homeFilterWidgets)
if (w.display) w,
if (!w.deleted && w.display) w,
];
bool get hasDisplayedHomeFilterWidgets =>
_homeFilterWidgets.any((w) => w.display);
_homeFilterWidgets.any((w) => !w.deleted && w.display);
HomeBuiltInSections get homeBuiltInSections => _homeBuiltInSections;
@@ -118,7 +132,10 @@ class PreferencesController extends ChangeNotifier {
}
Future<void> addOverviewFavourite(OverviewFavourite favourite) async {
_overviewFavourites = [..._overviewFavourites, favourite];
final stamped = favourite.updatedAt == 0
? favourite.copyWith(touchUpdatedAt: true)
: favourite;
_overviewFavourites = [..._overviewFavourites, stamped];
notifyListeners();
await _persistFavourites();
}
@@ -127,18 +144,19 @@ class PreferencesController extends ChangeNotifier {
final index = _overviewFavourites.indexWhere((f) => f.id == favourite.id);
if (index < 0) return;
final next = [..._overviewFavourites];
next[index] = favourite;
next[index] = favourite.copyWith(touchUpdatedAt: true);
_overviewFavourites = next;
notifyListeners();
await _persistFavourites();
}
Future<void> removeOverviewFavourite(String id) async {
final next = [
for (final fav in _overviewFavourites)
if (fav.id != id) fav,
];
if (next.length == _overviewFavourites.length) return;
final index = _overviewFavourites.indexWhere((f) => f.id == id);
if (index < 0) return;
final current = _overviewFavourites[index];
if (current.deleted) return;
final next = [..._overviewFavourites];
next[index] = current.copyWith(deleted: true, touchUpdatedAt: true);
_overviewFavourites = next;
notifyListeners();
await _persistFavourites();
@@ -148,7 +166,7 @@ class PreferencesController extends ChangeNotifier {
final index = _overviewFavourites.indexWhere((f) => f.id == id);
if (index < 0) return;
final current = _overviewFavourites[index];
if (current.display == display) return;
if (current.deleted || current.display == display) return;
await updateOverviewFavourite(current.copyWith(display: display));
}
@@ -164,16 +182,20 @@ class PreferencesController extends ChangeNotifier {
}
Future<void> addHomeFilterWidget(HomeFilterWidget widget) async {
final order = _homeFilterWidgets.isEmpty
final order = _homeFilterWidgets.where((w) => !w.deleted).isEmpty
? 0
: _homeFilterWidgets
.where((w) => !w.deleted)
.map((w) => w.sortOrder)
.reduce((a, b) => a > b ? a : b) +
1;
_homeFilterWidgets = [
..._homeFilterWidgets,
widget.copyWith(sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder),
];
final stamped = (widget.updatedAt == 0
? widget.copyWith(touchUpdatedAt: true)
: widget)
.copyWith(
sortOrder: widget.sortOrder == 0 ? order : widget.sortOrder,
);
_homeFilterWidgets = [..._homeFilterWidgets, stamped];
notifyListeners();
await _persistHomeWidgets();
}
@@ -182,7 +204,7 @@ class PreferencesController extends ChangeNotifier {
final index = _homeFilterWidgets.indexWhere((w) => w.id == widget.id);
if (index < 0) return;
final next = [..._homeFilterWidgets];
next[index] = widget;
next[index] = widget.copyWith(touchUpdatedAt: true);
next.sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
_homeFilterWidgets = next;
notifyListeners();
@@ -190,11 +212,12 @@ class PreferencesController extends ChangeNotifier {
}
Future<void> removeHomeFilterWidget(String id) async {
final next = [
for (final w in _homeFilterWidgets)
if (w.id != id) w,
];
if (next.length == _homeFilterWidgets.length) return;
final index = _homeFilterWidgets.indexWhere((w) => w.id == id);
if (index < 0) return;
final current = _homeFilterWidgets[index];
if (current.deleted) return;
final next = [..._homeFilterWidgets];
next[index] = current.copyWith(deleted: true, touchUpdatedAt: true);
_homeFilterWidgets = next;
notifyListeners();
await _persistHomeWidgets();
@@ -204,19 +227,32 @@ class PreferencesController extends ChangeNotifier {
final index = _homeFilterWidgets.indexWhere((w) => w.id == id);
if (index < 0) return;
final current = _homeFilterWidgets[index];
if (current.display == display) return;
if (current.deleted || current.display == display) return;
await updateHomeFilterWidget(current.copyWith(display: display));
}
Future<void> reorderHomeFilterWidgets(int oldIndex, int newIndex) async {
if (oldIndex == newIndex) return;
final next = [..._homeFilterWidgets];
final item = next.removeAt(oldIndex);
final target = newIndex.clamp(0, next.length);
next.insert(target, item);
_homeFilterWidgets = [
for (var i = 0; i < next.length; i++) next[i].copyWith(sortOrder: i),
final visible = [
for (final w in _homeFilterWidgets)
if (!w.deleted) w,
];
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();
await _persistHomeWidgets();
}
+101 -12
View File
@@ -6,6 +6,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../models/app_cloud_prefs.dart';
import '../models/bank.dart';
import '../models/home_filter_widget.dart';
import '../models/overview_favourite.dart';
import '../services/pb_sync_service.dart';
@@ -46,6 +47,7 @@ class SyncController extends ChangeNotifier {
_prefs.onAppPrefsPersisted = _scheduleAppPrefsPush;
_auth.addListener(_onAuthChanged);
_bank.onAfterSuccessfulLoad = _onBankDataLoaded;
_bank.onPendingRemoteDeletesChanged = _scheduleBankTxPush;
if (_auth.isLoggedIn) {
unawaited(syncNow());
}
@@ -64,6 +66,7 @@ class SyncController extends ChangeNotifier {
bool _pushFavouritesPending = false;
bool _pushHomeWidgetsPending = false;
bool _pushAppPrefsPending = false;
bool _pushBankPending = false;
Timer? _pushDebounce;
Timer? _progressHold;
Timer? _uiNotifyThrottle;
@@ -198,6 +201,7 @@ class SyncController extends ChangeNotifier {
_pushFavouritesPending = false;
_pushHomeWidgetsPending = false;
_pushAppPrefsPending = false;
_pushBankPending = false;
_lastError = null;
_statusDetail = '';
_showProgressRing = false;
@@ -265,6 +269,47 @@ class SyncController extends ChangeNotifier {
_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() {
if (!_auth.isLoggedIn) return;
if (_syncing) {
@@ -297,6 +342,9 @@ class SyncController extends ChangeNotifier {
try {
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(
onProgress: (p) {
_setProgress(p * 0.35);
@@ -324,7 +372,7 @@ class SyncController extends ChangeNotifier {
);
}
final mergedFavourites = mergeOverviewFavourites(
_prefs.overviewFavourites,
_prefs.overviewFavouritesForSync,
remoteFavourites,
);
List<HomeFilterWidget> remoteHomeWidgets = const [];
@@ -338,7 +386,7 @@ class SyncController extends ChangeNotifier {
);
}
final mergedHomeWidgets = mergeHomeFilterWidgets(
_prefs.homeFilterWidgets,
_prefs.homeFilterWidgetsForSync,
remoteHomeWidgets,
);
AppCloudPrefs? remoteAppPrefs;
@@ -408,8 +456,14 @@ class SyncController extends ChangeNotifier {
} catch (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(
_bank.transactions,
deleteTombs: tombs,
progressStart: 0.82,
progressEnd: 1.0,
onProgress: _setProgress,
@@ -454,11 +508,17 @@ class SyncController extends ChangeNotifier {
final pushFavourites = _pushFavouritesPending;
final pushHomeWidgets = _pushHomeWidgetsPending;
final pushAppPrefs = _pushAppPrefsPending;
final pushBank = _pushBankPending || _bank.pendingRemoteDeletes.isNotEmpty;
_pushMetaPending = false;
_pushFavouritesPending = false;
_pushHomeWidgetsPending = false;
_pushAppPrefsPending = false;
if (!pushMeta && !pushFavourites && !pushHomeWidgets && !pushAppPrefs) {
_pushBankPending = false;
if (!pushMeta &&
!pushFavourites &&
!pushHomeWidgets &&
!pushAppPrefs &&
!pushBank) {
return;
}
@@ -471,13 +531,15 @@ class SyncController extends ChangeNotifier {
pushFavourites,
pushHomeWidgets,
pushAppPrefs,
pushBank,
)) {
(false, false, false, true) => 'App prefs',
(false, false, true, false) => 'Home widgets',
(false, false, true, true) => 'Home layout',
(false, true, false, false) => 'Favourites',
(false, true, true, _) => 'Home layout',
(true, false, false, false) => 'Annotations',
(false, false, false, false, true) => 'Bank txs',
(false, false, false, true, false) => 'App prefs',
(false, false, true, false, false) => 'Home widgets',
(false, false, true, true, _) => 'Home layout',
(false, true, false, false, false) => 'Favourites',
(false, true, true, _, _) => 'Home layout',
(true, false, false, false, false) => 'Annotations',
_ => 'Changes',
};
_statusDetail = 'Pushing $label…';
@@ -491,7 +553,8 @@ class SyncController extends ChangeNotifier {
final total = (pushMeta ? 1 : 0) +
(pushFavourites ? 1 : 0) +
(pushHomeWidgets ? 1 : 0) +
(pushAppPrefs ? 1 : 0);
(pushAppPrefs ? 1 : 0) +
(pushBank ? 1 : 0);
void bump() {
done++;
_setProgress(done / total);
@@ -513,7 +576,7 @@ class SyncController extends ChangeNotifier {
_setStatus('Pushing favourites…');
try {
await service.pushOverviewFavourites(
_prefs.overviewFavourites,
_prefs.overviewFavouritesForSync,
onStatus: _setStatus,
);
parts.add('Favourites updated');
@@ -521,6 +584,7 @@ class SyncController extends ChangeNotifier {
debugPrint(
'SyncController: overview_favourites push skipped ($error)',
);
_pushFavouritesPending = true;
}
bump();
}
@@ -528,7 +592,7 @@ class SyncController extends ChangeNotifier {
_setStatus('Pushing home widgets…');
try {
await service.pushHomeFilterWidgets(
_prefs.homeFilterWidgets,
_prefs.homeFilterWidgetsForSync,
onStatus: _setStatus,
);
parts.add('Home widgets updated');
@@ -536,6 +600,7 @@ class SyncController extends ChangeNotifier {
debugPrint(
'SyncController: home_filter_widgets push skipped ($error)',
);
_pushHomeWidgetsPending = true;
}
bump();
}
@@ -555,6 +620,29 @@ class SyncController extends ChangeNotifier {
}
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);
_lastSyncedAt = DateTime.now();
@@ -588,6 +676,7 @@ class SyncController extends ChangeNotifier {
_uiNotifyThrottle?.cancel();
_auth.removeListener(_onAuthChanged);
_bank.onAfterSuccessfulLoad = null;
_bank.onPendingRemoteDeletesChanged = null;
_meta.onPersisted = null;
_prefs.onFavouritesPersisted = null;
_prefs.onHomeWidgetsPersisted = null;
+74 -11
View File
@@ -521,10 +521,15 @@ class TxMetaController extends ChangeNotifier {
}
/// Replace labels on every transaction in [txs] (per-id overrides).
///
/// When [matchPattern] is set (apply-to-matched), also refreshes the
/// merchant rule so future transactions matching the pattern inherit the
/// full label set (parent + sub-labels).
Future<void> setLabelsForMany(
List<BankTransaction> txs,
List<String> labelIds,
) async {
List<String> labelIds, {
String? matchPattern,
}) async {
if (txs.isEmpty) return;
final expanded = expandWithAncestors([
@@ -545,6 +550,22 @@ class TxMetaController extends ChangeNotifier {
: current.copyWith(labelIds: unique);
}
_byId = nextById;
final pattern = (matchPattern ?? '').trim().toLowerCase();
if (pattern.isNotEmpty) {
final nextRules = Map<String, List<String>>.of(_labelsByMerchant);
if (unique.isEmpty) {
nextRules.remove(pattern);
for (final tx in txs) {
nextRules.remove(tx.merchantKey);
nextRules.remove(tx.descriptionKey);
}
} else {
nextRules[pattern] = unique;
}
_labelsByMerchant = nextRules;
}
_schedulePersist();
}
@@ -722,6 +743,25 @@ class TxMetaController extends ChangeNotifier {
_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(
BankTransaction tx,
String notes, {
@@ -800,6 +840,7 @@ class TxMetaController extends ChangeNotifier {
List<BankTransaction> txs,
String query, {
Set<String> labelIds = const {},
LabelMatchMode labelMatch = LabelMatchMode.and,
}) {
final needle = query.trim().toLowerCase();
final hasText = needle.isNotEmpty;
@@ -809,7 +850,15 @@ class TxMetaController extends ChangeNotifier {
final labelIndex = _labelById;
return [
for (final tx in txs)
if (_matchesSearch(tx, needle, hasText, labelIds, hasLabels, labelIndex))
if (_matchesSearch(
tx,
needle,
hasText,
labelIds,
hasLabels,
labelMatch,
labelIndex,
))
tx,
];
}
@@ -820,10 +869,11 @@ class TxMetaController extends ChangeNotifier {
bool hasText,
Set<String> labelIds,
bool hasLabels,
LabelMatchMode labelMatch,
Map<String, TxLabel> labelIndex,
) {
final meta = metaFor(tx);
if (hasLabels && !_matchesLabelFilter(meta, labelIds)) {
if (hasLabels && !_matchesLabelFilter(meta, labelIds, labelMatch)) {
return false;
}
if (!hasText) return true;
@@ -838,20 +888,33 @@ class TxMetaController extends ChangeNotifier {
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
/// other labels matches nothing (a tx cannot be both).
bool _matchesLabelFilter(TxMeta meta, Set<String> labelIds) {
/// Uncategorized alone matches unlabeled txs. Uncategorized + others with
/// AND matches nothing; with OR matches unlabeled or any of the labels.
bool _matchesLabelFilter(
TxMeta meta,
Set<String> labelIds,
LabelMatchMode mode,
) {
final wantUncategorized = labelIds.contains(kUncategorizedFilterId);
final required = <String>[
for (final id in labelIds)
if (id != kUncategorizedFilterId) id,
];
if (wantUncategorized) {
if (required.isNotEmpty) return false;
return !meta.hasLabel;
if (required.isEmpty) {
if (wantUncategorized) 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) {
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.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -10,11 +12,26 @@ import '../providers/sync_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../utils/connect_bank.dart';
import '../utils/constants.dart';
import '../widgets/admin_create_transaction_dialog.dart';
import '../widgets/responsive_body.dart';
class AccountScreen extends StatelessWidget {
class AccountScreen extends StatefulWidget {
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
Widget build(BuildContext context) {
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),
Card(
child: Column(
@@ -91,6 +126,59 @@ class AccountScreen extends StatelessWidget {
),
),
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(
child: ListTile(
leading: Icon(
+139 -12
View File
@@ -10,8 +10,10 @@ import '../providers/preferences_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart';
import '../utils/connect_bank.dart';
import '../utils/deferred_tab_work.dart';
import '../utils/helpers.dart';
import '../utils/navigation.dart';
import '../utils/tx_filter_metrics.dart';
import '../widgets/empty_state.dart';
import '../widgets/home_filter_widget_card.dart';
import '../widgets/home_widgets_sheet.dart';
@@ -22,6 +24,18 @@ import '../widgets/skip_build_when_covered.dart';
import 'transaction_detail_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 {
const HomeScreen({super.key});
@@ -33,16 +47,93 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
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
Widget build(BuildContext context) {
// Inactive IndexedStack tabs must not watch providers — otherwise every
// 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();
}
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(
appBar: AppBar(
@@ -54,7 +145,9 @@ class _HomeScreenState extends State<HomeScreen> {
onPressed: () =>
setState(() => _editingWidgets = !_editingWidgets),
icon: Icon(
_editingWidgets ? Icons.check : Icons.dashboard_customize_outlined,
_editingWidgets
? Icons.check
: Icons.dashboard_customize_outlined,
),
),
...bankAppBarActions(
@@ -67,10 +160,19 @@ class _HomeScreenState extends State<HomeScreen> {
body: RefreshIndicator(
onRefresh: bank.refresh,
child: ResponsiveBody(
child: _HomeBody(
bank: bank,
editingWidgets: _editingWidgets,
),
child: showSpinner
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
LoadingView(message: 'Loading home…'),
],
)
: _HomeBody(
bank: bank,
editingWidgets: _editingWidgets,
snapshot: _work.value,
),
),
),
);
@@ -81,10 +183,12 @@ class _HomeBody extends StatelessWidget {
const _HomeBody({
required this.bank,
required this.editingWidgets,
required this.snapshot,
});
final BankController bank;
final bool editingWidgets;
final _HomeSnapshot? snapshot;
void _openTransactions(BuildContext context) {
Navigator.of(context).push(
@@ -141,7 +245,7 @@ class _HomeBody extends StatelessWidget {
final colors = theme.colorScheme;
final meta = context.watch<TxMetaController>();
final recent = bank.transactions.take(5).toList(growable: false);
final categorized =
final categorized = snapshot?.categorized ??
meta.labeledTransactionCount(bank.transactions);
final widgets = editingWidgets
? prefs.homeFilterWidgets
@@ -319,6 +423,9 @@ class _HomeBody extends StatelessWidget {
child: HomeFilterWidgetCard(
widgetConfig: widgetConfig,
editing: true,
precomputedStats: snapshot?.statsById[widgetConfig.id],
precomputedBudget:
snapshot?.budgetById[widgetConfig.id],
onToggleDisplay: (display) =>
prefs.setHomeFilterWidgetDisplay(
widgetConfig.id,
@@ -347,7 +454,11 @@ class _HomeBody extends StatelessWidget {
},
)
else
..._packedHomeWidgetRows(widgets),
..._packedHomeWidgetRows(
widgets,
statsById: snapshot?.statsById,
budgetById: snapshot?.budgetById,
),
],
if (_showBuiltIn(
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>[];
var i = 0;
while (i < widgets.length) {
@@ -571,11 +686,19 @@ List<Widget> _packedHomeWidgetRows(List<HomeFilterWidget> widgets) {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: HomeFilterWidgetCard(widgetConfig: a),
child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
),
const SizedBox(width: 10),
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),
child: _HomeWidgetSlot(
halfWidth: a.halfWidth,
child: HomeFilterWidgetCard(widgetConfig: a),
child: HomeFilterWidgetCard(
widgetConfig: a,
precomputedStats: statsById?[a.id],
precomputedBudget: budgetById?[a.id],
),
),
),
);
+71 -59
View File
@@ -2,6 +2,7 @@
library;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:provider/provider.dart';
@@ -46,6 +47,8 @@ class _LoginScreenState extends State<LoginScreen> {
email: _emailController.text,
password: _passwordController.text,
);
// Lets iOS/Android password managers offer to save / confirm the login.
TextInput.finishAutofillContext();
if (!mounted) return;
if (!widget.asRoot) {
Navigator.of(context).pop(true);
@@ -96,68 +99,77 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
const SizedBox(height: 24),
Form(
key: _formKey,
child: Column(
children: [
AppTextField(
controller: _emailController,
label: 'Email',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofocus: true,
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return 'Enter your email';
if (!text.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter your password';
}
return null;
},
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
tooltip: _obscurePassword
? 'Show password'
: 'Hide password',
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
AutofillGroup(
child: Form(
key: _formKey,
child: Column(
children: [
AppTextField(
controller: _emailController,
label: 'Email',
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofocus: true,
autofillHints: const [
AutofillHints.email,
AutofillHints.username,
],
validator: (value) {
final text = value?.trim() ?? '';
if (text.isEmpty) return 'Enter your email';
if (!text.contains('@')) return 'Enter a valid email';
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
enableSuggestions: false,
autocorrect: false,
onFieldSubmitted: (_) => _submit(),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Enter your password';
}
return null;
},
decoration: InputDecoration(
labelText: 'Password',
suffixIcon: IconButton(
tooltip: _obscurePassword
? 'Show password'
: 'Hide password',
onPressed: () => setState(
() => _obscurePassword = !_obscurePassword,
),
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _busy ? null : _submit,
icon: _busy
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.login),
label: Text(_busy ? 'Signing in…' : 'Sign in'),
),
],
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _busy ? null : _submit,
icon: _busy
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.login),
label: Text(_busy ? 'Signing in…' : 'Sign in'),
),
],
),
),
),
],
+678 -131
View File
@@ -1,6 +1,9 @@
/// Look ahead: projected balance from recurring templates through a date.
library;
import 'dart:async';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -9,13 +12,14 @@ import '../models/transaction_meta.dart';
import '../providers/bank_provider.dart';
import '../providers/preferences_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/helpers.dart';
import '../utils/look_ahead.dart';
import '../utils/navigation.dart';
import '../utils/tx_calendar.dart';
import '../widgets/empty_state.dart';
import '../widgets/loading_view.dart';
import '../widgets/recurring_template_sheet.dart';
import '../widgets/responsive_body.dart';
import '../widgets/shell_tab_scope.dart';
@@ -23,6 +27,25 @@ import '../widgets/skip_build_when_covered.dart';
enum _PeriodMode { monthly, yearly }
class _ItemsSnapshot {
const _ItemsSnapshot({required this.key, required this.items});
final int key;
final List<RecurringItem> items;
}
class _BalancePoint {
const _BalancePoint({
required this.key,
required this.label,
required this.balance,
});
final DateTime key;
final String label;
final double balance;
}
class LookAheadScreen extends StatefulWidget {
const LookAheadScreen({super.key});
@@ -35,8 +58,10 @@ class LookAheadScreen extends StatefulWidget {
class _LookAheadScreenState extends State<LookAheadScreen> {
var _period = _PeriodMode.monthly;
late DateTime _cursor;
List<RecurringItem>? _cachedItems;
int? _itemsCacheKey;
_ItemsSnapshot? _snapshot;
int? _loadingKey;
int _computeGen = 0;
var _showChart = false;
@override
void initState() {
@@ -52,14 +77,15 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
/// Inclusive end date for the selected period.
DateTime get _through {
if (_period == _PeriodMode.yearly) {
// Chart view (and yearly list) always project through year-end.
if (_showChart || _period == _PeriodMode.yearly) {
return DateTime(_cursor.year, 12, 31);
}
return DateTime(_cursor.year, _cursor.month + 1, 0);
}
String get _periodTitle {
if (_period == _PeriodMode.yearly) return '${_cursor.year}';
if (_showChart || _period == _PeriodMode.yearly) return '${_cursor.year}';
return formatMonthYear(_cursor);
}
@@ -68,28 +94,32 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return DateTime(t.year + maxYears, t.month);
}
int _maxYear(int maxYears) => _today.year + maxYears;
bool get _navByYear => _showChart || _period == _PeriodMode.yearly;
bool _canGoBack() {
if (_period == _PeriodMode.yearly) {
return _cursor.year > _today.year;
}
if (_navByYear) return _cursor.year > _today.year;
return monthStart(_cursor).isAfter(_minMonth);
}
bool _canGoForward(int maxYears) {
if (_period == _PeriodMode.yearly) {
return _cursor.year < _today.year + maxYears;
}
if (_navByYear) return _cursor.year < _maxYear(maxYears);
return monthStart(_cursor).isBefore(_maxMonthFor(maxYears));
}
void _clampCursor(int maxYears) {
if (_period == _PeriodMode.yearly) {
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
if (_navByYear) {
if (_cursor.year > _maxYear(maxYears)) {
_cursor = DateTime(_maxYear(maxYears));
}
if (_cursor.year < _today.year) {
_cursor = DateTime(_today.year);
}
} else {
final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
if (_cursor.isBefore(_minMonth)) _cursor = _minMonth;
}
}
@@ -97,26 +127,22 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
if (mode == _period) return;
setState(() {
_period = mode;
if (mode == _PeriodMode.yearly) {
if (mode == _PeriodMode.yearly || _showChart) {
_cursor = DateTime(_cursor.year);
if (_cursor.year < _today.year) _cursor = DateTime(_today.year);
if (_cursor.year > _today.year + maxYears) {
_cursor = DateTime(_today.year + maxYears);
}
_clampCursor(maxYears);
} else {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
final maxMonth = _maxMonthFor(maxYears);
if (_cursor.isAfter(maxMonth)) _cursor = maxMonth;
_clampCursor(maxYears);
}
});
}
void _go(int delta, int maxYears) {
setState(() {
if (_period == _PeriodMode.yearly) {
if (_navByYear) {
final next = DateTime(_cursor.year + delta);
if (next.year < _today.year || next.year > _today.year + maxYears) {
if (next.year < _today.year || next.year > _maxYear(maxYears)) {
return;
}
_cursor = next;
@@ -129,9 +155,149 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
});
}
void _toggleChart(int maxYears) {
setState(() {
_showChart = !_showChart;
if (_showChart) {
_cursor = DateTime(_cursor.year);
} else if (_period == _PeriodMode.monthly) {
final month = DateTime(_cursor.year, _today.month);
_cursor = month.isBefore(_minMonth) ? _minMonth : monthStart(month);
}
_clampCursor(maxYears);
});
}
int _itemsCacheKey(TxMetaController meta, BankController bank) =>
Object.hash(meta.storeRevision, identityHashCode(bank.transactions));
/// Projected account balance at each month-end / year-end.
List<_BalancePoint> _balanceSeries({
required List<RecurringItem> items,
required double currentBalance,
required int maxYears,
}) {
final today = _today;
if (_period == _PeriodMode.monthly) {
final year = _cursor.year;
const months = [
'J', 'F', 'M', 'A', 'M', 'J', 'J', 'A', 'S', 'O', 'N', 'D',
];
final out = <_BalancePoint>[];
for (var m = 1; m <= 12; m++) {
final end = DateTime(year, m + 1, 0);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(year, m),
label: months[m - 1],
balance: balance,
),
);
}
return out;
}
final startYear = _today.year;
final endYear = _maxYear(maxYears);
final out = <_BalancePoint>[];
for (var y = startYear; y <= endYear; y++) {
final end = DateTime(y, 12, 31);
final balance = !end.isAfter(today)
? currentBalance
: predictBalance(
currentBalance: currentBalance,
from: today,
throughDate: end,
items: items,
);
out.add(
_BalancePoint(
key: DateTime(y),
label: '$y',
balance: balance,
),
);
}
return out;
}
/// Resolve recurring amounts off the build path, yielding so nav stays live.
void _ensureItemsLoaded({
required int cacheKey,
required TxMetaController meta,
required BankController bank,
}) {
if (_snapshot?.key == cacheKey || _loadingKey == cacheKey) return;
_loadingKey = cacheKey;
final gen = ++_computeGen;
final templates = List<RecurringTemplate>.of(meta.recurringTemplates);
final transactions = bank.transactions;
unawaited(() async {
// Let the spinner / tab switch paint before heavy work.
await Future<void>.delayed(Duration.zero);
if (!mounted || gen != _computeGen) return;
final items = <RecurringItem>[];
for (var i = 0; i < templates.length; i++) {
final tpl = templates[i];
items.add(
RecurringItem(
templateId: tpl.id,
description: tpl.name,
amount: resolveRecurringAmount(
template: tpl,
transactions: transactions,
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer,
labelIdsFor: meta.labelIdsFor,
),
currency: tpl.currency,
dayOfMonth: tpl.dayOfMonth.clamp(1, 28),
ruleSummary: recurringRuleSummary(
template: tpl,
labelName: (id) => meta.labelById(id)?.name ?? 'label',
),
sourceTxId: tpl.sourceTxId,
labelIds: [
if (tpl.labelId != null && tpl.labelId!.isNotEmpty) tpl.labelId!,
],
),
);
// Yield often so bottom-nav taps are not starved.
if (i % 2 == 1) {
await Future<void>.delayed(Duration.zero);
if (!mounted || gen != _computeGen) return;
}
}
if (!mounted || gen != _computeGen) return;
setState(() {
_snapshot = _ItemsSnapshot(key: cacheKey, items: items);
if (_loadingKey == cacheKey) _loadingKey = null;
});
}());
}
@override
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 SkipBuildWhenCovered(
@@ -165,22 +331,38 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
final currentAmount = balance?.amount ?? 0;
final today = _today;
final through = _through;
final cacheKey = Object.hash(
meta.storeRevision,
identityHashCode(bank.transactions),
);
if (_cachedItems == null || _itemsCacheKey != cacheKey) {
_itemsCacheKey = cacheKey;
_cachedItems = recurringItemsFromTemplates(
templates: meta.recurringTemplates,
transactions: bank.transactions,
isInternalTransfer: (tx) => meta.metaFor(tx).internalTransfer,
labelIdsFor: meta.labelIdsFor,
labelName: (id) => meta.labelById(id)?.name ?? 'label',
final cacheKey = _itemsCacheKey(meta, bank);
if (_snapshot?.key != cacheKey && _loadingKey != cacheKey) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (ShellTabScope.of(context) != LookAheadScreen.tabIndex) return;
_ensureItemsLoaded(cacheKey: cacheKey, meta: meta, bank: bank);
});
}
final snapshot = _snapshot;
final ready = snapshot != null && snapshot.key == cacheKey;
if (!ready) {
return Scaffold(
appBar: AppBar(
title: const Text('Look ahead'),
actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
),
body: const LoadingView(message: 'Calculating look ahead…'),
);
}
final items = _cachedItems!;
final items = snapshot.items;
final projected = projectedOccurrences(
items: items,
from: today,
@@ -216,7 +398,16 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
return Scaffold(
appBar: AppBar(
title: const Text('Look ahead'),
actions: bankAppBarActions(context: context),
actions: [
IconButton(
tooltip: _showChart ? 'Show list' : 'Show chart',
onPressed: () => _toggleChart(maxYears),
icon: Icon(
_showChart ? Icons.view_list_outlined : Icons.calendar_month,
),
),
...bankAppBarActions(context: context),
],
),
body: ResponsiveBody(
child: ListView(
@@ -234,10 +425,8 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
canGoForward: _canGoForward(maxYears),
onBack: () => _go(-1, maxYears),
onForward: () => _go(1, maxYears),
backTooltip:
_period == _PeriodMode.yearly ? 'Previous year' : 'Previous month',
forwardTooltip:
_period == _PeriodMode.yearly ? 'Next year' : 'Next month',
backTooltip: _navByYear ? 'Previous year' : 'Previous month',
forwardTooltip: _navByYear ? 'Next year' : 'Next month',
),
const SizedBox(height: 12),
Material(
@@ -321,93 +510,110 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: Text(
'Recurring spendings',
style: theme.textTheme.titleMedium,
),
if (_showChart) ...[
const SizedBox(height: 16),
_LookAheadBalanceChart(
points: _balanceSeries(
items: items,
currentBalance: currentAmount,
maxYears: maxYears,
),
TextButton.icon(
onPressed: () => showCreateRecurringTemplateSheet(context),
icon: const Icon(Icons.add, size: 18),
label: const Text('New'),
),
],
),
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,
period: _period,
currency: currency,
),
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _SummaryCard(
icon: Icons.south_west,
iconBackground: AppTheme.accent.withValues(alpha: 0.18),
iconColor: AppTheme.accent,
amount: formatMoney(monthlyIncome, currency),
label: 'Monthly income',
const SizedBox(height: 8),
Text(
_period == _PeriodMode.yearly
? 'Projected account balance by year'
: 'Projected account balance by month',
style: theme.textTheme.bodySmall?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
] else ...[
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: Text(
'Recurring spendings',
style: theme.textTheme.titleMedium,
),
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
icon: Icons.north_east,
iconBackground: AppTheme.expense.withValues(alpha: 0.18),
iconColor: AppTheme.expense,
amount: formatMoney(monthlyExpense, currency),
label: 'Monthly expense',
TextButton.icon(
onPressed: () => showCreateRecurringTemplateSheet(context),
icon: const Icon(Icons.add, size: 18),
label: const Text('New'),
),
),
],
),
const SizedBox(height: 10),
if (items.isEmpty)
const EmptyState(
icon: Icons.event_repeat_outlined,
title: 'No recurring spendings',
message:
'Open a transaction and tap Create recurring spending, '
'or use New to pick a transaction or label average.',
)
else
Card(
color: colors.surfaceContainer,
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
indent: 16,
color: colors.outlineVariant.withValues(alpha: 0.4),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _SummaryCard(
icon: Icons.south_west,
iconBackground: AppTheme.accent.withValues(alpha: 0.18),
iconColor: AppTheme.accent,
amount: formatMoney(monthlyIncome, currency),
label: 'Monthly income',
),
),
const SizedBox(width: 12),
Expanded(
child: _SummaryCard(
icon: Icons.north_east,
iconBackground: AppTheme.expense.withValues(alpha: 0.18),
iconColor: AppTheme.expense,
amount: formatMoney(monthlyExpense, currency),
label: 'Monthly expense',
),
),
],
),
const SizedBox(height: 10),
_SavingsCard(
monthlySavings: monthlyIncome - monthlyExpense,
currency: currency,
),
const SizedBox(height: 10),
if (items.isEmpty)
const EmptyState(
icon: Icons.event_repeat_outlined,
title: 'No recurring spendings',
message:
'Open a transaction and tap Create recurring spending, '
'or use New to pick a transaction or label average.',
)
else
Card(
color: colors.surfaceContainer,
elevation: 0,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
for (var i = 0; i < items.length; i++) ...[
if (i > 0)
Divider(
height: 1,
indent: 16,
color: colors.outlineVariant.withValues(alpha: 0.4),
),
_TemplateTile(
item: items[i],
template:
meta.recurringTemplateById(items[i].templateId),
transactions: bank.transactions,
),
_TemplateTile(
item: items[i],
template:
meta.recurringTemplateById(items[i].templateId),
transactions: bank.transactions,
),
],
],
],
),
),
),
],
],
),
),
@@ -415,6 +621,315 @@ class _LookAheadScreenState extends State<LookAheadScreen> {
}
}
class _LookAheadBalanceChart extends StatelessWidget {
const _LookAheadBalanceChart({
required this.points,
required this.period,
required this.currency,
});
final List<_BalancePoint> points;
final _PeriodMode period;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
if (points.isEmpty) {
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'No projected balance for this period',
style: theme.textTheme.bodyMedium?.copyWith(
color: colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
),
);
}
var minY = points.first.balance;
var maxY = points.first.balance;
for (final p in points) {
if (p.balance < minY) minY = p.balance;
if (p.balance > maxY) maxY = p.balance;
}
if (minY == maxY) {
minY -= 1;
maxY += 1;
}
final pad = (maxY - minY).abs() * 0.12;
final safeMin = minY - pad;
final safeMax = maxY + pad;
final interval = ((safeMax - safeMin) / 3).abs();
final safeInterval = interval <= 0 ? 1.0 : interval;
final spots = [
for (var i = 0; i < points.length; i++)
FlSpot(i.toDouble(), points[i].balance),
];
return Card(
color: colors.surfaceContainer,
elevation: 0,
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Projected balance',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
minX: 0,
maxX: (points.length - 1).toDouble(),
minY: safeMin,
maxY: safeMax,
lineTouchData: LineTouchData(
handleBuiltInTouches: true,
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (touched) {
return [
for (final spot in touched)
if (spot.x.round() >= 0 &&
spot.x.round() < points.length)
LineTooltipItem(
'${points[spot.x.round()].label}\n'
'${formatMoney(points[spot.x.round()].balance, currency)}',
theme.textTheme.labelMedium!.copyWith(
color: colors.onInverseSurface,
fontWeight: FontWeight.w600,
),
),
];
},
),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: safeInterval,
getTitlesWidget: (value, meta) {
if ((value - safeMax).abs() < 0.01 ||
(value - safeMin).abs() < 0.01) {
return const SizedBox.shrink();
}
return Text(
_compactMoney(value),
style: theme.textTheme.labelSmall,
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= points.length) {
return const SizedBox.shrink();
}
if (period == _PeriodMode.yearly &&
points.length > 8 &&
index % 2 == 1) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
points[index].label,
style: theme.textTheme.labelSmall,
),
);
},
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: safeInterval,
getDrawingHorizontalLine: (value) => FlLine(
color: colors.outlineVariant.withValues(alpha: 0.35),
strokeWidth: 1,
dashArray: const [4, 4],
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.18,
color: colors.primary,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, bar, index) {
return FlDotCirclePainter(
radius: 3.5,
color: colors.primary,
strokeWidth: 0,
);
},
),
belowBarData: BarAreaData(
show: true,
color: colors.primary.withValues(alpha: 0.14),
),
),
],
),
duration: Duration.zero,
),
),
],
),
),
);
}
}
class _SavingsCard extends StatelessWidget {
const _SavingsCard({
required this.monthlySavings,
required this.currency,
});
final double monthlySavings;
final String currency;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
final yearly = monthlySavings * 12;
final positive = monthlySavings >= 0;
final tone = positive ? AppTheme.accent : AppTheme.expense;
return Card(
color: colors.surfaceContainer,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: tone.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
),
child: Icon(Icons.savings_outlined, color: tone, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Monthly savings',
amount: formatMoney(monthlySavings, currency),
color: tone,
),
),
const SizedBox(width: 12),
Expanded(
child: _SavingsMetric(
label: 'Yearly savings',
amount: formatMoney(yearly, currency),
color: tone,
alignEnd: true,
),
),
],
),
),
);
}
}
class _SavingsMetric extends StatelessWidget {
const _SavingsMetric({
required this.label,
required this.amount,
required this.color,
this.alignEnd = false,
});
final String label;
final String amount;
final Color color;
final bool alignEnd;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colors = theme.colorScheme;
return Column(
crossAxisAlignment:
alignEnd ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
Text(
label.toUpperCase(),
style: theme.textTheme.labelSmall?.copyWith(
letterSpacing: 0.6,
fontWeight: FontWeight.w600,
color: colors.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
amount,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: color,
),
),
],
);
}
}
String _compactMoney(double value) {
final abs = value.abs();
if (abs >= 1000000) return '${(value / 1000000).toStringAsFixed(1)}M';
if (abs >= 1000) return '${(value / 1000).toStringAsFixed(1)}k';
if (abs >= 100) return value.toStringAsFixed(0);
return value.toStringAsFixed(abs < 10 && abs > 0 ? 1 : 0);
}
class _PeriodToolbar extends StatelessWidget {
const _PeriodToolbar({
required this.period,
@@ -622,6 +1137,46 @@ class _TemplateTile extends StatelessWidget {
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
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -658,15 +1213,7 @@ class _TemplateTile extends StatelessWidget {
color: income ? AppTheme.accent : AppTheme.expense,
),
),
onTap: () {
final tx = _sourceTx;
if (tx == null) return;
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionDetailScreen(tx: tx),
),
);
},
onTap: () => _openTransactions(context),
);
}
}
File diff suppressed because it is too large Load Diff
+110 -9
View File
@@ -6,10 +6,12 @@ import 'package:provider/provider.dart';
import '../models/bank.dart';
import '../models/transaction_meta.dart';
import '../providers/auth_provider.dart';
import '../providers/bank_provider.dart';
import '../providers/tx_meta_provider.dart';
import '../utils/app_theme.dart';
import '../utils/helpers.dart';
import '../widgets/admin_create_transaction_dialog.dart';
import '../widgets/label_parent_picker.dart';
import '../widgets/recurring_template_sheet.dart';
import '../widgets/responsive_body.dart';
@@ -26,6 +28,7 @@ class TransactionDetailScreen extends StatefulWidget {
}
class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
late BankTransaction _tx;
late final TextEditingController _notesController;
late final TextEditingController _labelSearchController;
late SimilarMatchCriteria _matchCriteria;
@@ -41,11 +44,12 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
@override
void initState() {
super.initState();
_tx = widget.tx;
final meta = context.read<TxMetaController>();
final notes = meta.metaFor(widget.tx).notes;
final notes = meta.metaFor(_tx).notes;
_notesController = TextEditingController(text: notes);
_labelSearchController = TextEditingController();
final tx = widget.tx;
final tx = _tx;
_matchCriteria = SimilarMatchCriteria(
pattern: defaultSimilarPattern(tx.description),
flow: tx.amount < 0
@@ -63,7 +67,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
if (!mounted) return;
final all = context.read<BankController>().transactions;
final count = context.read<TxMetaController>().countSimilar(
widget.tx,
_tx,
all,
criteria: _matchCriteria,
);
@@ -88,14 +92,14 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
List<BankTransaction> _similarScope(TxMetaController meta) {
final all = context.read<BankController>().transactions;
if (!_applyToSimilar) return const [];
return meta.similarTo(widget.tx, all, criteria: _matchCriteria);
return meta.similarTo(_tx, all, criteria: _matchCriteria);
}
Future<void> _editMatchCriteria() async {
final all = context.read<BankController>().transactions;
final result = await showSimilarMatchDialog(
context,
prototype: widget.tx,
prototype: _tx,
all: all,
initial: _matchCriteria,
);
@@ -138,7 +142,7 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
final similar = _similarScope(meta);
final count = similar.isEmpty ? 1 : similar.length;
await meta.setNotes(
widget.tx,
_tx,
_notesController.text,
applyToSimilar: similar,
matchPattern: _pattern,
@@ -180,15 +184,16 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
() async {
final meta = context.read<TxMetaController>();
final similar = toMatched ? _similarScope(meta) : const <BankTransaction>[];
final targets = similar.isEmpty ? [widget.tx] : similar;
final targets = similar.isEmpty ? [_tx] : similar;
await meta.setLabelsForMany(
targets,
_draftLabelIds.toList(growable: false),
matchPattern: toMatched ? _pattern : null,
);
if (!mounted) return;
setState(() {
_labelsDirty = false;
_draftLabelIds = meta.metaFor(widget.tx).labelIds.toSet();
_draftLabelIds = meta.metaFor(_tx).labelIds.toSet();
});
final count = targets.length;
ScaffoldMessenger.of(context).showSnackBar(
@@ -291,9 +296,79 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
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
Widget build(BuildContext context) {
final tx = widget.tx;
final tx = _tx;
final metaController = context.watch<TxMetaController>();
final meta = metaController.metaFor(tx);
final similarCount = _similarCount;
@@ -317,11 +392,37 @@ class _TransactionDetailScreenState extends State<TransactionDetailScreen> {
if (keep.contains(entry.label.id)) entry,
];
}();
final canEditManual =
context.watch<AuthController>().isAdmin && tx.isManual;
return Scaffold(
appBar: AppBar(
title: const Text('Transaction'),
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)
TextButton(
onPressed: _busy ? null : _saveNotes,

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