V1 - Personal Account and Budgeting Done

Personal Account and Budgeting
This commit is contained in:
2026-08-16 20:13:21 +05:30
commit 17c0526ed6
247 changed files with 19002 additions and 0 deletions

45
kifi-app/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release

45
kifi-app/.metadata Normal file
View File

@@ -0,0 +1,45 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "8b872868494e429d94fa06dca855c306438b22c0"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: android
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: ios
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: linux
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: macos
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: web
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
- platform: windows
create_revision: 8b872868494e429d94fa06dca855c306438b22c0
base_revision: 8b872868494e429d94fa06dca855c306438b22c0
# User provided section
# List of Local paths (relative to this file) that should be
# ignored by the migrate tool.
#
# Files that are not part of the templates will be ignored by default.
unmanaged_files:
- 'lib/main.dart'
- 'ios/Runner.xcodeproj/project.pbxproj'

16
kifi-app/README.md Normal file
View File

@@ -0,0 +1,16 @@
# kifi_app
A new Flutter project.
## Getting Started
This project is a starting point for a Flutter application.
A few resources to get you started if this is your first Flutter project:
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

14
kifi-app/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,14 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks

View File

@@ -0,0 +1,50 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
android {
namespace = "com.sarascore.kifi_app"
compileSdk = 37
ndkVersion = flutter.ndkVersion
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
}
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.sarascore.kifi_app"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
}
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}
flutter {
source = "../.."
}

View File

@@ -0,0 +1,4 @@
-dontwarn com.google.mlkit.vision.text.chinese.**
-dontwarn com.google.mlkit.vision.text.devanagari.**
-dontwarn com.google.mlkit.vision.text.japanese.**
-dontwarn com.google.mlkit.vision.text.korean.**

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,45 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="kifi_app"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -0,0 +1,5 @@
package com.sarascore.kifi_app
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>

View File

@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View File

@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip

View File

@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.11.1" apply false
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
}
include(":app")

BIN
kifi-app/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 KiB

View File

@@ -0,0 +1,4 @@
flutter_icons:
android: true
ios: true
image_path: "assets/icon.png"

34
kifi-app/ios/.gitignore vendored Normal file
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

View File

@@ -0,0 +1,26 @@
<?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>
<key>MinimumOSVersion</key>
<string>13.0</string>
</dict>
</plist>

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"

View File

@@ -0,0 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"

46
kifi-app/ios/Podfile Normal file
View File

@@ -0,0 +1,46 @@
# Uncomment this line to define a global platform for your project
platform :ios, '15.5'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
target.build_configurations.each do |config|
config.build_settings.delete 'EXCLUDED_ARCHS'
end
end
end

179
kifi-app/ios/Podfile.lock Normal file
View File

@@ -0,0 +1,179 @@
PODS:
- Flutter (1.0.0)
- flutter_image_compress_common (1.0.0):
- Flutter
- SDWebImage
- SDWebImageWebPCoder
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- google_mlkit_commons (0.12.0):
- Flutter
- MLKitVision (~> 10.0.0)
- google_mlkit_text_recognition (0.16.0):
- Flutter
- google_mlkit_commons
- GoogleMLKit/TextRecognition (~> 9.0.0)
- GoogleDataTransport (10.1.1):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleMLKit/MLKitCore (9.0.0):
- MLKitCommon (~> 14.0.0)
- GoogleMLKit/TextRecognition (9.0.0):
- GoogleMLKit/MLKitCore
- MLKitTextRecognition (~> 7.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
- GoogleToolboxForMac/Logger (4.2.1):
- GoogleToolboxForMac/Defines (= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (4.2.1)":
- GoogleToolboxForMac/Defines (= 4.2.1)
- GoogleUtilities/Environment (8.1.2):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.2):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.2)
- GoogleUtilities/UserDefaults (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMSessionFetcher/Core (3.5.0)
- image_picker_ios (0.0.1):
- Flutter
- libwebp (1.6.0):
- libwebp/demux (= 1.6.0)
- libwebp/mux (= 1.6.0)
- libwebp/sharpyuv (= 1.6.0)
- libwebp/webp (= 1.6.0)
- libwebp/demux (1.6.0):
- libwebp/webp
- libwebp/mux (1.6.0):
- libwebp/demux
- libwebp/sharpyuv (1.6.0)
- libwebp/webp (1.6.0):
- libwebp/sharpyuv
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- MLImage (1.0.0-beta8)
- MLKitCommon (14.0.0):
- GoogleDataTransport (~> 10.0)
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitTextRecognition (7.0.0):
- MLKitCommon (~> 14.0)
- MLKitTextRecognitionCommon (= 6.0.0)
- MLKitVision (~> 10.0)
- MLKitTextRecognitionCommon (6.0.0):
- MLKitCommon (~> 14.0)
- MLKitVision (~> 10.0)
- MLKitVision (10.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLImage (= 1.0.0-beta8)
- MLKitCommon (~> 14.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.1)
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- SDWebImageWebPCoder (0.15.0):
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.17)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
- google_mlkit_text_recognition (from `.symlinks/plugins/google_mlkit_text_recognition/ios`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
SPEC REPOS:
trunk:
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
- GoogleUtilities
- GTMSessionFetcher
- libwebp
- MLImage
- MLKitCommon
- MLKitTextRecognition
- MLKitTextRecognitionCommon
- MLKitVision
- nanopb
- PromisesObjC
- SDWebImage
- SDWebImageWebPCoder
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_image_compress_common:
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
google_mlkit_commons:
:path: ".symlinks/plugins/google_mlkit_commons/ios"
google_mlkit_text_recognition:
:path: ".symlinks/plugins/google_mlkit_text_recognition/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_image_compress_common: 11d5dcfb36f4ded92320bfa896261813a073240e
flutter_local_notifications: 643a3eda1ce1c0599413ca31672536d423dee214
flutter_secure_storage_darwin: 46e401699982ee74142909676535e0f6a7321e58
google_mlkit_commons: b4dfe2f6ae4cbf24010dc6640a28984d01343c86
google_mlkit_text_recognition: 9b164471713e3afdb4c129cabe6d1d64fef7e817
GoogleDataTransport: a24e58982ab3ba2f64d79613e027fe7f57e88539
GoogleMLKit: b1eee21a41c57704fe72483b15c85cb2c0cd7444
GoogleToolboxForMac: d1a2cbf009c453f4d6ded37c105e2f67a32206d8
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
libwebp: af5937a13536ef73f3784d69cc342b98961acf33
local_auth_darwin: c3ee6cce0a8d56be34c8ccb66ba31f7f180aaebb
MLImage: 0de5c6c2bf9e93b80ef752e2797f0836f03b58c0
MLKitCommon: 47d47b50a031d00db62f1b0efe5a1d8b09a3b2e6
MLKitTextRecognition: c0ad24510481dfc8893ad15dfcb8a5f05ed9e826
MLKitTextRecognitionCommon: 234ceb1cfdfb5fceb4fd664943046609a2961cc2
MLKitVision: 39a5a812db83c4a0794445088e567f3631c11961
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
SDWebImageWebPCoder: 0e06e365080397465cc73a7a9b472d8a3bd0f377
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
PODFILE CHECKSUM: 05bcc0ae31139d41b9fa5949f7a59ee09c6fb20d
COCOAPODS: 1.17.0

View File

@@ -0,0 +1,751 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
312D2FA747F4BF324094ED55 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F06C0555F49CA279B7F03E87 /* Pods_RunnerTests.framework */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
52339C7D96FB484157166BA2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10E322288A433A3444E6480D /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
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 */
10E322288A433A3444E6480D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
3CB337752F0813F6923ADC49 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
47C12038BB6EBB708F74F4C5 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<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>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
94C8738DA289CD156F426C3C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<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>"; };
A1AD163901F88FB7876836C0 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
A1F0AAF9706AEFA06CC31D9A /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
F06C0555F49CA279B7F03E87 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
F315CD68535668FE5A36584C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
7E550CFF6B1E014E5F6406DB /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
312D2FA747F4BF324094ED55 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
52339C7D96FB484157166BA2 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
53DF7287B9D01C6A8EF8654D /* Frameworks */ = {
isa = PBXGroup;
children = (
10E322288A433A3444E6480D /* Pods_Runner.framework */,
F06C0555F49CA279B7F03E87 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
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 */,
A53102319B25770F648A9197 /* Pods */,
53DF7287B9D01C6A8EF8654D /* Frameworks */,
);
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 */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
A53102319B25770F648A9197 /* Pods */ = {
isa = PBXGroup;
children = (
3CB337752F0813F6923ADC49 /* Pods-Runner.debug.xcconfig */,
47C12038BB6EBB708F74F4C5 /* Pods-Runner.release.xcconfig */,
A1F0AAF9706AEFA06CC31D9A /* Pods-Runner.profile.xcconfig */,
94C8738DA289CD156F426C3C /* Pods-RunnerTests.debug.xcconfig */,
A1AD163901F88FB7876836C0 /* Pods-RunnerTests.release.xcconfig */,
F315CD68535668FE5A36584C /* Pods-RunnerTests.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
303776586152ACB29BA42786 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
7E550CFF6B1E014E5F6406DB /* Frameworks */,
);
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 = (
C9C52B4D2082ADD2599053FA /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
9A9CAD73968105EABAD607AB /* [CP] Embed Pods Frameworks */,
4F5F2B2DF690B64C3E6E3C86 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
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;
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 */
303776586152ACB29BA42786 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
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";
};
4F5F2B2DF690B64C3E6E3C86 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
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";
};
9A9CAD73968105EABAD607AB /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C9C52B4D2082ADD2599053FA /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* 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 */,
);
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.5;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
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)";
DEVELOPMENT_TEAM = GA7G8SBPKB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp;
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;
baseConfigurationReference = 94C8738DA289CD156F426C3C /* Pods-RunnerTests.debug.xcconfig */;
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.sarascore.kifiApp.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;
baseConfigurationReference = A1AD163901F88FB7876836C0 /* Pods-RunnerTests.release.xcconfig */;
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.sarascore.kifiApp.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;
baseConfigurationReference = F315CD68535668FE5A36584C /* Pods-RunnerTests.profile.xcconfig */;
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.sarascore.kifiApp.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 = AppIcon;
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.5;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
97C147041CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = AppIcon;
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.5;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
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)";
DEVELOPMENT_TEAM = GA7G8SBPKB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp;
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)";
DEVELOPMENT_TEAM = GA7G8SBPKB;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.sarascore.kifiApp;
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 */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -0,0 +1,13 @@
import Flutter
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

View File

@@ -0,0 +1 @@
{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,23 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "LaunchImage.png",
"scale" : "1x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@2x.png",
"scale" : "2x"
},
{
"idiom" : "universal",
"filename" : "LaunchImage@3x.png",
"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

View File

@@ -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.

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,55 @@
<?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>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Kifi App</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>kifi_app</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>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>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>Used to scan receipts for auto-filling transaction details.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Used to select receipts for auto-filling transaction details.</string>
<key>NSFaceIDUsageDescription</key>
<string>Authenticate to access your personal finance data securely.</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@@ -0,0 +1 @@
#import "GeneratedPluginRegistrant.h"

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.
}
}

View File

@@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../main.dart';
import '../../features/auth/presentation/auth_screen.dart';
class DioClient {
static final DioClient _instance = DioClient._internal();
final Dio dio;
final FlutterSecureStorage storage;
factory DioClient() {
return _instance;
}
DioClient._internal()
: dio = Dio(BaseOptions(
baseUrl: 'https://app.technobeesolutions.in/api/kifi',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
)),
storage = const FlutterSecureStorage() {
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await storage.read(key: 'jwt_token');
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
return handler.next(options);
},
onError: (DioException error, handler) async {
if (error.response?.statusCode == 401) {
// Token is invalid or expired
await storage.delete(key: 'jwt_token');
if (navigatorKey.currentContext != null) {
Navigator.of(navigatorKey.currentContext!).pushAndRemoveUntil(
MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false,
);
}
}
String cleanMessage = "An unexpected error occurred.";
if (error.type == DioExceptionType.connectionTimeout || error.type == DioExceptionType.sendTimeout || error.type == DioExceptionType.receiveTimeout) {
cleanMessage = "Network error: Unable to connect to server. Please check your internet connection.";
} else if (error.response?.statusCode == 500) {
cleanMessage = "Server error. Please try again later.";
} else if (error.response?.data != null && error.response!.data is Map && error.response!.data['message'] != null) {
cleanMessage = error.response!.data['message'];
} else if (error.type == DioExceptionType.connectionError) {
cleanMessage = "Network error: Unable to connect to server.";
}
return handler.reject(
DioException(
requestOptions: error.requestOptions,
error: CleanException(cleanMessage),
type: error.type,
response: error.response,
)
);
},
));
}
}
class CleanException implements Exception {
final String message;
CleanException(this.message);
@override
String toString() => message;
}

View File

@@ -0,0 +1,38 @@
import 'package:encrypt/encrypt.dart';
import 'package:pointycastle/asymmetric/api.dart';
import 'package:dio/dio.dart';
import '../network/dio_client.dart';
class CryptoService {
static final CryptoService _instance = CryptoService._internal();
factory CryptoService() => _instance;
CryptoService._internal();
RSAPublicKey? _publicKey;
Future<void> fetchPublicKey() async {
try {
final dio = Dio(BaseOptions(
baseUrl: DioClient().dio.options.baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
final response = await dio.get('/auth/public-key');
final publicKeyBase64 = response.data['publicKey'] as String;
_publicKey = RSAKeyParser().parse('-----BEGIN PUBLIC KEY-----\n$publicKeyBase64\n-----END PUBLIC KEY-----') as RSAPublicKey;
} catch (e) {
throw Exception('Failed to fetch public key: $e');
}
}
String encrypt(String plainText) {
if (_publicKey == null) {
throw Exception('Public key not initialized. Call fetchPublicKey() first.');
}
final encrypter = Encrypter(RSA(publicKey: _publicKey));
final encrypted = encrypter.encrypt(plainText);
return encrypted.base64;
}
bool get isInitialized => _publicKey != null;
}

View File

@@ -0,0 +1,76 @@
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:timezone/data/latest_all.dart' as tz;
import 'package:timezone/timezone.dart' as tz;
import 'package:flutter/foundation.dart';
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin();
bool _initialized = false;
Future<void> initialize() async {
if (_initialized) return;
tz.initializeTimeZones();
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
final DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
final InitializationSettings initializationSettings = InitializationSettings(
android: initializationSettingsAndroid,
iOS: initializationSettingsDarwin,
);
await _flutterLocalNotificationsPlugin.initialize(settings: initializationSettings);
_initialized = true;
}
Future<void> scheduleNotification({
required int id,
required String title,
required String body,
required DateTime scheduledDate,
}) async {
if (!_initialized) await initialize();
try {
await _flutterLocalNotificationsPlugin.zonedSchedule(
id: id,
title: title,
body: body,
scheduledDate: tz.TZDateTime.from(scheduledDate, tz.local),
notificationDetails: const NotificationDetails(
android: AndroidNotificationDetails(
'payables_alerts_channel',
'Payables Alerts',
channelDescription: 'Notifications for upcoming payable due dates',
importance: Importance.max,
priority: Priority.high,
),
iOS: DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
),
),
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
);
} catch (e) {
debugPrint('Error scheduling notification: $e');
}
}
Future<void> cancelNotification(int id) async {
await _flutterLocalNotificationsPlugin.cancel(id: id);
}
}

View File

@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:google_mlkit_text_recognition/google_mlkit_text_recognition.dart';
import 'package:image_picker/image_picker.dart';
class OcrResult {
final double? amount;
final String? rawText;
OcrResult({this.amount, this.rawText});
}
class OcrService {
final ImagePicker _picker = ImagePicker();
Future<OcrResult?> scanReceiptFromCamera() async {
final XFile? image = await _picker.pickImage(source: ImageSource.camera);
if (image == null) return null;
return await _processImage(File(image.path));
}
Future<OcrResult?> scanReceiptFromGallery() async {
final XFile? image = await _picker.pickImage(source: ImageSource.gallery);
if (image == null) return null;
return await _processImage(File(image.path));
}
Future<OcrResult> _processImage(File file) async {
final inputImage = InputImage.fromFile(file);
final textRecognizer = TextRecognizer(script: TextRecognitionScript.latin);
try {
final RecognizedText recognizedText = await textRecognizer.processImage(inputImage);
String text = recognizedText.text;
// Simple regex to find a monetary amount (e.g., $145.50 or 145.50)
final RegExp amountRegex = RegExp(r'\$?\d+\.\d{2}');
final Iterable<Match> matches = amountRegex.allMatches(text);
double? maxAmount;
for (final Match m in matches) {
final matchText = m.group(0)?.replaceAll('\$', '');
if (matchText != null) {
final amount = double.tryParse(matchText);
if (amount != null) {
if (maxAmount == null || amount > maxAmount) {
maxAmount = amount; // Usually the total is the largest amount
}
}
}
}
return OcrResult(
amount: maxAmount,
rawText: text,
);
} finally {
textRecognizer.close();
}
}
}

View File

@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class AppTheme {
static const Color primaryColor = Color(0xFF6C63FF);
static const Color secondaryColor = Color(0xFFFF6584);
static const Color backgroundColor = Color(0xFFF7F9FC);
static const Color surfaceColor = Colors.white;
static const Color textPrimaryColor = Color(0xFF2D3142);
static const Color textSecondaryColor = Color(0xFF9094A6);
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.light(
primary: primaryColor,
secondary: secondaryColor,
surface: surfaceColor,
onSurface: textPrimaryColor,
),
scaffoldBackgroundColor: backgroundColor,
textTheme: GoogleFonts.outfitTextTheme().copyWith(
displayLarge: GoogleFonts.outfit(color: textPrimaryColor, fontWeight: FontWeight.bold, fontSize: 32),
bodyLarge: GoogleFonts.outfit(color: textPrimaryColor, fontSize: 16),
bodyMedium: GoogleFonts.outfit(color: textSecondaryColor, fontSize: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
hintStyle: const TextStyle(color: textSecondaryColor),
),
cardTheme: CardThemeData(
color: surfaceColor,
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.05),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
static ThemeData get darkTheme {
const Color darkBackground = Color(0xFF121212);
const Color darkSurface = Color(0xFF1E1E1E);
const Color darkTextPrimary = Color(0xFFE0E0E0);
const Color darkTextSecondary = Color(0xFFA0A0A0);
return ThemeData(
useMaterial3: true,
colorScheme: const ColorScheme.dark(
primary: primaryColor,
secondary: secondaryColor,
surface: darkSurface,
onSurface: darkTextPrimary,
),
scaffoldBackgroundColor: darkBackground,
textTheme: GoogleFonts.outfitTextTheme().copyWith(
displayLarge: GoogleFonts.outfit(color: darkTextPrimary, fontWeight: FontWeight.bold, fontSize: 32),
bodyLarge: GoogleFonts.outfit(color: darkTextPrimary, fontSize: 16),
bodyMedium: GoogleFonts.outfit(color: darkTextSecondary, fontSize: 14),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: darkSurface,
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
hintStyle: const TextStyle(color: darkTextSecondary),
),
cardTheme: CardThemeData(
color: darkSurface,
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
);
}
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
class NatureColors {
static Color getColor(String nature) {
switch (nature) {
case 'INCOME':
return Colors.green.shade700;
case 'RECEIVABLES':
case 'LENDING':
return Colors.lightGreen;
case 'EXPENSE':
return Colors.red;
case 'PAYABLES':
case 'LOAN':
return Colors.orange;
case 'INVESTMENTS':
case 'INVESTMENT':
return Colors.blue;
case 'SAVINGS':
return Colors.teal;
case 'CASH':
case 'BALANCE':
return Colors.blueGrey;
case 'TRANSFER':
return Colors.blueGrey;
default:
return Colors.grey;
}
}
static List<Color> getPalette(String nature) {
switch (nature) {
case 'INCOME':
return [Colors.green.shade900, Colors.green.shade700, Colors.green.shade500, Colors.green.shade300, Colors.greenAccent.shade700, Colors.teal.shade400, Colors.lightGreen.shade400];
case 'RECEIVABLES':
case 'LENDING':
return [Colors.lightGreen.shade900, Colors.lightGreen.shade700, Colors.lightGreen, Colors.lime.shade600, Colors.lightGreenAccent.shade700, Colors.green.shade400];
case 'EXPENSE':
return [Colors.red.shade900, Colors.red.shade700, Colors.red, Colors.deepOrange.shade400, Colors.pink.shade400, Colors.redAccent.shade700, Colors.orange.shade800];
case 'PAYABLES':
case 'LOAN':
return [Colors.orange.shade900, Colors.orange.shade700, Colors.orange, Colors.deepOrange.shade600, Colors.amber.shade700, Colors.orangeAccent.shade700, Colors.brown.shade400];
case 'INVESTMENTS':
case 'INVESTMENT':
return [Colors.blue.shade900, Colors.blue.shade700, Colors.blue, Colors.lightBlue.shade600, Colors.cyan.shade600, Colors.blueAccent.shade700, Colors.indigo.shade400];
default:
return [Colors.blueGrey.shade800, Colors.blueGrey.shade600, Colors.blueGrey, Colors.grey.shade600, Colors.grey.shade400, Colors.black54];
}
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeNotifier extends Notifier<ThemeMode> {
static const _themePrefKey = 'theme_pref';
@override
ThemeMode build() {
_loadTheme();
return ThemeMode.system;
}
Future<void> _loadTheme() async {
final prefs = await SharedPreferences.getInstance();
final themeString = prefs.getString(_themePrefKey);
if (themeString != null) {
if (themeString == 'light') {
state = ThemeMode.light;
} else if (themeString == 'dark') {
state = ThemeMode.dark;
}
}
}
Future<void> setTheme(ThemeMode mode) async {
state = mode;
final prefs = await SharedPreferences.getInstance();
if (mode == ThemeMode.light) {
await prefs.setString(_themePrefKey, 'light');
} else if (mode == ThemeMode.dark) {
await prefs.setString(_themePrefKey, 'dark');
} else {
await prefs.setString(_themePrefKey, 'system');
}
}
}
final themeProvider = NotifierProvider<ThemeNotifier, ThemeMode>(() {
return ThemeNotifier();
});

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
class SnackBarService {
static void showSuccess(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(LucideIcons.checkCircle, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.green.shade600,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
margin: const EdgeInsets.all(16),
),
);
}
static void showError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(LucideIcons.alertCircle, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.red.shade600,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
margin: const EdgeInsets.all(16),
),
);
}
static void showWarning(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(LucideIcons.alertTriangle, color: Colors.white),
const SizedBox(width: 12),
Expanded(child: Text(message)),
],
),
backgroundColor: Colors.orange.shade700,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
margin: const EdgeInsets.all(16),
duration: const Duration(seconds: 4),
),
);
}
}

View File

@@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
class EmptyStateWidget extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const EmptyStateWidget({
super.key,
required this.icon,
required this.title,
required this.message,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 80,
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5),
),
const SizedBox(height: 24),
Text(
title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
class ShimmerLoading extends StatelessWidget {
final double width;
final double height;
final double borderRadius;
const ShimmerLoading({
super.key,
required this.width,
required this.height,
this.borderRadius = 8.0,
});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Shimmer.fromColors(
baseColor: isDark ? Colors.grey.shade800 : Colors.grey.shade300,
highlightColor: isDark ? Colors.grey.shade700 : Colors.grey.shade100,
child: Container(
width: width,
height: height,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(borderRadius),
),
),
);
}
}
class ShimmerCard extends StatelessWidget {
const ShimmerCard({super.key});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ShimmerLoading(width: 120, height: 16),
const SizedBox(height: 16),
const ShimmerLoading(width: double.infinity, height: 24),
const SizedBox(height: 8),
const ShimmerLoading(width: double.infinity, height: 24),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
ShimmerLoading(width: 60, height: 16),
ShimmerLoading(width: 60, height: 16),
],
)
],
),
),
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:dio/dio.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/security/crypto_service.dart';
class AuthRepository {
final Dio dio = DioClient().dio;
final CryptoService _crypto = CryptoService();
Future<void> _ensureCryptoReady() async {
if (!_crypto.isInitialized) {
await _crypto.fetchPublicKey();
}
}
Future<void> signup(String email, String password) async {
try {
await _ensureCryptoReady();
await dio.post('/auth/signup', data: {
'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
});
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? e.message);
}
}
Future<Map<String, dynamic>> login(String email, String password) async {
try {
await _ensureCryptoReady();
final response = await dio.post('/auth/login', data: {
'email': _crypto.encrypt(email),
'password': _crypto.encrypt(password),
});
return response.data;
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Invalid email or password');
}
}
Future<Map<String, dynamic>> verifyOtp(String email, String otp) async {
try {
final response = await dio.post('/auth/verify-otp', data: {
'email': email,
'otp': otp,
});
return response.data;
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Invalid OTP');
}
}
Future<void> forgotPassword(String email) async {
try {
await dio.post('/auth/forgot-password', data: {
'email': email,
});
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Failed to send reset code');
}
}
Future<void> resetPassword(String email, String otp, String newPassword) async {
try {
await _ensureCryptoReady();
await dio.post('/auth/reset-password', data: {
'email': email,
'otp': otp,
'newPassword': _crypto.encrypt(newPassword),
});
} on DioException catch (e) {
final data = e.response?.data;
final errorMsg = data is Map ? data['error'] : data;
throw Exception(errorMsg ?? 'Failed to reset password');
}
}
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import 'otp_screen.dart';
import 'forgot_password_screen.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
class AuthScreen extends ConsumerStatefulWidget {
const AuthScreen({super.key});
@override
ConsumerState<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends ConsumerState<AuthScreen> {
bool isLogin = true;
final emailController = TextEditingController();
final passwordController = TextEditingController();
void toggleMode() {
setState(() {
isLogin = !isLogin;
});
}
Future<void> submit() async {
final email = emailController.text.trim();
final password = passwordController.text;
if (email.isEmpty || password.isEmpty) return;
if (isLogin) {
final success = await ref.read(authControllerProvider.notifier).login(email, password);
if (success && mounted) {
Navigator.pushReplacement(
context, MaterialPageRoute(builder: (_) => const DashboardScreen()));
}
} else {
final success = await ref.read(authControllerProvider.notifier).signup(email, password);
if (success && mounted) {
Navigator.push(context, MaterialPageRoute(builder: (_) => OtpScreen(email: email)));
}
}
}
@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${next.error}')),
);
}
});
final state = ref.watch(authControllerProvider);
final isLoading = state.isLoading;
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
isLogin ? 'Welcome Back!' : 'Create Account',
style: Theme.of(context).textTheme.displayLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
isLogin ? 'Login to continue to Kifi' : 'Sign up to manage your expenses',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Email Address',
prefixIcon: Icon(LucideIcons.mail, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (isLogin) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const ForgotPasswordScreen()));
},
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF6C63FF),
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: const Text('Forgot Password?', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
),
),
],
const SizedBox(height: 24),
ElevatedButton(
onPressed: isLoading ? null : submit,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(isLogin ? 'Login' : 'Sign Up', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
const SizedBox(height: 16),
TextButton(
onPressed: toggleMode,
child: Text(isLogin ? 'Don\'t have an account? Sign Up' : 'Already have an account? Login'),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/auth_provider.dart';
import 'reset_password_screen.dart';
class ForgotPasswordScreen extends ConsumerStatefulWidget {
const ForgotPasswordScreen({super.key});
@override
ConsumerState<ForgotPasswordScreen> createState() => _ForgotPasswordScreenState();
}
class _ForgotPasswordScreenState extends ConsumerState<ForgotPasswordScreen> {
final emailController = TextEditingController();
bool _isLoading = false;
String? _errorMessage;
@override
void dispose() {
emailController.dispose();
super.dispose();
}
Future<void> _sendOtp() async {
final email = emailController.text.trim();
if (email.isEmpty || !email.contains('@')) {
setState(() => _errorMessage = 'Please enter a valid email address');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final success = await ref.read(authControllerProvider.notifier).forgotPassword(email);
if (mounted) {
setState(() => _isLoading = false);
if (success) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => ResetPasswordScreen(email: email)),
);
} else {
setState(() => _errorMessage = 'Failed to send reset code. Please check your email.');
}
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
style: IconButton.styleFrom(
backgroundColor: Colors.grey.shade100,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 40),
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(LucideIcons.keyRound, color: Colors.white, size: 36),
),
),
const SizedBox(height: 32),
const Center(
child: Text(
'Forgot Password?',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
Center(
child: Text(
'Enter your email address and we\'ll send you\na verification code to reset your password.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5),
),
),
const SizedBox(height: 48),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _sendOtp(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Email Address',
prefixIcon: Icon(LucideIcons.mail, color: Colors.grey.shade500),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))),
],
),
),
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _sendOtp,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Send Reset Code', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
class OtpScreen extends ConsumerStatefulWidget {
final String email;
const OtpScreen({super.key, required this.email});
@override
ConsumerState<OtpScreen> createState() => _OtpScreenState();
}
class _OtpScreenState extends ConsumerState<OtpScreen> {
final otpController = TextEditingController();
Future<void> verify() async {
final otp = otpController.text.trim();
if (otp.isEmpty) return;
final success = await ref.read(authControllerProvider.notifier).verifyOtp(widget.email, otp);
if (success && mounted) {
Navigator.pushAndRemoveUntil(
context, MaterialPageRoute(builder: (_) => const DashboardScreen()), (route) => false);
}
}
@override
Widget build(BuildContext context) {
ref.listen<AsyncValue<void>>(authControllerProvider, (previous, next) {
if (next.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Error: ${next.error}')),
);
}
});
final state = ref.watch(authControllerProvider);
final isLoading = state.isLoading;
return Scaffold(
appBar: AppBar(backgroundColor: Colors.transparent, elevation: 0),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Verify Email',
style: Theme.of(context).textTheme.displayLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Enter the 6-digit OTP sent to ${widget.email}',
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: const TextStyle(fontSize: 24, letterSpacing: 8, fontWeight: FontWeight.bold, color: Color(0xFF6C63FF)),
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(letterSpacing: 8, color: Colors.grey.shade400),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: isLoading ? null : verify,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Verify', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,148 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:cross_file/cross_file.dart';
import 'auth_screen.dart';
import '../../../core/network/dio_client.dart';
import '../providers/auth_provider.dart';
import '../../transactions/providers/providers.dart';
import '../../../core/theme/theme_provider.dart';
class ProfileScreen extends ConsumerStatefulWidget {
const ProfileScreen({super.key});
@override
ConsumerState<ProfileScreen> createState() => _ProfileScreenState();
}
class _ProfileScreenState extends ConsumerState<ProfileScreen> {
bool _isExporting = false;
Future<void> _logout(BuildContext context) async {
const storage = FlutterSecureStorage();
await storage.delete(key: 'jwt_token');
if (context.mounted) {
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false,
);
}
}
Future<void> _exportData() async {
setState(() => _isExporting = true);
try {
final bytes = await ref.read(apiRepositoryProvider).exportTransactions();
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/transactions_export.csv');
await file.writeAsBytes(bytes);
final xFile = XFile(file.path);
await Share.shareXFiles([xFile], text: 'Here is my Kifi transactions export.');
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Export failed: $e')));
}
} finally {
if (mounted) {
setState(() => _isExporting = false);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Profile'),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 20),
CircleAvatar(
radius: 50,
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.1),
child: Icon(LucideIcons.user, size: 50, color: Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 24),
const Text('Kifi User', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
const Text('maddy23285@gmail.com', style: TextStyle(color: Colors.grey, fontSize: 16)),
const SizedBox(height: 48),
Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Column(
children: [
ListTile(
leading: const Icon(LucideIcons.downloadCloud),
title: const Text('Export Data to CSV'),
trailing: _isExporting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Icon(LucideIcons.chevronRight),
onTap: _isExporting ? null : _exportData,
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.moon),
title: const Text('Theme'),
trailing: SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(value: ThemeMode.light, icon: Icon(LucideIcons.sun)),
ButtonSegment(value: ThemeMode.system, icon: Icon(LucideIcons.monitor)),
ButtonSegment(value: ThemeMode.dark, icon: Icon(LucideIcons.moon)),
],
selected: {ref.watch(themeProvider)},
onSelectionChanged: (Set<ThemeMode> newSelection) {
ref.read(themeProvider.notifier).setTheme(newSelection.first);
},
showSelectedIcon: false,
style: ButtonStyle(visualDensity: VisualDensity.compact),
),
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.helpCircle),
title: const Text('Help & Support'),
trailing: const Icon(LucideIcons.chevronRight),
onTap: () {},
),
],
),
),
const Spacer(),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _logout(context),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red.withOpacity(0.1),
foregroundColor: Colors.red,
padding: const EdgeInsets.symmetric(vertical: 16),
),
icon: const Icon(LucideIcons.logOut),
label: const Text('Log Out', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,238 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../providers/auth_provider.dart';
import 'auth_screen.dart';
class ResetPasswordScreen extends ConsumerStatefulWidget {
final String email;
const ResetPasswordScreen({super.key, required this.email});
@override
ConsumerState<ResetPasswordScreen> createState() => _ResetPasswordScreenState();
}
class _ResetPasswordScreenState extends ConsumerState<ResetPasswordScreen> {
final otpController = TextEditingController();
final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController();
bool _isLoading = false;
bool _obscurePassword = true;
bool _obscureConfirm = true;
String? _errorMessage;
@override
void dispose() {
otpController.dispose();
newPasswordController.dispose();
confirmPasswordController.dispose();
super.dispose();
}
Future<void> _resetPassword() async {
final otp = otpController.text.trim();
final newPassword = newPasswordController.text;
final confirmPassword = confirmPasswordController.text;
if (otp.isEmpty || otp.length != 6) {
setState(() => _errorMessage = 'Please enter a valid 6-digit OTP');
return;
}
if (newPassword.isEmpty || newPassword.length < 6) {
setState(() => _errorMessage = 'Password must be at least 6 characters');
return;
}
if (newPassword != confirmPassword) {
setState(() => _errorMessage = 'Passwords do not match');
return;
}
setState(() {
_isLoading = true;
_errorMessage = null;
});
final success = await ref.read(authControllerProvider.notifier).resetPassword(widget.email, otp, newPassword);
if (mounted) {
setState(() => _isLoading = false);
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password reset successfully! Please log in.'),
backgroundColor: Color(0xFF6C63FF),
),
);
Navigator.pushAndRemoveUntil(
context,
MaterialPageRoute(builder: (_) => const AuthScreen()),
(route) => false,
);
} else {
setState(() => _errorMessage = 'Invalid OTP or reset failed. Please try again.');
}
}
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
IconButton(
onPressed: () => Navigator.pop(context),
icon: const Icon(Icons.arrow_back_ios_new, size: 20),
style: IconButton.styleFrom(
backgroundColor: Colors.grey.shade100,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 32),
Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6C63FF), Color(0xFF48C6EF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(24),
),
child: const Icon(LucideIcons.shieldCheck, color: Colors.white, size: 36),
),
),
const SizedBox(height: 28),
const Center(
child: Text(
'Reset Password',
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 12),
Center(
child: Text(
'We\'ve sent a 6-digit code to\n${widget.email}',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 15, color: Colors.grey.shade600, height: 1.5),
),
),
const SizedBox(height: 40),
// OTP Field
TextField(
controller: otpController,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
maxLength: 6,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 20, letterSpacing: 8),
textAlign: TextAlign.center,
decoration: InputDecoration(
hintText: '000000',
hintStyle: TextStyle(color: Colors.grey.shade300, letterSpacing: 8),
counterText: '',
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 20),
// New Password
TextField(
controller: newPasswordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.next,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'New Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
// Confirm Password
TextField(
controller: confirmPasswordController,
obscureText: _obscureConfirm,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _resetPassword(),
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Confirm Password',
prefixIcon: Icon(LucideIcons.lock, color: Colors.grey.shade500),
suffixIcon: IconButton(
icon: Icon(_obscureConfirm ? LucideIcons.eyeOff : LucideIcons.eye, color: Colors.grey.shade500),
onPressed: () => setState(() => _obscureConfirm = !_obscureConfirm),
),
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 20),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(LucideIcons.alertCircle, color: Colors.red.shade400, size: 18),
const SizedBox(width: 10),
Expanded(child: Text(_errorMessage!, style: TextStyle(color: Colors.red.shade700, fontSize: 13))),
],
),
),
],
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : _resetPassword,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: _isLoading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('Reset Password', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,81 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/auth_repository.dart';
import '../../../../core/network/dio_client.dart';
final authRepositoryProvider = Provider((ref) => AuthRepository());
class AuthController extends AsyncNotifier<void> {
late AuthRepository _repository;
@override
FutureOr<void> build() {
_repository = ref.watch(authRepositoryProvider);
}
Future<bool> login(String email, String password) async {
state = const AsyncValue.loading();
try {
final data = await _repository.login(email, password);
await DioClient().storage.write(key: 'jwt_token', value: data['token']);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> signup(String email, String password) async {
state = const AsyncValue.loading();
try {
await _repository.signup(email, password);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> verifyOtp(String email, String otp) async {
state = const AsyncValue.loading();
try {
final data = await _repository.verifyOtp(email, otp);
await DioClient().storage.write(key: 'jwt_token', value: data['token']);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> forgotPassword(String email) async {
state = const AsyncValue.loading();
try {
await _repository.forgotPassword(email);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
Future<bool> resetPassword(String email, String otp, String newPassword) async {
state = const AsyncValue.loading();
try {
await _repository.resetPassword(email, otp, newPassword);
state = const AsyncValue.data(null);
return true;
} catch (e, st) {
state = AsyncValue.error(e, st);
return false;
}
}
}
final authControllerProvider = AsyncNotifierProvider<AuthController, void>(() {
return AuthController();
});

View File

@@ -0,0 +1,425 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
import '../../../core/widgets/shimmer_loading.dart';
class BudgetScreen extends ConsumerStatefulWidget {
const BudgetScreen({super.key});
@override
ConsumerState<BudgetScreen> createState() => _BudgetScreenState();
}
class _BudgetScreenState extends ConsumerState<BudgetScreen> {
String _searchQuery = '';
String? _filterNature;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _onRefresh() async {
ref.invalidate(budgetProvider);
ref.invalidate(walletProvider);
ref.invalidate(transactionProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
void _showSetBudgetDialog(BuildContext context, Wallet wallet, Budget? existingBudget) {
final controller = TextEditingController(text: existingBudget?.monthlyLimit.toString() ?? '');
bool isShared = existingBudget?.isShared ?? false;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setState) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Set Budget for ${wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: controller,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Monthly Limit',
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
SwitchListTile(
title: const Text('Share budget with wallet members', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
value: isShared,
activeColor: const Color(0xFF6C63FF),
contentPadding: EdgeInsets.zero,
onChanged: (val) {
setState(() {
isShared = val;
});
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
final limit = double.tryParse(controller.text);
if (limit != null && limit > 0) {
final newBudget = Budget(
id: existingBudget?.id ?? 0,
walletId: wallet.id,
monthlyLimit: limit,
isShared: isShared,
);
await ref.read(budgetProvider.notifier).addOrUpdateBudget(newBudget);
if (mounted) Navigator.pop(ctx);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
);
}
),
);
}
void _showFilterSheet() {
final natures = ['ALL', 'CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
String? tempFilterNature = _filterNature;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => StatefulBuilder(
builder: (context, setSheetState) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filter Budgets', style: Theme.of(context).textTheme.titleLarge),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(ctx)),
],
),
const SizedBox(height: 24),
Text('Account Nature', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: tempFilterNature ?? 'ALL',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) {
setSheetState(() {
tempFilterNature = val == 'ALL' ? null : val;
});
}
},
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() => _filterNature = null);
Navigator.pop(ctx);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
side: BorderSide(color: Colors.grey.shade300),
),
child: Text('Reset', style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.bold)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() => _filterNature = tempFilterNature);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Apply Filter', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
const SizedBox(height: 16),
],
),
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final budgetsState = ref.watch(budgetProvider);
final walletsState = ref.watch(walletProvider);
final transState = ref.watch(transactionProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _searchController,
onChanged: (val) => setState(() => _searchQuery = val),
decoration: InputDecoration(
hintText: 'Search budgets...',
prefixIcon: const Icon(LucideIcons.search),
suffixIcon: _searchController.text.isNotEmpty ? IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
) : null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
LucideIcons.filter,
color: _filterNature != null ? const Color(0xFF6C63FF) : null,
),
onPressed: _showFilterSheet,
),
],
),
),
Expanded(
child: budgetsState.when(
loading: () => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 4,
itemBuilder: (context, index) => const ShimmerCard(),
),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (budgets) {
if (!walletsState.hasValue || !transState.hasValue) {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 4,
itemBuilder: (context, index) => const ShimmerCard(),
);
}
final wallets = walletsState.value!.where((w) {
final nature = (w.nature ?? 'CASH').trim().toUpperCase();
final hasBudget = budgets.any((b) => b.walletId == w.id);
final matchSearch = _searchQuery.isEmpty || w.name.toLowerCase().contains(_searchQuery.toLowerCase());
final matchNature = _filterNature == null || nature == _filterNature;
final matchBaseCondition = hasBudget || nature == 'EXPENSE' || nature == 'INVESTMENTS';
return matchBaseCondition && matchSearch && matchNature;
}).toList();
final transactions = transState.value!;
final now = DateTime.now();
// Calculate spent per wallet for the current month
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year);
final Map<int, double> spentByWallet = {};
for (var t in currentMonthTxs) {
if (t.toWalletId != null) {
spentByWallet[t.toWalletId!] = (spentByWallet[t.toWalletId!] ?? 0) + t.amount;
}
}
if (wallets.isEmpty) {
return const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('No budgets or eligible accounts found.')));
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: wallets.length,
itemBuilder: (context, index) {
final wallet = wallets[index];
final existingBudgetIndex = budgets.indexWhere((b) => b.walletId == wallet.id);
final budget = existingBudgetIndex >= 0 ? budgets[existingBudgetIndex] : null;
final spent = spentByWallet[wallet.id] ?? 0.0;
if (budget == null) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: const CircleAvatar(child: Icon(LucideIcons.wallet)),
title: Text(wallet.name),
subtitle: Text('${wallet.nature} • No budget set'),
trailing: TextButton(
onPressed: () => _showSetBudgetDialog(context, wallet, null),
child: const Text('Set Budget'),
),
),
);
}
final limit = budget.monthlyLimit;
final double progress;
if (limit <= 0) {
progress = 1.0;
} else {
progress = (spent / limit).clamp(0.0, 1.0);
}
Color progressColor = Colors.green;
if (progress > 0.9) progressColor = Colors.red;
else if (progress > 0.7) progressColor = Colors.orange;
return Card(
margin: const EdgeInsets.only(bottom: 16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Row(
children: [
const Icon(LucideIcons.wallet, size: 20, color: Colors.blue),
const SizedBox(width: 8),
Expanded(child: Text(wallet.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), overflow: TextOverflow.ellipsis)),
],
),
),
IconButton(
icon: const Icon(LucideIcons.edit3, size: 18),
onPressed: () => _showSetBudgetDialog(context, wallet, budget),
)
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
valueColor: AlwaysStoppedAnimation<Color>(progressColor),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Spent: Rs. ${spent.toStringAsFixed(2)}', style: TextStyle(color: Colors.grey.shade700)),
Text('Limit: Rs. ${limit.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold)),
],
),
if (progress >= 1.0)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text('Budget Exceeded!', style: TextStyle(color: Colors.red.shade700, fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
);
}),
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,881 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
import '../../transactions/data/repository.dart';
import 'wallet_ledger_screen.dart';
class AccountsScreen extends ConsumerStatefulWidget {
final String? initialFilterNature;
const AccountsScreen({super.key, this.initialFilterNature});
@override
ConsumerState<AccountsScreen> createState() => _AccountsScreenState();
}
class _AccountsScreenState extends ConsumerState<AccountsScreen> {
String _searchQuery = '';
String? _filterNature;
bool _isSearching = false;
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
_filterNature = widget.initialFilterNature;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<void> _onRefresh() async {
ref.invalidate(walletProvider);
ref.invalidate(invitationProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
void _showCreateWalletDialog() {
final ctrl = TextEditingController();
final amtCtrl = TextEditingController();
DateTime openingDate = DateTime.now();
String selectedNature = 'CASH';
final natures = ['CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setStateDialog) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF6C63FF).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.wallet, color: Color(0xFF6C63FF), size: 24),
),
const SizedBox(width: 16),
const Text(
'New Account',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 24),
TextField(
controller: ctrl,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Account Name (e.g. Household)',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: selectedNature,
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87),
decoration: InputDecoration(
labelText: 'Account Nature',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => selectedNature = val);
},
),
const SizedBox(height: 16),
TextField(
controller: amtCtrl,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
decoration: InputDecoration(
hintText: 'Opening Balance (Optional)',
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
const SizedBox(height: 16),
InkWell(
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: openingDate,
firstDate: DateTime.now().subtract(const Duration(days: 365 * 10)),
lastDate: DateTime.now(),
);
if (picked != null) {
setStateDialog(() => openingDate = picked);
}
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
const Icon(LucideIcons.calendar, color: Colors.grey),
const SizedBox(width: 12),
Text(
'Date: ${DateFormat('MMM dd, yyyy').format(openingDate)}',
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
),
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
style: TextButton.styleFrom(
foregroundColor: Colors.grey.shade700,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
if (ctrl.text.isNotEmpty) {
final double amt = double.tryParse(amtCtrl.text) ?? 0.0;
final newWallet = await ref.read(walletProvider.notifier).createWallet(
name: ctrl.text,
nature: selectedNature,
initialBalance: 0.0,
);
if (amt > 0) {
final tx = Transaction(
id: 0,
type: 'INCOME',
amount: amt,
date: openingDate,
description: 'Opening Balance',
toWalletId: newWallet.id,
);
await ref.read(transactionProvider.notifier).addTransaction(tx);
}
if (context.mounted) Navigator.pop(ctx);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Create', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
);
},
),
);
}
void _showFilterSheet() {
final natures = ['ALL', 'CASH', 'SAVINGS', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'INCOME', 'EXPENSE', 'RECEIVABLES'];
String? tempFilterNature = _filterNature;
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => StatefulBuilder(
builder: (context, setSheetState) {
return Container(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filter Accounts', style: Theme.of(context).textTheme.titleLarge),
IconButton(icon: const Icon(LucideIcons.x), onPressed: () => Navigator.pop(ctx)),
],
),
const SizedBox(height: 24),
Text('Account Nature', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
value: tempFilterNature ?? 'ALL',
style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.black87, fontSize: 16),
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) {
setSheetState(() {
tempFilterNature = val == 'ALL' ? null : val;
});
}
},
),
const SizedBox(height: 32),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
setState(() => _filterNature = null);
Navigator.pop(ctx);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
side: BorderSide(color: Colors.grey.shade300),
),
child: Text('Reset', style: TextStyle(color: Colors.grey.shade700, fontWeight: FontWeight.bold)),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: () {
setState(() => _filterNature = tempFilterNature);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text('Apply Filter', style: TextStyle(fontWeight: FontWeight.bold)),
),
),
],
),
const SizedBox(height: 16),
],
),
),
),
);
},
),
);
}
@override
Widget build(BuildContext context) {
final walletsState = ref.watch(walletProvider);
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
bottom: false,
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Row(
children: [
if (Navigator.of(context).canPop())
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.pop(context),
),
),
Expanded(
child: TextField(
controller: _searchController,
onChanged: (val) => setState(() => _searchQuery = val),
decoration: InputDecoration(
hintText: 'Search accounts...',
prefixIcon: const Icon(LucideIcons.search),
suffixIcon: _searchController.text.isNotEmpty ? IconButton(
icon: const Icon(LucideIcons.x),
onPressed: () {
_searchController.clear();
setState(() => _searchQuery = '');
},
) : null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
const SizedBox(width: 8),
IconButton(
icon: Icon(
LucideIcons.filter,
color: _filterNature != null ? const Color(0xFF6C63FF) : null,
),
onPressed: _showFilterSheet,
),
IconButton(
icon: const Icon(LucideIcons.plusCircle),
onPressed: _showCreateWalletDialog,
),
],
),
),
Expanded(
child: walletsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(child: Text('Error: $e')),
data: (allWallets) {
final wallets = allWallets.where((w) {
final matchSearch = _searchQuery.isEmpty ||
w.name.toLowerCase().contains(_searchQuery.toLowerCase());
final matchNature = _filterNature == null || w.nature == _filterNature;
return matchSearch && matchNature;
}).toList();
if (wallets.isEmpty) {
return const Center(child: Text('No accounts found.'));
}
return RefreshIndicator(
onRefresh: _onRefresh,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: wallets.length,
itemBuilder: (context, index) {
final w = wallets[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)),
elevation: 0,
child: Column(
children: [
InkWell(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
},
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
CircleAvatar(
backgroundColor: Colors.blue.withValues(alpha: 0.1),
child: const Icon(LucideIcons.wallet, color: Colors.blue),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(w.name, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 4),
Text('${w.nature ?? "CASH"} • Balance: Rs. ${w.balance}', style: TextStyle(color: Colors.grey.shade600, fontSize: 13)),
],
),
),
],
),
),
),
Divider(height: 1, color: Colors.grey.shade200),
FittedBox(
fit: BoxFit.scaleDown,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
icon: const Icon(LucideIcons.userPlus, size: 14),
label: const Text('Invite', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
showDialog(
context: context,
builder: (ctx) => InviteDialogWidget(wallet: w),
);
},
),
TextButton.icon(
icon: const Icon(LucideIcons.edit2, size: 14),
label: const Text('Edit', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
final editCtrl = TextEditingController(text: w.name);
String editNature = w.nature ?? 'CASH';
final natures = ['CASH', 'SAVINGS', 'EXPENSE', 'INVESTMENTS', 'LOAN', 'LENDING', 'PAYABLES', 'RECEIVABLES', 'INCOME'];
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (context, setStateDialog) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Edit Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: editCtrl,
decoration: InputDecoration(
hintText: 'Account Name',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: editNature,
decoration: InputDecoration(
labelText: 'Account Nature',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
),
items: natures.map((n) => DropdownMenuItem(value: n, child: Text(n))).toList(),
onChanged: (val) {
if (val != null) setStateDialog(() => editNature = val);
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () async {
if (editCtrl.text.isNotEmpty) {
try {
await ref.read(walletProvider.notifier).editWallet(
w.id,
name: editCtrl.text.trim(),
nature: editNature,
);
if (context.mounted) {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet updated')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to edit: $e')));
}
}
}
},
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C63FF), foregroundColor: Colors.white),
child: const Text('Save', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
),
),
);
},
),
TextButton.icon(
icon: const Icon(LucideIcons.trash2, size: 14, color: Colors.red),
label: const Text('Delete', style: TextStyle(color: Colors.red, fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(28)),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Delete Account', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.red)),
const SizedBox(height: 16),
Text('Are you sure you want to delete ${w.name}? This action cannot be undone.', style: const TextStyle(fontSize: 16)),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16))),
child: const Text('Delete', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
],
),
),
),
);
if (confirm == true) {
try {
await ref.read(walletProvider.notifier).deleteWallet(w.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Wallet deleted')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
},
),
TextButton.icon(
icon: const Icon(LucideIcons.list, size: 14),
label: const Text('Ledger', style: TextStyle(fontSize: 12)),
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 8)),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => WalletLedgerScreen(wallet: w)));
},
),
],
),
), // Close FittedBox
],
),
);
},
),
);
},
),
),
],
),
),
);
}
}
class InviteDialogWidget extends ConsumerStatefulWidget {
final Wallet wallet;
const InviteDialogWidget({super.key, required this.wallet});
@override
ConsumerState<InviteDialogWidget> createState() => _InviteDialogWidgetState();
}
class _InviteDialogWidgetState extends ConsumerState<InviteDialogWidget> {
TextEditingController _autoCompleteCtrl = TextEditingController();
bool _isInviting = false;
bool _isLoadingMembers = true;
List<WalletMember> _members = [];
List<String> _knownContacts = [];
@override
void initState() {
super.initState();
_fetchMembers();
}
@override
void dispose() {
_autoCompleteCtrl.dispose();
super.dispose();
}
Future<void> _fetchMembers() async {
try {
final members = await ApiRepository().getWalletMembers(widget.wallet.id);
List<String> contacts = [];
try {
contacts = await ApiRepository().getKnownContacts();
} catch (e) {
debugPrint("Error fetching contacts: $e");
}
if (mounted) {
setState(() {
_members = members;
_knownContacts = contacts.where((c) => !members.any((m) => m.email == c)).toList();
_isLoadingMembers = false;
});
}
} catch (e) {
if (mounted) {
setState(() => _isLoadingMembers = false);
}
}
}
Future<void> _removeMember(WalletMember member) async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Remove Member'),
content: Text('Are you sure you want to remove ${member.email} from this wallet?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Cancel')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red, foregroundColor: Colors.white),
child: const Text('Remove'),
),
],
),
);
if (confirm == true) {
try {
await ApiRepository().removeWalletMember(widget.wallet.id, member.userId);
await _fetchMembers();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Member removed.')));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 20, offset: const Offset(0, 10))],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Invite to ${widget.wallet.name}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 24),
TextField(
controller: _autoCompleteCtrl,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.done,
style: const TextStyle(fontWeight: FontWeight.w500),
onChanged: (val) {
setState(() {});
},
decoration: InputDecoration(
hintText: 'Enter Email Address to invite',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2)),
),
),
if (_autoCompleteCtrl?.text.trim().isNotEmpty == true && (_autoCompleteCtrl?.text.length ?? 0) >= 2)
Builder(
builder: (context) {
final query = _autoCompleteCtrl.text.trim().toLowerCase();
final matches = _knownContacts.where((c) => c.toLowerCase().contains(query)).toList();
if (matches.isEmpty || (matches.length == 1 && matches.first.toLowerCase() == query)) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.only(top: 8),
constraints: const BoxConstraints(maxHeight: 160),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4))],
),
child: ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: matches.length,
separatorBuilder: (_, __) => const Divider(height: 1, indent: 16, endIndent: 16),
itemBuilder: (ctx, index) {
final email = matches[index];
return ListTile(
leading: const CircleAvatar(radius: 14, backgroundColor: Color(0xFF6C63FF), child: Icon(LucideIcons.user, size: 14, color: Colors.white)),
title: Text(email, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
onTap: () {
setState(() {
_autoCompleteCtrl.text = email;
_autoCompleteCtrl.selection = TextSelection.fromPosition(TextPosition(offset: email.length));
});
FocusScope.of(context).unfocus();
},
);
},
),
);
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: _isInviting ? null : () => Navigator.pop(context),
style: TextButton.styleFrom(foregroundColor: Colors.grey.shade700, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('Cancel', style: TextStyle(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 12),
ElevatedButton(
onPressed: _isInviting ? null : () async {
final email = _autoCompleteCtrl.text.trim();
if (email.isNotEmpty && email.contains('@')) {
setState(() => _isInviting = true);
try {
await ref.read(walletProvider.notifier).inviteUser(widget.wallet.id, email);
if (context.mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('User invited!')));
}
} catch (e) {
if (context.mounted) {
setState(() => _isInviting = false);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString().replaceAll('Exception: ', ''))));
}
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: _isInviting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: const Text('Invite', style: TextStyle(fontWeight: FontWeight.bold)),
)
],
),
const SizedBox(height: 24),
const Text('Existing Members', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_isLoadingMembers
? const Center(child: CircularProgressIndicator())
: _members.isEmpty
? const Text('No members found.', style: TextStyle(color: Colors.grey))
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: _members.length,
itemBuilder: (context, index) {
final member = _members[index];
final isOwner = member.role == 'OWNER';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(backgroundColor: Colors.grey.shade200, child: const Icon(LucideIcons.user, color: Colors.grey)),
title: Text(member.email, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
subtitle: Text(member.role, style: TextStyle(color: isOwner ? Colors.blue : Colors.grey, fontSize: 12)),
trailing: !isOwner
? IconButton(
icon: const Icon(LucideIcons.userMinus, color: Colors.red, size: 20),
onPressed: () => _removeMember(member),
)
: null,
);
},
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,604 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../transactions/presentation/add_transaction_screen.dart';
import '../../transactions/presentation/all_transactions_screen.dart';
import '../../transactions/providers/providers.dart';
import '../../auth/presentation/profile_screen.dart';
import '../../transactions/data/models.dart';
import '../../budget/presentation/budget_screen.dart';
import '../providers/insight_provider.dart';
import 'widgets/budget_status_card.dart';
import 'widgets/statistics_tab.dart';
import '../../../core/widgets/shimmer_loading.dart';
import '../../../core/theme/nature_colors.dart';
import 'wallet_ledger_screen.dart';
import 'maturity_dialog.dart';
import 'accounts_screen.dart';
class DashboardScreen extends ConsumerStatefulWidget {
const DashboardScreen({super.key});
@override
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
int _currentIndex = 0;
String _selectedFilter = 'All Time';
DateTimeRange? _customDateRange;
Timer? _notificationTimer;
final List<String> _filters = ['Today', 'Last 3 Days', 'Week', 'Month', 'Year', 'All Time', 'Custom'];
@override
void initState() {
super.initState();
_notificationTimer = Timer.periodic(const Duration(minutes: 2), (_) {
ref.invalidate(invitationProvider);
});
}
@override
void dispose() {
_notificationTimer?.cancel();
super.dispose();
}
Future<void> _onRefresh() async {
ref.invalidate(transactionProvider);
ref.invalidate(walletProvider);
ref.invalidate(categoryProvider);
ref.invalidate(budgetProvider);
ref.invalidate(invitationProvider);
await Future.delayed(const Duration(milliseconds: 500));
}
DateTimeRange _getDateRange(List<Transaction> all) {
if (_selectedFilter == 'All Time') {
DateTime start = DateTime(2000);
if (all.isNotEmpty) {
start = all.map((t) => t.date).reduce((a, b) => a.isBefore(b) ? a : b);
}
return DateTimeRange(start: start, end: DateTime.now());
}
final now = DateTime.now();
DateTime start;
DateTime end = now;
switch (_selectedFilter) {
case 'Today':
start = DateTime(now.year, now.month, now.day);
break;
case 'Last 3 Days':
start = now.subtract(const Duration(days: 3));
break;
case 'Week':
start = now.subtract(const Duration(days: 7));
break;
case 'Month':
start = DateTime(now.year, now.month - 1, now.day);
break;
case 'Year':
start = DateTime(now.year - 1, now.month, now.day);
break;
case 'Custom':
if (_customDateRange != null) {
start = _customDateRange!.start;
end = _customDateRange!.end;
} else {
start = DateTime(2000);
if (all.isNotEmpty) {
start = all.map((t) => t.date).reduce((a, b) => a.isBefore(b) ? a : b);
}
}
break;
default:
start = DateTime(2000);
}
return DateTimeRange(start: start, end: end);
}
List<Transaction> _filterTransactions(List<Transaction> all) {
if (_selectedFilter == 'All Time' || (_selectedFilter == 'Custom' && _customDateRange == null)) return all;
final range = _getDateRange(all);
final start = range.start;
final end = range.end;
return all.where((t) {
final d = t.date;
return d.isAfter(start.subtract(const Duration(seconds: 1))) &&
d.isBefore(end.add(const Duration(days: 1)));
}).toList();
}
Future<void> _pickCustomDateRange() async {
final picked = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2101),
);
if (picked != null) {
setState(() {
_customDateRange = picked;
_selectedFilter = 'Custom';
});
}
}
@override
Widget build(BuildContext context) {
final transState = ref.watch(transactionProvider);
final categoriesState = ref.watch(categoryProvider);
final insight = ref.watch(insightProvider);
final walletsState = ref.watch(walletProvider);
final allTransactions = transState.value ?? [];
final range = _getDateRange(allTransactions);
final startDate = range.start;
final endDate = range.end;
String title;
if (_currentIndex == 0) title = 'Dashboard';
else if (_currentIndex == 1) title = 'Statistics';
else if (_currentIndex == 2) title = 'My Accounts';
else title = 'Budgets';
return Scaffold(
appBar: AppBar(
title: Text(title),
backgroundColor: Colors.transparent,
elevation: 0,
actions: [
Consumer(
builder: (context, ref, child) {
final invitationsState = ref.watch(invitationProvider);
final pendingInvites = invitationsState.value?.where((inv) => inv.status == 'PENDING').toList() ?? [];
return PopupMenuButton<String>(
icon: Stack(
children: [
const Icon(LucideIcons.bell),
if (pendingInvites.isNotEmpty)
Positioned(
right: 0,
top: 0,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(6),
),
constraints: const BoxConstraints(minWidth: 12, minHeight: 12),
child: Text(
'${pendingInvites.length}',
style: const TextStyle(color: Colors.white, fontSize: 8, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
),
)
],
),
offset: const Offset(0, 50),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
itemBuilder: (context) {
if (pendingInvites.isEmpty) {
return [
const PopupMenuItem(
enabled: false,
child: Text('No new notifications'),
)
];
}
return pendingInvites.map((inv) => PopupMenuItem<String>(
enabled: false,
child: Container(
width: 250,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('You have been invited to a wallet by User ${inv.inviterId}', style: const TextStyle(fontSize: 14)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
Navigator.pop(context);
ref.read(invitationProvider.notifier).rejectInvitation(inv.id);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Reject'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
ref.read(invitationProvider.notifier).acceptInvitation(inv.id);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text('Accept'),
),
],
)
],
),
),
)).toList();
},
);
}
),
IconButton(
icon: const Icon(LucideIcons.user),
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const ProfileScreen()));
},
),
],
),
body: transState.when(
loading: () => ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 4,
itemBuilder: (context, index) => const ShimmerCard(),
),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (allTransactions) {
final transactions = _filterTransactions(allTransactions);
final totalIncome = transactions.where((t) => t.type == 'INCOME').fold(0.0, (s, t) => s + t.amount);
final totalExpense = transactions.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount);
final totalInvestment = transactions.where((t) => t.type == 'INVESTMENT').fold(0.0, (s, t) => s + t.amount);
double totalPayablesPeriod = 0.0;
double totalReceivablesPeriod = 0.0;
final safeWallets = walletsState.hasValue ? walletsState.value! : <Wallet>[];
for (var t in transactions) {
if (t.toWalletId != null) {
final w = safeWallets.where((w) => w.id == t.toWalletId);
if (w.isNotEmpty) {
final nature = w.first.nature;
if (nature == 'PAYABLES' || nature == 'LOAN') {
totalPayablesPeriod += t.amount;
} else if (nature == 'RECEIVABLES' || nature == 'LENDING') {
totalReceivablesPeriod += t.amount;
}
}
}
}
double cashBalance = 0;
double expenseBalance = 0;
double savingsBalance = 0;
double investmentsBalance = 0;
double payablesBalance = 0;
double receivablesBalance = 0;
if (walletsState.hasValue) {
for (var w in walletsState.value!) {
final nature = w.nature ?? 'CASH';
if (nature == 'CASH') {
cashBalance += w.balance;
} else if (nature == 'EXPENSE') {
expenseBalance += w.balance;
} else if (nature == 'SAVINGS' || nature == 'INCOME') {
savingsBalance += w.balance;
} else if (nature == 'INVESTMENTS') {
investmentsBalance += w.balance;
} else if (nature == 'LOAN' || nature == 'PAYABLES') {
payablesBalance += w.balance;
} else if (nature == 'LENDING') {
receivablesBalance += w.balance;
}
}
}
return IndexedStack(
index: _currentIndex,
children: [
// ---------------- HOME TAB ----------------
RefreshIndicator(
onRefresh: _onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (insight != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: insight.type == 'WARNING' ? Colors.orange.shade50 : (insight.type == 'SUCCESS' ? Colors.green.shade50 : Colors.blue.shade50),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: insight.type == 'WARNING' ? Colors.orange : (insight.type == 'SUCCESS' ? Colors.green : Colors.blue), width: 1),
),
child: Row(
children: [
Icon(
insight.type == 'WARNING' ? LucideIcons.alertTriangle : (insight.type == 'SUCCESS' ? LucideIcons.checkCircle : LucideIcons.info),
color: insight.type == 'WARNING' ? Colors.orange : (insight.type == 'SUCCESS' ? Colors.green : Colors.blue),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(insight.title, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(insight.message, style: const TextStyle(fontSize: 12)),
],
),
),
],
),
),
const SizedBox(height: 24),
],
const BudgetStatusCard(),
const SizedBox(height: 24),
// Summary Cards Grid
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.5,
children: [
_buildSummaryCard(context, 'Balance', cashBalance, NatureColors.getColor('BALANCE'), LucideIcons.wallet, 'CASH'),
_buildSummaryCard(context, 'Expense', expenseBalance, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUpRight, 'EXPENSE'),
_buildSummaryCard(context, 'Savings', savingsBalance, NatureColors.getColor('SAVINGS'), LucideIcons.piggyBank, 'SAVINGS'),
_buildSummaryCard(context, 'Investments', investmentsBalance, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp, 'INVESTMENTS'),
_buildSummaryCard(context, 'Payables', payablesBalance, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle, 'PAYABLES'),
_buildSummaryCard(context, 'Receivables', receivablesBalance, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft, 'RECEIVABLES'),
],
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Recent Transactions', style: Theme.of(context).textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold)),
TextButton(
onPressed: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AllTransactionsScreen()));
},
child: const Text('See All'),
),
],
),
const SizedBox(height: 8),
allTransactions.isEmpty
? const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('No transactions yet!')))
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: allTransactions.length > 5 ? 5 : allTransactions.length,
itemBuilder: (context, index) {
final t = allTransactions[index];
final isIncome = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null);
final isInvestment = t.type == 'INVESTMENT';
final isExpense = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null);
final isTransfer = t.type == 'TRANSFER' && t.fromWalletId != null && t.toWalletId != null;
Color typeColor = Colors.grey;
IconData typeIcon = LucideIcons.arrowRightLeft;
if (isIncome) {
typeColor = NatureColors.getColor('INCOME');
typeIcon = LucideIcons.arrowDownCircle;
} else if (isExpense) {
typeColor = NatureColors.getColor('EXPENSE');
typeIcon = LucideIcons.arrowUpCircle;
} else if (isInvestment) {
typeColor = NatureColors.getColor('INVESTMENTS');
typeIcon = LucideIcons.trendingUp;
} else if (isTransfer) {
typeColor = NatureColors.getColor('TRANSFER');
if (walletsState.hasValue) {
final toW = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull;
final fromW = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull;
if (toW != null && (toW.nature == 'PAYABLES' || toW.nature == 'LOAN')) {
typeColor = NatureColors.getColor('PAYABLES');
typeIcon = LucideIcons.alertCircle;
} else if (toW != null && (toW.nature == 'RECEIVABLES' || toW.nature == 'LENDING')) {
typeColor = NatureColors.getColor('RECEIVABLES');
typeIcon = LucideIcons.arrowDownLeft;
} else if (fromW != null && (fromW.nature == 'PAYABLES' || fromW.nature == 'LOAN')) {
typeColor = NatureColors.getColor('PAYABLES');
typeIcon = LucideIcons.alertCircle;
} else if (fromW != null && (fromW.nature == 'RECEIVABLES' || fromW.nature == 'LENDING')) {
typeColor = NatureColors.getColor('RECEIVABLES');
typeIcon = LucideIcons.arrowDownLeft;
}
}
}
String categoryName = 'Unknown';
if (categoriesState.hasValue) {
final match = categoriesState.value!.where((c) => c.id == t.categoryId);
if (match.isNotEmpty) categoryName = match.first.name;
}
String accountName = 'Unknown';
if (walletsState.hasValue) {
if (isExpense && t.toWalletId != null) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = match.first.name;
} else if (isIncome && (t.toWalletId != null)) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = match.first.name;
} else if (isTransfer && t.toWalletId != null) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) accountName = 'To ${match.first.name}';
}
}
String fromName = '';
if (t.fromWalletId != null && walletsState.hasValue) {
final match = walletsState.value!.where((w) => w.id == t.fromWalletId);
if (match.isNotEmpty) fromName = match.first.name;
}
String toName = '';
if (t.toWalletId != null && walletsState.hasValue) {
final match = walletsState.value!.where((w) => w.id == t.toWalletId);
if (match.isNotEmpty) toName = match.first.name;
}
String subtitleText = '';
if (categoryName == 'Unknown' && fromName.isNotEmpty && toName.isNotEmpty) {
subtitleText = '$fromName$toName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else if (categoryName == 'Unknown' && toName.isNotEmpty) {
subtitleText = 'To $toName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else if (categoryName == 'Unknown' && fromName.isNotEmpty) {
subtitleText = 'From $fromName${DateFormat('MMM dd, yyyy').format(t.date)}';
} else {
subtitleText = '$categoryName${DateFormat('MMM dd, yyyy').format(t.date)}${fromName.isNotEmpty ? ' • 💼 $fromName' : ''}';
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => AddTransactionScreen(transaction: t)));
},
leading: CircleAvatar(
backgroundColor: typeColor.withOpacity(0.1),
child: Icon(typeIcon, color: typeColor),
),
title: Text((t.description != null && t.description!.isNotEmpty) ? t.description! : accountName, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(subtitleText),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${isIncome ? '+' : (isTransfer ? '' : '-')}Rs. ${t.amount.toStringAsFixed(0)}',
style: TextStyle(fontWeight: FontWeight.bold, color: typeColor),
),
if (t.investmentStatus == 'OPEN' && (typeColor == NatureColors.getColor('INVESTMENTS') || typeColor == NatureColors.getColor('RECEIVABLES')))
GestureDetector(
onTap: () {
showDialog(context: context, builder: (_) => MaturityDialog(transaction: t));
},
child: const Padding(
padding: EdgeInsets.only(top: 4.0),
child: Text('Close', style: TextStyle(color: Colors.blue, fontWeight: FontWeight.bold, fontSize: 12)),
),
),
],
),
),
);
},
)
],
),
),
),
// ---------------- STATS TAB ----------------
StatisticsTab(
transactions: transactions,
wallets: safeWallets,
categories: categoriesState.value ?? [],
selectedFilter: _selectedFilter,
startDate: startDate,
endDate: endDate,
filters: _filters,
onFilterChanged: (val) {
setState(() => _selectedFilter = val);
},
onPickCustomDateRange: _pickCustomDateRange,
onRefresh: _onRefresh,
),
// ---------------- WALLETS TAB ----------------
const AccountsScreen(),
// ---------------- BUDGETS TAB ----------------
const BudgetScreen(),
],
);
},
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: _currentIndex >= 2 ? _currentIndex + 1 : _currentIndex,
type: BottomNavigationBarType.fixed,
onTap: (index) {
if (index == 2) {
Navigator.push(context, MaterialPageRoute(builder: (_) => const AddTransactionScreen()));
} else {
setState(() {
_currentIndex = index > 2 ? index - 1 : index;
});
}
},
selectedItemColor: Theme.of(context).colorScheme.primary,
unselectedItemColor: Colors.grey,
showSelectedLabels: true,
showUnselectedLabels: true,
items: const [
BottomNavigationBarItem(icon: Icon(LucideIcons.home), label: 'Home'),
BottomNavigationBarItem(icon: Icon(LucideIcons.pieChart), label: 'Stats'),
BottomNavigationBarItem(icon: Icon(LucideIcons.plusCircle), label: 'Add'),
BottomNavigationBarItem(icon: Icon(LucideIcons.wallet), label: 'Wallets'),
BottomNavigationBarItem(icon: Icon(LucideIcons.target), label: 'Budgets'),
],
),
);
}
Widget _buildSummaryCard(BuildContext context, String title, double amount, Color color, IconData icon, [String? nature]) {
return GestureDetector(
onTap: nature != null ? () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => AccountsScreen(initialFilterNature: nature),
),
);
} : null,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(icon, color: color, size: 16),
const SizedBox(width: 8),
Text(title, style: TextStyle(color: color, fontWeight: FontWeight.w600)),
],
),
Text(
'Rs. ${amount.toStringAsFixed(0)}',
style: TextStyle(color: color, fontSize: 20, fontWeight: FontWeight.bold),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:intl/intl.dart';
import '../../transactions/data/models.dart';
import '../../transactions/providers/providers.dart';
class MaturityDialog extends ConsumerStatefulWidget {
final Transaction transaction;
const MaturityDialog({super.key, required this.transaction});
@override
ConsumerState<MaturityDialog> createState() => _MaturityDialogState();
}
class _MaturityDialogState extends ConsumerState<MaturityDialog> {
final _amountController = TextEditingController();
DateTime _maturityDate = DateTime.now();
Wallet? _toWallet;
@override
void initState() {
super.initState();
_amountController.text = widget.transaction.amount.toStringAsFixed(2);
}
void _pickDate() async {
final date = await showDatePicker(
context: context,
initialDate: _maturityDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
builder: (context, child) {
return Theme(
data: Theme.of(context).copyWith(
colorScheme: const ColorScheme.light(
primary: Color(0xFF6C63FF),
onPrimary: Colors.white,
onSurface: Colors.black,
),
),
child: child!,
);
},
);
if (date != null) {
setState(() => _maturityDate = date);
}
}
@override
Widget build(BuildContext context) {
final walletsState = ref.watch(walletProvider);
List<Wallet> validWallets = [];
if (walletsState.hasValue) {
validWallets = walletsState.value!.where((w) => w.nature == 'CASH' || w.nature == 'SAVINGS').toList();
if (_toWallet == null && validWallets.length == 1) {
_toWallet = validWallets.first;
}
}
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(28)),
elevation: 0,
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF6C63FF).withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(LucideIcons.checkCircle, color: Color(0xFF6C63FF)),
),
const SizedBox(width: 16),
const Expanded(
child: Text(
'Close Investment',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFF1E1E2D),
),
),
),
],
),
const SizedBox(height: 24),
// Original Investment Details Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Original Investment',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
widget.transaction.description ?? 'Investment',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 4),
Text(
'Rs. ${widget.transaction.amount.toStringAsFixed(2)}',
style: const TextStyle(color: Color(0xFF6C63FF), fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 24),
// Maturity Amount Field
Text(
'Maturity Amount',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
TextField(
controller: _amountController,
keyboardType: const TextInputType.numberWithOptions(decimal: true, signed: false),
textInputAction: TextInputAction.done,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
decoration: InputDecoration(
prefixText: 'Rs. ',
prefixStyle: const TextStyle(color: Colors.black87, fontWeight: FontWeight.w500, fontSize: 16),
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
),
const SizedBox(height: 20),
// Date Picker Field
Text(
'Closure Date',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
InkWell(
onTap: _pickDate,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16),
),
child: Row(
children: [
Icon(LucideIcons.calendar, color: Colors.grey.shade600, size: 20),
const SizedBox(width: 12),
Text(
DateFormat('dd MMM yyyy').format(_maturityDate),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
],
),
),
),
const SizedBox(height: 20),
// To Account Dropdown
Text(
'Deposit To (Savings/Cash)',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.grey.shade700),
),
const SizedBox(height: 8),
DropdownButtonFormField<Wallet>(
decoration: InputDecoration(
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(color: Color(0xFF6C63FF), width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
icon: Icon(LucideIcons.chevronDown, color: Colors.grey.shade600),
value: _toWallet,
hint: const Text('Select an account'),
items: validWallets.map((w) {
return DropdownMenuItem(
value: w,
child: Text(w.name, style: const TextStyle(fontWeight: FontWeight.w500)),
);
}).toList(),
onChanged: (val) {
setState(() => _toWallet = val);
},
),
const SizedBox(height: 32),
// Action Buttons
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.pop(context),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
),
child: Text(
'Cancel',
style: TextStyle(color: Colors.grey.shade600, fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () async {
final amount = double.tryParse(_amountController.text);
if (amount == null || amount <= 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Enter a valid amount')));
return;
}
if (_toWallet == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Select a To Account')));
return;
}
try {
await ref.read(transactionProvider.notifier).closeInvestment(
widget.transaction.id,
amount,
_maturityDate,
_toWallet!.id
);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('Closure processed successfully!'),
backgroundColor: Colors.green,
behavior: SnackBarBehavior.floating,
));
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('Error: $e'),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
));
}
}
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6C63FF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
elevation: 0,
),
child: const Text(
'Confirm',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
import '../../../core/theme/nature_colors.dart';
class WalletLedgerScreen extends ConsumerStatefulWidget {
final Wallet wallet;
const WalletLedgerScreen({super.key, required this.wallet});
@override
ConsumerState<WalletLedgerScreen> createState() => _WalletLedgerScreenState();
}
class _WalletLedgerScreenState extends ConsumerState<WalletLedgerScreen> {
String _selectedFilter = 'All Time';
DateTimeRange? _customDateRange;
void _showFilterSheet() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('Filter Ledger', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
ListTile(
title: const Text('Today'),
onTap: () {
setState(() => _selectedFilter = 'Today');
Navigator.pop(ctx);
},
),
ListTile(
title: const Text('Current Month'),
onTap: () {
setState(() => _selectedFilter = 'Current Month');
Navigator.pop(ctx);
},
),
ListTile(
title: const Text('All Time'),
onTap: () {
setState(() => _selectedFilter = 'All Time');
Navigator.pop(ctx);
},
),
ListTile(
title: const Text('Custom Range'),
onTap: () async {
Navigator.pop(ctx);
final range = await showDateRangePicker(
context: context,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (range != null) {
setState(() {
_selectedFilter = 'Custom Range';
_customDateRange = range;
});
}
},
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
final transactionsState = ref.watch(transactionProvider);
final walletsState = ref.watch(walletProvider);
return Scaffold(
appBar: AppBar(
title: Text('${widget.wallet.name} Ledger'),
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: _showFilterSheet,
),
],
),
body: transactionsState.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, stack) => Center(child: Text('Error: $err')),
data: (transactions) {
// 1. Filter all transactions related to this wallet
final walletTxs = transactions.where((t) => t.fromWalletId == widget.wallet.id || t.toWalletId == widget.wallet.id).toList();
// Sort chronologically (oldest first) to compute running balance
walletTxs.sort((a, b) => a.date.compareTo(b.date));
// 2. Determine date range
DateTime? startDate;
DateTime? endDate;
final now = DateTime.now();
if (_selectedFilter == 'Today') {
startDate = DateTime(now.year, now.month, now.day);
endDate = DateTime(now.year, now.month, now.day, 23, 59, 59);
} else if (_selectedFilter == 'Current Month') {
startDate = DateTime(now.year, now.month, 1);
endDate = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
} else if (_selectedFilter == 'Custom Range' && _customDateRange != null) {
startDate = _customDateRange!.start;
endDate = DateTime(_customDateRange!.end.year, _customDateRange!.end.month, _customDateRange!.end.day, 23, 59, 59);
}
// 3. Calculate opening balance (sum of all transactions BEFORE start date)
double openingBalance = 0.0;
List<Transaction> visibleTxs = [];
for (var t in walletTxs) {
double impact = 0;
if (t.toWalletId == widget.wallet.id) {
impact = t.amount;
} else if (t.fromWalletId == widget.wallet.id) {
impact = -t.amount;
}
if (startDate != null && t.date.isBefore(startDate)) {
openingBalance += impact;
} else if (endDate != null && t.date.isAfter(endDate)) {
// skip
} else {
visibleTxs.add(t);
}
}
// 4. Generate Ledger Rows
double runningBalance = openingBalance;
List<DataRow> rows = [];
// Add Opening Balance Row if we have a date filter
if (startDate != null) {
rows.add(DataRow(
cells: [
const DataCell(Text('-')),
DataCell(Text(DateFormat('dd MMM yyyy').format(startDate))),
DataCell(Text('Rs. ${openingBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))),
const DataCell(Text('-')),
const DataCell(Text('-')),
DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))),
],
));
}
for (int i = 0; i < visibleTxs.length; i++) {
final t = visibleTxs[i];
bool isIncome = t.toWalletId == widget.wallet.id && t.fromWalletId == null;
bool isExpense = t.fromWalletId == widget.wallet.id && t.toWalletId == null;
bool isTransferIn = t.toWalletId == widget.wallet.id && t.fromWalletId != null;
bool isTransferOut = t.fromWalletId == widget.wallet.id && t.toWalletId != null;
double amount = t.amount;
if (isExpense || isTransferOut) {
runningBalance -= amount;
} else {
runningBalance += amount;
}
String fromName = '-';
String toName = '-';
if (isIncome || isTransferIn) {
if (t.fromWalletId != null && walletsState.hasValue) {
fromName = walletsState.value!.where((w) => w.id == t.fromWalletId).firstOrNull?.name ?? 'Unknown';
} else {
fromName = 'External';
}
toName = widget.wallet.name;
} else if (isExpense || isTransferOut) {
fromName = widget.wallet.name;
if (t.toWalletId != null && walletsState.hasValue) {
toName = walletsState.value!.where((w) => w.id == t.toWalletId).firstOrNull?.name ?? 'Unknown';
} else {
toName = 'External';
}
}
Color amountColor = (isExpense || isTransferOut) ? Colors.red : Colors.green.shade700;
String amountPrefix = (isExpense || isTransferOut) ? '-' : '+';
rows.add(DataRow(
cells: [
DataCell(Text('${startDate != null ? i + 1 : i + 1}')),
DataCell(Text(DateFormat('dd MMM yyyy').format(t.date))),
DataCell(Text('$amountPrefix Rs. ${amount.toStringAsFixed(2)}', style: TextStyle(color: amountColor, fontWeight: FontWeight.w600))),
DataCell(Text(fromName)),
DataCell(Text(toName)),
DataCell(Text('Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold))),
],
));
}
if (rows.isEmpty) {
return const Center(child: Text('No transactions found for this period.'));
}
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Filter: $_selectedFilter', style: TextStyle(color: Colors.grey.shade600)),
Text('Closing Balance: Rs. ${runningBalance.toStringAsFixed(2)}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
],
),
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
headingRowColor: WidgetStateProperty.resolveWith((states) => Colors.grey.shade100),
columnSpacing: 24,
columns: const [
DataColumn(label: Text('S.No', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Date', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Amount', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('From', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('To', style: TextStyle(fontWeight: FontWeight.bold))),
DataColumn(label: Text('Balance', style: TextStyle(fontWeight: FontWeight.bold))),
],
rows: rows,
),
),
),
),
],
);
},
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../transactions/providers/providers.dart';
class BudgetStatusCard extends ConsumerWidget {
const BudgetStatusCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final transState = ref.watch(transactionProvider);
final budgetState = ref.watch(budgetProvider);
if (transState.isLoading || budgetState.isLoading) {
return const CircularProgressIndicator();
}
final transactions = transState.value ?? [];
final budgets = budgetState.value ?? [];
if (budgets.isEmpty) {
return const SizedBox.shrink(); // Hide if no budgets
}
final now = DateTime.now();
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year);
double totalSpent = 0;
for (var b in budgets) {
if (b.walletId != null) {
final spentForWallet = currentMonthTxs.where((t) => t.toWalletId == b.walletId).fold(0.0, (s, t) => s + t.amount);
totalSpent += spentForWallet;
}
}
final totalLimit = budgets.fold(0.0, (s, b) => s + b.monthlyLimit);
final progress = totalLimit > 0 ? (totalSpent / totalLimit).clamp(0.0, 1.0) : 0.0;
Color progressColor = Colors.green;
if (progress > 0.9) progressColor = Colors.red;
else if (progress > 0.7) progressColor = Colors.orange;
return Card(
child: InkWell(
onTap: () {
// Could navigate to budget tab or push budget screen
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(LucideIcons.target, size: 18),
SizedBox(width: 8),
Text('Overall Budget Status', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
],
),
const SizedBox(height: 12),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
color: progressColor,
minHeight: 12,
borderRadius: BorderRadius.circular(6),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Spent: Rs. ${totalSpent.toStringAsFixed(0)}', style: TextStyle(color: Colors.grey.shade700)),
Text('Limit: Rs. ${totalLimit.toStringAsFixed(0)}', style: const TextStyle(fontWeight: FontWeight.bold)),
],
)
],
),
),
),
);
}
}

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart';
class CategorySpendingChart extends StatefulWidget {
final List<Transaction> transactions;
final List<Category> categories;
final String selectedNature;
const CategorySpendingChart({
super.key,
required this.transactions,
required this.categories,
required this.selectedNature,
});
@override
State<CategorySpendingChart> createState() => _CategorySpendingChartState();
}
class _CategorySpendingChartState extends State<CategorySpendingChart> {
int touchedIndex = -1;
@override
Widget build(BuildContext context) {
final Map<int, double> categoryTotals = {};
for (var t in widget.transactions) {
bool matches = false;
if (widget.selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null);
else if (widget.selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null);
else if (widget.selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT';
if (matches) {
final catId = t.categoryId ?? -1;
categoryTotals[catId] = (categoryTotals[catId] ?? 0) + t.amount;
}
}
if (categoryTotals.isEmpty) {
return const SizedBox();
}
final List<MapEntry<int, double>> sortedTotals = categoryTotals.entries.toList()
..sort((a, b) => b.value.compareTo(a.value));
final List<Color> colors = NatureColors.getPalette(widget.selectedNature);
double totalAmount = categoryTotals.values.fold(0.0, (s, e) => s + e);
List<PieChartSectionData> sections = [];
for (int i = 0; i < sortedTotals.length; i++) {
final isTouched = i == touchedIndex;
final radius = isTouched ? 45.0 : 35.0;
final fontSize = isTouched ? 16.0 : 12.0;
final amount = sortedTotals[i].value;
final percentage = (amount / totalAmount * 100).toStringAsFixed(1);
sections.add(
PieChartSectionData(
showTitle: false,
color: colors[i % colors.length],
value: amount,
title: '$percentage%',
radius: radius,
titleStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [Shadow(color: Colors.black26, blurRadius: 2)],
),
badgeWidget: isTouched
? Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.1), blurRadius: 4)],
),
child: Icon(Icons.touch_app, size: 16, color: colors[i % colors.length]),
)
: null,
badgePositionPercentageOffset: 1.1,
),
);
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(32), side: BorderSide(color: Colors.grey.shade200)),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.white, Colors.grey.shade50],
),
),
padding: const EdgeInsets.all(28.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Category Breakdown', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w800, letterSpacing: -0.5)),
const SizedBox(height: 32),
SizedBox(
height: 220,
child: Stack(
alignment: Alignment.center,
children: [
PieChart(
PieChartData(
pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event, pieTouchResponse) {
setState(() {
if (!event.isInterestedForInteractions || pieTouchResponse == null || pieTouchResponse.touchedSection == null) {
touchedIndex = -1;
return;
}
touchedIndex = pieTouchResponse.touchedSection!.touchedSectionIndex;
});
},
),
borderData: FlBorderData(show: false),
sectionsSpace: 4,
centerSpaceRadius: 65,
sections: sections,
),
),
// Center Text
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Total', style: TextStyle(fontSize: 14, color: Colors.grey.shade500, fontWeight: FontWeight.w600)),
Text('Rs. ${totalAmount.toStringAsFixed(0)}', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900)),
],
),
],
),
),
const SizedBox(height: 40),
const Text('Details', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.black87)),
const SizedBox(height: 16),
// Modern List for Legends
...List.generate(sortedTotals.length, (i) {
String categoryName = 'Uncategorized';
if (sortedTotals[i].key != -1) {
final match = widget.categories.where((c) => c.id == sortedTotals[i].key);
if (match.isNotEmpty) categoryName = match.first.name;
}
final amount = sortedTotals[i].value;
final percentage = (amount / totalAmount * 100).toStringAsFixed(1);
final isTouched = i == touchedIndex;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
decoration: BoxDecoration(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.05) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isTouched ? colors[i % colors.length].withValues(alpha: 0.3) : Colors.grey.shade100,
width: isTouched ? 1.5 : 1.0,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: isTouched ? 0.05 : 0.02),
blurRadius: isTouched ? 12 : 6,
offset: const Offset(0, 4),
)
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: colors[i % colors.length].withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: Icon(Icons.category, color: colors[i % colors.length], size: 20),
),
const SizedBox(width: 16),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(categoryName, style: TextStyle(fontWeight: FontWeight.w700, fontSize: 15, color: Colors.grey.shade800)),
const SizedBox(height: 4),
Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13, color: Colors.grey.shade500)),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: colors[i % colors.length].withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text('$percentage%', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 13, color: colors[i % colors.length])),
),
],
),
);
}),
],
),
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart';
class DayWiseSpendingChart extends StatelessWidget {
final List<Transaction> transactions;
final List<Wallet> wallets;
final String selectedNature;
final DateTime startDate;
final DateTime endDate;
const DayWiseSpendingChart({
super.key,
required this.transactions,
required this.wallets,
required this.selectedNature,
required this.startDate,
required this.endDate,
});
@override
Widget build(BuildContext context) {
// 1. Determine date range length
final int daysCount = endDate.difference(startDate).inDays + 1;
final int displayDays = daysCount > 0 && daysCount < 1000 ? daysCount : 30; // Guard against 'All Time' returning thousands
final days = List.generate(displayDays, (i) => startDate.add(Duration(days: i)));
// 2. Initialize daily totals
final Map<String, double> dailyTotals = {};
for (var d in days) {
dailyTotals["${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}"] = 0.0;
}
// 3. Populate totals
for (var t in transactions) {
final tDate = "${t.date.year}-${t.date.month.toString().padLeft(2, '0')}-${t.date.day.toString().padLeft(2, '0')}";
if (dailyTotals.containsKey(tDate)) {
bool matches = false;
if (selectedNature == 'INCOME') matches = t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null);
else if (selectedNature == 'EXPENSE') matches = t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null);
else if (selectedNature == 'INVESTMENTS') matches = t.type == 'INVESTMENT';
else if (selectedNature == 'PAYABLES' || selectedNature == 'RECEIVABLES') {
if (t.type == 'TRANSFER' && t.toWalletId != null) {
final w = wallets.where((w) => w.id == t.toWalletId);
if (w.isNotEmpty) {
final nature = w.first.nature;
if (selectedNature == 'PAYABLES' && (nature == 'PAYABLES' || nature == 'LOAN')) matches = true;
if (selectedNature == 'RECEIVABLES' && (nature == 'RECEIVABLES' || nature == 'LENDING')) matches = true;
}
}
}
if (matches) {
dailyTotals[tDate] = dailyTotals[tDate]! + t.amount;
}
}
}
double maxY = 0.0;
for (var total in dailyTotals.values) {
if (total > maxY) maxY = total;
}
if (maxY == 0) maxY = 100;
Color barColor = NatureColors.getColor(selectedNature);
// Make chart horizontally scrollable if there are many days
final screenWidth = MediaQuery.of(context).size.width;
double chartWidth = (screenWidth / 7) * displayDays;
if (chartWidth < screenWidth - 80) { // minimum width
chartWidth = screenWidth - 80;
}
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)),
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Spending Trends', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 32),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(
height: 200,
width: chartWidth,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: maxY * 1.2,
barTouchData: BarTouchData(enabled: false),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index < 0 || index >= days.length) return const SizedBox();
final date = days[index];
final text = "${date.day} ${_monthStr(date.month)}";
// To prevent overcrowding on large ranges
if (displayDays > 14 && index % 2 != 0) return const SizedBox();
if (displayDays > 30 && index % 5 != 0) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(text, style: const TextStyle(color: Colors.grey, fontSize: 10, fontWeight: FontWeight.bold)),
);
},
reservedSize: 28,
),
),
leftTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: maxY / 4 == 0 ? 1 : maxY / 4,
getDrawingHorizontalLine: (value) => FlLine(color: Colors.grey.shade200, strokeWidth: 1, dashArray: [4, 4]),
),
borderData: FlBorderData(show: false),
barGroups: dailyTotals.entries.toList().asMap().entries.map((entry) {
return BarChartGroupData(
x: entry.key,
barRods: [
BarChartRodData(
toY: entry.value.value,
color: barColor,
width: 16,
borderRadius: BorderRadius.circular(8),
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxY * 1.2,
color: Colors.grey.shade100,
),
),
],
);
}).toList(),
),
),
),
),
],
),
),
);
}
String _monthStr(int m) {
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[m - 1];
}
}

View File

@@ -0,0 +1,224 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../transactions/data/models.dart';
import '../../../../core/theme/nature_colors.dart';
import 'day_wise_spending_chart.dart';
import 'category_spending_chart.dart';
class StatisticsTab extends StatefulWidget {
final List<Transaction> transactions;
final List<Wallet> wallets;
final List<Category> categories;
final String selectedFilter;
final DateTime startDate;
final DateTime endDate;
final VoidCallback onPickCustomDateRange;
final Function(String) onFilterChanged;
final Future<void> Function() onRefresh;
final List<String> filters;
const StatisticsTab({
super.key,
required this.transactions,
required this.wallets,
required this.categories,
required this.selectedFilter,
required this.startDate,
required this.endDate,
required this.onPickCustomDateRange,
required this.onFilterChanged,
required this.onRefresh,
required this.filters,
});
@override
State<StatisticsTab> createState() => _StatisticsTabState();
}
class _StatisticsTabState extends State<StatisticsTab> {
String _selectedNature = 'EXPENSE';
Widget _buildTotalCard(String title, double amount, Color color, IconData icon) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16), side: BorderSide(color: Colors.grey.shade200)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
CircleAvatar(backgroundColor: color.withOpacity(0.1), radius: 16, child: Icon(icon, color: color, size: 16)),
const SizedBox(width: 8),
Expanded(child: Text(title, style: const TextStyle(fontSize: 12, color: Colors.grey, fontWeight: FontWeight.bold), maxLines: 1, overflow: TextOverflow.ellipsis)),
],
),
const SizedBox(height: 12),
Text('Rs. ${amount.toStringAsFixed(0)}', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: color)),
],
),
),
);
}
@override
Widget build(BuildContext context) {
// Compute totals
final totalIncome = widget.transactions.where((t) => t.type == 'INCOME' || (t.type == 'TRANSFER' && t.fromWalletId == null)).fold(0.0, (s, t) => s + t.amount);
final totalExpense = widget.transactions.where((t) => t.type == 'EXPENSE' || (t.type == 'TRANSFER' && t.toWalletId == null)).fold(0.0, (s, t) => s + t.amount);
final totalInvestment = widget.transactions.where((t) => t.type == 'INVESTMENT').fold(0.0, (s, t) => s + t.amount);
double totalPayablesPeriod = 0.0;
double totalReceivablesPeriod = 0.0;
for (var t in widget.transactions) {
if (t.toWalletId != null) {
final w = widget.wallets.where((w) => w.id == t.toWalletId);
if (w.isNotEmpty) {
final nature = w.first.nature;
if (nature == 'PAYABLES' || nature == 'LOAN') {
totalPayablesPeriod += t.amount;
} else if (nature == 'RECEIVABLES' || nature == 'LENDING') {
totalReceivablesPeriod += t.amount;
}
}
}
}
Color barColor = NatureColors.getColor(_selectedNature);
return RefreshIndicator(
onRefresh: widget.onRefresh,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: widget.filters.map((f) {
final isSelected = widget.selectedFilter == f;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(f),
selected: isSelected,
onSelected: (val) {
if (val) {
if (f == 'Custom') {
widget.onPickCustomDateRange();
} else {
widget.onFilterChanged(f);
}
}
},
),
);
}).toList(),
),
),
if (widget.selectedFilter == 'Custom')
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'${widget.startDate.day} ${widget.startDate.month} - ${widget.endDate.day} ${widget.endDate.month}',
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.grey),
),
),
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Analytics', style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
decoration: BoxDecoration(
color: barColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: DropdownButton<String>(
value: _selectedNature,
underline: const SizedBox(),
icon: Icon(Icons.arrow_drop_down, color: barColor),
style: TextStyle(color: barColor, fontWeight: FontWeight.bold),
items: const [
DropdownMenuItem(value: 'EXPENSE', child: Text('Expense')),
DropdownMenuItem(value: 'INCOME', child: Text('Income')),
DropdownMenuItem(value: 'PAYABLES', child: Text('Payables')),
DropdownMenuItem(value: 'RECEIVABLES', child: Text('Receivables')),
DropdownMenuItem(value: 'INVESTMENTS', child: Text('Investments')),
],
onChanged: (val) {
if (val != null) {
setState(() => _selectedNature = val);
}
},
),
),
],
),
const SizedBox(height: 24),
if (widget.transactions.isEmpty)
Card(
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24), side: BorderSide(color: Colors.grey.shade200)),
child: const Padding(
padding: EdgeInsets.all(32.0),
child: Center(child: Text('No transactions in this period.', style: TextStyle(color: Colors.grey))),
),
)
else ...[
DayWiseSpendingChart(
transactions: widget.transactions,
wallets: widget.wallets,
selectedNature: _selectedNature,
startDate: widget.startDate,
endDate: widget.endDate,
),
const SizedBox(height: 16),
CategorySpendingChart(
transactions: widget.transactions,
categories: widget.categories,
selectedNature: _selectedNature,
),
],
const SizedBox(height: 32),
const Text('Summary Totals', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.5,
children: [
_buildTotalCard('Income', totalIncome, NatureColors.getColor('INCOME'), LucideIcons.arrowDown),
_buildTotalCard('Expense', totalExpense, NatureColors.getColor('EXPENSE'), LucideIcons.arrowUp),
_buildTotalCard('Investment', totalInvestment, NatureColors.getColor('INVESTMENTS'), LucideIcons.trendingUp),
_buildTotalCard('Payables', totalPayablesPeriod, NatureColors.getColor('PAYABLES'), LucideIcons.alertCircle),
_buildTotalCard('Receivables', totalReceivablesPeriod, NatureColors.getColor('RECEIVABLES'), LucideIcons.arrowDownLeft),
],
),
const SizedBox(height: 32),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../transactions/providers/providers.dart';
import '../../transactions/data/models.dart';
class Insight {
final String title;
final String message;
final String type; // 'WARNING', 'SUCCESS', 'INFO'
Insight(this.title, this.message, this.type);
}
final insightProvider = Provider<Insight?>((ref) {
final transState = ref.watch(transactionProvider);
final budgetsState = ref.watch(budgetProvider);
final categoriesState = ref.watch(categoryProvider);
if (!transState.hasValue || !budgetsState.hasValue || !categoriesState.hasValue) {
return null;
}
final transactions = transState.value!;
final budgets = budgetsState.value!;
final categories = categoriesState.value!;
final now = DateTime.now();
final currentMonthTxs = transactions.where((t) => t.date.month == now.month && t.date.year == now.year).toList();
final totalIncome = currentMonthTxs.where((t) => t.type == 'INCOME').fold(0.0, (s, t) => s + t.amount);
final totalExpense = currentMonthTxs.where((t) => t.type == 'EXPENSE').fold(0.0, (s, t) => s + t.amount);
// Rule 1: Expenses > Income
if (totalExpense > totalIncome && totalIncome > 0) {
return Insight(
'Spending Alert',
'You have spent more than you earned this month! (Rs. ${totalExpense.toStringAsFixed(0)} vs Rs. ${totalIncome.toStringAsFixed(0)})',
'WARNING'
);
}
// Rule 2: Over Budget Categories
for (var budget in budgets) {
final spent = currentMonthTxs
.where((t) => t.type == 'EXPENSE' && t.categoryId == budget.categoryId)
.fold(0.0, (s, t) => s + t.amount);
if (spent > budget.monthlyLimit) {
final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name;
return Insight(
'Budget Exceeded',
'You have exceeded your $categoryName budget by Rs. ${(spent - budget.monthlyLimit).toStringAsFixed(0)}.',
'WARNING'
);
} else if (spent > budget.monthlyLimit * 0.9) {
final categoryName = categories.firstWhere((c) => c.id == budget.categoryId, orElse: () => Category(id: 0, name: 'Unknown')).name;
return Insight(
'Nearing Budget Limit',
'Careful! You have used ${(spent / budget.monthlyLimit * 100).toStringAsFixed(0)}% of your $categoryName budget.',
'WARNING'
);
}
}
// Rule 3: Highest spending category warning if > 40% of total
if (totalExpense > 0) {
final Map<int, double> spentByCategory = {};
for (var t in currentMonthTxs.where((t) => t.type == 'EXPENSE')) {
if (t.categoryId != null) {
spentByCategory[t.categoryId!] = (spentByCategory[t.categoryId!] ?? 0) + t.amount;
}
}
if (spentByCategory.isNotEmpty) {
final maxEntry = spentByCategory.entries.reduce((a, b) => a.value > b.value ? a : b);
if (maxEntry.value > totalExpense * 0.4) {
final categoryName = categories.firstWhere((c) => c.id == maxEntry.key, orElse: () => Category(id: 0, name: 'Unknown')).name;
return Insight(
'High Concentration',
'${(maxEntry.value / totalExpense * 100).toStringAsFixed(0)}% of your expenses this month went to $categoryName.',
'INFO'
);
}
}
}
// Default Positive Insight
if (totalIncome > 0 && totalExpense < totalIncome * 0.5) {
return Insight(
'Great Job!',
'You have saved over 50% of your income this month. Keep it up!',
'SUCCESS'
);
}
return Insight(
'On Track',
'Your finances are looking stable this month.',
'SUCCESS'
);
});

View File

@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../dashboard/presentation/dashboard_screen.dart';
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final PageController _pageController = PageController();
int _currentPage = 0;
final List<Map<String, dynamic>> _pages = [
{
'title': 'Welcome to Kifi',
'description': 'A beautiful, intelligent way to track your personal finances.',
'icon': LucideIcons.wallet,
},
{
'title': 'Double-Entry, Simplified',
'description': 'We track money moving from one place to another. Every expense comes from a Wallet (like Cash) and goes to a Category (like Food).',
'icon': LucideIcons.arrowRightLeft,
},
{
'title': 'Intelligent Automation',
'description': 'Set up recurring transactions and let Kifi handle your monthly subscriptions and salary deposits automatically.',
'icon': LucideIcons.bot,
},
{
'title': 'Powerful Insights',
'description': 'View day-by-day spending trends and strict monthly budgets to take control of your financial future.',
'icon': LucideIcons.barChart2,
},
];
void _completeOnboarding() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('has_seen_onboarding', true);
if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (context) => const DashboardScreen()),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
Expanded(
child: PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() => _currentPage = index);
},
itemCount: _pages.length,
itemBuilder: (context, index) {
final page = _pages[index];
return Padding(
padding: const EdgeInsets.all(40.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
page['icon'],
size: 100,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 60),
Text(
page['title'],
style: Theme.of(context).textTheme.displayLarge?.copyWith(fontSize: 28),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Text(
page['description'],
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.5,
),
textAlign: TextAlign.center,
),
],
),
);
},
),
),
Padding(
padding: const EdgeInsets.all(40.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: List.generate(
_pages.length,
(index) => Container(
margin: const EdgeInsets.only(right: 8),
height: 8,
width: _currentPage == index ? 24 : 8,
decoration: BoxDecoration(
color: _currentPage == index
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.primary.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
),
),
),
ElevatedButton(
onPressed: () {
if (_currentPage == _pages.length - 1) {
_completeOnboarding();
} else {
_pageController.nextPage(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
child: Text(_currentPage == _pages.length - 1 ? 'Get Started' : 'Next'),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,358 @@
class Category {
final int id;
final String name;
final String? iconName;
Category({required this.id, required this.name, this.iconName});
factory Category.fromJson(Map<String, dynamic> json) {
return Category(
id: json['id'],
name: json['name'],
iconName: json['iconName'],
);
}
Map<String, dynamic> toJson() => {'name': name, 'iconName': iconName};
}
class TransactionItem {
final int? id;
final String name;
final double amount;
TransactionItem({this.id, required this.name, required this.amount});
factory TransactionItem.fromJson(Map<String, dynamic> json) {
return TransactionItem(
id: json['id'],
name: json['name'],
amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'],
);
}
Map<String, dynamic> toJson() => {
'name': name,
'amount': amount,
};
}
class TransactionAttachment {
final int id;
final String fileName;
final String filePath;
final String contentType;
TransactionAttachment({required this.id, required this.fileName, required this.filePath, required this.contentType});
factory TransactionAttachment.fromJson(Map<String, dynamic> json) {
return TransactionAttachment(
id: json['id'],
fileName: json['fileName'],
filePath: json['filePath'],
contentType: json['contentType'],
);
}
}
class Transaction {
final int id;
final int? categoryId;
final int? fromWalletId;
final int? toWalletId;
final String? type; // 'INCOME' or 'EXPENSE' or 'INVESTMENT' (kept for backward compatibility during migration)
final double amount;
final DateTime date;
final String? description;
final String? notes;
final String? investmentStatus;
final double? maturityAmount;
final double? profitLoss;
final DateTime? closingDate;
final DateTime? dueDate;
final String? alertSchedule;
final String? alertTime;
final List<TransactionItem>? items;
final List<TransactionAttachment>? attachments;
Transaction({
required this.id,
this.categoryId,
this.fromWalletId,
this.toWalletId,
this.type,
required this.amount,
required this.date,
this.description,
this.notes,
this.investmentStatus,
this.maturityAmount,
this.profitLoss,
this.closingDate,
this.dueDate,
this.alertSchedule,
this.alertTime,
this.items,
this.attachments,
});
factory Transaction.fromJson(Map<String, dynamic> json) {
return Transaction(
id: json['id'],
categoryId: json['categoryId'],
fromWalletId: json['fromWalletId'],
toWalletId: json['toWalletId'],
type: json['type'],
amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'],
date: DateTime.parse(json['date']),
description: json['description'],
notes: json['notes'],
investmentStatus: json['investmentStatus'],
maturityAmount: json['maturityAmount'] != null ? ((json['maturityAmount'] is int) ? (json['maturityAmount'] as int).toDouble() : json['maturityAmount']) : null,
profitLoss: json['profitLoss'] != null ? ((json['profitLoss'] is int) ? (json['profitLoss'] as int).toDouble() : json['profitLoss']) : null,
closingDate: json['closingDate'] != null ? DateTime.parse(json['closingDate']) : null,
dueDate: json['dueDate'] != null ? DateTime.parse(json['dueDate']) : null,
alertSchedule: json['alertSchedule'],
alertTime: json['alertTime'],
items: json['items'] != null ? (json['items'] as List).map((i) => TransactionItem.fromJson(i)).toList() : null,
attachments: json['attachments'] != null ? (json['attachments'] as List).map((i) => TransactionAttachment.fromJson(i)).toList() : null,
);
}
Map<String, dynamic> toJson() {
final map = <String, dynamic>{
'categoryId': categoryId,
'fromWalletId': fromWalletId,
'toWalletId': toWalletId,
'type': type,
'amount': amount,
'date': date.toIso8601String().split('T')[0],
'description': description,
'notes': notes,
'investmentStatus': investmentStatus,
'maturityAmount': maturityAmount,
'profitLoss': profitLoss,
'closingDate': closingDate?.toIso8601String().split('T')[0],
'dueDate': dueDate?.toIso8601String().split('T')[0],
'alertSchedule': alertSchedule,
'alertTime': alertTime,
};
if (items != null) {
map['items'] = items!.map((i) => i.toJson()).toList();
}
return map;
}
}
class Budget {
final int id;
final int? categoryId;
final int? walletId;
final double monthlyLimit;
final bool isShared;
Budget({required this.id, this.categoryId, this.walletId, required this.monthlyLimit, this.isShared = false});
factory Budget.fromJson(Map<String, dynamic> json) {
double limit = 0.0;
if (json['monthlyLimit'] != null) {
if (json['monthlyLimit'] is num) {
limit = (json['monthlyLimit'] as num).toDouble();
} else if (json['monthlyLimit'] is String) {
limit = double.tryParse(json['monthlyLimit']) ?? 0.0;
}
}
int parseId(dynamic val, [int defaultVal = 0]) {
if (val == null) return defaultVal;
if (val is int) return val;
if (val is num) return val.toInt();
if (val is String) return int.tryParse(val) ?? double.tryParse(val)?.toInt() ?? defaultVal;
return defaultVal;
}
return Budget(
id: parseId(json['id']),
categoryId: json['categoryId'] != null ? parseId(json['categoryId']) : null,
walletId: json['walletId'] != null ? parseId(json['walletId']) : null,
monthlyLimit: limit,
isShared: json['isShared'] ?? false,
);
}
Map<String, dynamic> toJson() => {
'categoryId': categoryId,
'walletId': walletId,
'monthlyLimit': monthlyLimit,
'isShared': isShared,
};
}
class RecurringTransaction {
final int id;
final int? categoryId;
final int? fromWalletId;
final int? toWalletId;
final String type;
final double amount;
final String frequency; // DAILY, WEEKLY, MONTHLY
final DateTime? nextExecutionDate;
final DateTime? endDate;
final String? status;
final String? description;
RecurringTransaction({
required this.id,
this.categoryId,
this.fromWalletId,
this.toWalletId,
required this.type,
required this.amount,
required this.frequency,
this.nextExecutionDate,
this.endDate,
this.status,
this.description,
});
factory RecurringTransaction.fromJson(Map<String, dynamic> json) {
return RecurringTransaction(
id: json['id'],
categoryId: json['categoryId'],
fromWalletId: json['fromWalletId'],
toWalletId: json['toWalletId'],
type: json['type'],
amount: (json['amount'] is int) ? (json['amount'] as int).toDouble() : json['amount'],
frequency: json['frequency'],
nextExecutionDate: json['nextExecutionDate'] != null ? DateTime.parse(json['nextExecutionDate']) : null,
endDate: json['endDate'] != null ? DateTime.parse(json['endDate']) : null,
status: json['status'],
description: json['description'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'categoryId': categoryId,
'fromWalletId': fromWalletId,
'toWalletId': toWalletId,
'type': type,
'amount': amount,
'frequency': frequency,
'nextExecutionDate': nextExecutionDate?.toIso8601String(),
'endDate': endDate?.toIso8601String(),
'status': status ?? 'ACTIVE',
'description': description,
};
}
}
class Wallet {
final int id;
final String name;
final int ownerId;
final String? nature;
final double balance;
final String? currency;
final String? icon;
final String? color;
Wallet({
required this.id,
required this.name,
required this.ownerId,
this.nature,
this.balance = 0.0,
this.currency,
this.icon,
this.color,
});
factory Wallet.fromJson(Map<String, dynamic> json) {
int parseId(dynamic val, [int defaultVal = 0]) {
if (val == null) return defaultVal;
if (val is int) return val;
if (val is num) return val.toInt();
if (val is String) return int.tryParse(val) ?? double.tryParse(val)?.toInt() ?? defaultVal;
return defaultVal;
}
double parseDouble(dynamic val, [double defaultVal = 0.0]) {
if (val == null) return defaultVal;
if (val is num) return val.toDouble();
if (val is String) return double.tryParse(val) ?? defaultVal;
return defaultVal;
}
return Wallet(
id: parseId(json['id']),
name: json['name'],
ownerId: parseId(json['ownerId']),
nature: json['nature'],
balance: parseDouble(json['balance']),
currency: json['currency'],
icon: json['icon'],
color: json['color'],
);
}
Map<String, dynamic> toJson() => {
'name': name,
'nature': nature,
'currency': currency,
'initialBalance': balance, // Note: backend uses initialBalance on creation
'icon': icon,
'color': color,
};
}
class WalletInvitation {
final int id;
final int walletId;
final int inviterId;
final String inviteeEmail;
final String status;
final String createdAt;
WalletInvitation({
required this.id,
required this.walletId,
required this.inviterId,
required this.inviteeEmail,
required this.status,
required this.createdAt,
});
factory WalletInvitation.fromJson(Map<String, dynamic> json) {
return WalletInvitation(
id: json['id'],
walletId: json['walletId'],
inviterId: json['inviterId'],
inviteeEmail: json['inviteeEmail'],
status: json['status'],
createdAt: json['createdAt'] ?? '',
);
}
}
class WalletMember {
final int userId;
final String email;
final String role;
final String joinedAt;
WalletMember({
required this.userId,
required this.email,
required this.role,
required this.joinedAt,
});
factory WalletMember.fromJson(Map<String, dynamic> json) {
return WalletMember(
userId: json['userId'],
email: json['email'],
role: json['role'],
joinedAt: json['joinedAt'] ?? '',
);
}
}

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