Initial commit

This commit is contained in:
developer 2026-07-22 20:22:25 +08:00
commit 3526c3a8ac
176 changed files with 11086 additions and 0 deletions

45
.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
.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: "9f455d2486bcb28cad87b062475f42edc959f636"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: android
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: ios
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: linux
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: macos
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: web
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
- platform: windows
create_revision: 9f455d2486bcb28cad87b062475f42edc959f636
base_revision: 9f455d2486bcb28cad87b062475f42edc959f636
# 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'

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"java.configuration.updateBuildConfiguration": "automatic"
}

16
README.md Normal file
View File

@ -0,0 +1,16 @@
# tabungankuy
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.

28
analysis_options.yaml Normal file
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
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

64
android/app/build.gradle Normal file
View File

@ -0,0 +1,64 @@
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
// Load key.properties
def keystoreProperties = new Properties()
def keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.example.tabungankuy"
compileSdk = flutter.compileSdkVersion
ndkVersion = flutter.ndkVersion
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11
}
defaultConfig {
applicationId = "com.example.tabungankuy"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
debug {
// default debug keystore
}
release {
if (keystorePropertiesFile.exists()) {
storeFile file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
}
}
}
buildTypes {
release {
signingConfig = signingConfigs.debug
}
}
}
flutter {
source = "../.."
}
dependencies {}
apply plugin: 'com.google.gms.google-services'

View File

@ -0,0 +1,30 @@
{
"project_info": {
"project_number": "1040012191226",
"firebase_url": "https://tabungankuy-30a9d-default-rtdb.firebaseio.com",
"project_id": "tabungankuy-30a9d",
"storage_bucket": "tabungankuy-30a9d.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1040012191226:android:927024fda961c1e44cf043",
"android_client_info": {
"package_name": "com.example.tabungankuy"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDOt0tippHwz6JaPG9Zo147ATJ9USV5bKs"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

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,63 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Permissions untuk notifications -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="TABUNGANKUY"
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>
<!-- Tambahan untuk WhatsApp -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http" />
</intent>
<package android:name="com.whatsapp" />
</queries>
</manifest>

View File

@ -0,0 +1,5 @@
package com.example.tabungankuy
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: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 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>

35
android/build.gradle Normal file
View File

@ -0,0 +1,35 @@
buildscript {
ext.kotlin_version = '2.1.0'
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:8.3.0"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'com.google.gms:google-services:4.3.15'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=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.12-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.9.1" apply false
id("org.jetbrains.kotlin.android") version "1.9.24" apply false
}
include(":app")

34
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 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1 @@
#include "Generated.xcconfig"

View File

@ -0,0 +1,616 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
proxyType = 1;
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
remoteInfo = Runner;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 10;
files = (
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
331C807B294A618700263BE5 /* RunnerTests.swift */,
);
path = RunnerTests;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
9740EEB31CF90195004384FC /* Generated.xcconfig */,
);
name = Flutter;
sourceTree = "<group>";
};
97C146E51CF9000F007C117D = {
isa = PBXGroup;
children = (
9740EEB11CF90186004384FC /* Flutter */,
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
);
sourceTree = "<group>";
};
97C146EF1CF9000F007C117D /* Products */ = {
isa = PBXGroup;
children = (
97C146EE1CF9000F007C117D /* Runner.app */,
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
);
name = Products;
sourceTree = "<group>";
};
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
97C146FA1CF9000F007C117D /* Main.storyboard */,
97C146FD1CF9000F007C117D /* Assets.xcassets */,
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
97C147021CF9000F007C117D /* Info.plist */,
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
331C8080294A63A400263BE5 /* RunnerTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
);
buildRules = (
);
dependencies = (
331C8086294A63A400263BE5 /* PBXTargetDependency */,
);
name = RunnerTests;
productName = RunnerTests;
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
97C146ED1CF9000F007C117D /* Runner */ = {
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
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 */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
331C807D294A63A400263BE5 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EA1CF9000F007C117D /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
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 = 13.0;
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)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.tabungankuy;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Profile;
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.tabungankuy.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Debug;
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.tabungankuy.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Release;
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.tabungankuy.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
};
name = Profile;
};
97C147031CF9000F007C117D /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
GCC_C_LANGUAGE_STANDARD = gnu99;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
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 = 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 = 13.0;
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)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.tabungankuy;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
};
97C147071CF9000F007C117D /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.tabungankuy;
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,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "group:Runner.xcodeproj">
</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,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,122 @@
{
"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" : "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" : "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: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

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>

49
ios/Runner/Info.plist Normal file
View File

@ -0,0 +1,49 @@
<?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>Tabungankuy</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>tabungankuy</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>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,42 @@
class ProfilModel {
final String userId;
final String nama;
final String target;
final int targetNominal;
final String pin;
final int totalSaldo;
final DateTime? createdAt;
ProfilModel({
required this.userId,
required this.nama,
required this.target,
required this.targetNominal,
required this.pin,
required this.totalSaldo,
this.createdAt,
});
factory ProfilModel.fromMap(Map<String, dynamic> map) {
return ProfilModel(
userId: map['userId'] ?? '',
nama: map['nama'] ?? '',
target: map['target'] ?? '',
targetNominal: (map['targetNominal'] ?? 0).toInt(),
pin: map['pin'] ?? '',
totalSaldo: (map['totalSaldo'] ?? 0).toInt(),
createdAt: map['createdAt']?.toDate(),
);
}
Map<String, dynamic> toMap() {
return {
'userId': userId,
'nama': nama,
'target': target,
'targetNominal': targetNominal,
'pin': pin,
'totalSaldo': totalSaldo,
};
}
}

View File

@ -0,0 +1,20 @@
class TargetEmojiMap {
static const Map<String, String> _map = {
'Iphone': '📱',
'Sepatu': '👟',
'Baju': '👕',
'Motor': '🏍️',
'Mobil': '🚗',
'Rumah': '🏠',
'Liburan': '✈️',
'Laptop': '💻',
'Pendidikan': '📚',
'Perhiasan': '💍',
'PS 5': '🎮',
'Olahraga': '🏋️',
};
static String getEmoji(String label) {
return _map[label] ?? '🎯';
}
}

View File

@ -0,0 +1,25 @@
class TransactionModel {
final String id;
final String userId;
final int nominal;
final String tipe;
final DateTime? createdAt;
TransactionModel({
required this.id,
required this.userId,
required this.nominal,
required this.tipe,
this.createdAt,
});
factory TransactionModel.fromMap(String id, Map<String, dynamic> map) {
return TransactionModel(
id: id,
userId: map['userId'] ?? '',
nominal: (map['nominal'] ?? 0).toInt(),
tipe: map['tipe'] ?? 'masuk',
createdAt: map['createdAt']?.toDate(),
);
}
}

View File

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/history_controller.dart';
class HistoryBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<HistoryController>(
() => HistoryController(),
);
}
}

View File

@ -0,0 +1,422 @@
import 'dart:typed_data';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:pdf/pdf.dart';
import 'package:pdf/widgets.dart' as pw;
import 'package:printing/printing.dart';
class HistoryModel {
final String amount;
final String date;
final String percent;
final String tipe;
final int nominal;
final DateTime? createdAt;
const HistoryModel({
required this.amount,
required this.date,
required this.percent,
required this.tipe,
required this.nominal,
this.createdAt,
});
}
class HistoryController extends GetxController {
final historyList = <HistoryModel>[].obs;
final isLoading = true.obs;
final isDownloading = false.obs;
final namaUser = ''.obs;
final _firestore = FirebaseFirestore.instance;
final _auth = FirebaseAuth.instance;
int _targetNominal = 0;
final _currencyFormat = NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp ',
decimalDigits: 0,
);
@override
void onInit() {
super.onInit();
fetchHistory();
}
Future<void> fetchHistory() async {
try {
isLoading.value = true;
final uid = _auth.currentUser?.uid;
if (uid == null) return;
final profilDoc = await _firestore.collection('profil').doc(uid).get();
if (profilDoc.exists) {
final data = profilDoc.data()!;
namaUser.value = data['nama'] ?? '';
_targetNominal = (data['targetNominal'] ?? 0).toInt();
}
QuerySnapshot snapshot;
try {
snapshot = await _firestore
.collection('transactions')
.where('userId', isEqualTo: uid)
.orderBy('createdAt', descending: true)
.get();
} catch (e) {
snapshot = await _firestore
.collection('transactions')
.where('userId', isEqualTo: uid)
.get();
}
final docs = snapshot.docs.toList()
..sort((a, b) {
final aTime = (a.data() as Map)['createdAt'] as Timestamp?;
final bTime = (b.data() as Map)['createdAt'] as Timestamp?;
if (aTime == null && bTime == null) return 0;
if (aTime == null) return 1;
if (bTime == null) return -1;
return bTime.compareTo(aTime);
});
historyList.value = docs.map((doc) {
final data = doc.data() as Map<String, dynamic>;
final nominal = (data['nominal'] ?? 0).toInt();
final tipe = data['tipe'] ?? 'masuk';
final createdAt = (data['createdAt'] as Timestamp?)?.toDate();
final formattedDate = createdAt != null
? DateFormat('hh:mm a dd MMM yyyy', 'id_ID').format(createdAt)
: '-';
final percentStr = _targetNominal > 0
? '${((nominal / _targetNominal) * 100).toStringAsFixed(1)}%'
: '0%';
return HistoryModel(
amount: _currencyFormat.format(nominal),
date: formattedDate,
percent: percentStr,
tipe: tipe,
nominal: nominal,
createdAt: createdAt,
);
}).toList();
} catch (e) {
Get.snackbar(
'Error',
'Gagal memuat riwayat: $e',
snackPosition: SnackPosition.TOP,
);
} finally {
isLoading.value = false;
}
}
// Generate & Download PDF
Future<void> downloadPdf() async {
if (isDownloading.value) return;
try {
isDownloading.value = true;
final pdfBytes = await _generatePdf();
await Printing.sharePdf(
bytes: pdfBytes,
filename:
'rekap-transaksi-${DateFormat('yyyyMMdd').format(DateTime.now())}.pdf',
);
} catch (e) {
Get.snackbar(
'Error',
'Gagal membuat PDF: $e',
snackPosition: SnackPosition.TOP,
);
} finally {
isDownloading.value = false;
}
}
Future<Uint8List> _generatePdf() async {
final doc = pw.Document();
final now = DateFormat(
'dd MMMM yyyy, HH:mm',
'id_ID',
).format(DateTime.now());
// Hitung total masuk & keluar
int totalMasuk = 0;
int totalKeluar = 0;
for (final item in historyList) {
if (item.tipe == 'masuk') {
totalMasuk += item.nominal;
} else {
totalKeluar += item.nominal;
}
}
final saldoAkhir = totalMasuk - totalKeluar;
// Warna
const primaryColor = PdfColor.fromInt(0xFF5B9BD5);
const lightBlue = PdfColor.fromInt(0xFFEAF4FC);
const textDark = PdfColor.fromInt(0xFF1A1A2E);
const textGrey = PdfColor.fromInt(0xFF888888);
const successColor = PdfColor.fromInt(0xFF43A047);
const dangerColor = PdfColor.fromInt(0xFFE53935);
doc.addPage(
pw.MultiPage(
pageFormat: PdfPageFormat.a4,
margin: const pw.EdgeInsets.all(32),
header: (context) => pw.Container(
padding: const pw.EdgeInsets.only(bottom: 16),
decoration: const pw.BoxDecoration(
border: pw.Border(
bottom: pw.BorderSide(color: primaryColor, width: 2),
),
),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.start,
children: [
pw.Text(
'TabunganKuy',
style: pw.TextStyle(
fontSize: 22,
fontWeight: pw.FontWeight.bold,
color: primaryColor,
),
),
pw.SizedBox(height: 2),
pw.Text(
'Rekap Transaksi Celengan',
style: pw.TextStyle(fontSize: 11, color: textGrey),
),
],
),
pw.Column(
crossAxisAlignment: pw.CrossAxisAlignment.end,
children: [
pw.Text(
namaUser.value,
style: pw.TextStyle(
fontSize: 13,
fontWeight: pw.FontWeight.bold,
color: textDark,
),
),
pw.SizedBox(height: 2),
pw.Text(
'Dicetak: $now',
style: pw.TextStyle(fontSize: 9, color: textGrey),
),
],
),
],
),
),
build: (context) => [
pw.SizedBox(height: 20),
// Kartu ringkasan
pw.Container(
padding: const pw.EdgeInsets.all(16),
decoration: pw.BoxDecoration(
color: lightBlue,
borderRadius: pw.BorderRadius.circular(12),
),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceAround,
children: [
_summaryCard(
'Total Masuk',
_currencyFormat.format(totalMasuk),
successColor,
),
_summaryCard(
'Total Keluar',
_currencyFormat.format(totalKeluar),
dangerColor,
),
_summaryCard(
'Saldo Akhir',
_currencyFormat.format(saldoAkhir),
primaryColor,
),
],
),
),
pw.SizedBox(height: 24),
// Judul tabel
pw.Text(
'Detail Transaksi',
style: pw.TextStyle(
fontSize: 14,
fontWeight: pw.FontWeight.bold,
color: textDark,
),
),
pw.SizedBox(height: 10),
// Tabel
pw.Table(
border: pw.TableBorder.all(
color: const PdfColor.fromInt(0xFFE0E0E0),
width: 0.5,
),
columnWidths: {
0: const pw.FlexColumnWidth(0.5), // No
1: const pw.FlexColumnWidth(2.2), // Tanggal
2: const pw.FlexColumnWidth(1.5), // Nominal
3: const pw.FlexColumnWidth(0.8), // Tipe
4: const pw.FlexColumnWidth(0.8), // %
},
children: [
// Header row
pw.TableRow(
decoration: const pw.BoxDecoration(color: primaryColor),
children: ['No', 'Tanggal', 'Nominal', 'Tipe', '%Target']
.map(
(h) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: pw.Text(
h,
style: pw.TextStyle(
color: PdfColors.white,
fontWeight: pw.FontWeight.bold,
fontSize: 10,
),
),
),
)
.toList(),
),
// Data rows
...historyList.asMap().entries.map((entry) {
final i = entry.key;
final item = entry.value;
final isMasuk = item.tipe == 'masuk';
final rowBg = i.isEven
? PdfColors.white
: const PdfColor.fromInt(0xFFF9F9F9);
return pw.TableRow(
decoration: pw.BoxDecoration(color: rowBg),
children: [
_tableCell('${i + 1}', textGrey),
_tableCell(item.date, textDark),
_tableCell(
item.amount,
isMasuk ? successColor : dangerColor,
bold: true,
),
_tableBadge(
isMasuk ? 'Masuk' : 'Keluar',
isMasuk ? successColor : dangerColor,
),
_tableCell(item.percent, textGrey),
],
);
}),
],
),
pw.SizedBox(height: 32),
// Footer note
pw.Container(
padding: const pw.EdgeInsets.all(12),
decoration: pw.BoxDecoration(
border: pw.Border.all(
color: const PdfColor.fromInt(0xFFE0E0E0),
width: 0.5,
),
borderRadius: pw.BorderRadius.circular(8),
),
child: pw.Text(
'Dokumen ini dibuat secara otomatis oleh aplikasi TabunganKuy. '
'Total ${historyList.length} transaksi tercatat.',
style: pw.TextStyle(fontSize: 9, color: textGrey),
),
),
],
),
);
return doc.save();
}
pw.Widget _summaryCard(String label, String value, PdfColor valueColor) {
return pw.Column(
children: [
pw.Text(
label,
style: pw.TextStyle(
fontSize: 9,
color: const PdfColor.fromInt(0xFF888888),
),
),
pw.SizedBox(height: 4),
pw.Text(
value,
style: pw.TextStyle(
fontSize: 11,
fontWeight: pw.FontWeight.bold,
color: valueColor,
),
),
],
);
}
pw.Widget _tableCell(String text, PdfColor color, {bool bold = false}) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 7),
child: pw.Text(
text,
style: pw.TextStyle(
fontSize: 9,
color: color,
fontWeight: bold ? pw.FontWeight.bold : pw.FontWeight.normal,
),
),
);
}
pw.Widget _tableBadge(String text, PdfColor color) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: pw.Container(
padding: const pw.EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: pw.BoxDecoration(
color: color == const PdfColor.fromInt(0xFF43A047)
? const PdfColor.fromInt(0xFFE8F5E9)
: const PdfColor.fromInt(0xFFFFEBEE),
borderRadius: pw.BorderRadius.circular(4),
),
child: pw.Text(
text,
style: pw.TextStyle(
fontSize: 8,
fontWeight: pw.FontWeight.bold,
color: color,
),
),
),
);
}
}

View File

@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/history_controller.dart';
import '../../../widgets/history_card_widget.dart';
class HistoryView extends GetView<HistoryController> {
const HistoryView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Greeting dengan nama user reaktif
Obx(
() => RichText(
text: TextSpan(
children: [
const TextSpan(
text: 'Hello! ',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
TextSpan(
text: controller.namaUser.value,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Color(0xFF5B9BD5),
),
),
],
),
),
),
const SizedBox(height: 20),
// Header card dengan tombol download PDF
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 18,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF5B9BD5), Color(0xFF90C4E8)],
begin: Alignment.centerLeft,
end: Alignment.centerRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Riwayat Transaksi',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
// Tombol download PDF
Obx(() {
final isDownloading = controller.isDownloading.value;
return GestureDetector(
onTap: isDownloading ? null : controller.downloadPdf,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white.withOpacity(
isDownloading ? 0.1 : 0.25,
),
borderRadius: BorderRadius.circular(10),
),
child: isDownloading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
)
: const Icon(
Icons.file_present_outlined,
color: Colors.white,
size: 22,
),
),
);
}),
],
),
),
const SizedBox(height: 24),
// List transaksi
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(
child: CircularProgressIndicator(
color: Color(0xFF5B9BD5),
),
);
}
if (controller.historyList.isEmpty) {
return const Center(
child: Text(
'Belum ada riwayat transaksi',
style: TextStyle(color: Colors.grey, fontSize: 14),
),
);
}
return RefreshIndicator(
color: const Color(0xFF5B9BD5),
onRefresh: controller.fetchHistory,
child: ListView.separated(
itemCount: controller.historyList.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, color: Color(0xFFF0F0F0)),
itemBuilder: (_, index) {
final item = controller.historyList[index];
return HistoryCardWidget(
amount: item.amount,
date: item.date,
percent: item.percent,
tipe: item.tipe,
);
},
),
);
}),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/home_controller.dart';
class HomeBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<HomeController>(
() => HomeController(),
);
}
}

View File

@ -0,0 +1,714 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:intl/intl.dart';
import 'package:tabungankuy/app/modules/navbar/controllers/navbar_controller.dart';
import '../../../models/profil_model.dart';
import '../../../models/transaction_model.dart';
import '../../../models/target_emoji_map.dart';
import '../../../routes/app_pages.dart';
import '../../../services/notification_service.dart';
import '../../../widgets/open_celengan_modal.dart';
import '../../../widgets/numpad_pin_widget.dart';
import '../../../widgets/edit_target_modal.dart';
import '../../../widgets/konfirmasi_uang_modal.dart';
class HistoryItem {
final String amount;
final String date;
final String percent;
final String tipe;
const HistoryItem({
required this.amount,
required this.date,
required this.percent,
required this.tipe,
});
}
class HomeController extends GetxController {
final _auth = FirebaseAuth.instance;
final _firestore = FirebaseFirestore.instance;
final _rtdb = FirebaseDatabase.instance.ref();
final namaUser = ''.obs;
final targetEmoji = '🎯'.obs;
final targetLabel = ''.obs;
final targetPercent = 0.0.obs;
final targetNominal = 0.obs;
final totalSaldo = 0.obs;
final totalUang = 'Rp 0'.obs;
final isSaving = true.obs;
final isLoading = true.obs;
final recentHistory = <HistoryItem>[].obs;
final _pinController = TextEditingController();
final _editNominalController = TextEditingController();
final _editTargetController = TextEditingController();
final _pinWidgetKey = GlobalKey<NumpadPinInputWidgetState>();
int _pinAttempts = 0;
static const int _maxPinAttempts = 3;
StreamSubscription<DatabaseEvent>? _detectedValueSub;
StreamSubscription<DatabaseEvent>? _masukkanUangSub;
bool _konfirmasiModalTampil = false;
bool _pintuTerbuka = false;
bool _sudahNotifTargetTercapai = false;
bool _pinModalTerbuka = false;
Timer? _konfirmasiResetTimer;
int? _pendingNominal;
String get _uid => _auth.currentUser?.uid ?? '';
final _currencyFormat = NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp ',
decimalDigits: 0,
);
@override
void onInit() {
super.onInit();
NotificationService.requestPermission();
_loadData();
_listenMasukkanUang();
_listenDetectedValue();
}
void _listenMasukkanUang() {
_masukkanUangSub = _rtdb.child('tabungan/masukkan_uang').onValue.listen((
event,
) {
final sebelumnya = _pintuTerbuka;
_pintuTerbuka = event.snapshot.value == true;
if (sebelumnya && !_pintuTerbuka) _cobaTampilkanKonfirmasi();
});
}
void _listenDetectedValue() {
_detectedValueSub = _rtdb.child('tabungan/detected_value').onValue.listen((
event,
) {
final value = event.snapshot.value;
if (value != null && value is int && value > 0) {
_pendingNominal = value;
_cobaTampilkanKonfirmasi();
}
});
}
void _cobaTampilkanKonfirmasi() {
if (_pendingNominal == null || _pendingNominal! <= 0) return;
if (_pintuTerbuka || _konfirmasiModalTampil) return;
final context = Get.context;
if (context == null || !context.mounted) return;
_tampilkanKonfirmasiUang(context, _pendingNominal!);
}
void _tampilkanKonfirmasiUang(BuildContext context, int nominal) {
_konfirmasiModalTampil = true;
_pendingNominal = null;
KonfirmasiUangModal.show(
context,
nominal: nominal,
onKonfirmasi: () async {
Navigator.of(context, rootNavigator: true).pop();
_konfirmasiModalTampil = false;
_konfirmasiResetTimer?.cancel();
await _rtdb.child('tabungan').update({
'konfirmasi_masuk': true,
'detected_value': 0,
});
_konfirmasiResetTimer = Timer(const Duration(seconds: 5), () async {
if (!isClosed) {
await _rtdb.child('tabungan').update({'konfirmasi_masuk': false});
}
});
await _simpanTransaksiMasuk(nominal);
},
onBatal: () async {
Navigator.of(context, rootNavigator: true).pop();
_konfirmasiModalTampil = false;
_konfirmasiResetTimer?.cancel();
await _rtdb.child('tabungan').update({
'konfirmasi_masuk': false,
'detected_value': 0,
'masukkan_uang': true,
});
_pintuTerbuka = true;
Get.find<NavbarController>().isIotActive.value = true;
Get.snackbar(
'↩️ Dibatalkan',
'Pintu dibuka kembali, silakan coba lagi',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.orange.shade50,
colorText: Colors.orange.shade800,
duration: const Duration(seconds: 3),
);
},
);
}
Future<void> _simpanTransaksiMasuk(int nominal) async {
try {
await _firestore.collection('transactions').add({
'userId': _uid,
'nominal': nominal,
'tipe': 'masuk',
'createdAt': FieldValue.serverTimestamp(),
});
await _loadData();
Get.snackbar(
'✅ Berhasil!',
'Berhasil menabung ${_currencyFormat.format(nominal)}',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green.shade100,
colorText: Colors.green.shade800,
duration: const Duration(seconds: 3),
);
} catch (e) {
Get.snackbar(
'Error',
'Gagal menyimpan transaksi: $e',
snackPosition: SnackPosition.TOP,
);
}
}
Future<void> _loadData() async {
isLoading.value = true;
await _loadProfil();
await _loadTransaksi();
isLoading.value = false;
}
Future<void> _loadProfil() async {
try {
if (_uid.isEmpty) return;
final doc = await _firestore.collection('profil').doc(_uid).get();
if (!doc.exists) return;
final profil = ProfilModel.fromMap(doc.data()!);
namaUser.value = profil.nama;
targetLabel.value = profil.target;
targetEmoji.value = TargetEmojiMap.getEmoji(profil.target);
targetNominal.value = profil.targetNominal;
} catch (e) {
Get.snackbar(
'Error',
'Gagal memuat profil: $e',
snackPosition: SnackPosition.TOP,
);
}
}
Future<void> _loadTransaksi() async {
try {
if (_uid.isEmpty) return;
QuerySnapshot snapshot;
try {
snapshot = await _firestore
.collection('transactions')
.where('userId', isEqualTo: _uid)
.orderBy('createdAt', descending: true)
.get();
} catch (_) {
snapshot = await _firestore
.collection('transactions')
.where('userId', isEqualTo: _uid)
.get();
}
final docs = snapshot.docs.toList()
..sort((a, b) {
final aTime = (a.data() as Map)['createdAt'] as Timestamp?;
final bTime = (b.data() as Map)['createdAt'] as Timestamp?;
if (aTime == null && bTime == null) return 0;
if (aTime == null) return 1;
if (bTime == null) return -1;
return bTime.compareTo(aTime);
});
final transactions = docs
.map(
(doc) => TransactionModel.fromMap(
doc.id,
doc.data() as Map<String, dynamic>,
),
)
.toList();
int saldo = 0;
for (final t in transactions) {
saldo += t.tipe == 'masuk' ? t.nominal : -t.nominal;
}
totalSaldo.value = saldo;
totalUang.value = _currencyFormat.format(saldo);
final persenSebelumnya = targetPercent.value;
_hitungPersen();
_cekDanKirimNotifikasiTarget(persenSebelumnya);
recentHistory.value = transactions.take(2).map((t) {
final dateStr = t.createdAt != null
? DateFormat('hh:mm a dd MMM yyyy', 'id_ID').format(t.createdAt!)
: '-';
final persen = targetNominal.value > 0
? '${((t.nominal / targetNominal.value) * 100).toStringAsFixed(1)}%'
: '0%';
return HistoryItem(
amount: _currencyFormat.format(t.nominal),
date: dateStr,
percent: persen,
tipe: t.tipe,
);
}).toList();
} catch (e) {
Get.snackbar(
'Error',
'Gagal memuat transaksi: $e',
snackPosition: SnackPosition.TOP,
);
}
}
void _hitungPersen() {
targetPercent.value = targetNominal.value > 0
? ((totalSaldo.value / targetNominal.value) * 100).clamp(0.0, 100.0)
: 0.0;
}
void _cekDanKirimNotifikasiTarget(double persenSebelumnya) {
final sudahTercapai = targetPercent.value >= 100.0;
if (sudahTercapai &&
(persenSebelumnya < 100.0 || !_sudahNotifTargetTercapai)) {
if (!_sudahNotifTargetTercapai) {
_sudahNotifTargetTercapai = true;
NotificationService.showTargetTercapai(
namaTarget: targetLabel.value,
nominal: _currencyFormat.format(targetNominal.value),
);
}
}
if (!sudahTercapai) _sudahNotifTargetTercapai = false;
}
void toggleSaving() => isSaving.value = !isSaving.value;
void bukaCelengan(BuildContext context) {
OpenCelenganModal.show(
context,
targetTercapai: targetPercent.value >= 100.0,
onKonfirmasiBuka: () => _tampilkanInputPin(context),
);
}
void _tampilkanInputPin(BuildContext context) {
_pinController.clear();
_pinAttempts = 0;
_pinModalTerbuka = true;
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (sheetContext) => NumpadPinInputWidget(
key: _pinWidgetKey,
displayController: _pinController,
onKey: _onPinKey,
onSimpan: () => _validasiPin(context),
onVerifikasiPassword: () => _tampilkanVerifikasiPassword(context),
),
).whenComplete(() => _pinModalTerbuka = false);
}
Future<void> _validasiPin(BuildContext context) async {
final pin = _pinController.text;
try {
if (_uid.isEmpty) return;
final doc = await _firestore.collection('profil').doc(_uid).get();
if (!doc.exists) {
_pinWidgetKey.currentState?.showError('Data profil tidak ditemukan');
return;
}
final savedPin = (doc.data()?['pin'] ?? '').toString();
if (pin != savedPin) {
_pinAttempts++;
final sisaPercobaan = _maxPinAttempts - _pinAttempts;
if (_pinAttempts >= _maxPinAttempts) {
_pinWidgetKey.currentState?.showError(
'PIN salah 3x, verifikasi dengan password akun',
showVerifikasiPassword: true,
);
} else {
_pinWidgetKey.currentState?.showError(
'PIN salah ($sisaPercobaan percobaan tersisa)',
);
}
return;
}
_pinAttempts = 0;
await _rtdb.child('tabungan').update({'buka_tabungan': true});
Get.back();
await Future.delayed(const Duration(milliseconds: 350));
await _simpanTransaksiAmbil(totalSaldo.value);
} catch (_) {
_pinWidgetKey.currentState?.showError('Terjadi kesalahan, coba lagi');
}
}
Future<void> _tampilkanVerifikasiPassword(BuildContext context) async {
final emailController = TextEditingController();
final passwordController = TextEditingController();
final verified = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
String errorMsg = '';
bool isLoading = false;
return StatefulBuilder(
builder: (ctx, setStateDialog) => Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
backgroundColor: Colors.white,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Verifikasi Akun',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 8),
const Text(
'Masukkan email dan password akun untuk membuka celengan',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.black54),
),
const SizedBox(height: 20),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
autofocus: true,
decoration: InputDecoration(
hintText: 'Email akun',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
const SizedBox(height: 12),
TextField(
controller: passwordController,
obscureText: true,
decoration: InputDecoration(
hintText: 'Password akun',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
if (errorMsg.isNotEmpty) ...[
const SizedBox(height: 10),
Text(
errorMsg,
style: TextStyle(
color: Colors.red.shade600,
fontSize: 13,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: () =>
Navigator.of(dialogContext).pop(false),
child: const Text('Batal'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF5B9BD5),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: isLoading
? null
: () async {
final email = emailController.text.trim();
final password = passwordController.text
.trim();
if (email.isEmpty || password.isEmpty) {
setStateDialog(() {
errorMsg =
'Email dan password tidak boleh kosong';
});
return;
}
setStateDialog(() {
isLoading = true;
errorMsg = '';
});
try {
final user = _auth.currentUser;
if (user == null) {
throw Exception('User tidak ditemukan');
}
final credential =
EmailAuthProvider.credential(
email: email,
password: password,
);
await user.reauthenticateWithCredential(
credential,
);
Navigator.of(dialogContext).pop(true);
} on FirebaseAuthException catch (e) {
setStateDialog(() {
isLoading = false;
errorMsg = switch (e.code) {
'wrong-password' ||
'invalid-credential' =>
'Email atau password salah, coba lagi',
'user-mismatch' =>
'Email tidak sesuai dengan akun ini',
'invalid-email' =>
'Format email tidak valid',
'too-many-requests' =>
'Terlalu banyak percobaan, coba lagi nanti',
_ => 'Gagal verifikasi: ${e.message}',
};
});
} catch (_) {
setStateDialog(() {
isLoading = false;
errorMsg = 'Terjadi kesalahan, coba lagi';
});
}
},
child: isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Verifikasi',
style: TextStyle(color: Colors.white),
),
),
),
],
),
],
),
),
),
);
},
);
emailController.dispose();
passwordController.dispose();
if (verified != true) return;
_pinAttempts = 0;
if (_pinModalTerbuka) {
Get.back();
await Future.delayed(const Duration(milliseconds: 400));
}
await _rtdb.child('tabungan').update({'buka_tabungan': true});
await _simpanTransaksiAmbil(totalSaldo.value);
}
Future<void> _simpanTransaksiAmbil(int jumlah) async {
try {
await _firestore.collection('transactions').add({
'userId': _uid,
'nominal': jumlah,
'tipe': 'keluar',
'createdAt': FieldValue.serverTimestamp(),
});
Future.delayed(const Duration(seconds: 10), () async {
if (!isClosed) {
await _rtdb.child('tabungan').update({'buka_tabungan': false});
}
});
await _loadData();
Get.snackbar(
'✅ Berhasil!',
'Berhasil mengambil ${_currencyFormat.format(jumlah)} dari celengan',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green.shade100,
colorText: Colors.green.shade800,
duration: const Duration(seconds: 3),
);
await Future.delayed(const Duration(milliseconds: 400));
final navContext = Get.context;
if (navContext != null && navContext.mounted) {
editTarget(navContext);
}
} catch (e) {
Get.snackbar(
'Error',
'Gagal menyimpan transaksi: $e',
snackPosition: SnackPosition.TOP,
);
}
}
void editTarget(BuildContext context) {
_editNominalController.text = targetNominal.value.toString();
_editTargetController.text = targetLabel.value;
EditTargetModal.show(
context,
nominalController: _editNominalController,
targetController: _editTargetController,
onNominalKey: _onEditNominalKey,
onSimpan: () async {
final newTarget = _editTargetController.text;
final newNominal =
int.tryParse(
_editNominalController.text.replaceAll(RegExp(r'[^0-9]'), ''),
) ??
0;
await _firestore.collection('profil').doc(_uid).update({
'target': newTarget,
'targetNominal': newNominal,
});
targetLabel.value = newTarget;
targetEmoji.value = TargetEmojiMap.getEmoji(newTarget);
targetNominal.value = newNominal;
_hitungPersen();
await _loadTransaksi();
Get.back();
},
);
}
Future<void> refreshData() => _loadData();
Future<void> logout() async {
_detectedValueSub?.cancel();
_masukkanUangSub?.cancel();
_konfirmasiResetTimer?.cancel();
await _rtdb.child('tabungan').update({
'masukkan_uang': false,
'konfirmasi_masuk': false,
'buka_tabungan': false,
'detected_value': 0,
});
await _auth.signOut();
Get.offAllNamed(Routes.LOGIN);
}
void _onEditNominalKey(String key) {
if (key == 'del') {
if (_editNominalController.text.isNotEmpty) {
_editNominalController.text = _editNominalController.text.substring(
0,
_editNominalController.text.length - 1,
);
}
} else {
_editNominalController.text += key;
}
}
void _onPinKey(String key) {
if (key == 'del') {
if (_pinController.text.isNotEmpty) {
_pinController.text = _pinController.text.substring(
0,
_pinController.text.length - 1,
);
}
} else if (_pinController.text.length < 6) {
_pinController.text += key;
}
}
@override
void onClose() {
_detectedValueSub?.cancel();
_masukkanUangSub?.cancel();
_konfirmasiResetTimer?.cancel();
_pinController.dispose();
_editNominalController.dispose();
_editTargetController.dispose();
super.onClose();
}
}

View File

@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:get/get.dart';
import '../controllers/home_controller.dart';
import '../../../widgets/history_card_widget.dart';
import '../../../widgets/target_progress_chart.dart';
import '../../../widgets/logout_dialog.dart';
class HomeView extends GetView<HomeController> {
const HomeView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(
child: CircularProgressIndicator(color: Color(0xFF5B9BD5)),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Hello,',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Obx(
() => Text(
controller.namaUser.value,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: Color(0xFF5B9BD5),
height: 1.1,
),
),
),
],
),
GestureDetector(
onTap: () => _confirmLogout(context),
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: const Color(0xFFF2F2F2),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
Icons.logout_rounded,
color: Color(0xFF5B9BD5),
size: 22,
),
),
),
],
),
const SizedBox(height: 24),
// Card Target (tanpa tombol edit)
Obx(
() => Container(
width: double.infinity,
padding: const EdgeInsets.all(22),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF5B9BD5), Color(0xFF90C4E8)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Kiri: info target
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Target Kamu!',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 10),
// Emoji + nama target
Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
controller.targetEmoji.value,
style: const TextStyle(fontSize: 26),
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
controller.targetLabel.value,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.bold,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 14),
// Label nominal
Text(
'Nominal Target',
style: TextStyle(
color: Colors.white.withOpacity(0.75),
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
NumberFormat.currency(
locale: 'id_ID',
symbol: 'Rp ',
decimalDigits: 0,
).format(controller.targetNominal.value),
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
const SizedBox(width: 16),
// Kanan: chart
TargetProgressChart(
percent: controller.targetPercent.value,
size: 130,
),
],
),
),
),
const SizedBox(height: 28),
// Buka Celengan
GestureDetector(
onTap: () => controller.bukaCelengan(context),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: const [
Text(
'Buka Celenganmu',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
Icon(Icons.chevron_right, color: Colors.black54),
],
),
),
const SizedBox(height: 24),
// Total Uang
Obx(
() => Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: const Color(0xFFF2F2F2),
borderRadius: BorderRadius.circular(14),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.totalUang.value,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 22,
vertical: 12,
),
decoration: BoxDecoration(
color: const Color(0xFF5B9BD5),
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'Total Uang',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
],
),
),
),
const SizedBox(height: 28),
// History
const Text(
'Uang Masuk',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 4),
Container(
height: 3,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 12),
Obx(
() => ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.recentHistory.length,
separatorBuilder: (_, __) =>
const Divider(height: 1, color: Color(0xFFF0F0F0)),
itemBuilder: (_, index) {
final item = controller.recentHistory[index];
return HistoryCardWidget(
amount: item.amount,
date: item.date,
percent: item.percent,
tipe: item.tipe,
);
},
),
),
],
),
);
}),
),
);
}
void _confirmLogout(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (_) => LogoutDialog(onLogout: controller.logout),
);
}
}

View File

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/login_controller.dart';
class LoginBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<LoginController>(
() => LoginController(),
);
}
}

View File

@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../../../routes/app_pages.dart';
class LoginController extends GetxController {
final emailController = TextEditingController();
final passwordController = TextEditingController();
final isPasswordVisible = false.obs;
final isLoading = false.obs;
final _auth = FirebaseAuth.instance;
@override
void onClose() {
emailController.dispose();
passwordController.dispose();
super.onClose();
}
void togglePasswordVisibility() =>
isPasswordVisible.value = !isPasswordVisible.value;
Future<void> login() async {
if (emailController.text.isEmpty || passwordController.text.isEmpty) {
Get.snackbar(
'Perhatian',
'Email dan password harus diisi!',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade100,
);
return;
}
try {
isLoading.value = true;
await _auth.signInWithEmailAndPassword(
email: emailController.text.trim(),
password: passwordController.text.trim(),
);
Get.offAllNamed(Routes.NAVBAR);
} on FirebaseAuthException catch (e) {
String message = 'Login gagal, coba lagi.';
if (e.code == 'user-not-found') {
message = 'Email tidak terdaftar.';
} else if (e.code == 'wrong-password') {
message = 'Password salah.';
} else if (e.code == 'invalid-email') {
message = 'Format email tidak valid.';
} else if (e.code == 'user-disabled') {
message = 'Akun dinonaktifkan.';
}
Get.snackbar(
'Login Gagal',
message,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade100,
);
} finally {
isLoading.value = false;
}
}
Future<void> lupaPassword() async {
if (emailController.text.isEmpty) {
Get.snackbar(
'Perhatian',
'Masukkan email terlebih dahulu!',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.orange.shade100,
);
return;
}
try {
await _auth.sendPasswordResetEmail(email: emailController.text.trim());
Get.snackbar(
'Berhasil',
'Link reset password telah dikirim ke ${emailController.text.trim()}',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green.shade100,
duration: const Duration(seconds: 4),
);
} on FirebaseAuthException catch (e) {
String message = 'Gagal mengirim email reset.';
if (e.code == 'user-not-found') {
message = 'Email tidak terdaftar.';
} else if (e.code == 'invalid-email') {
message = 'Format email tidak valid.';
}
Get.snackbar(
'Gagal',
message,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade100,
);
}
}
void goToRegister() {
Get.toNamed(Routes.REGISTER);
}
}

View File

@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/login_controller.dart';
import 'package:flutter/gestures.dart';
class LoginView extends GetView<LoginController> {
const LoginView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
RichText(
text: const TextSpan(
children: [
TextSpan(
text: 'Hello, ',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
TextSpan(
text: 'Selamat Datang',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Color(0xFF5B9BD5),
),
),
],
),
),
const SizedBox(height: 20),
// Blue label
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF5B9BD5), Color(0xFF7BB3E0)],
),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'Masuk ke akun Anda',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
const SizedBox(height: 20),
// Email
_buildTextField(
controller: controller.emailController,
hint: 'Email',
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 14),
// Password
Obx(
() => _buildTextField(
controller: controller.passwordController,
hint: 'Password',
icon: Icons.lock_outline,
obscureText: !controller.isPasswordVisible.value,
suffixIcon: IconButton(
icon: Icon(
controller.isPasswordVisible.value
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Colors.grey,
),
onPressed: controller.togglePasswordVisibility,
),
),
),
// Lupa Password rata kanan, tepat di bawah input password
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: GestureDetector(
onTap: controller.lupaPassword,
child: const Text(
'Lupa Password?',
style: TextStyle(
color: Color(0xFF5B9BD5),
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
),
),
const SizedBox(height: 24),
// Login Button
Obx(
() => GestureDetector(
onTap: controller.isLoading.value ? null : controller.login,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
color: controller.isLoading.value
? Colors.grey
: const Color(0xFF5B9BD5),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: controller.isLoading.value
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'Login',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
),
),
const SizedBox(height: 16),
// Belum punya akun? Register
Center(
child: RichText(
text: TextSpan(
children: [
const TextSpan(
text: 'Belum punya akun? ',
style: TextStyle(color: Colors.black54, fontSize: 14),
),
TextSpan(
text: 'Register',
style: const TextStyle(
color: Color(0xFF5B9BD5),
fontWeight: FontWeight.bold,
fontSize: 14,
),
recognizer: TapGestureRecognizer()
..onTap = controller.goToRegister,
),
],
),
),
),
],
),
),
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String hint,
required IconData icon,
bool obscureText = false,
Widget? suffixIcon,
TextInputType keyboardType = TextInputType.text,
}) {
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF2F2F2),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Colors.grey, fontSize: 14),
prefixIcon: Icon(icon, color: Colors.grey, size: 20),
suffixIcon: suffixIcon,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
);
}
}

View File

@ -0,0 +1,13 @@
import 'package:get/get.dart';
import '../controllers/navbar_controller.dart';
import '../../home/controllers/home_controller.dart';
import '../../history/controllers/history_controller.dart';
class NavbarBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<NavbarController>(() => NavbarController());
Get.lazyPut<HomeController>(() => HomeController());
Get.lazyPut<HistoryController>(() => HistoryController());
}
}

View File

@ -0,0 +1,26 @@
// lib/app/modules/navbar/controllers/navbar_controller.dart
import 'package:firebase_database/firebase_database.dart';
import 'package:get/get.dart';
class NavbarController extends GetxController {
var selectedIndex = 0.obs;
var isIotActive = false.obs;
final _rtdb = FirebaseDatabase.instance.ref();
void changeTabIndex(int index) => selectedIndex.value = index;
Future<void> toggleIot() async {
final newValue = !isIotActive.value;
try {
await _rtdb.child('tabungan').update({'masukkan_uang': newValue});
isIotActive.value = newValue;
} catch (e) {
Get.snackbar(
'Error',
'Gagal mengubah status IoT: $e',
snackPosition: SnackPosition.TOP,
);
}
}
}

View File

@ -0,0 +1,233 @@
// lib/app/modules/navbar/views/navbar_view.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../controllers/navbar_controller.dart';
import '../../home/views/home_view.dart';
import '../../history/views/history_view.dart';
class NavbarView extends StatelessWidget {
const NavbarView({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<NavbarController>();
return Scaffold(
backgroundColor: Colors.white,
body: Obx(
() => IndexedStack(
index: controller.selectedIndex.value,
children: const [HomeView(), HistoryView()],
),
),
bottomNavigationBar: SafeArea(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
height: 70,
child: Stack(
clipBehavior: Clip.none,
alignment: Alignment.center,
children: [
// Navbar bar
Container(
height: 70,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(22),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: [
Expanded(
child: Obx(
() => _NavItem(
icon: Icons.home_rounded,
label: 'Home',
active: controller.selectedIndex.value == 0,
onTap: () {
HapticFeedback.lightImpact();
controller.changeTabIndex(0);
},
),
),
),
const SizedBox(width: 76),
Expanded(
child: Obx(
() => _NavItem(
icon: Icons.receipt_long_rounded,
label: 'Riwayat',
active: controller.selectedIndex.value == 1,
onTap: () {
HapticFeedback.lightImpact();
controller.changeTabIndex(1);
},
),
),
),
],
),
),
// Tombol tengah posisi sedikit naik dari navbar
Positioned(
top: -18, // naik 18px dari tengah navbar, tidak terlalu tinggi
child: const _CoinSlotButton(),
),
],
),
),
),
);
}
}
class _NavItem extends StatelessWidget {
final IconData icon;
final String label;
final bool active;
final VoidCallback onTap;
const _NavItem({
required this.icon,
required this.label,
required this.active,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 26,
color: active ? const Color(0xFF5B9BD5) : const Color(0xFFBDCFE0),
),
const SizedBox(height: 3),
Text(
label,
style: TextStyle(
color: active ? const Color(0xFF5B9BD5) : const Color(0xFFBDCFE0),
fontSize: 10,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
],
),
);
}
}
class _CoinSlotButton extends StatefulWidget {
const _CoinSlotButton();
@override
State<_CoinSlotButton> createState() => _CoinSlotButtonState();
}
class _CoinSlotButtonState extends State<_CoinSlotButton>
with SingleTickerProviderStateMixin {
late AnimationController _pressCtrl;
late Animation<double> _scaleAnim;
final NavbarController controller = Get.find<NavbarController>();
@override
void initState() {
super.initState();
_pressCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 100),
);
_scaleAnim = Tween<double>(
begin: 1.0,
end: 0.92,
).animate(CurvedAnimation(parent: _pressCtrl, curve: Curves.easeOut));
}
@override
void dispose() {
_pressCtrl.dispose();
super.dispose();
}
void _handleTap() async {
HapticFeedback.mediumImpact();
await _pressCtrl.forward();
_pressCtrl.reverse();
controller.toggleIot();
}
@override
Widget build(BuildContext context) {
return Obx(() {
final isOn = controller.isIotActive.value;
return AnimatedBuilder(
animation: _scaleAnim,
builder: (_, __) => Transform.scale(
scale: _scaleAnim.value,
child: GestureDetector(
onTap: _handleTap,
child: Container(
width: 62,
height: 62,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isOn ? const Color(0xFFE57373) : const Color(0xFF5B9BD5),
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color:
(isOn
? const Color(0xFFE57373)
: const Color(0xFF5B9BD5))
.withOpacity(0.35),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
transitionBuilder: (child, anim) =>
ScaleTransition(scale: anim, child: child),
child: Icon(
isOn ? Icons.move_to_inbox_rounded : Icons.inventory_2,
key: ValueKey(isOn),
color: Colors.white,
size: 26,
),
),
const SizedBox(height: 2),
Text(
isOn ? 'TUTUP' : 'BUKA',
style: const TextStyle(
color: Colors.white,
fontSize: 7,
fontWeight: FontWeight.w800,
letterSpacing: 1.0,
),
),
],
),
),
),
),
);
});
}
}

View File

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/onboarding_controller.dart';
class OnboardingBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<OnboardingController>(
() => OnboardingController(),
);
}
}

View File

@ -0,0 +1,16 @@
import 'package:get/get.dart';
import 'package:firebase_auth/firebase_auth.dart';
import '../../../routes/app_pages.dart';
class OnboardingController extends GetxController {
void goToNavbar() {
final user = FirebaseAuth.instance.currentUser;
if (user != null) {
// Sudah login langsung ke NAVBAR
Get.offAllNamed(Routes.NAVBAR);
} else {
// Belum login ke LOGIN
Get.offAllNamed(Routes.LOGIN);
}
}
}

View File

@ -0,0 +1,93 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/onboarding_controller.dart';
class OnboardingView extends GetView<OnboardingController> {
const OnboardingView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF5B9BD5),
body: SafeArea(
child: Stack(
children: [
// Background subtle circle decoration (top right)
Positioned(
top: -60,
right: -60,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withOpacity(0.07),
),
),
),
// Background subtle circle decoration (bottom left)
Positioned(
bottom: -80,
left: -40,
child: Container(
width: 220,
height: 220,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withOpacity(0.07),
),
),
),
// Main content (Center widget to center the logo and text)
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.account_balance_wallet_outlined,
size: 120,
color: Colors.white,
),
const SizedBox(height: 12),
const Text(
'TABUNGANKUY',
style: TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w800,
letterSpacing: 3,
),
),
],
),
),
// Arrow button in the bottom right corner (keeps its position)
Positioned(
right: 32,
bottom: 32,
child: GestureDetector(
onTap: controller.goToNavbar,
child: Container(
width: 60,
height: 60,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
),
child: const Icon(
Icons.arrow_forward,
color: Color(0xFF5B9BD5),
size: 28,
),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,12 @@
import 'package:get/get.dart';
import '../controllers/register_controller.dart';
class RegisterBinding extends Bindings {
@override
void dependencies() {
Get.lazyPut<RegisterController>(
() => RegisterController(),
);
}
}

View File

@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import '../../../routes/app_pages.dart';
import '../../../widgets/numpad_nominal_widget.dart';
import '../../../widgets/numpad_pin_widget.dart';
import '../../../widgets/target_celengan_picker.dart';
class RegisterController extends GetxController {
final namaController = TextEditingController();
final emailController = TextEditingController();
final passwordController = TextEditingController();
final targetCelenganController = TextEditingController();
final nominalTargetController = TextEditingController();
final pinController = TextEditingController();
final confirmPinController = TextEditingController();
final isPinVisible = false.obs;
final isConfirmPinVisible = false.obs;
final isLoading = false.obs;
String _activeField = '';
final _auth = FirebaseAuth.instance;
final _firestore = FirebaseFirestore.instance;
@override
void onClose() {
namaController.dispose();
emailController.dispose();
passwordController.dispose();
targetCelenganController.dispose();
nominalTargetController.dispose();
pinController.dispose();
confirmPinController.dispose();
super.onClose();
}
void togglePinVisibility() => isPinVisible.value = !isPinVisible.value;
void toggleConfirmPinVisibility() =>
isConfirmPinVisible.value = !isConfirmPinVisible.value;
void showTargetPicker(BuildContext context) {
TargetCelenganPicker.show(
context,
selected: targetCelenganController.text.isEmpty
? null
: targetCelenganController.text,
onSelected: (label) => targetCelenganController.text = label,
);
}
void showNumpad(BuildContext context, String field) {
_activeField = field;
final activeCtrl = _getController(field);
final isPinField = field == 'pin' || field == 'confirmPin';
if (isPinField) {
NumpadPinInputWidget.show(
context,
controller: activeCtrl,
onKey: _onNumpadKey,
onSimpan: () => Get.back(),
title: field == 'pin' ? 'Masukkan PIN' : 'Ulangi PIN',
);
} else {
NumpadNominalWidget.show(
context,
controller: activeCtrl,
onKey: _onNumpadKey,
onSimpan: () => Get.back(),
);
}
}
TextEditingController _getController(String field) {
if (field == 'pin') return pinController;
if (field == 'confirmPin') return confirmPinController;
return nominalTargetController;
}
void _onNumpadKey(String key) {
final ctrl = _getController(_activeField);
final isPinField = _activeField == 'pin' || _activeField == 'confirmPin';
if (key == 'del') {
if (ctrl.text.isNotEmpty)
ctrl.text = ctrl.text.substring(0, ctrl.text.length - 1);
} else {
if (isPinField && ctrl.text.length >= 6) return;
ctrl.text += key;
}
}
/// Parse nominal dari string seperti "Rp 2.500.000" 2500000
int _parseNominal(String raw) {
final cleaned = raw.replaceAll(RegExp(r'[^0-9]'), '');
return int.tryParse(cleaned) ?? 0;
}
Future<void> simpan() async {
if (namaController.text.isEmpty ||
emailController.text.isEmpty ||
passwordController.text.isEmpty ||
targetCelenganController.text.isEmpty ||
nominalTargetController.text.isEmpty ||
pinController.text.isEmpty ||
confirmPinController.text.isEmpty) {
Get.snackbar(
'Perhatian',
'Semua field harus diisi!',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade100,
);
return;
}
if (pinController.text != confirmPinController.text) {
Get.snackbar(
'Perhatian',
'PIN tidak cocok!',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade100,
);
return;
}
try {
isLoading.value = true;
// 1. Buat akun di Firebase Auth
final credential = await _auth.createUserWithEmailAndPassword(
email: emailController.text.trim(),
password: passwordController.text.trim(),
);
final uid = credential.user!.uid;
final now = FieldValue.serverTimestamp();
// 2. Simpan ke collection 'users'
await _firestore.collection('users').doc(uid).set({
'email': emailController.text.trim(),
'password': 'hashed_password', // Hanya placeholder, auth sudah handle
'createdAt': now,
});
// 3. Simpan ke collection 'profil'
await _firestore.collection('profil').doc(uid).set({
'userId': uid,
'nama': namaController.text.trim(),
'target': targetCelenganController.text.trim(),
'targetNominal': _parseNominal(nominalTargetController.text),
'pin': pinController.text,
'totalSaldo': 0,
'createdAt': now,
});
Get.snackbar(
'Berhasil',
'Akun berhasil dibuat!',
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.green.shade100,
);
Get.offAllNamed(Routes.LOGIN);
} on FirebaseAuthException catch (e) {
String message = 'Registrasi gagal.';
if (e.code == 'email-already-in-use') {
message = 'Email sudah digunakan.';
} else if (e.code == 'weak-password') {
message = 'Password terlalu lemah (min. 6 karakter).';
} else if (e.code == 'invalid-email') {
message = 'Format email tidak valid.';
}
Get.snackbar(
'Registrasi Gagal',
message,
snackPosition: SnackPosition.TOP,
backgroundColor: Colors.red.shade100,
);
} finally {
isLoading.value = false;
}
}
}

View File

@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/register_controller.dart';
class RegisterView extends GetView<RegisterController> {
const RegisterView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title
RichText(
text: const TextSpan(
children: [
TextSpan(
text: 'Hello, ',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
TextSpan(
text: 'Selamat Datang',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: Color(0xFF5B9BD5),
),
),
],
),
),
const SizedBox(height: 20),
// Blue label
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF5B9BD5), Color(0xFF7BB3E0)],
),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'Lengkapin Formulir dibawah ini',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
),
),
const SizedBox(height: 20),
// Nama Lengkap
_buildTextField(
controller: controller.namaController,
hint: 'Nama Lengkap',
icon: Icons.person_outline,
),
const SizedBox(height: 14),
// Setelah field Nama Lengkap
_buildTextField(
controller: controller.emailController,
hint: 'Email',
icon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 14),
_buildTextField(
controller: controller.passwordController,
hint: 'Password',
icon: Icons.lock_outline,
),
const SizedBox(height: 14),
// Target Celengan tap buka picker
_buildTextField(
controller: controller.targetCelenganController,
hint: 'Target Celengan',
icon: Icons.savings_outlined,
readOnly: true,
onTap: () => controller.showTargetPicker(context),
suffixIcon: const Icon(
Icons.keyboard_arrow_down_rounded,
color: Colors.grey,
),
),
const SizedBox(height: 14),
// Nominal Target
_buildTextField(
controller: controller.nominalTargetController,
hint: 'Nominal Target',
icon: Icons.attach_money_outlined,
keyboardType: TextInputType.number,
onTap: () => controller.showNumpad(context, 'nominal'),
readOnly: true,
),
const SizedBox(height: 14),
// Masukkan PIN
Obx(
() => _buildTextField(
controller: controller.pinController,
hint: 'masukkan pin',
icon: Icons.lock_outline,
obscureText: !controller.isPinVisible.value,
suffixIcon: IconButton(
icon: Icon(
controller.isPinVisible.value
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Colors.grey,
),
onPressed: controller.togglePinVisibility,
),
keyboardType: TextInputType.number,
onTap: () => controller.showNumpad(context, 'pin'),
readOnly: true,
),
),
const SizedBox(height: 14),
// Ulangi PIN
Obx(
() => _buildTextField(
controller: controller.confirmPinController,
hint: 'ulangi pin',
icon: Icons.lock_outline,
obscureText: !controller.isConfirmPinVisible.value,
suffixIcon: IconButton(
icon: Icon(
controller.isConfirmPinVisible.value
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
color: Colors.grey,
),
onPressed: controller.toggleConfirmPinVisibility,
),
keyboardType: TextInputType.number,
onTap: () => controller.showNumpad(context, 'confirmPin'),
readOnly: true,
),
),
const SizedBox(height: 32),
// Simpan Button
GestureDetector(
onTap: controller.simpan,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
color: const Color(0xFF5B9BD5),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'Simpan',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
),
],
),
),
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String hint,
required IconData icon,
bool obscureText = false,
Widget? suffixIcon,
TextInputType keyboardType = TextInputType.text,
VoidCallback? onTap,
bool readOnly = false,
}) {
return Container(
decoration: BoxDecoration(
color: const Color(0xFFF2F2F2),
borderRadius: BorderRadius.circular(12),
),
child: TextField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
readOnly: readOnly,
onTap: onTap,
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Colors.grey, fontSize: 14),
prefixIcon: Icon(icon, color: Colors.grey, size: 20),
suffixIcon: suffixIcon,
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
);
}
}

View File

@ -0,0 +1,55 @@
import 'package:get/get.dart';
import '../modules/history/bindings/history_binding.dart';
import '../modules/history/views/history_view.dart';
import '../modules/home/bindings/home_binding.dart';
import '../modules/home/views/home_view.dart';
import '../modules/login/bindings/login_binding.dart';
import '../modules/login/views/login_view.dart';
import '../modules/navbar/bindings/navbar_binding.dart';
import '../modules/navbar/views/navbar_view.dart';
import '../modules/onboarding/bindings/onboarding_binding.dart';
import '../modules/onboarding/views/onboarding_view.dart';
import '../modules/register/bindings/register_binding.dart';
import '../modules/register/views/register_view.dart';
part 'app_routes.dart';
class AppPages {
AppPages._();
static const INITIAL = Routes.HOME;
static final routes = [
GetPage(
name: _Paths.HOME,
page: () => const HomeView(),
binding: HomeBinding(),
),
GetPage(
name: _Paths.ONBOARDING,
page: () => const OnboardingView(),
binding: OnboardingBinding(),
),
GetPage(
name: _Paths.HISTORY,
page: () => const HistoryView(),
binding: HistoryBinding(),
),
GetPage(
name: _Paths.REGISTER,
page: () => const RegisterView(),
binding: RegisterBinding(),
),
GetPage(
name: _Paths.NAVBAR,
page: () => const NavbarView(),
binding: NavbarBinding(),
),
GetPage(
name: _Paths.LOGIN,
page: () => const LoginView(),
binding: LoginBinding(),
),
];
}

View File

@ -0,0 +1,22 @@
part of 'app_pages.dart';
// DO NOT EDIT. This is code generated via package:get_cli/get_cli.dart
abstract class Routes {
Routes._();
static const HOME = _Paths.HOME;
static const ONBOARDING = _Paths.ONBOARDING;
static const HISTORY = _Paths.HISTORY;
static const REGISTER = _Paths.REGISTER;
static const NAVBAR = _Paths.NAVBAR;
static const LOGIN = _Paths.LOGIN;
}
abstract class _Paths {
_Paths._();
static const HOME = '/home';
static const ONBOARDING = '/onboarding';
static const HISTORY = '/history';
static const REGISTER = '/register';
static const NAVBAR = '/navbar';
static const LOGIN = '/login';
}

View File

@ -0,0 +1,55 @@
import 'package:awesome_notifications/awesome_notifications.dart';
import 'package:flutter/material.dart';
class NotificationService {
static const _channelKey = 'tabungankuy_channel';
/// Panggil di main.dart sebelum runApp()
static Future<void> initialize() async {
await AwesomeNotifications().initialize(
null, // null = pakai icon default app
[
NotificationChannel(
channelKey: _channelKey,
channelName: 'TabunganKuy',
channelDescription: 'Notifikasi pencapaian target tabungan',
defaultColor: const Color(0xFF5B9BD5),
ledColor: const Color(0xFF5B9BD5),
importance: NotificationImportance.High,
channelShowBadge: true,
playSound: true,
enableVibration: true,
),
],
debug: false,
);
}
/// Minta izin notifikasi panggil di halaman pertama setelah login
static Future<void> requestPermission() async {
final isAllowed = await AwesomeNotifications().isNotificationAllowed();
if (!isAllowed) {
await AwesomeNotifications().requestPermissionToSendNotifications();
}
}
/// Notifikasi target tercapai
static Future<void> showTargetTercapai({
required String namaTarget,
required String nominal,
}) async {
await AwesomeNotifications().createNotification(
content: NotificationContent(
id: 1,
channelKey: _channelKey,
title: '🎉 Target Tercapai!',
body:
'Selamat! Tabungan "$namaTarget" kamu sudah mencapai target $nominal. Yuk buka celenganmu!',
notificationLayout: NotificationLayout.BigText,
category: NotificationCategory.Reminder,
wakeUpScreen: true,
autoDismissible: true,
),
);
}
}

View File

@ -0,0 +1,324 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'numpad_nominal_widget.dart';
class AmbilUangModal extends StatefulWidget {
final int saldoSaatIni;
final void Function(int jumlah) onAmbil;
const AmbilUangModal({
super.key,
required this.saldoSaatIni,
required this.onAmbil,
});
static void show(
BuildContext context, {
required int saldoSaatIni,
required void Function(int jumlah) onAmbil,
}) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (_) =>
AmbilUangModal(saldoSaatIni: saldoSaatIni, onAmbil: onAmbil),
);
}
@override
State<AmbilUangModal> createState() => _AmbilUangModalState();
}
class _AmbilUangModalState extends State<AmbilUangModal> {
final _nominalController = TextEditingController();
bool _ambilSemua = false;
String _errorMessage = '';
final _currencyFormat = RegExp(r'\B(?=(\d{3})+(?!\d))');
String _formatRupiah(int value) {
return 'Rp ${value.toString().replaceAllMapped(_currencyFormat, (m) => '.')}';
}
int get _parsedNominal {
final raw = _nominalController.text.replaceAll(RegExp(r'[^0-9]'), '');
return int.tryParse(raw) ?? 0;
}
void _onNominalKey(String key) {
setState(() {
_ambilSemua = false;
_errorMessage = '';
});
if (key == 'del') {
if (_nominalController.text.isNotEmpty) {
_nominalController.text = _nominalController.text.substring(
0,
_nominalController.text.length - 1,
);
}
} else {
_nominalController.text += key;
}
}
void _pilihAmbilSemua() {
setState(() {
_ambilSemua = true;
_errorMessage = '';
_nominalController.text = widget.saldoSaatIni.toString();
});
}
void _konfirmasi() {
final jumlah = _ambilSemua ? widget.saldoSaatIni : _parsedNominal;
if (jumlah <= 0) {
setState(() => _errorMessage = 'Masukkan jumlah yang ingin diambil');
return;
}
if (jumlah > widget.saldoSaatIni) {
setState(
() => _errorMessage =
'Jumlah melebihi saldo (${_formatRupiah(widget.saldoSaatIni)})',
);
return;
}
Navigator.of(context).pop();
widget.onAmbil(jumlah);
}
@override
void dispose() {
_nominalController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.fromLTRB(
24,
16,
24,
MediaQuery.of(context).viewInsets.bottom + 32,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Handle bar
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
const Center(
child: Text(
'🎉 Celengan Dibuka!',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
),
const SizedBox(height: 6),
Center(
child: Text(
'Mau ambil berapa?',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
),
const SizedBox(height: 20),
// Info saldo
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFFEAF4FC),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Saldo Celengan',
style: TextStyle(fontSize: 13, color: Colors.black54),
),
Text(
_formatRupiah(widget.saldoSaatIni),
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFF5B9BD5),
),
),
],
),
),
const SizedBox(height: 16),
// Input nominal
const Text(
'Jumlah yang diambil',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.black54,
),
),
const SizedBox(height: 8),
GestureDetector(
onTap: () => NumpadNominalWidget.show(
context,
controller: _nominalController,
onKey: _onNominalKey,
onSimpan: () => Get.back(),
),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: const Color(0xFFF2F2F2),
borderRadius: BorderRadius.circular(12),
border: _errorMessage.isNotEmpty
? Border.all(color: Colors.red.shade300)
: null,
),
child: Row(
children: [
const Icon(
Icons.attach_money_outlined,
color: Colors.grey,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: _nominalController,
builder: (_, value, __) {
final raw = value.text.replaceAll(
RegExp(r'[^0-9]'),
'',
);
final display = raw.isEmpty
? 'Masukkan nominal'
: _formatRupiah(int.tryParse(raw) ?? 0);
return Text(
display,
style: TextStyle(
fontSize: 14,
color: raw.isEmpty ? Colors.grey : Colors.black87,
),
);
},
),
),
],
),
),
),
// Error message
if (_errorMessage.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
_errorMessage,
style: TextStyle(color: Colors.red.shade600, fontSize: 12),
),
),
const SizedBox(height: 12),
// Shortcut: Ambil Semua
GestureDetector(
onTap: _pilihAmbilSemua,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: _ambilSemua
? const Color(0xFF5B9BD5).withOpacity(0.12)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _ambilSemua
? const Color(0xFF5B9BD5)
: Colors.grey.shade300,
width: _ambilSemua ? 1.5 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.select_all_rounded,
size: 18,
color: _ambilSemua
? const Color(0xFF5B9BD5)
: Colors.grey.shade600,
),
const SizedBox(width: 8),
Text(
'Ambil Semua (${_formatRupiah(widget.saldoSaatIni)})',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _ambilSemua
? const Color(0xFF5B9BD5)
: Colors.grey.shade600,
),
),
],
),
),
),
const SizedBox(height: 20),
// Tombol Ambil
GestureDetector(
onTap: _konfirmasi,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: const Color(0xFF5B9BD5),
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Text(
'Ambil Uang',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
),
],
),
);
}
}

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