commit 8f0b0015016f73e91b8242e02f53754bdab6c41c Author: Wira Basalamah Date: Fri Apr 24 04:55:24 2026 +0700 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a84372b --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +.DS_Store +.claude/ + +# Xcode user-specific files +xcuserdata/ +*.xcuserdatad/ +*.xcuserstate + +# Build artifacts +build/ +DerivedData/ + +# Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +# CocoaPods dependencies +Pods/ + +# Misc +*.moved-aside +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 diff --git a/Emoney Info.xcodeproj/project.pbxproj b/Emoney Info.xcodeproj/project.pbxproj new file mode 100644 index 0000000..15d93c9 --- /dev/null +++ b/Emoney Info.xcodeproj/project.pbxproj @@ -0,0 +1,727 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 530347112C577B1F00776F00 /* GradientView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 530347102C577B1F00776F00 /* GradientView.swift */; }; + 533746552C50DC9E00A08FD0 /* ApduResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533746542C50DC9E00A08FD0 /* ApduResponse.swift */; }; + 533746572C512F8500A08FD0 /* ApduCallback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533746562C512F8500A08FD0 /* ApduCallback.swift */; }; + 533746592C513DAA00A08FD0 /* CommonConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 533746582C513DAA00A08FD0 /* CommonConstants.swift */; }; + 534964522C5472CB0063C392 /* BcaFlazzApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534964512C5472CB0063C392 /* BcaFlazzApi.swift */; }; + 536C0F2E2C4E146B0076466F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F2D2C4E146B0076466F /* AppDelegate.swift */; }; + 536C0F302C4E146B0076466F /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F2F2C4E146B0076466F /* SceneDelegate.swift */; }; + 536C0F372C4E146D0076466F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 536C0F362C4E146D0076466F /* Assets.xcassets */; }; + 536C0F3A2C4E146D0076466F /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 536C0F392C4E146D0076466F /* Base */; }; + 536C0F642C4E14C40076466F /* ApduRunner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F432C4E14C40076466F /* ApduRunner.swift */; }; + 536C0F652C4E14C40076466F /* ByteArrayAndHexHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F452C4E14C40076466F /* ByteArrayAndHexHelper.swift */; }; + 536C0F712C4E14C40076466F /* UnifiedNfcApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F522C4E14C40076466F /* UnifiedNfcApi.swift */; }; + 536C0F722C4E14C40076466F /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F542C4E14C40076466F /* Array.swift */; }; + 536C0F732C4E14C40076466F /* Data.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F552C4E14C40076466F /* Data.swift */; }; + 536C0F742C4E14C40076466F /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F562C4E14C40076466F /* String.swift */; }; + 536C0F762C4E14C40076466F /* CardErrorCodes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F5A2C4E14C40076466F /* CardErrorCodes.swift */; }; + 536C0F7B2C4E14C40076466F /* ToastHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536C0F602C4E14C40076466F /* ToastHelper.swift */; }; + 537BCE802C6C8CBD002A3127 /* AdsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 537BCE7E2C6C8CBD002A3127 /* AdsCell.xib */; }; + 538EE4AC2C54D34A0033EDC8 /* MandiriEmoneyApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 538EE4AB2C54D34A0033EDC8 /* MandiriEmoneyApi.swift */; }; + 538EE4AE2C54FEC80033EDC8 /* JackCardApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 538EE4AD2C54FEC80033EDC8 /* JackCardApi.swift */; }; + 538EE4B02C5501F60033EDC8 /* MegaCashApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 538EE4AF2C5501F60033EDC8 /* MegaCashApi.swift */; }; + 538EE4B22C550A370033EDC8 /* BrizziApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 538EE4B12C550A370033EDC8 /* BrizziApi.swift */; }; + 538EE4B42C55147F0033EDC8 /* BrizziSamHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 538EE4B32C55147F0033EDC8 /* BrizziSamHelper.swift */; }; + 539A25212C4FFA38006997BE /* EmoneyApduCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 539A25202C4FFA38006997BE /* EmoneyApduCommands.swift */; }; + 53A3AF4E2C58A7620072A8C8 /* AboutViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53A3AF4D2C58A7620072A8C8 /* AboutViewController.swift */; }; + 53A3AF562C58C8810072A8C8 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 53A3AF542C58C8810072A8C8 /* Localizable.strings */; }; + 53BDEF432C6F507E00AF4766 /* logo.png in Resources */ = {isa = PBXBuildFile; fileRef = 53BDEF422C6F507E00AF4766 /* logo.png */; }; + 53E275C72C528378002C4C3B /* RiwayatCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E275C62C528378002C4C3B /* RiwayatCard.swift */; }; + 53E275C92C52E2CB002C4C3B /* Emoney.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E275C82C52E2CB002C4C3B /* Emoney.swift */; }; + 53E275CB2C52E4DD002C4C3B /* TapCashApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E275CA2C52E4DD002C4C3B /* TapCashApi.swift */; }; + 53E275CD2C52E68D002C4C3B /* TapCashData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E275CC2C52E68D002C4C3B /* TapCashData.swift */; }; + 53E275D02C535F04002C4C3B /* halter.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 53E275CF2C535F04002C4C3B /* halter.ttf */; }; + 66D1680E685BE7C994196F4A /* Pods_Emoney_Info.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1C0A2FB40E3C89EB399B18E5 /* Pods_Emoney_Info.framework */; }; + 8C4C92F72F82167D000F02ED /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C92F62F82167D000F02ED /* Theme.swift */; }; + 8C4C92F92F82175A000F02ED /* MainTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C92F82F82175A000F02ED /* MainTabView.swift */; }; + 8C4C92FB2F821877000F02ED /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C92FA2F821877000F02ED /* HomeView.swift */; }; + 8C4C92FD2F821943000F02ED /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C92FC2F821943000F02ED /* SettingsView.swift */; }; + 8C4C92FF2F821A04000F02ED /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C92FE2F821A04000F02ED /* HistoryView.swift */; }; + 8C4C93012F8226C8000F02ED /* L10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C93002F8226C8000F02ED /* L10n.swift */; }; + 8C4C93042F825D65000F02ED /* FAQData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C93022F825D65000F02ED /* FAQData.swift */; }; + 8C4C93052F825D65000F02ED /* FAQViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C93032F825D65000F02ED /* FAQViewController.swift */; }; + 8C4C93082F8271AE000F02ED /* PrivacyPolicyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C93062F8271AE000F02ED /* PrivacyPolicyViewController.swift */; }; + 8C4C93092F8271AE000F02ED /* TermsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C93072F8271AE000F02ED /* TermsViewController.swift */; }; + 8C4C930D2F82A9BC000F02ED /* Station.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C4C930C2F82A9BC000F02ED /* Station.swift */; }; + 8CC07EA92F860E08009CFD0A /* header.png in Resources */ = {isa = PBXBuildFile; fileRef = 8CC07EA82F860E08009CFD0A /* header.png */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1C0A2FB40E3C89EB399B18E5 /* Pods_Emoney_Info.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Emoney_Info.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 530347102C577B1F00776F00 /* GradientView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientView.swift; sourceTree = ""; }; + 533746542C50DC9E00A08FD0 /* ApduResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApduResponse.swift; sourceTree = ""; }; + 533746562C512F8500A08FD0 /* ApduCallback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApduCallback.swift; sourceTree = ""; }; + 533746582C513DAA00A08FD0 /* CommonConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CommonConstants.swift; sourceTree = ""; }; + 534964512C5472CB0063C392 /* BcaFlazzApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BcaFlazzApi.swift; sourceTree = ""; }; + 536C0F2A2C4E146B0076466F /* Emoney Info.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Emoney Info.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 536C0F2D2C4E146B0076466F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 536C0F2F2C4E146B0076466F /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 536C0F362C4E146D0076466F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 536C0F392C4E146D0076466F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 536C0F3B2C4E146D0076466F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 536C0F432C4E14C40076466F /* ApduRunner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ApduRunner.swift; sourceTree = ""; }; + 536C0F452C4E14C40076466F /* ByteArrayAndHexHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ByteArrayAndHexHelper.swift; sourceTree = ""; }; + 536C0F522C4E14C40076466F /* UnifiedNfcApi.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnifiedNfcApi.swift; sourceTree = ""; }; + 536C0F542C4E14C40076466F /* Array.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; + 536C0F552C4E14C40076466F /* Data.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Data.swift; sourceTree = ""; }; + 536C0F562C4E14C40076466F /* String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; + 536C0F5A2C4E14C40076466F /* CardErrorCodes.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CardErrorCodes.swift; sourceTree = ""; }; + 536C0F602C4E14C40076466F /* ToastHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ToastHelper.swift; sourceTree = ""; }; + 537BCE7E2C6C8CBD002A3127 /* AdsCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AdsCell.xib; sourceTree = ""; }; + 538EE4AB2C54D34A0033EDC8 /* MandiriEmoneyApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MandiriEmoneyApi.swift; sourceTree = ""; }; + 538EE4AD2C54FEC80033EDC8 /* JackCardApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JackCardApi.swift; sourceTree = ""; }; + 538EE4AF2C5501F60033EDC8 /* MegaCashApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MegaCashApi.swift; sourceTree = ""; }; + 538EE4B12C550A370033EDC8 /* BrizziApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrizziApi.swift; sourceTree = ""; }; + 538EE4B32C55147F0033EDC8 /* BrizziSamHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrizziSamHelper.swift; sourceTree = ""; }; + 539A25202C4FFA38006997BE /* EmoneyApduCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmoneyApduCommands.swift; sourceTree = ""; }; + 53A3AF4D2C58A7620072A8C8 /* AboutViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutViewController.swift; sourceTree = ""; }; + 53A3AF552C58C8810072A8C8 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 53A3AF572C58C8BE0072A8C8 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Localizable.strings; sourceTree = ""; }; + 53BDEF422C6F507E00AF4766 /* logo.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = logo.png; sourceTree = ""; }; + 53DFF4862C4F969E00235A56 /* Emoney Info.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Emoney Info.entitlements"; sourceTree = ""; }; + 53E275C62C528378002C4C3B /* RiwayatCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RiwayatCard.swift; sourceTree = ""; }; + 53E275C82C52E2CB002C4C3B /* Emoney.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoney.swift; sourceTree = ""; }; + 53E275CA2C52E4DD002C4C3B /* TapCashApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapCashApi.swift; sourceTree = ""; }; + 53E275CC2C52E68D002C4C3B /* TapCashData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapCashData.swift; sourceTree = ""; }; + 53E275CF2C535F04002C4C3B /* halter.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = halter.ttf; sourceTree = ""; }; + 8C4C92F62F82167D000F02ED /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; + 8C4C92F82F82175A000F02ED /* MainTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainTabView.swift; sourceTree = ""; }; + 8C4C92FA2F821877000F02ED /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + 8C4C92FC2F821943000F02ED /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 8C4C92FE2F821A04000F02ED /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; + 8C4C93002F8226C8000F02ED /* L10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = L10n.swift; sourceTree = ""; }; + 8C4C93022F825D65000F02ED /* FAQData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQData.swift; sourceTree = ""; }; + 8C4C93032F825D65000F02ED /* FAQViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FAQViewController.swift; sourceTree = ""; }; + 8C4C93062F8271AE000F02ED /* PrivacyPolicyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivacyPolicyViewController.swift; sourceTree = ""; }; + 8C4C93072F8271AE000F02ED /* TermsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TermsViewController.swift; sourceTree = ""; }; + 8C4C930C2F82A9BC000F02ED /* Station.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Station.swift; sourceTree = ""; }; + 8CC07EA82F860E08009CFD0A /* header.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = header.png; sourceTree = ""; }; + B03D5F72678ED8DD7DFE3F40 /* Pods-Emoney Info.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Emoney Info.release.xcconfig"; path = "Target Support Files/Pods-Emoney Info/Pods-Emoney Info.release.xcconfig"; sourceTree = ""; }; + CFD118D4B349F6A8169BA732 /* Pods-Emoney Info.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Emoney Info.debug.xcconfig"; path = "Target Support Files/Pods-Emoney Info/Pods-Emoney Info.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 536C0F272C4E146B0076466F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 66D1680E685BE7C994196F4A /* Pods_Emoney_Info.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 29A1D7C82170743F7C68260A /* Pods */ = { + isa = PBXGroup; + children = ( + CFD118D4B349F6A8169BA732 /* Pods-Emoney Info.debug.xcconfig */, + B03D5F72678ED8DD7DFE3F40 /* Pods-Emoney Info.release.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 536C0F212C4E146B0076466F = { + isa = PBXGroup; + children = ( + 536C0F2C2C4E146B0076466F /* Emoney Info */, + 536C0F2B2C4E146B0076466F /* Products */, + 29A1D7C82170743F7C68260A /* Pods */, + E857A0B41B9A6DFF4C1AAD91 /* Frameworks */, + ); + sourceTree = ""; + }; + 536C0F2B2C4E146B0076466F /* Products */ = { + isa = PBXGroup; + children = ( + 536C0F2A2C4E146B0076466F /* Emoney Info.app */, + ); + name = Products; + sourceTree = ""; + }; + 536C0F2C2C4E146B0076466F /* Emoney Info */ = { + isa = PBXGroup; + children = ( + 8C4C93062F8271AE000F02ED /* PrivacyPolicyViewController.swift */, + 8C4C93072F8271AE000F02ED /* TermsViewController.swift */, + 8C4C93022F825D65000F02ED /* FAQData.swift */, + 8C4C93032F825D65000F02ED /* FAQViewController.swift */, + 8C4C92FE2F821A04000F02ED /* HistoryView.swift */, + 8C4C92FC2F821943000F02ED /* SettingsView.swift */, + 8C4C92FA2F821877000F02ED /* HomeView.swift */, + 8C4C92F82F82175A000F02ED /* MainTabView.swift */, + 537BCE7E2C6C8CBD002A3127 /* AdsCell.xib */, + 53E275CE2C535EF2002C4C3B /* Resources */, + 53DFF4862C4F969E00235A56 /* Emoney Info.entitlements */, + 536C0F622C4E14C40076466F /* Classes */, + 536C0F2D2C4E146B0076466F /* AppDelegate.swift */, + 536C0F2F2C4E146B0076466F /* SceneDelegate.swift */, + 536C0F362C4E146D0076466F /* Assets.xcassets */, + 536C0F382C4E146D0076466F /* LaunchScreen.storyboard */, + 536C0F3B2C4E146D0076466F /* Info.plist */, + 53A3AF4D2C58A7620072A8C8 /* AboutViewController.swift */, + 53A3AF542C58C8810072A8C8 /* Localizable.strings */, + ); + path = "Emoney Info"; + sourceTree = ""; + }; + 536C0F422C4E14C40076466F /* callback */ = { + isa = PBXGroup; + children = ( + 533746562C512F8500A08FD0 /* ApduCallback.swift */, + ); + path = callback; + sourceTree = ""; + }; + 536C0F442C4E14C40076466F /* nfc */ = { + isa = PBXGroup; + children = ( + 536C0F432C4E14C40076466F /* ApduRunner.swift */, + 533746542C50DC9E00A08FD0 /* ApduResponse.swift */, + 53E275C62C528378002C4C3B /* RiwayatCard.swift */, + 53E275C82C52E2CB002C4C3B /* Emoney.swift */, + ); + path = nfc; + sourceTree = ""; + }; + 536C0F4B2C4E14C40076466F /* utils */ = { + isa = PBXGroup; + children = ( + 536C0F452C4E14C40076466F /* ByteArrayAndHexHelper.swift */, + 538EE4B32C55147F0033EDC8 /* BrizziSamHelper.swift */, + ); + path = utils; + sourceTree = ""; + }; + 536C0F532C4E14C40076466F /* api */ = { + isa = PBXGroup; + children = ( + 536C0F422C4E14C40076466F /* callback */, + 536C0F442C4E14C40076466F /* nfc */, + 536C0F4B2C4E14C40076466F /* utils */, + 536C0F522C4E14C40076466F /* UnifiedNfcApi.swift */, + 53E275CA2C52E4DD002C4C3B /* TapCashApi.swift */, + 534964512C5472CB0063C392 /* BcaFlazzApi.swift */, + 538EE4AB2C54D34A0033EDC8 /* MandiriEmoneyApi.swift */, + 538EE4AD2C54FEC80033EDC8 /* JackCardApi.swift */, + 538EE4AF2C5501F60033EDC8 /* MegaCashApi.swift */, + 538EE4B12C550A370033EDC8 /* BrizziApi.swift */, + ); + path = api; + sourceTree = ""; + }; + 536C0F572C4E14C40076466F /* extensions */ = { + isa = PBXGroup; + children = ( + 536C0F542C4E14C40076466F /* Array.swift */, + 536C0F552C4E14C40076466F /* Data.swift */, + 536C0F562C4E14C40076466F /* String.swift */, + ); + path = extensions; + sourceTree = ""; + }; + 536C0F5F2C4E14C40076466F /* smartCard */ = { + isa = PBXGroup; + children = ( + 533746582C513DAA00A08FD0 /* CommonConstants.swift */, + 536C0F5A2C4E14C40076466F /* CardErrorCodes.swift */, + 539A25202C4FFA38006997BE /* EmoneyApduCommands.swift */, + 53E275CC2C52E68D002C4C3B /* TapCashData.swift */, + ); + path = smartCard; + sourceTree = ""; + }; + 536C0F612C4E14C40076466F /* utils */ = { + isa = PBXGroup; + children = ( + 8C4C930C2F82A9BC000F02ED /* Station.swift */, + 8C4C93002F8226C8000F02ED /* L10n.swift */, + 8C4C92F62F82167D000F02ED /* Theme.swift */, + 536C0F602C4E14C40076466F /* ToastHelper.swift */, + 530347102C577B1F00776F00 /* GradientView.swift */, + ); + path = utils; + sourceTree = ""; + }; + 536C0F622C4E14C40076466F /* Classes */ = { + isa = PBXGroup; + children = ( + 536C0F532C4E14C40076466F /* api */, + 536C0F572C4E14C40076466F /* extensions */, + 536C0F5F2C4E14C40076466F /* smartCard */, + 536C0F612C4E14C40076466F /* utils */, + ); + path = Classes; + sourceTree = ""; + }; + 53E275CE2C535EF2002C4C3B /* Resources */ = { + isa = PBXGroup; + children = ( + 8CC07EA82F860E08009CFD0A /* header.png */, + 53BDEF422C6F507E00AF4766 /* logo.png */, + 53E275CF2C535F04002C4C3B /* halter.ttf */, + ); + path = Resources; + sourceTree = ""; + }; + E857A0B41B9A6DFF4C1AAD91 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 1C0A2FB40E3C89EB399B18E5 /* Pods_Emoney_Info.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 536C0F292C4E146B0076466F /* Emoney Info */ = { + isa = PBXNativeTarget; + buildConfigurationList = 536C0F3E2C4E146D0076466F /* Build configuration list for PBXNativeTarget "Emoney Info" */; + buildPhases = ( + A5C3045DA3FE995E648EAF8C /* [CP] Check Pods Manifest.lock */, + 536C0F262C4E146B0076466F /* Sources */, + 536C0F272C4E146B0076466F /* Frameworks */, + 536C0F282C4E146B0076466F /* Resources */, + 398370EEF67CDBA3C57E5750 /* [CP] Copy Pods Resources */, + EA56F63227B51D1105DA9374 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = "Emoney Info"; + productName = "Emoney Info"; + productReference = 536C0F2A2C4E146B0076466F /* Emoney Info.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 536C0F222C4E146B0076466F /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1540; + TargetAttributes = { + 536C0F292C4E146B0076466F = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = 536C0F252C4E146B0076466F /* Build configuration list for PBXProject "Emoney Info" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + id, + ); + mainGroup = 536C0F212C4E146B0076466F; + productRefGroup = 536C0F2B2C4E146B0076466F /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 536C0F292C4E146B0076466F /* Emoney Info */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 536C0F282C4E146B0076466F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 53A3AF562C58C8810072A8C8 /* Localizable.strings in Resources */, + 536C0F372C4E146D0076466F /* Assets.xcassets in Resources */, + 537BCE802C6C8CBD002A3127 /* AdsCell.xib in Resources */, + 8CC07EA92F860E08009CFD0A /* header.png in Resources */, + 536C0F3A2C4E146D0076466F /* Base in Resources */, + 53E275D02C535F04002C4C3B /* halter.ttf in Resources */, + 53BDEF432C6F507E00AF4766 /* logo.png in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 398370EEF67CDBA3C57E5750 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Emoney Info/Pods-Emoney Info-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Emoney Info/Pods-Emoney Info-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Emoney Info/Pods-Emoney Info-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + A5C3045DA3FE995E648EAF8C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Emoney Info-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + EA56F63227B51D1105DA9374 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Emoney Info/Pods-Emoney Info-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + inputPaths = ( + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Emoney Info/Pods-Emoney Info-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Emoney Info/Pods-Emoney Info-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 536C0F262C4E146B0076466F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 536C0F712C4E14C40076466F /* UnifiedNfcApi.swift in Sources */, + 533746572C512F8500A08FD0 /* ApduCallback.swift in Sources */, + 536C0F652C4E14C40076466F /* ByteArrayAndHexHelper.swift in Sources */, + 538EE4AC2C54D34A0033EDC8 /* MandiriEmoneyApi.swift in Sources */, + 534964522C5472CB0063C392 /* BcaFlazzApi.swift in Sources */, + 538EE4B42C55147F0033EDC8 /* BrizziSamHelper.swift in Sources */, + 536C0F642C4E14C40076466F /* ApduRunner.swift in Sources */, + 8C4C93042F825D65000F02ED /* FAQData.swift in Sources */, + 8C4C93052F825D65000F02ED /* FAQViewController.swift in Sources */, + 53E275CD2C52E68D002C4C3B /* TapCashData.swift in Sources */, + 530347112C577B1F00776F00 /* GradientView.swift in Sources */, + 8C4C92FB2F821877000F02ED /* HomeView.swift in Sources */, + 53A3AF4E2C58A7620072A8C8 /* AboutViewController.swift in Sources */, + 53E275CB2C52E4DD002C4C3B /* TapCashApi.swift in Sources */, + 8C4C92FF2F821A04000F02ED /* HistoryView.swift in Sources */, + 536C0F722C4E14C40076466F /* Array.swift in Sources */, + 536C0F732C4E14C40076466F /* Data.swift in Sources */, + 8C4C93012F8226C8000F02ED /* L10n.swift in Sources */, + 53E275C72C528378002C4C3B /* RiwayatCard.swift in Sources */, + 8C4C92F92F82175A000F02ED /* MainTabView.swift in Sources */, + 8C4C92FD2F821943000F02ED /* SettingsView.swift in Sources */, + 533746592C513DAA00A08FD0 /* CommonConstants.swift in Sources */, + 538EE4B02C5501F60033EDC8 /* MegaCashApi.swift in Sources */, + 8C4C93082F8271AE000F02ED /* PrivacyPolicyViewController.swift in Sources */, + 8C4C93092F8271AE000F02ED /* TermsViewController.swift in Sources */, + 536C0F2E2C4E146B0076466F /* AppDelegate.swift in Sources */, + 533746552C50DC9E00A08FD0 /* ApduResponse.swift in Sources */, + 8C4C92F72F82167D000F02ED /* Theme.swift in Sources */, + 536C0F7B2C4E14C40076466F /* ToastHelper.swift in Sources */, + 539A25212C4FFA38006997BE /* EmoneyApduCommands.swift in Sources */, + 536C0F762C4E14C40076466F /* CardErrorCodes.swift in Sources */, + 53E275C92C52E2CB002C4C3B /* Emoney.swift in Sources */, + 538EE4AE2C54FEC80033EDC8 /* JackCardApi.swift in Sources */, + 536C0F742C4E14C40076466F /* String.swift in Sources */, + 538EE4B22C550A370033EDC8 /* BrizziApi.swift in Sources */, + 536C0F302C4E146B0076466F /* SceneDelegate.swift in Sources */, + 8C4C930D2F82A9BC000F02ED /* Station.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 536C0F382C4E146D0076466F /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 536C0F392C4E146D0076466F /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; + 53A3AF542C58C8810072A8C8 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 53A3AF552C58C8810072A8C8 /* en */, + 53A3AF572C58C8BE0072A8C8 /* id */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 536C0F3C2C4E146D0076466F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 536C0F3D2C4E146D0076466F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = 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_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 536C0F3F2C4E146D0076466F /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = CFD118D4B349F6A8169BA732 /* Pods-Emoney Info.debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = 6S5573WXX4; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Emoney Info/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Emoney Info"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NFCReaderUsageDescription = "Aplikasi memerlukan akses NFC untuk memindai kartu uang elektronik Anda (e-money, Flazz, dll.). This app requires NFC access to scan your cards and display your balance locally."; + INFOPLIST_KEY_NSUserTrackingUsageDescription = "Data Anda akan digunakan untuk menampilkan iklan yang relevan. This data is used to provide a personalized ad experience."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = nfc; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 536C0F402C4E146D0076466F /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B03D5F72678ED8DD7DFE3F40 /* Pods-Emoney Info.release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Emoney Info/Emoney Info.entitlements"; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 10; + DEVELOPMENT_TEAM = 6S5573WXX4; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "Emoney Info/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = "Emoney Info"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; + INFOPLIST_KEY_NFCReaderUsageDescription = "Aplikasi memerlukan akses NFC untuk memindai kartu uang elektronik Anda (e-money, Flazz, dll.). This app requires NFC access to scan your cards and display your balance locally."; + INFOPLIST_KEY_NSUserTrackingUsageDescription = "Data Anda akan digunakan untuk menampilkan iklan yang relevan. This data is used to provide a personalized ad experience."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIRequiredDeviceCapabilities = nfc; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0.1; + PRODUCT_BUNDLE_IDENTIFIER = com.iiyh.emoneyinfo; + PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 536C0F252C4E146B0076466F /* Build configuration list for PBXProject "Emoney Info" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 536C0F3C2C4E146D0076466F /* Debug */, + 536C0F3D2C4E146D0076466F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 536C0F3E2C4E146D0076466F /* Build configuration list for PBXNativeTarget "Emoney Info" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 536C0F3F2C4E146D0076466F /* Debug */, + 536C0F402C4E146D0076466F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 536C0F222C4E146B0076466F /* Project object */; +} diff --git a/Emoney Info.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Emoney Info.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100755 index 0000000..919434a --- /dev/null +++ b/Emoney Info.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Emoney Info.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Emoney Info.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100755 index 0000000..18d9810 --- /dev/null +++ b/Emoney Info.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Emoney Info.xcworkspace/contents.xcworkspacedata b/Emoney Info.xcworkspace/contents.xcworkspacedata new file mode 100755 index 0000000..88abad3 --- /dev/null +++ b/Emoney Info.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/Emoney Info.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Emoney Info.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100755 index 0000000..18d9810 --- /dev/null +++ b/Emoney Info.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Emoney Info/AboutViewController.swift b/Emoney Info/AboutViewController.swift new file mode 100644 index 0000000..9cf3879 --- /dev/null +++ b/Emoney Info/AboutViewController.swift @@ -0,0 +1,366 @@ +// AboutViewController.swift +// Emoney Info + +import UIKit + +class AboutViewController: UIViewController { + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Theme.Color.background + setupUI() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(true, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(false, animated: animated) + } + + // MARK: - Setup + + private func setupUI() { + let scrollView = UIScrollView() + let contentView = UIView() + scrollView.showsVerticalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + + // MARK: Back button + nav title + let backButton = UIButton(type: .system) + let chevron = UIImage(systemName: "chevron.left", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)) + backButton.setImage(chevron, for: .normal) + backButton.tintColor = Theme.Color.textPrimary + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.translatesAutoresizingMaskIntoConstraints = false + + let navTitleLabel = UILabel() + navTitleLabel.text = L10n.aboutAppTitle + navTitleLabel.font = Theme.Font.caption(weight: .semibold) + navTitleLabel.textColor = Theme.Color.textSecondary + navTitleLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: App icon + let iconView = UIImageView(image: UIImage(named: "AppLogo")) + iconView.contentMode = .scaleAspectFit + iconView.layer.cornerRadius = 24 + iconView.clipsToBounds = true + iconView.layer.shadowColor = UIColor.black.cgColor + iconView.layer.shadowOpacity = 0.12 + iconView.layer.shadowOffset = CGSize(width: 0, height: 4) + iconView.layer.shadowRadius = 12 + iconView.translatesAutoresizingMaskIntoConstraints = false + + // MARK: App name + version + let appNameLabel = UILabel() + appNameLabel.text = "Emoney Info" + appNameLabel.font = .systemFont(ofSize: 28, weight: .bold) + appNameLabel.textColor = Theme.Color.textPrimary + appNameLabel.textAlignment = .center + appNameLabel.translatesAutoresizingMaskIntoConstraints = false + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let versionLabel = UILabel() + let versionText = "VERSI \(appVersion)" + let versionAttr = NSAttributedString(string: versionText, attributes: [ + .kern: 1.5, + .font: Theme.Font.caption(weight: .semibold), + .foregroundColor: Theme.Color.textSecondary, + ]) + versionLabel.attributedText = versionAttr + versionLabel.textAlignment = .center + versionLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Description + let descLabel = UILabel() + descLabel.text = L10n.aboutAppDescription + descLabel.font = Theme.Font.body(weight: .regular) + descLabel.textColor = Theme.Color.textSecondary + descLabel.numberOfLines = 0 + descLabel.textAlignment = .center + descLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Feature chips + let chipsStack = UIStackView(arrangedSubviews: [ + makeFeatureChip(L10n.aboutChipNfc), + makeFeatureChip(L10n.aboutChipRealtime), + makeFeatureChip(L10n.aboutChipMulti), + ]) + chipsStack.axis = .horizontal + chipsStack.spacing = 10 + chipsStack.alignment = .center + chipsStack.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Legal rows card + let legalCard = makeCard() + + let termsRow = makeLegalRow(icon: "doc.text", title: L10n.aboutTerms) + termsRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(termsTapped))) + termsRow.isUserInteractionEnabled = true + + let separator = UIView() + separator.backgroundColor = Theme.Color.background + separator.translatesAutoresizingMaskIntoConstraints = false + separator.heightAnchor.constraint(equalToConstant: 1).isActive = true + + let privacyRow = makeLegalRow(icon: "lock.shield", title: L10n.aboutPrivacy) + privacyRow.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(privacyTapped))) + privacyRow.isUserInteractionEnabled = true + + let legalStack = UIStackView(arrangedSubviews: [termsRow, separator, privacyRow]) + legalStack.axis = .vertical + legalStack.spacing = 0 + legalStack.translatesAutoresizingMaskIntoConstraints = false + legalCard.addSubview(legalStack) + + // MARK: Connect card (teal gradient) + let connectCard = UIView() + connectCard.layer.cornerRadius = 20 + connectCard.clipsToBounds = true + connectCard.translatesAutoresizingMaskIntoConstraints = false + + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [ + Theme.Color.primary.cgColor, + Theme.Color.secondary.cgColor, + ] + gradientLayer.startPoint = CGPoint(x: 0, y: 0) + gradientLayer.endPoint = CGPoint(x: 1, y: 1) + connectCard.layer.insertSublayer(gradientLayer, at: 0) + + let connectIcon = UIImageView(image: UIImage(systemName: "wave.3.right.circle.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 32, weight: .medium))) + connectIcon.tintColor = UIColor.white.withAlphaComponent(0.35) + connectIcon.contentMode = .scaleAspectFit + connectIcon.translatesAutoresizingMaskIntoConstraints = false + + let connectTitle = UILabel() + connectTitle.text = L10n.aboutConnectTitle + connectTitle.font = Theme.Font.subtitle(weight: .bold) + connectTitle.textColor = .white + connectTitle.numberOfLines = 0 + connectTitle.translatesAutoresizingMaskIntoConstraints = false + + let connectDesc = UILabel() + connectDesc.text = L10n.aboutConnectDesc + connectDesc.font = Theme.Font.caption(weight: .regular) + connectDesc.textColor = UIColor.white.withAlphaComponent(0.85) + connectDesc.numberOfLines = 0 + connectDesc.translatesAutoresizingMaskIntoConstraints = false + + [connectIcon, connectTitle, connectDesc].forEach { connectCard.addSubview($0) } + + // MARK: Copyright + let copyrightLabel = UILabel() + copyrightLabel.text = L10n.footerCopyright + copyrightLabel.font = Theme.Font.caption(weight: .regular) + copyrightLabel.textColor = Theme.Color.textSecondary + copyrightLabel.textAlignment = .center + copyrightLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Add to contentView + [backButton, navTitleLabel, iconView, appNameLabel, versionLabel, + descLabel, chipsStack, legalCard, connectCard, copyrightLabel] + .forEach { contentView.addSubview($0) } + + NSLayoutConstraint.activate([ + // Nav row + backButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 56), + backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + backButton.widthAnchor.constraint(equalToConstant: 32), + backButton.heightAnchor.constraint(equalToConstant: 32), + + navTitleLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor), + navTitleLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8), + + // Icon + iconView.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 28), + iconView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + iconView.widthAnchor.constraint(equalToConstant: 96), + iconView.heightAnchor.constraint(equalToConstant: 96), + + // Name + version + appNameLabel.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 16), + appNameLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + versionLabel.topAnchor.constraint(equalTo: appNameLabel.bottomAnchor, constant: 6), + versionLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + // Desc + descLabel.topAnchor.constraint(equalTo: versionLabel.bottomAnchor, constant: 16), + descLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 32), + descLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -32), + + // Chips + chipsStack.topAnchor.constraint(equalTo: descLabel.bottomAnchor, constant: 20), + chipsStack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + // Legal card + legalCard.topAnchor.constraint(equalTo: chipsStack.bottomAnchor, constant: 28), + legalCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + legalCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + legalStack.topAnchor.constraint(equalTo: legalCard.topAnchor, constant: 4), + legalStack.leadingAnchor.constraint(equalTo: legalCard.leadingAnchor), + legalStack.trailingAnchor.constraint(equalTo: legalCard.trailingAnchor), + legalStack.bottomAnchor.constraint(equalTo: legalCard.bottomAnchor, constant: -4), + + // Connect card + connectCard.topAnchor.constraint(equalTo: legalCard.bottomAnchor, constant: 20), + connectCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + connectCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + connectIcon.topAnchor.constraint(equalTo: connectCard.topAnchor, constant: 20), + connectIcon.trailingAnchor.constraint(equalTo: connectCard.trailingAnchor, constant: -20), + connectIcon.widthAnchor.constraint(equalToConstant: 44), + connectIcon.heightAnchor.constraint(equalToConstant: 44), + + connectTitle.topAnchor.constraint(equalTo: connectCard.topAnchor, constant: 24), + connectTitle.leadingAnchor.constraint(equalTo: connectCard.leadingAnchor, constant: 20), + connectTitle.trailingAnchor.constraint(equalTo: connectIcon.leadingAnchor, constant: -12), + + connectDesc.topAnchor.constraint(equalTo: connectTitle.bottomAnchor, constant: 10), + connectDesc.leadingAnchor.constraint(equalTo: connectCard.leadingAnchor, constant: 20), + connectDesc.trailingAnchor.constraint(equalTo: connectCard.trailingAnchor, constant: -20), + connectDesc.bottomAnchor.constraint(equalTo: connectCard.bottomAnchor, constant: -24), + + // Copyright + copyrightLabel.topAnchor.constraint(equalTo: connectCard.bottomAnchor, constant: 24), + copyrightLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + copyrightLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -32), + ]) + + // Gradient frame — set after layout + DispatchQueue.main.async { + gradientLayer.frame = connectCard.bounds + } + } + + // MARK: - Helpers + + private func makeCard() -> UIView { + let v = UIView() + v.backgroundColor = Theme.Color.card + v.layer.cornerRadius = 16 + v.layer.shadowColor = UIColor.black.cgColor + v.layer.shadowOpacity = 0.06 + v.layer.shadowOffset = CGSize(width: 0, height: 2) + v.layer.shadowRadius = 8 + v.translatesAutoresizingMaskIntoConstraints = false + return v + } + + private func makeFeatureChip(_ title: String) -> UIView { + let container = UIView() + container.backgroundColor = Theme.Color.primary.withAlphaComponent(0.12) + container.layer.cornerRadius = 12 + container.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = title + label.font = Theme.Font.caption(weight: .semibold) + label.textColor = Theme.Color.secondary + label.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(label) + + NSLayoutConstraint.activate([ + label.topAnchor.constraint(equalTo: container.topAnchor, constant: 6), + label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12), + label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12), + label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -6), + ]) + return container + } + + private func makeLegalRow(icon: String, title: String) -> UIView { + let row = UIView() + row.translatesAutoresizingMaskIntoConstraints = false + + let iconContainer = UIView() + iconContainer.backgroundColor = Theme.Color.background + iconContainer.layer.cornerRadius = 10 + iconContainer.translatesAutoresizingMaskIntoConstraints = false + + let iconView = UIImageView(image: UIImage(systemName: icon, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .medium))) + iconView.tintColor = Theme.Color.secondary + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + iconContainer.addSubview(iconView) + + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = Theme.Font.body(weight: .medium) + titleLabel.textColor = Theme.Color.textPrimary + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let chevronImg = UIImage(systemName: "chevron.right", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold)) + let chevron = UIImageView(image: chevronImg) + chevron.tintColor = Theme.Color.textSecondary + chevron.translatesAutoresizingMaskIntoConstraints = false + + [iconContainer, titleLabel, chevron].forEach { row.addSubview($0) } + + NSLayoutConstraint.activate([ + iconContainer.widthAnchor.constraint(equalToConstant: 36), + iconContainer.heightAnchor.constraint(equalToConstant: 36), + iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 18), + iconView.heightAnchor.constraint(equalToConstant: 18), + + iconContainer.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 16), + iconContainer.centerYAnchor.constraint(equalTo: row.centerYAnchor), + + titleLabel.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 14), + titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8), + + chevron.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -16), + chevron.centerYAnchor.constraint(equalTo: row.centerYAnchor), + + row.heightAnchor.constraint(equalToConstant: 60), + ]) + return row + } + + // MARK: - Actions + + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + + @objc private func termsTapped() { + let vc = TermsViewController() + navigationController?.pushViewController(vc, animated: true) + } + + @objc private func privacyTapped() { + let vc = PrivacyPolicyViewController() + navigationController?.pushViewController(vc, animated: true) + } +} diff --git a/Emoney Info/AdsCell.xib b/Emoney Info/AdsCell.xib new file mode 100755 index 0000000..ba3081b --- /dev/null +++ b/Emoney Info/AdsCell.xib @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Emoney Info/AppDelegate.swift b/Emoney Info/AppDelegate.swift new file mode 100755 index 0000000..9914e44 --- /dev/null +++ b/Emoney Info/AppDelegate.swift @@ -0,0 +1,33 @@ +// +// AppDelegate.swift +// Emoney Info +// +// Created by Wira Irawan on 22/07/24. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + // MARK: UISceneSession Lifecycle + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } + + +} + diff --git a/Emoney Info/Assets.xcassets/AccentColor.colorset/Contents.json b/Emoney Info/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100755 index 0000000..eb87897 --- /dev/null +++ b/Emoney Info/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/AppIcon.appiconset/Contents.json b/Emoney Info/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100755 index 0000000..55791a5 --- /dev/null +++ b/Emoney Info/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "logos.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/AppIcon.appiconset/logos.png b/Emoney Info/Assets.xcassets/AppIcon.appiconset/logos.png new file mode 100755 index 0000000..b82d778 Binary files /dev/null and b/Emoney Info/Assets.xcassets/AppIcon.appiconset/logos.png differ diff --git a/Emoney Info/Assets.xcassets/AppLogo.imageset/Contents.json b/Emoney Info/Assets.xcassets/AppLogo.imageset/Contents.json new file mode 100644 index 0000000..9d9f237 --- /dev/null +++ b/Emoney Info/Assets.xcassets/AppLogo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logos.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/AppLogo.imageset/logos.png b/Emoney Info/Assets.xcassets/AppLogo.imageset/logos.png new file mode 100755 index 0000000..b82d778 Binary files /dev/null and b/Emoney Info/Assets.xcassets/AppLogo.imageset/logos.png differ diff --git a/Emoney Info/Assets.xcassets/Contents.json b/Emoney Info/Assets.xcassets/Contents.json new file mode 100755 index 0000000..73c0059 --- /dev/null +++ b/Emoney Info/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/button.imageset/Contents.json b/Emoney Info/Assets.xcassets/button.imageset/Contents.json new file mode 100644 index 0000000..a33984c --- /dev/null +++ b/Emoney Info/Assets.xcassets/button.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "background.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/button.imageset/background.png b/Emoney Info/Assets.xcassets/button.imageset/background.png new file mode 100644 index 0000000..81e42c1 Binary files /dev/null and b/Emoney Info/Assets.xcassets/button.imageset/background.png differ diff --git a/Emoney Info/Assets.xcassets/card.imageset/32.png b/Emoney Info/Assets.xcassets/card.imageset/32.png new file mode 100644 index 0000000..31178ac Binary files /dev/null and b/Emoney Info/Assets.xcassets/card.imageset/32.png differ diff --git a/Emoney Info/Assets.xcassets/card.imageset/64.png b/Emoney Info/Assets.xcassets/card.imageset/64.png new file mode 100644 index 0000000..d57cfec Binary files /dev/null and b/Emoney Info/Assets.xcassets/card.imageset/64.png differ diff --git a/Emoney Info/Assets.xcassets/card.imageset/96.png b/Emoney Info/Assets.xcassets/card.imageset/96.png new file mode 100644 index 0000000..f8d047b Binary files /dev/null and b/Emoney Info/Assets.xcassets/card.imageset/96.png differ diff --git a/Emoney Info/Assets.xcassets/card.imageset/Contents.json b/Emoney Info/Assets.xcassets/card.imageset/Contents.json new file mode 100644 index 0000000..de42f3c --- /dev/null +++ b/Emoney Info/Assets.xcassets/card.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "32.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "64.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "96.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/info.imageset/Contents.json b/Emoney Info/Assets.xcassets/info.imageset/Contents.json new file mode 100755 index 0000000..d4301e8 --- /dev/null +++ b/Emoney Info/Assets.xcassets/info.imageset/Contents.json @@ -0,0 +1,89 @@ +{ + "images" : [ + { + "filename" : "info.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "info 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "info 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "info@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "info@2x 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "info@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "info@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "info@3x 2.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "info@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/info.imageset/info 1.png b/Emoney Info/Assets.xcassets/info.imageset/info 1.png new file mode 100755 index 0000000..1bbcbb6 Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info 1.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info 2.png b/Emoney Info/Assets.xcassets/info.imageset/info 2.png new file mode 100755 index 0000000..781651e Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info 2.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info.png b/Emoney Info/Assets.xcassets/info.imageset/info.png new file mode 100755 index 0000000..781651e Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info@2x 1.png b/Emoney Info/Assets.xcassets/info.imageset/info@2x 1.png new file mode 100755 index 0000000..75c4fa3 Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info@2x 1.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info@2x 2.png b/Emoney Info/Assets.xcassets/info.imageset/info@2x 2.png new file mode 100755 index 0000000..539a144 Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info@2x 2.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info@2x.png b/Emoney Info/Assets.xcassets/info.imageset/info@2x.png new file mode 100755 index 0000000..539a144 Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info@2x.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info@3x 1.png b/Emoney Info/Assets.xcassets/info.imageset/info@3x 1.png new file mode 100755 index 0000000..27af1d6 Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info@3x 1.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info@3x 2.png b/Emoney Info/Assets.xcassets/info.imageset/info@3x 2.png new file mode 100755 index 0000000..1fd76a4 Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info@3x 2.png differ diff --git a/Emoney Info/Assets.xcassets/info.imageset/info@3x.png b/Emoney Info/Assets.xcassets/info.imageset/info@3x.png new file mode 100755 index 0000000..1fd76a4 Binary files /dev/null and b/Emoney Info/Assets.xcassets/info.imageset/info@3x.png differ diff --git a/Emoney Info/Assets.xcassets/kmt.imageset/32.png b/Emoney Info/Assets.xcassets/kmt.imageset/32.png new file mode 100644 index 0000000..3f6dcc1 Binary files /dev/null and b/Emoney Info/Assets.xcassets/kmt.imageset/32.png differ diff --git a/Emoney Info/Assets.xcassets/kmt.imageset/64.png b/Emoney Info/Assets.xcassets/kmt.imageset/64.png new file mode 100644 index 0000000..1a171bb Binary files /dev/null and b/Emoney Info/Assets.xcassets/kmt.imageset/64.png differ diff --git a/Emoney Info/Assets.xcassets/kmt.imageset/96.png b/Emoney Info/Assets.xcassets/kmt.imageset/96.png new file mode 100644 index 0000000..9ea069b Binary files /dev/null and b/Emoney Info/Assets.xcassets/kmt.imageset/96.png differ diff --git a/Emoney Info/Assets.xcassets/kmt.imageset/Contents.json b/Emoney Info/Assets.xcassets/kmt.imageset/Contents.json new file mode 100644 index 0000000..de42f3c --- /dev/null +++ b/Emoney Info/Assets.xcassets/kmt.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "32.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "64.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "96.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/payment.imageset/Contents.json b/Emoney Info/Assets.xcassets/payment.imageset/Contents.json new file mode 100755 index 0000000..ed15f64 --- /dev/null +++ b/Emoney Info/Assets.xcassets/payment.imageset/Contents.json @@ -0,0 +1,89 @@ +{ + "images" : [ + { + "filename" : "payment_black 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "payment_black.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "payment_white.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "payment_black@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "payment_black@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "payment_white@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "payment_black@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "payment_black@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "payment_white@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_black 1.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_black 1.png new file mode 100755 index 0000000..b8e5f82 Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_black 1.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_black.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_black.png new file mode 100755 index 0000000..b8e5f82 Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_black.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_black@2x 1.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@2x 1.png new file mode 100755 index 0000000..7754888 Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@2x 1.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_black@2x.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@2x.png new file mode 100755 index 0000000..7754888 Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@2x.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_black@3x 1.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@3x 1.png new file mode 100755 index 0000000..ec61dab Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@3x 1.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_black@3x.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@3x.png new file mode 100755 index 0000000..ec61dab Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_black@3x.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_white.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_white.png new file mode 100755 index 0000000..6229ca8 Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_white.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_white@2x.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_white@2x.png new file mode 100755 index 0000000..8f470a1 Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_white@2x.png differ diff --git a/Emoney Info/Assets.xcassets/payment.imageset/payment_white@3x.png b/Emoney Info/Assets.xcassets/payment.imageset/payment_white@3x.png new file mode 100755 index 0000000..883fccd Binary files /dev/null and b/Emoney Info/Assets.xcassets/payment.imageset/payment_white@3x.png differ diff --git a/Emoney Info/Assets.xcassets/prefs.imageset/32.png b/Emoney Info/Assets.xcassets/prefs.imageset/32.png new file mode 100644 index 0000000..c1af8a9 Binary files /dev/null and b/Emoney Info/Assets.xcassets/prefs.imageset/32.png differ diff --git a/Emoney Info/Assets.xcassets/prefs.imageset/64.png b/Emoney Info/Assets.xcassets/prefs.imageset/64.png new file mode 100644 index 0000000..ce9f95b Binary files /dev/null and b/Emoney Info/Assets.xcassets/prefs.imageset/64.png differ diff --git a/Emoney Info/Assets.xcassets/prefs.imageset/96.png b/Emoney Info/Assets.xcassets/prefs.imageset/96.png new file mode 100644 index 0000000..fcd6087 Binary files /dev/null and b/Emoney Info/Assets.xcassets/prefs.imageset/96.png differ diff --git a/Emoney Info/Assets.xcassets/prefs.imageset/Contents.json b/Emoney Info/Assets.xcassets/prefs.imageset/Contents.json new file mode 100644 index 0000000..de42f3c --- /dev/null +++ b/Emoney Info/Assets.xcassets/prefs.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "32.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "64.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "96.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/setting.imageset/Contents.json b/Emoney Info/Assets.xcassets/setting.imageset/Contents.json new file mode 100755 index 0000000..276403b --- /dev/null +++ b/Emoney Info/Assets.xcassets/setting.imageset/Contents.json @@ -0,0 +1,89 @@ +{ + "images" : [ + { + "filename" : "gear.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "gear 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "gear 2.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "gear@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "gear@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "gear@2x 2.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "gear@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "gear@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "gear@3x 2.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear 1.png b/Emoney Info/Assets.xcassets/setting.imageset/gear 1.png new file mode 100755 index 0000000..b675d4a Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear 1.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear 2.png b/Emoney Info/Assets.xcassets/setting.imageset/gear 2.png new file mode 100755 index 0000000..5eb47f7 Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear 2.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear.png b/Emoney Info/Assets.xcassets/setting.imageset/gear.png new file mode 100755 index 0000000..b675d4a Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear@2x 1.png b/Emoney Info/Assets.xcassets/setting.imageset/gear@2x 1.png new file mode 100755 index 0000000..b3b31b3 Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear@2x 1.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear@2x 2.png b/Emoney Info/Assets.xcassets/setting.imageset/gear@2x 2.png new file mode 100755 index 0000000..8d57168 Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear@2x 2.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear@2x.png b/Emoney Info/Assets.xcassets/setting.imageset/gear@2x.png new file mode 100755 index 0000000..b3b31b3 Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear@2x.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear@3x 1.png b/Emoney Info/Assets.xcassets/setting.imageset/gear@3x 1.png new file mode 100755 index 0000000..d6f06b9 Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear@3x 1.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear@3x 2.png b/Emoney Info/Assets.xcassets/setting.imageset/gear@3x 2.png new file mode 100755 index 0000000..b0859b2 Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear@3x 2.png differ diff --git a/Emoney Info/Assets.xcassets/setting.imageset/gear@3x.png b/Emoney Info/Assets.xcassets/setting.imageset/gear@3x.png new file mode 100755 index 0000000..d6f06b9 Binary files /dev/null and b/Emoney Info/Assets.xcassets/setting.imageset/gear@3x.png differ diff --git a/Emoney Info/Assets.xcassets/simcard.imageset/Contents.json b/Emoney Info/Assets.xcassets/simcard.imageset/Contents.json new file mode 100644 index 0000000..2387b38 --- /dev/null +++ b/Emoney Info/Assets.xcassets/simcard.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "simcard.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/simcard.imageset/simcard.png b/Emoney Info/Assets.xcassets/simcard.imageset/simcard.png new file mode 100644 index 0000000..8fd99b5 Binary files /dev/null and b/Emoney Info/Assets.xcassets/simcard.imageset/simcard.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/Contents.json b/Emoney Info/Assets.xcassets/topup.imageset/Contents.json new file mode 100755 index 0000000..a9d5e21 --- /dev/null +++ b/Emoney Info/Assets.xcassets/topup.imageset/Contents.json @@ -0,0 +1,89 @@ +{ + "images" : [ + { + "filename" : "creditcard_black 1.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "creditcard_black.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "creditcard.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "creditcard_black@2x 1.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "creditcard_black@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "creditcard@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "creditcard_black@3x 1.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "filename" : "creditcard_black@3x.png", + "idiom" : "universal", + "scale" : "3x" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "creditcard@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard.png new file mode 100755 index 0000000..070c533 Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard@2x.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard@2x.png new file mode 100755 index 0000000..1e833e2 Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard@2x.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard@3x.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard@3x.png new file mode 100755 index 0000000..3f721d0 Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard@3x.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black 1.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black 1.png new file mode 100755 index 0000000..15745cf Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black 1.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black.png new file mode 100755 index 0000000..15745cf Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@2x 1.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@2x 1.png new file mode 100755 index 0000000..21c8e9a Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@2x 1.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@2x.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@2x.png new file mode 100755 index 0000000..21c8e9a Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@2x.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@3x 1.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@3x 1.png new file mode 100755 index 0000000..fc6114e Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@3x 1.png differ diff --git a/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@3x.png b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@3x.png new file mode 100755 index 0000000..fc6114e Binary files /dev/null and b/Emoney Info/Assets.xcassets/topup.imageset/creditcard_black@3x.png differ diff --git a/Emoney Info/BRIEF.md b/Emoney Info/BRIEF.md new file mode 100644 index 0000000..8445d17 --- /dev/null +++ b/Emoney Info/BRIEF.md @@ -0,0 +1,18 @@ +# emoneyInfo - UI Brief +Reference in Design folder +## App Description +E-Money wallet app untuk cek saldo kartu e-money via NFC. +## Design System +- Primary color: Green (#7AD4D1) +- Success/Active: Green (#5D7D7B) +- Background: Light gray (#F3F3F8) +- Cards: White with rounded corners (16pt) +- Font: System font (San Francisco) +## Screens +1. Home - Saldo, NFC tap, promo, last transaction +2. History - List semua transaksi +3. Settings - Account settings, preferences +## Tech Stack +- SwiftUI +- Core NFC (existing) +- iOS 16+ \ No newline at end of file diff --git a/Emoney Info/Base.lproj/LaunchScreen.storyboard b/Emoney Info/Base.lproj/LaunchScreen.storyboard new file mode 100755 index 0000000..e7bc094 --- /dev/null +++ b/Emoney Info/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Emoney Info/Classes/api/BcaFlazzApi.swift b/Emoney Info/Classes/api/BcaFlazzApi.swift new file mode 100755 index 0000000..b569a02 --- /dev/null +++ b/Emoney Info/Classes/api/BcaFlazzApi.swift @@ -0,0 +1,408 @@ +// +// BcaFlazzApi.swift +// Emoney Info +// +// Created by Wira Irawan on 27/07/24. +// + +import Foundation +import CoreNFC + +public class BcaFlazzApi : UnifiedNfcApi { + var emoney : Emoney = Emoney() + var riwayatList: [RiwayatCard] = [] + var start = 0 + var finishV2 = 5 + var finish2V202 = 256 + var finishV1 = 16 + var mapv1 : String = "" + var mapv2 : String = "" + + public override init() {} + + public func checkFlazzCard(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU01, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.emoney.setCardLabel("BCA Flazz") + self.getCardNumber() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func getCardNumber(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU02, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + let raw = String(data: response.getData(), encoding: .isoLatin1) + let start = raw!.firstIndex(of: ";")?.utf16Offset(in: raw!) + let end = raw!.firstIndex(of: "=")?.utf16Offset(in: raw!) + self.emoney.setCardNumber(String((raw?.subString(from: (start! + 1), to: end!))!)) + self.getBalance() + } else { + self.apduRunner.invalidateSession() + } + }) + } + + private func getBalance(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU03, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + let raw = response.getData().hexEncodedString() + let balance = raw.subString(from: 2, to: 8) + self.emoney.setBalance(balance.hex2decimal()) + self.checkLog() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func checkLog(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BCA_APDU04, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog("log v2") +// self.updateScreen() + self.getLogV2step01(index: self.start) + } else { + debugLog("log v1") +// self.updateScreen() + self.getLogV1step01(index: self.start) + } + }) + } + + private func getLogV2step01(index : Int){ + debugLog("log getLogV2step01") + //00 B0 85 00 78 + let st = index*60 + let hex = String(st, radix: 16) + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB0, p1Parameter : 0x85, p2Parameter : hex.hex2byte().bytes.first!, data : Data(), expectedResponseLength : 120) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog("log data", response.getData().hexEncodedString()) + + + self.mapv1.append(response.getData().hexEncodedString()) + self.start+=1 + if (self.start < (self.finishV2)){ + self.getLogV2step01(index: self.start) + } else { + //mapping first + self.parseLogV201() + self.start = 0 + self.getLogV2step02(index: self.start) + } + } else { + self.parseLogV201() + self.start = 0 + self.getLogV2step02(index: self.start) + } + }) + } + + private func getLogV1step01(index : Int){ + debugLog("log getLogV1step01") + //00 b0 84 00 3c + let st = index*15 + let hex = String(st, radix: 16) + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB0, p1Parameter : 0x84, p2Parameter : hex.hex2byte().bytes.first!, data : Data(), expectedResponseLength : 60) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.mapv1.append(response.getData().hexEncodedString()) + self.start+=1 + if (self.start < (self.finishV1)){ + self.getLogV1step01(index: self.start) + } else { + self.start = 0 + self.getLogV1step02(index: self.start) + } + } else { + self.start = 0 + self.getLogV1step02(index: self.start) + } + }) + } + + private func getLogV1step02(index : Int){ + debugLog("log getLogV1step02") + //00b085003c + let st = index*15 + let hex = String(st, radix: 16) + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB0, p1Parameter : 0x85, p2Parameter : hex.hex2byte().bytes.first!, data : Data(), expectedResponseLength : 60) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.mapv1.append(response.getData().hexEncodedString()) + self.start+=1 + if (self.start < (self.finishV1)){ + self.getLogV1step02(index: self.start) + } else { + self.parseLogV101() + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + + if (self.riwayatList.count > 0){ + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + } else { + self.parseLogV101() + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + + if (self.riwayatList.count > 0){ + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func getLogV2step02(index : Int){ + debugLog("log getLogV2step02") + //00b08400f0 + let st = index*60 + let hex = String(st, radix: 16) + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB0, p1Parameter : 0x84, p2Parameter : hex.hex2byte().bytes.first!, data : Data(), expectedResponseLength : 240) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.mapv1.append(response.getData().hexEncodedString()) + self.start+=1 + if (self.start < (self.finishV2)){ + self.getLogV2step02(index: self.start) + } else { + self.start = 0 + self.getLogV2step03() + } + } else { + self.start = 0 + self.getLogV2step03() + } + }) + } + + private func getLogV2step03(){ + debugLog("log getLogV2step03") + //00 84 00 00 08 + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0x84, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(), expectedResponseLength : 8) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.getLogV2step04(data: response.getData()) + } else { + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func getLogV2step04(data: Data){ + debugLog("log getLogV2step04") + //90 32 03 00 0A 0801 0000000000000000 29 + let send = "0801" + data.hexEncodedString() + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x32, p1Parameter : 0x03, p2Parameter : 0x00, data : Data(_: send.hex2byte()), expectedResponseLength : 41) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.getLogV2step05(index: self.start) + } else { + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func getLogV2step05(index : Int){ + debugLog("log getLogV2step05") + //00 B0 89 00 40 + let st = index + let hex = String(st, radix: 16) + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB0, p1Parameter : 0x89, p2Parameter : hex.hex2byte().bytes.first!, data : Data(), expectedResponseLength : 64) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.mapv2.append(response.getData().hexEncodedString()) + self.start+=1 + if (self.start < (self.finish2V202)){ + self.getLogV2step05(index: self.start) + } else { + self.start = 0 + self.getLogV2step06(index: self.start) + } + } else { + self.start = 0 + self.getLogV2step06(index: self.start) + } + }) + } + + private func getLogV2step06(index : Int){ + debugLog("log getLogV2step06") + //90 32 03 00 01 00 20 + let st = index + let hex = String(st, radix: 16) + let BCAV2_LOG = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x32, p1Parameter : 0x03, p2Parameter : 0x00, data : Data(_: hex.hex2byte()), expectedResponseLength : 32) + apduRunner.exchangeApdu(apduCommand: BCAV2_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.mapv2.append(response.getData().hexEncodedString()) + self.start+=1 + if (self.start < (self.finish2V202)){ + self.getLogV2step06(index: self.start) + } else { + //mapping the data + self.parseLogV202() + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + + if (self.riwayatList.count > 0){ + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + } else { + //mapping the data + self.parseLogV202() + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + + if (self.riwayatList.count > 0){ + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func parseLogV101(){ + debugLog("log parseLogV101") + let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines) + if (logs.count % 120 != 0){ + return + } + let total = logs.count/120 + for i in 0.. 0){ + riwayatList.append(riwayat) + } + } + } + + private func parseLogV201(){ + debugLog("log parseLogV201") + let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines) + if (logs.count % 120 != 0){ + return + } + let total = logs.count/120 + for i in 0.. 0){ + riwayatList.append(riwayat) + } + } + } + + private func parseLogV202(){ + debugLog("log parseLogV202") + let logs = self.mapv2.trimmingCharacters(in: .whitespacesAndNewlines) + if (logs.count % 64 != 0){ + return + } + let total = logs.count/64 + for i in 0.. 0){ + riwayatList.append(riwayat) + } + } + } + + private func formatDate(seconds : Int) -> Date{ + // Specify date components + var dateComponents = DateComponents() + dateComponents.year = 1980 + dateComponents.month = 1 + dateComponents.day = 1 + dateComponents.timeZone = TimeZone(identifier: "Asia/Jakarta")! + dateComponents.hour = 0 + dateComponents.minute = 0 + dateComponents.second = seconds + // Create date from components + let userCalendar = Calendar.current // user calendar + let someDateTime = userCalendar.date(from: dateComponents) + return someDateTime! + } + + private func updateScreen(){ + if (self.apduRunner.callback != nil){ + self.apduRunner.callback?.complete(emoney: self.emoney) + } + } + +} diff --git a/Emoney Info/Classes/api/BrizziApi.swift b/Emoney Info/Classes/api/BrizziApi.swift new file mode 100755 index 0000000..cd7b12e --- /dev/null +++ b/Emoney Info/Classes/api/BrizziApi.swift @@ -0,0 +1,247 @@ +// +// BrizziApi.swift +// Emoney Info +// +// Created by Wira Irawan on 27/07/24. +// + +import Foundation +import CoreNFC + +public class BrizziApi : UnifiedNfcApi { + var emoney : Emoney = Emoney() + var riwayatList: [RiwayatCard] = [] + var uid : String? + var rawLog : String = "" + + public override init() {} + + public func getUid(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_UID01, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0xAF){ + self.getUid02() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func getUid02(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_UID02, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0xAF){ + self.getUid02() + } else { + self.uid = response.getData().hexEncodedString().subString(from: 0, to: 14) + self.getCardNumber() + } + }) + } + + private func getCardNumber(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_APDU01, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0x00){ + self.emoney.setCardLabel("Brizzi") + self.emoney.setCardNumber(response.getData().hexEncodedString().subString(from: 6, to: 22)) + self.process01() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func process01(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_APDU02, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0x00){ + self.process02() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func process02(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_APDU03, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0x00) || (response.sw1 == 0x91 && response.sw2 == 0xAF){ + self.process03(data: response.getData()) + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func process03(data : Data){ + let brizziSamHelper = BrizziSamHelper() + brizziSamHelper.keyCard = data.hexEncodedString() + let random = "8DC0DC40FE1DC582CF7099E2AACFBC10".hex2byte() + let command = self.emoney.getCardNumber() + self.uid! + "FF" + let decrypted = BrizziSamHelper.decryptDeSeDe(random)?.hexEncodedString() + + let decryptedFinal = decrypted?.subString(from: 0, to: 32) + let encrypted = BrizziSamHelper.encryptDeSeDe(command, decryptedFinal!, "0000000000000000")?.hexEncodedString() + + brizziSamHelper.encryptedKey = encrypted?.subString(from: 0, to: 32) + + + let randomHex = "3C37029CA595FE4E7E62FCB2F7909B2C".hex2byte() + let randomHexDecrypted = BrizziSamHelper.decryptDeSeDe(randomHex) + + let randomHexFinal = randomHexDecrypted?.hexEncodedString().subString(from: 0, to: 32) + let randomHexEncrypted = BrizziSamHelper.encryptDeSeDe(brizziSamHelper.encryptedKey!, randomHexFinal!, brizziSamHelper.authKey) + brizziSamHelper.random = (randomHexEncrypted?.hexEncodedString())!.subString(from: 0, to: 32) + + + let samChallenge = brizziSamHelper.generateSamRandom().subString(from: 0, to: 32) + let BRI_APDU04 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0xAF, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_: samChallenge.hex2byte()), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + apduRunner.exchangeApdu(apduCommand: BRI_APDU04, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0x00) || (response.sw1 == 0x91 && response.sw2 == 0xAF){ + self.process04() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func process04(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_APDU05, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0x00) || (response.sw1 == 0x91 && response.sw2 == 0xAF){ + self.emoney.setBalance(self.getRealBalance(reverseHexa: response.getData().hexEncodedString().subString(from: 0, to: 8))) + self.getLog() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func getLog(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_LOG01, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0x00) || (response.sw1 == 0x91 && response.sw2 == 0xAF){ + self.rawLog.append(response.getData().hexEncodedString()) + self.getMoreLog() + } else { + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func getMoreLog(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRI_LOG02, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0xAF){ + self.rawLog.append(response.getData().hexEncodedString()) + self.getMoreLog() + } else { + self.rawLog.append(response.getData().hexEncodedString()) + + if (self.parseLog()){ + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func parseLog() -> Bool { + let logs = self.rawLog.trimmingCharacters(in: .whitespacesAndNewlines) + if (logs.count % 64 != 0){ + return false + } + let total = logs.count/64 + for i in 0.. Date?{ + let dateFormatter2 = DateFormatter() + dateFormatter2.dateFormat = "HHmmss" + let date12 = dateFormatter2.date(from: formatTime)! + + dateFormatter2.dateFormat = "hh:mm a" + let date22 = dateFormatter2.string(from: date12) + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") // set locale to reliable US_POSIX + dateFormatter.dateFormat = "ddMMyy hh:mm a" + return dateFormatter.date(from:(formatDate + " " + date22)) + } + + private func getCode(trxCode: String) -> String { + let trxUp = trxCode.uppercased() + if trxUp.contains("5F") { + return "reactivation".localizeString(string: self.langCode!) + } else if trxUp.contains("EB") { + return "payment".localizeString(string: self.langCode!) + } else if trxUp.contains("EC") { + return "topup".localizeString(string: self.langCode!) + } else if trxUp.contains("ED") { + return "void".localizeString(string: self.langCode!) + } else if trxUp.contains("EF") { + return "updateBalance".localizeString(string: self.langCode!) + } else { + return "-" + } + } + + private func getTipe(trxCode: String) -> Int { + let trxUp = trxCode.uppercased() + if trxUp.contains("5F") { + return 2 + } else if trxUp.contains("EB") { + return 1 + } else if trxUp.contains("EC") { + return 0 + } else if trxUp.contains("ED") { + return 0 + } else if trxUp.contains("EF") { + return 1 + } else { + return 0 + } + } + + + func getRealBalance(reverseHexa: String?) -> Int { + guard let reverseHexa = reverseHexa?.trimmingCharacters(in: .whitespacesAndNewlines), !reverseHexa.isEmpty else { + return 0 + } + + if reverseHexa.count % 2 != 0 { + return 0 + } + + var sb = "" + for l in stride(from: reverseHexa.count / 2, through: 1, by: -1) { + let index1 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 2) + let index2 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 1) + sb.append(reverseHexa[index1]) + sb.append(reverseHexa[index2]) + } + return sb.hex2decimal() + } + + private func updateScreen(){ + if (self.apduRunner.callback != nil){ + self.apduRunner.callback?.complete(emoney: self.emoney) + } + } +} diff --git a/Emoney Info/Classes/api/JackCardApi.swift b/Emoney Info/Classes/api/JackCardApi.swift new file mode 100755 index 0000000..ea0d95a --- /dev/null +++ b/Emoney Info/Classes/api/JackCardApi.swift @@ -0,0 +1,37 @@ +// +// JackCardApi.swift +// Emoney Info +// +// Created by Wira Irawan on 27/07/24. +// + +import Foundation + +public class JackCardApi : UnifiedNfcApi { + var emoney : Emoney = Emoney() + var riwayatList: [RiwayatCard] = [] + + public override init() {} + + public func getBalance(resp : Data){ + self.emoney.setCardLabel("Jackcard") + self.emoney.setCardNumber(resp.hexEncodedString().subString(from: 16, to: 32)) + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.DKI_APDU01, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.emoney.setBalance(response.getData().hexEncodedString().hex2decimal()) + self.emoney.setTampilRiwayat(false) + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func updateScreen(){ + if (self.apduRunner.callback != nil){ + self.apduRunner.callback?.complete(emoney: self.emoney) + } + } +} diff --git a/Emoney Info/Classes/api/MandiriEmoneyApi.swift b/Emoney Info/Classes/api/MandiriEmoneyApi.swift new file mode 100755 index 0000000..6bc9045 --- /dev/null +++ b/Emoney Info/Classes/api/MandiriEmoneyApi.swift @@ -0,0 +1,263 @@ +// +// MandiriEmoneyApi.swift +// Emoney Info +// +// Created by Wira Irawan on 27/07/24. +// + +import Foundation +import CoreNFC + +public class MandiriEmoneyApi : UnifiedNfcApi { + var emoney : Emoney = Emoney() + var riwayatList: [RiwayatCard] = [] + var cardType : Int? + var mapv1 : String = "" + var start = 0 + var finish = 256 + var finish2 = 10 + + public override init() {} + + public func getCardNumber(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MANDIRI_APDU01, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.emoney.setCardLabel("Mandiri e-Money") + self.emoney.setCardNumber(response.getData().hexEncodedString().subString(from: 0, to: 16)) + self.cardType = response.getData().hexEncodedString().subString(from: 36, to: 38).hex2decimal() + debugLog(self.cardType!) + self.getBalance() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func getBalance(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MANDIRI_APDU02, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog(response.getData().hexEncodedString()) + let balance = response.getData().hexEncodedString().subString(from: 0, to: 8) + self.emoney.setBalance(self.getRealBalance(reverseHexa: balance)) +// self.updateScreen() + if (self.cardType! == 131){ + self.getLogStep01(index: self.start) + } else { + self.getLogStep02(index: self.start) + } + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + private func getLogStep01(index : Int){ + //00 d1 00 00 00 + let hex = String(index, radix: 16) + let MANDIRI_LOG = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xD1, p1Parameter : hex.hex2byte().bytes.first!, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + apduRunner.exchangeApdu(apduCommand: MANDIRI_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.mapv1.append(response.getData().hexEncodedString()) + self.start+=1 + if (self.start < (self.finish)){ + self.getLogStep01(index: self.start) + } else { + self.parseNewLog() + self.start = 0 + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + } else { + self.parseNewLog() + self.start = 0 + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + func parseNewLog(){ + let logs = self.mapv1.trimmingCharacters(in: .whitespacesAndNewlines) + if (logs.count % 48 != 0){ + return + } + let total = logs.count/48 + for i in 0.. Date?{ + let dateFormatter2 = DateFormatter() + dateFormatter2.dateFormat = "HHmmss" + let date12 = dateFormatter2.date(from: formatTime)! + + dateFormatter2.dateFormat = "hh:mm a" + let date22 = dateFormatter2.string(from: date12) + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") // set locale to reliable US_POSIX + dateFormatter.dateFormat = "ddMMyy hh:mm a" + return dateFormatter.date(from:(formatDate + " " + date22)) + } + + func getRealBalance(reverseHexa: String?) -> Int { + guard let reverseHexa = reverseHexa?.trimmingCharacters(in: .whitespacesAndNewlines), !reverseHexa.isEmpty else { + return 0 + } + + if reverseHexa.count % 2 != 0 { + return 0 + } + + var sb = "" + for l in stride(from: reverseHexa.count / 2, through: 1, by: -1) { + let index1 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 2) + let index2 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 1) + sb.append(reverseHexa[index1]) + sb.append(reverseHexa[index2]) + } + return sb.hex2decimal() + } + + private func updateScreen(){ + if (self.apduRunner.callback != nil){ + self.apduRunner.callback?.complete(emoney: self.emoney) + } + } + + func riwayatCard(_ bArr: [UInt8]) -> RiwayatCard? { + var str: String + let riwayatCard = RiwayatCard() + var wrap = Data(bArr) + var bArr2 = [UInt8](repeating: 0, count: 6) + var bArr3 = [UInt8](repeating: 0, count: 16) + + wrap.copyBytes(to: &bArr2, count: 6) + wrap.removeFirst(10) + + let len = wrap.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } + wrap.removeFirst(4) + let amount = wrap.withUnsafeBytes { $0.load(as: Int32.self).littleEndian } + wrap.removeFirst(4) + let desk = wrap.withUnsafeBytes { $0.load(as: Int32.self).littleEndian } + wrap.removeFirst(4) + + do { + wrap.copyBytes(to: &bArr3, count: 16) + debugLog("bArr3: \(bArr3.map { String(format: "%02X", $0) }.joined())") + } catch { + debugLog("Error: \(error.localizedDescription)") + } + + var type = 0 + if len == 288 { + str = "payment".localizeString(string: self.langCode!) + type = 1 + } else if len == 256 || len == 336 { + type = 3 + str = "topup".localizeString(string: self.langCode!) + } else { + type = -1 + str = "unknown".localizeString(string: self.langCode!) + } + + + var str2 = "" + for y in 0..<6 { + str2 += String(format: "%02X", bArr2[y]) + } + + let transactionTime = self.getTransactionTime(formatDate: str.subString(from: 0, to: 6), formatTime: str.subString(from: 6, to: 12)) + riwayatCard.setTransactionTime(transactionTime!) + riwayatCard.setAmount(Int(amount)) + +// if str.caseInsensitiveCompare("payment") == .orderedSame { +// type = 1 +// } else if str.caseInsensitiveCompare("topup") != .orderedSame { +// type = 3 +// } + + riwayatCard.setProsesTipe(type) + riwayatCard.setTitle(str) + + if type == -1 { + return nil + } + + return riwayatCard + } +} diff --git a/Emoney Info/Classes/api/MegaCashApi.swift b/Emoney Info/Classes/api/MegaCashApi.swift new file mode 100755 index 0000000..c70a512 --- /dev/null +++ b/Emoney Info/Classes/api/MegaCashApi.swift @@ -0,0 +1,58 @@ +// +// MegaCashApi.swift +// Emoney Info +// +// Created by Wira Irawan on 27/07/24. +// + +import Foundation + +public class MegaCashApi : UnifiedNfcApi { + var emoney : Emoney = Emoney() + var riwayatList: [RiwayatCard] = [] + + public override init() {} + + public func getBalance(resp : Data){ + self.emoney.setCardLabel("MegaCash") + let value = resp.hexEncodedString() + self.emoney.setCardNumber(value.subString(from: 4, to: value.count)) + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MEGA_APDU02, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + let balance = response.getData().hexEncodedString() + self.emoney.setBalance(self.getRealBalance(reverseHexa: balance)) + self.emoney.setTampilRiwayat(false) + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } else { + self.apduRunner.invalidateSession(msg: "readFailed".localizeString(string: self.langCode!)) + } + }) + } + + func getRealBalance(reverseHexa: String?) -> Int { + guard let reverseHexa = reverseHexa?.trimmingCharacters(in: .whitespacesAndNewlines), !reverseHexa.isEmpty else { + return 0 + } + + if reverseHexa.count % 2 != 0 { + return 0 + } + + var sb = "" + for l in stride(from: reverseHexa.count / 2, through: 1, by: -1) { + let index1 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 2) + let index2 = reverseHexa.index(reverseHexa.startIndex, offsetBy: l * 2 - 1) + sb.append(reverseHexa[index1]) + sb.append(reverseHexa[index2]) + } + return sb.hex2decimal() + } + + private func updateScreen(){ + if (self.apduRunner.callback != nil){ + self.apduRunner.callback?.complete(emoney: self.emoney) + } + } +} diff --git a/Emoney Info/Classes/api/TapCashApi.swift b/Emoney Info/Classes/api/TapCashApi.swift new file mode 100755 index 0000000..54d6dee --- /dev/null +++ b/Emoney Info/Classes/api/TapCashApi.swift @@ -0,0 +1,178 @@ +// +// TapCashApi.swift +// Emoney Info +// +// Created by Wira Irawan on 26/07/24. +// + +import Foundation +import CoreNFC + +public class TapCashApi : UnifiedNfcApi { + var emoney : Emoney = Emoney() + var tapCashData : TapCashData = TapCashData() + var riwayatList: [RiwayatCard] = [] + var start = 0 + var totalLog = 0 + + public override init() {} + + public func checkBalance(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.TAPCASH_APDU01, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.emoney.setCardLabel("BNI TapCash") + self.tapCashData.setPurseData(response.getData().bytes) + let balance = self.tapCashData.getPurseBalance()?.hexString().hex2decimal() + self.emoney.setBalance(balance!) + self.emoney.setCardNumber(self.tapCashData.getCAN()!.hexString()) + self.totalLog = (self.tapCashData.getTotalRecords()?.hexString().hex2decimal())! + if (self.totalLog > 10){ + self.totalLog = 10 + } + debugLog("total log " + String(self.totalLog)) + self.getHistory(index: 0) + } else { + self.emoney.setTampilRiwayat(false) + self.updateScreen() + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func getHistory(index : Int){ + let st = String(index).leftPad(with: "0", length: 2) + //90 32 03 00 01 00 10 + let TAPCASH_LOG = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x32, p1Parameter : 0x03, p2Parameter : 0x00, data : Data(_ : st.stringToBytes()!), expectedResponseLength : 16) + debugLog(TAPCASH_LOG.toHexString()) + apduRunner.exchangeApdu(apduCommand: TAPCASH_LOG, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + self.addRiwayatTransaksi(data: response.getData().bytes) + } + self.start+=1 + if (self.start < (self.totalLog)){ + self.getHistory(index: self.start) + } else { + self.updateScreen() + self.riwayatList = self.riwayatList.sorted(by: { $0.getTransationTime()?.compare($1.getTransationTime()!) == .orderedDescending }) + + if (self.riwayatList.count > 0){ + self.emoney.setRiwayatList(self.riwayatList) + self.emoney.setTampilRiwayat(true) + } + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } + }) + } + + private func updateScreen(){ + if (self.apduRunner.callback != nil){ + self.apduRunner.callback?.complete(emoney: self.emoney) + } + } + + private func addRiwayatTransaksi(data: [UInt8]) { + let trxType = Array(data[0..<1]) + let trxAmount = Array(data[1..<4]) + let trxDateTimes = Array(data[4..<8]) + //let trxUserData = Array(data[8..<16]) + + let title = trxType.hexString() + var amount: Int + if title.lowercased() == "01" || title.lowercased() == "05" || title.lowercased() == "07" || title.lowercased() == "10" || title.lowercased() == "20" { + amount = trxAmount.hexString().secondComplementsAmount() + } else { + amount = trxType.hexString().hex2decimal() + } + + let riwayatCard = RiwayatCard() + riwayatCard.setTitle(getStatementTitle(header: trxType.hexString())) + riwayatCard.setProsesTipe(getTranscationType(header: trxType.hexString())) + riwayatCard.setAmount(amount) + let transactionTime = self.getTransactionTime(julian: trxDateTimes.hexString()) + riwayatCard.setTransactionTime(transactionTime) + riwayatList.append(riwayatCard) + } + + private func getStatementTitle(header: String) -> String { + var title = "" + switch header.uppercased() { + case "01": + title = "payment".localizeString(string: self.langCode!) + case "02": + title = "Black List Card" + case "03": + title = "topup".localizeString(string: self.langCode!) + case "04": + title = "topup".localizeString(string: self.langCode!) + case "05": + title = "statementFee".localizeString(string: self.langCode!) + case "06": + title = "updateBalance".localizeString(string: self.langCode!) + case "07": + title = "gracePeriod".localizeString(string: self.langCode!) + case "10": + title = "refund".localizeString(string: self.langCode!) + case "20": + title = "refund".localizeString(string: self.langCode!) + case "22": + title = "close".localizeString(string: self.langCode!) + case "F0": + title = "atu".localizeString(string: self.langCode!) + default: + break + } + return title + } + + private func getTranscationType(header: String) -> Int { + switch header.uppercased() { + case "01": + return 1 + case "02": + return 2 + case "03": + return 0 + case "04": + return 0 + case "05": + return 1 + case "06": + return 6 + case "07": + return 7 + case "10": + return 10 + case "20": + return 20 + case "22": + return 22 + default: + break + } + return 0 + } + + func getTransactionTime(julian: String) -> Date { + let dec = julian.hex2decimal() + + let cal = Calendar.current + // set to 1st January 1995 + var dateComponents = DateComponents() + dateComponents.year = 1995 + dateComponents.month = 1 + dateComponents.day = 1 + dateComponents.hour = 0 + dateComponents.minute = 0 + dateComponents.second = 0 + + if let date = cal.date(from: dateComponents) { + let newDate = date.addingTimeInterval(Double(dec)) + return newDate + } + + return Date() + } + +} diff --git a/Emoney Info/Classes/api/UnifiedNfcApi.swift b/Emoney Info/Classes/api/UnifiedNfcApi.swift new file mode 100755 index 0000000..de16634 --- /dev/null +++ b/Emoney Info/Classes/api/UnifiedNfcApi.swift @@ -0,0 +1,463 @@ +import Foundation +import CoreNFC + + +extension CFArray { + func toSwiftArray() -> [T] { + let array = Array(_immutableCocoaArray: self) + return array.compactMap { $0 as? T } + } +} + +extension Dictionary where Key == String, Value == Any { + var account: String? { + guard let account = self[kSecAttrAccount as String] as? String else { + return nil + } + return account + } +} + +@available(iOS 13.0, *) +public class UnifiedNfcApi { + var stationMap: [Int: Station] = [:] + + func parseData() { + stationMap = [ + 0: Station(id: 0, name: "PARKIR RESKA", subName: "PARKIR RESKA", latitude: "0", longitude: "0"), + 1: Station(id: 1, name: "Tanah Abang", subName: "Tanah Abang", latitude: "-6.18574476", longitude: "106.8108382"), + 67: Station(id: 67, name: "C-Access", subName: "C-Access", latitude: "0", longitude: "0"), + 257: Station(id: 257, name: "Bogor", subName: "Bogor", latitude: "-6.59561005", longitude: "106.7904379"), + 258: Station(id: 258, name: "Cilebut", subName: "Cilebut", latitude: "-6.53050343", longitude: "106.8005885"), + 259: Station(id: 259, name: "Bojonggede", subName: "Bojonggede", latitude: "-6.49326562", longitude: "106.7949173"), + 260: Station(id: 260, name: "Citayam", subName: "Citayam", latitude: "-6.44879141", longitude: "106.8024588"), + 261: Station(id: 261, name: "Depok", subName: "Depok", latitude: "-6.40493394", longitude: "106.8172447"), + 262: Station(id: 262, name: "Depok Baru", subName: "Depok Baru", latitude: "-6.39113047", longitude: "106.821707"), + 263: Station(id: 263, name: "Pondok Cina", subName: "Pondok Cina", latitude: "-6.36905168", longitude: "106.8322114"), + 264: Station(id: 264, name: "Univ. Indonesia", subName: "Univ. Indonesia", latitude: "-6.36075528", longitude: "106.8317544"), + 265: Station(id: 265, name: "Univ. Pancasila", subName: "Univ. Pancasila", latitude: "-6.33894476", longitude: "106.8344241"), + 272: Station(id: 272, name: "Lenteng Agung", subName: "Lenteng Agung", latitude: "-6.33065157", longitude: "106.8349938"), + 273: Station(id: 273, name: "Tanjung Barat", subName: "Tanjung Barat", latitude: "-6.30780817", longitude: "106.8388513"), + 274: Station(id: 274, name: "Pasar Minggu", subName: "Pasar Minggu", latitude: "-6.28440597", longitude: "106.8445384"), + 275: Station(id: 275, name: "Pasar Minggu Baru", subName: "Pasar Minggu Baru", latitude: "-6.26278132", longitude: "106.8518598"), + 276: Station(id: 276, name: "Duren Kalibata", subName: "Duren Kalibata", latitude: "-6.25534623", longitude: "106.8550195"), + 277: Station(id: 277, name: "Cawang", subName: "Cawang", latitude: "-6.24266069", longitude: "106.8588196"), + 278: Station(id: 278, name: "Tebet", subName: "Tebet", latitude: "-6.22606896", longitude: "106.8583004"), + 279: Station(id: 279, name: "Manggarai", subName: "Manggarai", latitude: "-6.20992352", longitude: "106.8502129"), + 280: Station(id: 280, name: "Cikini", subName: "Cikini", latitude: "-6.19856352", longitude: "106.8412599"), + 281: Station(id: 281, name: "Gondangdia", subName: "Gondangdia", latitude: "-6.18594019", longitude: "106.8325942"), + 288: Station(id: 288, name: "Juanda", subName: "Juanda", latitude: "-6.16672229", longitude: "106.8304674"), + 289: Station(id: 289, name: "Sawah Besar", subName: "Sawah Besar", latitude: "-6.16063965", longitude: "106.8276397"), + 290: Station(id: 290, name: "Mangga Besar", subName: "Mangga Besar", latitude: "-6.14979667", longitude: "106.8269796"), + 291: Station(id: 291, name: "Jayakarta", subName: "Jayakarta", latitude: "-6.14134112", longitude: "106.8230834"), + 292: Station(id: 292, name: "Jakarta Kota", subName: "Jakarta Kota", latitude: "-6.13761335", longitude: "106.8146308"), + 293: Station(id: 293, name: "Bekasi", subName: "Bekasi", latitude: "-6.23614485", longitude: "106.9994173"), + 294: Station(id: 294, name: "Kranji", subName: "Kranji", latitude: "-6.22433352", longitude: "106.9793992"), + 295: Station(id: 295, name: "Cakung", subName: "Cakung", latitude: "-6.21929974", longitude: "106.9521357"), + 296: Station(id: 296, name: "Klender Baru", subName: "Klender Baru", latitude: "-6.21743543", longitude: "106.9396893"), + 297: Station(id: 297, name: "Buaran", subName: "Buaran", latitude: "-6.21615092", longitude: "106.9283069"), + 304: Station(id: 304, name: "Klender", subName: "Klender", latitude: "-6.21335877", longitude: "106.8998889"), + 305: Station(id: 305, name: "Jatinegara", subName: "Jatinegara", latitude: "-6.21513342", longitude: "106.8703259"), + 313: Station(id: 313, name: "Tangerang", subName: "Tangerang", latitude: "-6.17679787", longitude: "106.63272688"), + 327: Station(id: 327, name: "Karet", subName: "Karet", latitude: "-6.2008165", longitude: "106.8159002"), + 328: Station(id: 328, name: "Sudirman", subName: "Sudirman", latitude: "-6.202438", longitude: "106.8234505"), + 329: Station(id: 329, name: "Tanah Abang", subName: "Tanah Abang", latitude: "-6.18574476", longitude: "106.8108382"), + 336: Station(id: 336, name: "Palmerah", subName: "Palmerah", latitude: "-6.20740425", longitude: "106.7974463"), + 337: Station(id: 337, name: "Kebayoran", subName: "Kebayoran", latitude: "-6.23718958", longitude: "106.782542"), + 338: Station(id: 338, name: "Pondok Ranji", subName: "Pondok Ranji", latitude: "-6.27633762", longitude: "106.7449376"), + 339: Station(id: 339, name: "Jurang Mangu", subName: "Jurang Mangu", latitude: "-6.28876225", longitude: "106.7291141"), + 340: Station(id: 340, name: "Sudimara", subName: "Sudimara", latitude: "-6.29694285", longitude: "106.7127952"), + 341: Station(id: 341, name: "Rawabuntu", subName: "Rawabuntu", latitude: "-6.31500105", longitude: "106.6761968"), + 342: Station(id: 342, name: "Serpong", subName: "Serpong", latitude: "-6.32004857", longitude: "106.6655717"), + 343: Station(id: 343, name: "Cisauk", subName: "Cisauk", latitude: "-6.3249995", longitude: "106.6407467"), + 344: Station(id: 344, name: "Cicayur", subName: "Cicayur", latitude: "-6.32951436", longitude: "106.6189624"), + 345: Station(id: 345, name: "Parung Panjang", subName: "Parung Panjang", latitude: "-6.34420808", longitude: "106.5698061"), + 352: Station(id: 352, name: "Cilejit", subName: "Cilejit", latitude: "-6.35434367", longitude: "106.5097328"), + 353: Station(id: 353, name: "Daru", subName: "Daru", latitude: "-6.33800742", longitude: "106.4923913"), + 354: Station(id: 354, name: "Tenjo", subName: "Tenjo", latitude: "-6.32725713", longitude: "106.4613542"), + 355: Station(id: 355, name: "Tigaraksa", subName: "Tigaraksa", latitude: "-6.32846118", longitude: "106.4347451"), + 356: Station(id: 356, name: "Maja", subName: "Maja", latitude: "-6.33230387", longitude: "106.3965692"), + 357: Station(id: 357, name: "Citeras", subName: "Citeras", latitude: "-6.33492764", longitude: "106.3327125"), + 358: Station(id: 358, name: "Rangkasbitung", subName: "Rangkasbitung", latitude: "-6.3526711", longitude: "106.251502"), + 374: Station(id: 374, name: "Bekasi Timur", subName: "Bekasitimur", latitude: "-6.246845", longitude: "107.0181248"), + 376: Station(id: 376, name: "Cikarang", subName: "Cikarang", latitude: "-6.2553926", longitude: "107.1451293") + ] + } + var langCode : String? + var apduRunner = ApduRunner() + + public init() { + langCode = Locale.current.languageCode + parseData() + } + + func setCallback(apduCallback : ApduCallback){ + apduRunner.setApduCallback(callback: apduCallback) + apduRunner.setUnifiedNfcApi(nfcApi: self) + } + + func setApduRunner(apRunner : ApduRunner){ + self.apduRunner = apRunner + } + + public func checkIfNfcSupported() -> Bool { + return NFCTagReaderSession.readingAvailable + } + + public func searchCard(){ + apduRunner.startScan() + } + + public func checkCard(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.BRIZZI_INIT_APDU, completionHandler: {response in + if (response.sw1 == 0x91 && response.sw2 == 0x00){ + debugLog("brizzi card") + let brizzi = BrizziApi() + brizzi.setApduRunner(apRunner: self.apduRunner) + brizzi.getUid() + } else { + self.checkNext01() + } + }) + } + + public func checkFelicaCard(tag: NFCFeliCaTag){ + readFelicaCard(tag: tag) + } + + func readFelicaCard(tag: NFCFeliCaTag){ + let kmt = Emoney() + + let serviceCode = Data([0x0B, 0x30]) + let blockList = Data([0x80, 0x00]) + + tag.readWithoutEncryption( + serviceCodeList: [serviceCode], + blockList: [blockList] + ) { (status1, status2, blockData, error) in + + if let error = error { + self.apduRunner.nfcApi?.stopCheckCard(message: "Gagal membaca: \(error.localizedDescription)") + return + } + + // Cek status keberhasilan dari kartu (0x00 0x00 berarti sukses) + guard status1 == 0x00 && status2 == 0x00 else { + self.apduRunner.nfcApi?.stopCheckCard(message: "Error Status: \(status1) \(status2)") + return + } + for (index, data) in blockData.enumerated() { + debugLog("Data Blok \(index): \(data.map { String(format: "%02X", $0) }.joined())") + if let cardNumberString = String(data: data, encoding: .utf8) { + kmt.setCardLabel("KMT") + kmt.setCardNumber(cardNumberString) + self.readFelicaBalance(tag: tag, kmt: kmt) + } + } + } + } + + func readFelicaBalance(tag: NFCFeliCaTag, kmt: Emoney){ + let serviceCode = Data([0x17, 0x10]) + let blockList = Data([0x80, 0x00]) + + tag.readWithoutEncryption( + serviceCodeList: [serviceCode], + blockList: [blockList] + ) { (status1, status2, blockData, error) in + if let error = error { + self.apduRunner.nfcApi?.stopCheckCard(message: "Gagal membaca: \(error.localizedDescription)") + return + } + + if status1 == 0x00 && status2 == 0x00 { + let cardBalance = [UInt8](blockData[0]) + var y: Int = 0 + for x in 0..<4 { + y += Int(cardBalance[x]) << (x * 8) + } + + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "id_ID") + formatter.numberStyle = .decimal + debugLog("balance") + kmt.setBalance(y) + if let balance = formatter.string(from: NSNumber(value: y)) { + debugLog("Saldo: \(balance)") // Hasil contoh: "67.305.985" + } + self.readFelicaCardHistory(tag: tag, kmt: kmt) +// kmt.setTampilRiwayat(false) +// self.apduRunner.callback?.complete(emoney: kmt) +// self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) +// self.apduRunner.invalidateSession() + } + } + } + + func readFelicaCardHistory(tag: NFCFeliCaTag, kmt: Emoney){ + let serviceCode = Data([0x0F, 0x20]) + + // 2. Buat daftar 15 blok (Blok 0 sampai 14) secara otomatis + var blockList = [Data]() + for i in 0..<15 { + blockList.append(Data([0x80, UInt8(i)])) + } + + // 3. Panggil fungsi pembacaan + tag.readWithoutEncryption( + serviceCodeList: [serviceCode], + blockList: blockList + ) { (status1, status2, blockData, error) in + var riwayatList: [RiwayatCard] = [] + if let error = error { + debugLog("Error: \(error.localizedDescription)") + return + } + + if status1 == 0x00 && status2 == 0x00 { + debugLog("Berhasil membaca 15 blok!") + // blockData akan berisi array of Data, masing-masing 16 byte + for (index, data) in blockData.enumerated() { + let riwayat = RiwayatCard() + var normal = true + let subId = data.subdata(in: 8..<10) + + let uid = self.convert(bytes: [UInt8](subId)) + debugLog("station: \(uid)") + + debugLog("Blok \(index): \(data.map { String(format: "%02X", $0) }.joined())") + if (uid == 0){ + normal = false + } + if (data.count > 10){ + let type = data[10] + debugLog(type) + switch type { + case 0x01: + riwayat.setProsesTipe(1) + riwayat.setTitle("payment".localizeString(string: self.langCode!)) + debugLog("Pembayaran") + case 0x00, 0x03: + riwayat.setProsesTipe(0) + riwayat.setTitle("topup".localizeString(string: self.langCode!)) + debugLog("Topup") + default: + riwayat.setProsesTipe(1) + riwayat.setTitle("payment".localizeString(string: self.langCode!)) + debugLog("Other") + } + + + } + if let station = self.stationMap[uid]{ + debugLog("station", station.name) + riwayat.setLocationName(station.name.uppercased(with: .autoupdatingCurrent)) + } +// let station = self.stationMap[uid] +// print("station", station!) +// riwayat.setPlace(self.stationMap[uid]!.name) + if (normal){ + + let subData = data.subdata(in: 0..<4) + + let date = self.getDate(data: [UInt8](subData)) + riwayat.setTransactionTime(date!) + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "id_ID") // Format Indonesia + formatter.dateFormat = "dd MMMM yyyy, HH:mm" + + let dateString = formatter.string(from: date!) + debugLog("Hasil Konversi: \(dateString)") + + let amn = data.subdata(in: 4..<8) + let amount = amn.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } + + //print("Amount: \(amount)") + +// if (data.count > 10){ +// let type = data[10] +// print(type) +// switch type { +// case 0x01: +// print("Pembayaran") +// case 0x00: +// print("Topup") +// default: +// print("Other") +// } +// +// let subId = data.subdata(in: 8..<10) +// +// let uid = self.convert(bytes: [UInt8](subId)) +// print("station: \(uid)") +// } + riwayat.setAmount(Int(amount)) + let nformatter = NumberFormatter() + nformatter.locale = Locale(identifier: "id_ID") + nformatter.numberStyle = .decimal + if let balance = nformatter.string(from: NSNumber(value: amount)) { + debugLog("amount: \(balance)") // Hasil contoh: "67.305.985" + } + debugLog("") + } else { + debugLog("RESKA PARKIR") + + let stringData = data.map { String(format: "%02X", $0) }.joined() + + let inputFormatter = DateFormatter() + inputFormatter.dateFormat = "ddMMyyyyHHmmssSS" + + let finalData = stringData.prefix(16) + // 2. Konversi String ke objek Date + if let date = inputFormatter.date(from: String(finalData)) { + + // 3. Inisialisasi Formatter untuk mengubah ke format tujuan + let outputFormatter = DateFormatter() + outputFormatter.dateFormat = "dd MMM yyyy HH:mm" + + let result = outputFormatter.string(from: date) + riwayat.setTransactionTime(date) + debugLog(result) // Hasil: 29-01-2026 16:00:44 + } else { + debugLog("Format string tidak cocok") + } + let amn = data.subdata(in: 8..<12) + let amount = amn.withUnsafeBytes { $0.load(as: Int32.self).bigEndian } + riwayat.setAmount(Int(amount)) + //print("Amount: \(amount)") + + + let nformatter = NumberFormatter() + nformatter.locale = Locale(identifier: "id_ID") + nformatter.numberStyle = .decimal + if let balance = nformatter.string(from: NSNumber(value: amount)) { + debugLog("amount: \(balance)") // Hasil contoh: "67.305.985" + } + } + debugLog("") + riwayatList.append(riwayat) + } + kmt.setRiwayatList(riwayatList) + kmt.setTampilRiwayat(true) + self.apduRunner.callback?.complete(emoney: kmt) + self.apduRunner.sessionEx?.alertMessage = "readFinish".localizeString(string: self.langCode!) + self.apduRunner.invalidateSession() + } else { + debugLog("Gagal. Status: \(status1), \(status2)") + } + } + } + + func getDate(data: [UInt8]) -> Date? { + // 1. Tentukan TimeZone Jakarta + let timeZone = TimeZone(identifier: "Asia/Jakarta")! + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + + // 2. Set tanggal dasar (1 Januari 2000, 07:00:00) + var components = DateComponents() + components.year = 2000 + components.month = 1 + components.day = 1 + components.hour = 7 + components.minute = 0 + components.second = 0 + + guard let baseDate = calendar.date(from: components) else { return nil } + + // 3. Ambil selisih detik dari data byte + let secondsToAdd = convert(bytes: data) + + // 4. Tambahkan detik ke baseDate + let finalDate = calendar.date(byAdding: .second, value: secondsToAdd, to: baseDate) + + return finalDate + } + + func convert(bytes: [UInt8]) -> Int { + switch bytes.count { + case 0: + fatalError("Data kosong") + case 1: + return Int(bytes[0]) + case 2: + // Big-endian: (byte[0] << 8) | byte[1] + return (Int(bytes[0]) << 8) | Int(bytes[1]) + case 3: + // (byte[0] << 16) | (byte[1] << 8) | byte[2] + return (Int(bytes[0]) << 16) | (Int(bytes[1]) << 8) | Int(bytes[2]) + default: + // Padanan ByteBuffer.wrap(bArr).getInt() (Big-endian) + return Int(bytes.withUnsafeBytes { $0.load(as: Int32.self).bigEndian }) + } + } + + public func stopCheckCard(message : String){ + apduRunner.sessionEx?.invalidate(errorMessage: message) + } + + private func checkNext01(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.FLAZZ_INIT_APDU, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog("flazz card") + let flazz = BcaFlazzApi() + flazz.setApduRunner(apRunner: self.apduRunner) + flazz.checkFlazzCard() + } else { + self.checkNext02() + } + }) + } + + private func checkNext02(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.TAPCASH_INIT_APDU, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog("tapcash card") + let tapCash = TapCashApi() + tapCash.setApduRunner(apRunner: self.apduRunner) + tapCash.checkBalance() + } else { + self.checkNext03() + } + }) + } + + private func checkNext03(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.EMONEY_INIT_APDU, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog("emoney card") + let emoney = MandiriEmoneyApi() + emoney.setApduRunner(apRunner: self.apduRunner) + emoney.getCardNumber() + } else { + self.checkNext04() + } + }) + } + + private func checkNext04(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.JACKCARD_INIT_APDU, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog("jack card") + let jack = JackCardApi() + jack.setApduRunner(apRunner: self.apduRunner) + jack.getBalance(resp: response.getData()) + } else { + self.checkNext05() + } + }) + } + + private func checkNext05(){ + apduRunner.exchangeApdu(apduCommand: EmoneyApduCommands.MEGA_APDU01, completionHandler: {response in + if (response.sw1 == 0x90 && response.sw2 == 0x00){ + debugLog("megacash") + let mega = MegaCashApi() + mega.setApduRunner(apRunner: self.apduRunner) + mega.getBalance(resp: response.getData()) + } else { + self.apduRunner.invalidateSession(msg: "Card not supported") + } + }) + } +} diff --git a/Emoney Info/Classes/api/callback/ApduCallback.swift b/Emoney Info/Classes/api/callback/ApduCallback.swift new file mode 100755 index 0000000..1fce9f0 --- /dev/null +++ b/Emoney Info/Classes/api/callback/ApduCallback.swift @@ -0,0 +1,16 @@ +// +// ApduCallback.swift +// Emoney Info +// +// Created by Wira Irawan on 24/07/24. +// + +import Foundation +import CoreNFC + +protocol ApduCallback { + func connected(unifiedNfcApi : UnifiedNfcApi) + func felicaConnected(unifiedNfcApi : UnifiedNfcApi, tag : NFCFeliCaTag) + func complete(emoney: Emoney) + func failed(error: NSError) +} diff --git a/Emoney Info/Classes/api/nfc/ApduResponse.swift b/Emoney Info/Classes/api/nfc/ApduResponse.swift new file mode 100755 index 0000000..0c4c30d --- /dev/null +++ b/Emoney Info/Classes/api/nfc/ApduResponse.swift @@ -0,0 +1,38 @@ +// +// ApduResponse.swift +// Emoney Info +// +// Created by Wira Irawan on 24/07/24. +// + +import Foundation + +class ApduResponse { + var data : Data? + var sw1 : UInt8? + var sw2 : UInt8? + + func setData(_data : Data){ + self.data = _data + } + + func setSw1(_sw1 : UInt8){ + self.sw1 = _sw1 + } + + func setSw2(_sw2 : UInt8){ + self.sw2 = _sw2 + } + + func getSw1() -> UInt8{ + return sw1! + } + + func getSw2() -> UInt8{ + return sw2! + } + + func getData() -> Data{ + return data! + } +} diff --git a/Emoney Info/Classes/api/nfc/ApduRunner.swift b/Emoney Info/Classes/api/nfc/ApduRunner.swift new file mode 100755 index 0000000..a7f2a7d --- /dev/null +++ b/Emoney Info/Classes/api/nfc/ApduRunner.swift @@ -0,0 +1,126 @@ +import Foundation +import CoreNFC + +@available(iOS 13.0, *) +extension NFCISO7816APDU { + func toHexString() -> String { + let dataFieldInHex = (self.data ?? Data(_ : [])).hexEncodedString() + return String(format:"%02X %02X %02X %02X", self.instructionClass, self.instructionCode, self.p1Parameter, self.p2Parameter) + " " + dataFieldInHex + String(format:"%02X", self.expectedResponseLength) + " " + } +} + +typealias CompletionHandler = (_ response:ApduResponse) -> Void + +@available(iOS 13.0, *) +public class ApduRunner: NSObject, NFCTagReaderSessionDelegate { + public static let NFC_TAG_CONNECTED_EVENT:String = "nfcTagConnected" + + var sessionEx: NFCTagReaderSession? + var callback: ApduCallback? + var nfcApi : UnifiedNfcApi? + + func setApduCallback(callback : ApduCallback) { + self.callback = callback + } + + func setUnifiedNfcApi(nfcApi : UnifiedNfcApi) { + self.nfcApi = nfcApi + } + + func startScan() { + let langCode = Locale.current.languageCode + self.sessionEx = NFCTagReaderSession(pollingOption: [.iso14443, .iso18092], delegate: self) + self.sessionEx?.alertMessage = "scanMessage".localizeString(string: langCode!) + self.sessionEx?.begin() + } + + + public func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) { + guard self.callback != nil else { + debugLog("NfcCallback is empty.") + return + } + guard self.sessionEx != nil else { + return + } + guard tags.count > 0 else { + debugLog("Nfc Tag???.") + return + } + if case NFCTag.iso7816(_) = tags.first! { + sessionEx?.connect(to: tags.first!) { [self] (error: Error?) in + if let err = error { + debugLog("Error connecting to Nfc Tag" + err.localizedDescription) + return + } + debugLog("Nfc Tag is connected.") + if (self.callback != nil){ + self.callback!.connected(unifiedNfcApi: self.nfcApi!) + } + } + } else if case .feliCa(let feliCaTag) = tags.first! { + sessionEx?.connect(to: tags.first!) { [self] (error: Error?) in + if let err = error { + debugLog("Error connecting to Nfc Tag" + err.localizedDescription) + return + } +// felicaTag = feliCaTag + debugLog("Felica is connected.") + if (self.callback != nil){ + debugLog("Felica is connected 2.") + self.callback!.felicaConnected(unifiedNfcApi: self.nfcApi!, tag: feliCaTag) + } +// self.sendFelicaCommand(tag: feliCaTag, session: sessionEx!) + +// let idm = feliCaTag.currentIDm.map { String(format: "%.2hhx", $0) }.joined() +// let hexString = "0F06" + idm + "010B30018000" +// print("hex: \(hexString)") +// if let apduData = hexString.hexToData() { +// feliCaTag.sendFeliCaCommand(commandPacket: apduData) +// } + +// if (self.callback != nil){ +// self.callback!.connected(felicaNfcApi: self.felicaNfcApi!) +// } + } + } + } + + + func exchangeApdu(apduCommand: NFCISO7816APDU, completionHandler: @escaping CompletionHandler) { + if case let NFCTag.iso7816(nfcTag) = self.sessionEx!.connectedTag! { + nfcTag.sendCommand(apdu: apduCommand) { (response: Data, sw1: UInt8, sw2: UInt8, error: Error?) + in + let resp = ApduResponse() + debugLog("SW1-SW2: " + String(format: "%02X, %02X", sw1, sw2)) + resp.setSw1(_sw1: sw1) + resp.setSw2(_sw2: sw2) + resp.setData(_data: response) + completionHandler(resp) + } + } + } + + + func invalidateSession() { + sessionEx?.invalidate() + } + + func invalidateSession(msg : String) { + sessionEx?.invalidate(errorMessage: msg) + } + + public func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) { + debugLog("Nfc session is active") + } + + public func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) { + debugLog("Error happend: " + error.localizedDescription) + NotificationCenter.default.post(name: Notification.Name("stopTimer"), object: nil) + if ((sessionEx?.isReady) != nil){ + self.invalidateSession(msg: error.localizedDescription) + } + } + + +} diff --git a/Emoney Info/Classes/api/nfc/Emoney.swift b/Emoney Info/Classes/api/nfc/Emoney.swift new file mode 100755 index 0000000..4fa078b --- /dev/null +++ b/Emoney Info/Classes/api/nfc/Emoney.swift @@ -0,0 +1,66 @@ +// +// Emoney.swift +// Emoney Info +// +// Created by Wira Irawan on 26/07/24. +// + +import Foundation + +class Emoney { + private var balance: Int = 0 + private var cardNumber: String? + private var cardType: String? + private var riwayatList: [RiwayatCard]? + private var tampilRiwayat: Bool = false + private var cardLabel: String? + + func getBalance() -> Int { + return self.balance + } + + func getCardNumber() -> String { + return self.cardNumber! + } + + func getCardType() -> String { + return self.cardType! + } + + func getRiwayatList() -> [RiwayatCard] { + return self.riwayatList! + } + + func isTampilRiwayat() -> Bool { + return self.tampilRiwayat + } + + func setBalance(_ j: Int) { + self.balance = j + } + + func setCardNumber(_ str: String) { + self.cardNumber = str + } + + func setCardType(_ str: String) { + self.cardType = str + } + + func setRiwayatList(_ list: [RiwayatCard]) { + self.riwayatList = list + } + + func setTampilRiwayat(_ z: Bool) { + self.tampilRiwayat = z + } + + func setCardLabel(_ str: String) { + self.cardLabel = str + } + + func getCardLabel() -> String { + return self.cardLabel! + } + +} diff --git a/Emoney Info/Classes/api/nfc/RiwayatCard.swift b/Emoney Info/Classes/api/nfc/RiwayatCard.swift new file mode 100755 index 0000000..eb0f26f --- /dev/null +++ b/Emoney Info/Classes/api/nfc/RiwayatCard.swift @@ -0,0 +1,124 @@ +// +// RiwayatCard.swift +// Emoney Info +// +// Created by Wira Irawan on 25/07/24. +// + +import Foundation + +class RiwayatCard +//: Comparable +{ +// static func < (lhs: RiwayatCard, rhs: RiwayatCard) -> Bool { +// return lhs.valueToCompare > rhs.valueToCompare +// } +// +// static func == (lhs: RiwayatCard, rhs: RiwayatCard) -> Bool { +// return false +// } + + private var amount: Int = 0 + private var desk: String? + private var jam: String? + private var locationId: String? + private var locationName: String? + private var prosesTipe: Int = 0 + private var tanggal: String? + private var title: String? + private var transactionTime : Date? + private var ads : Bool = false + +// private var valueToCompare: Date + +// init(valueToCompare: Date) { +// self.valueToCompare = valueToCompare +// } + + func getAmount() -> Int { + return self.amount + } + + func setAmount(_ amount: Int) { + self.amount = amount + } + + func getDesk() -> String? { + return self.desk + } + + func getJam() -> String? { + return self.jam + } + + func setJam(_ str: String) { + self.jam = str + } + + func getLocationId() -> String? { + return self.locationId + } + + func setLocationId(_ str: String) { + self.locationId = str + } + + func getLocationName() -> String? { + return self.locationName + } + + func setLocationName(_ str: String) { + self.locationName = str + } + + func getProsesTipe() -> Int { + return self.prosesTipe + } + + func setProsesTipe(_ proc: Int) { + self.prosesTipe = proc + } + + func getTanggal() -> String? { + return self.tanggal + } + + func setTanggal(_ str: String) { + self.tanggal = str + } + + func getTitle() -> String? { + return self.title + } + + func setTitle(_ str: String) { + self.title = str + } + + func setTransactionTime(_ date : Date){ + self.transactionTime = date + } + + func getTransationTime() -> Date?{ + return self.transactionTime + } + + func setAds(_ ads : Bool){ + self.ads = ads + } + + func isAds() -> Bool{ + return self.ads + } + + +// func setValueToCompare(_ valueToCompare: Date) { +// self.valueToCompare = valueToCompare +// } +// +// func getValueToCompare() -> Date { +// return self.valueToCompare +// } + +} + diff --git a/Emoney Info/Classes/api/utils/BrizziSamHelper.swift b/Emoney Info/Classes/api/utils/BrizziSamHelper.swift new file mode 100755 index 0000000..a9cd96a --- /dev/null +++ b/Emoney Info/Classes/api/utils/BrizziSamHelper.swift @@ -0,0 +1,109 @@ +// +// BrizziSamHelper.swift +// Emoney Info +// +// Created by Wira Irawan on 27/07/24. +// + +import Foundation +import CommonCrypto + +class BrizziSamHelper { + public var encryptedKey: String? + public var authKey = "0000030080000000" + public var keyCard: String? + public var random = "" + + static func encryptDeSeDe(_ str: String, _ str2: String, _ str3: String) -> Data? { + var key = str2 + if key.count != 48 { + if key.count == 32 { + key += key.prefix(16) + } else if key.count == 16 { + key += key + key + } else { + key = "00000000000000000000000000000000" + } + } + let keyData = key.hex2byte() + let ivData = str3.hex2byte() + + return crypt(input: str.hex2byte(), keyData: keyData, ivData: ivData, operation: CCOperation(kCCEncrypt)) + } + + static func decryptDeSeDe(_ datas: Data) -> Data? { + let keyData = ("C152153D5807784C721A433B5B59636D" + "C152153D5807784C").hex2byte() + let ivData = ("0000000000000000").hex2byte() + + return crypt(input: datas, keyData: keyData, ivData: ivData, operation: CCOperation(kCCDecrypt)) + } + + static func mix(_ bArr: [UInt8], _ bArr2: [UInt8]) -> [UInt8] { + guard !bArr2.isEmpty else { + fatalError("empty security key") + } + + var bArr3 = [UInt8](repeating: 0, count: bArr.count) + var i = 0 + for y in 0..= bArr2.count { + i = 0 + } + } + return bArr3 + } + + static func decrypt(_ data: String, _ key: String) -> Data? { + let keyData = key.hex2byte() + + return crypt(input: data.hex2byte(), keyData: keyData, ivData: nil, operation: CCOperation(kCCDecrypt)) + } + + static func encrypt(_ str: String, _ key: String) -> Data? { + let substring = String(key.prefix(16)) + guard let decryptedData = decrypt(str, substring) else { + return ("").hex2byte() + } + + let a9 = decryptedData.hexEncodedString() + let keyData = String(key.dropFirst(16).prefix(16)).hex2byte() + + guard let encryptedData = crypt(input: a9.hex2byte(), keyData: keyData, ivData: nil, operation: CCOperation(kCCEncrypt)) else { + return ("").hex2byte() + } + + guard let finalDecryptedData = decrypt(encryptedData.hexEncodedString(), substring) else { + return ("").hex2byte() + } + + return finalDecryptedData + } + + func generateSamRandom() -> String { + let sam = BrizziSamHelper.mix(((BrizziSamHelper.encrypt(self.keyCard!, self.random)!).hexEncodedString()).hex2byte().bytes, ("0000000000000000").hex2byte().bytes).hexString().subString(from: 0, to: 16) + let sams = sam[sam.index(sam.startIndex, offsetBy: 2).. Data? { + var outLength = Int(0) + var outBytes = [UInt8](repeating: 0, count: input.count + kCCBlockSize3DES) + var status: CCCryptorStatus + + if let ivData = ivData { + status = CCCrypt(operation, CCAlgorithm(kCCAlgorithm3DES), CCOptions(kCCOptionPKCS7Padding), keyData.bytes, kCCKeySize3DES, ivData.bytes, input.bytes, input.count, &outBytes, outBytes.count, &outLength) + } else { + status = CCCrypt(operation, CCAlgorithm(kCCAlgorithmDES), CCOptions(kCCOptionPKCS7Padding), keyData.bytes, kCCKeySizeDES, nil, input.bytes, input.count, &outBytes, outBytes.count, &outLength) + } + + guard status == kCCSuccess else { + return nil + } + + return Data(bytes: outBytes, count: outLength) + } +} diff --git a/Emoney Info/Classes/api/utils/ByteArrayAndHexHelper.swift b/Emoney Info/Classes/api/utils/ByteArrayAndHexHelper.swift new file mode 100755 index 0000000..5d02f8f --- /dev/null +++ b/Emoney Info/Classes/api/utils/ByteArrayAndHexHelper.swift @@ -0,0 +1,41 @@ +import Foundation + + +public class ByteArrayAndHexHelper { + + public static func digitalStrIntoAsciiUInt8Array(digitalStr : String) -> [UInt8]{ + var bytes = [UInt8]() + for s in digitalStr { + if let byte = UInt8(String(s)) { + bytes.append(0x30 + byte) + } + } + return bytes + } + + public static func hexStrToUInt8Array(hexStr: String) -> [UInt8] { + var startIndex = hexStr.startIndex + return (0.. Data { + .init(stride(from: 0, to: string.count, by: 2).map { + string[string.index(string.startIndex, offsetBy: $0) ... string.index(string.startIndex, offsetBy: $0 + 1)] + }.map { + UInt8($0, radix: 16)! + }) + } + + public static func makeShort(src: [UInt8], srcOff : Int) -> Int { + // if (srcOff < 0 || src.length < (srcOff + 2)) + // throw new IllegalArgumentException("Bad args!"); + let b0 = Int(src[srcOff] & 0xFF); + let b1 = Int(src[srcOff + 1] & 0xFF); + return (b0 << 8) + b1 + } + +} diff --git a/Emoney Info/Classes/extensions/Array.swift b/Emoney Info/Classes/extensions/Array.swift new file mode 100755 index 0000000..9ce99ce --- /dev/null +++ b/Emoney Info/Classes/extensions/Array.swift @@ -0,0 +1,14 @@ + +import Foundation + +extension Array { + + subscript (range r: Range) -> Array { + return Array(self[r]) + } + + + subscript (range r: ClosedRange) -> Array { + return Array(self[r]) + } +} diff --git a/Emoney Info/Classes/extensions/Data.swift b/Emoney Info/Classes/extensions/Data.swift new file mode 100755 index 0000000..81e1821 --- /dev/null +++ b/Emoney Info/Classes/extensions/Data.swift @@ -0,0 +1,35 @@ + +import Foundation +import UIKit +extension Data { + public var bytes: [UInt8] { + return [UInt8](self) + } + + func hexEncodedString() -> String { + return map { String(format: "%02hhx", $0) }.joined() + } + + func makeDigitalString() -> String { + //todo: check numeric + var s: String = "" + for i in 0...bytes.count-1 { + if (bytes[i] >= 0 && bytes[i] <= 9) { + s += String(bytes[i]) + } + } + return s + } +} + +@available(iOS 14.0, *) +extension UISwitch { + + func setOnValueChangeListener(onValueChanged :@escaping () -> Void){ + self.addAction(UIAction(){ action in + + onValueChanged() + + }, for: .valueChanged) + } +} diff --git a/Emoney Info/Classes/extensions/String.swift b/Emoney Info/Classes/extensions/String.swift new file mode 100755 index 0000000..9696c5d --- /dev/null +++ b/Emoney Info/Classes/extensions/String.swift @@ -0,0 +1,261 @@ + +import Foundation + +extension StringProtocol { + func substring(from start: S, options: String.CompareOptions = []) -> SubSequence? { + guard let lower = range(of: start, options: options)?.upperBound + else { return nil } + return self[lower...] + } + func substring(from start: S, to end: T, options: String.CompareOptions = []) -> SubSequence? { + guard let lower = range(of: start, options: options)?.upperBound, + let upper = self[lower...].range(of: end, options: options)?.lowerBound + else { return nil } + return self[lower.. Index { + return self.index(startIndex, offsetBy: from) + } + + func substring(with r: Range) -> String { + let startIndex = index(from: r.lowerBound) + let endIndex = index(from: r.upperBound) + return String(self[startIndex.. Int { + Int(self, radix: 16)! + } + + func hex2bin() -> String { + return String(Int(self, radix: 16)!, radix: 2) + } + + func bin2decimal() -> Int { + return Int(self, radix: 2)! + } + + func bin2hex() -> String { + return String(Int(self, radix: 2)!, radix: 16) + } + + func secondComplementsAmount() -> Int{ + let bin = self.hex2bin() + let characters = Array(bin) + var firstComplement = "" + for c in characters { + if (c == "0"){ + firstComplement.append("1") + } else { + firstComplement.append("0") + } + } + let flipped = Array(firstComplement) + var isFinish = false + var secondComplement = "" + for index in stride(from: firstComplement.count - 1, through: 0, by: -1) { + let c = flipped[index] + if (!isFinish){ + if (c == "0"){ + secondComplement.append("1") + isFinish = true + } else { + secondComplement.append("0") + } + } else { + secondComplement.append(c) + } + + } + let second = Array(secondComplement) + var flipback = "" + for index in stride(from: secondComplement.count - 1, through: 0, by: -1) { + let c = second[index] + flipback.append(c) + } + let binhex = flipback.bin2hex() + return binhex.hex2decimal() + } + + func formatCardNumber() -> String { + let chars = Array(self) + var cardNumber = "" + for i in 0.. String { + let chars = Array(self) + var cardNumber = "" + for i in 0.. String { + return String(self, radix: 2) + } + + func decimal2hex() -> String { + return String(self, radix: 16) + } + var bin: String { + String(self, radix: 2).leftPad(with: "0", length: 8) + } +} + +extension UInt8 { + var bin: String { + String(self, radix: 2).leftPad(with: "0", length: 8) + } +} + +extension [UInt8] { + func hexString() -> String { + return map { String(format: "%02hhx", $0) }.joined() + } +} + +extension String { + + // static func hexString(_ data: Data) -> String { + // return data.map { String(format: "%02x", $0) }.joined() + // } + + // static func hex2byte(_ str: String) -> Data { + // var data = Data() + // var tempStr = str + // while tempStr.count > 0 { + // let c = String(tempStr.prefix(2)) + // tempStr = String(tempStr.dropFirst(2)) + // if let num = UInt8(c, radix: 16) { + // data.append(num) + // } + // } + // return data + // } + + func hex2byte() -> Data { + var data = Data() + var tempStr = self + while tempStr.count > 0 { + let c = String(tempStr.prefix(2)) + tempStr = String(tempStr.dropFirst(2)) + if let num = UInt8(c, radix: 16) { + data.append(num) + } + } + return data + } + + func subString(from: Int, to: Int) -> String { + let startIndex = self.index(self.startIndex, offsetBy: from) + let endIndex = self.index(self.startIndex, offsetBy: to) + return String(self[startIndex.. String { + let maxLength = Int(length) - count + guard maxLength > 0 else { + return self + } + return String(repeating: String(character), count: maxLength) + self + } + + func stringToBytes() -> [UInt8]? { + let length = self.count + if length & 1 != 0 { + return nil + } + var bytes = [UInt8]() + bytes.reserveCapacity(length/2) + var index = self.startIndex + for _ in 0.. String { + + let path = Bundle.main.path(forResource: string, ofType: "lproj") + let bundle = Bundle(path: path!) + return NSLocalizedString(self, tableName: nil, bundle: bundle!, + value: "", comment: "") + } +} + + +extension String: LocalizedError { + static let numbers: Set = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] + static let hexSymbols: Set = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f", "A", "B", "C", "D", "E", "F" ] + + public var errorDescription: String? { return self } + + var isNumeric: Bool { + guard self.count > 0 else { return false } + return Set(self).isSubset(of: String.numbers) + } + + var isHex: Bool { + guard self.count > 0 && self.count % 2 == 0 else { return false } + return Set(self).isSubset(of: String.hexSymbols) + } + + subscript(_ range: CountableRange) -> String { + let start = index(startIndex, offsetBy: max(0, range.lowerBound)) + let end = index(start, offsetBy: min(self.count - range.lowerBound, + range.upperBound - range.lowerBound)) + return String(self[start..) -> String { + let start = index(startIndex, offsetBy: max(0, range.lowerBound)) + return String(self[start...]) + } + + func deletingPrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { return self } + return String(self.dropFirst(prefix.count)) + } + func indexInt(of char: Character) -> Int? { + return firstIndex(of: char)?.utf16Offset(in: self) + } + +} diff --git a/Emoney Info/Classes/smartCard/CardErrorCodes.swift b/Emoney Info/Classes/smartCard/CardErrorCodes.swift new file mode 100755 index 0000000..8305960 --- /dev/null +++ b/Emoney Info/Classes/smartCard/CardErrorCodes.swift @@ -0,0 +1,167 @@ +/* +* Copyright 2018-2020 TON DEV SOLUTIONS LTD. +* +* Licensed under the SOFTWARE EVALUATION License (the "License"); you may not use +* this file except in compliance with the License. +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific TON DEV software governing permissions and +* limitations under the License. +*/ + +import Foundation + +class CardErrorCodes { + + /* Standard status words that may be returned by any Java card */ + + static let SW_SUCCESS :UInt16 = 0x9000; + static let SW_WRONG_LENGTH :UInt16 = 0x6700; + static let SW_APPLET_SELECT_FAILED :UInt16 = 0x6999; + static let SW_RESPONSE_BYTES_REMAINING :UInt16 = 0x6100; + static let SW_CLA_NOT_SUPPORTED :UInt16 = 0x6E00; + static let SW_COMMAND_CHAINING_NOT_SUPPORTED :UInt16 = 0x6884; + static let SW_COMMAND_NOT_ALLOWED :UInt16 = 0x6986; + static let SW_CONDITIONS_OF_USE_NOT_SATISFIED :UInt16 = 0x6985; + static let SW_CORRECT_EXPECTED_LENGTH :UInt16 = 0x6C00; + static let SW_DATA_INVALID :UInt16 = 0x6984; + static let SW_NOT_ENOUGH_MEMORY_SPACE_IN_FILE :UInt16 = 0x6A84; + static let SW_FILE_INVALID :UInt16 = 0x6983; + static let SW_FILE_NOT_FOUND :UInt16 = 0x6A82; + static let SW_FUNCTION_NOT_SUPPORTED :UInt16 = 0x6A81; + static let SW_INCORRECT_P1_P2 :UInt16 = 0x6A86; + static let SW_INS_NOT_SUPPORTED :UInt16 = 0x6D00; + /*static let SW_LAST_COMMAND_IN_CHAIN_EXPECTED :UInt16 = 0x6883;*/ + static let SW_LOGICAL_CHANNEL_NOT_SUPPORTED :UInt16 = 0x6881; + static let SW_RECORD_NOT_FOUND :UInt16 = 0x6883; + static let SW_SECURE_MESSAGING_NOT_SUPPORTED :UInt16 = 0x6882; + static let SW_SECURITY_CONDITION_NOT_SATISFIED :UInt16 = 0x6982; + static let SW_COMMAND_ABORTED :UInt16 = 0x6F00; + static let SW_WRONG_DATA :UInt16 = 0x6A80; + static let SW_WRONG_P1_P2 :UInt16 = 0x6B00; + + + /* Status words that may be returned by TonWalletApplet */ + + // Common errors + static let SW_INTERNAL_BUFFER_IS_NULL_OR_TOO_SMALL :UInt16 = 0x4F00; + static let SW_PERSONALIZATION_NOT_FINISHED :UInt16 = 0x4F01; + static let SW_INCORRECT_OFFSET :UInt16 = 0x4F02; + static let SW_INCORRECT_PAYLOAD :UInt16 = 0x4F03; + + // Password authentication errors + static let SW_INCORRECT_PASSWORD_FOR_CARD_AUTHENICATION :UInt16 = 0x5F00; + static let SW_INCORRECT_PASSWORD_CARD_IS_BLOCKED :UInt16 = 0x5F01; + + // Signature errors + static let SW_SET_COIN_TYPE_FAILED :UInt16 = 0x6F01; + static let SW_SET_CURVE_FAILED :UInt16 = 0x6F02; + static let SW_GET_COIN_PUB_DATA_FAILED :UInt16 = 0x6F03; + static let SW_SIGN_DATA_FAILED :UInt16 = 0x6F04; + + // Pin verification errors + static let SW_COIN_MANAGER_INCORRECT_PIN :UInt16 = 0x9B01; + static let SW_COIN_MANAGER_UPDATE_PIN_ERROR :UInt16 = 0x9B02; + // static let SW_PIN_TRIES_EXPIRED :UInt16 = 0x9F08; + static let SW_INCORRECT_PIN :UInt16 = 0x6F07; + static let SW_PIN_TRIES_EXPIRED :UInt16 = 0x6F08; + + static let SW_LOAD_SEED_ERROR :UInt16 = 0x9F03; + + // Key chain errors + static let SW_INCORRECT_KEY_INDEX :UInt16 = 0x7F00; + static let SW_INCORRECT_KEY_CHUNK_START_OR_LEN :UInt16 = 0x7F01; + static let SW_INCORRECT_KEY_CHUNK_LEN :UInt16 = 0x7F02; + static let SW_NOT_ENOUGH_SPACE :UInt16 = 0x7F03; + static let SW_KEY_SIZE_UNKNOWN :UInt16 = 0x7F04; + static let SW_KEY_LEN_INCORRECT :UInt16 = 0x7F05; + static let SW_HMAC_EXISTS :UInt16 = 0x7F06; + static let SW_INCORRECT_KEY_INDEX_TO_CHANGE :UInt16 = 0x7F07; + static let SW_MAX_KEYS_NUMBER_EXCEEDED :UInt16 = 0x7F08; + static let SW_DELETE_KEY_CHUNK_IS_NOT_FINISHED :UInt16 = 0x7F09; + + // Hmac errors + static let SW_INCORRECT_SAULT :UInt16 = 0x8F01; + static let SW_DATA_INTEGRITY_CORRUPTED :UInt16 = 0x8F02; + static let SW_INCORRECT_APDU_HMAC :UInt16 = 0x8F03; + static let SW_HMAC_VERIFICATION_TRIES_EXPIRED :UInt16 = 0x8F04; + + // Recovery errors + static let SW_RECOVERY_DATA_TOO_LONG :UInt16 = 0x6F09; + static let SW_INCORRECT_START_POS_OR_LE :UInt16 = 0x6F0A; + static let SW_INTEGRITY_OF_RECOVERY_DATA_CORRUPTED :UInt16 = 0x6F0B; + static let SW_RECOVERY_DATA_ALREADY_EXISTS :UInt16 = 0x6F0C; + static let SW_RECOVERY_DATA_IS_NOT_SET:UInt16 = 0x6F0D; + + static func convertSw1Sw2IntoOneSw(sw1 : UInt8, sw2 : UInt8) -> Int { + Int(256) * Int(sw1) + Int(sw2) + } + + static let CARD_ERROR_MSGS = [SW_SUCCESS: "No error.", + SW_APPLET_SELECT_FAILED : "Applet select failed.", + SW_RESPONSE_BYTES_REMAINING : "Response bytes remaining.", + SW_CLA_NOT_SUPPORTED : "CLA value not supported.", + SW_COMMAND_CHAINING_NOT_SUPPORTED : "Command chaining not supported.", + SW_COMMAND_NOT_ALLOWED : "Command not allowed (no current EF).", + SW_CONDITIONS_OF_USE_NOT_SATISFIED : "Conditions of use not satisfied.", + SW_CORRECT_EXPECTED_LENGTH : "Correct Expected Length (Le).", + SW_DATA_INVALID : "Data invalid.", + SW_NOT_ENOUGH_MEMORY_SPACE_IN_FILE : "Not enough memory space in the file.", + SW_FILE_INVALID : "File invalid.", + SW_FILE_NOT_FOUND : "File not found.", + SW_FUNCTION_NOT_SUPPORTED : "Function not supported.", + SW_INCORRECT_P1_P2 : "Incorrect parameters (P1,P2).", + SW_INS_NOT_SUPPORTED : "INS value not supported.", + /* SW_LAST_COMMAND_IN_CHAIN_EXPECTED : "Last command in chain expected.",*/ + SW_LOGICAL_CHANNEL_NOT_SUPPORTED : "Card does not support the operation on the specified logical channel.", + SW_RECORD_NOT_FOUND : "Record not found.", + SW_SECURE_MESSAGING_NOT_SUPPORTED : "Card does not support secure messaging.", + SW_SECURITY_CONDITION_NOT_SATISFIED : "Security condition not satisfied.", + SW_COMMAND_ABORTED : "Command aborted, No precise diagnosis.", + SW_WRONG_DATA : "Wrong data.", + SW_WRONG_LENGTH : "Wrong length.", + SW_WRONG_P1_P2 : "Wrong parameter(s) P1-P2", + + SW_INTERNAL_BUFFER_IS_NULL_OR_TOO_SMALL : "Internal buffer is null or too small.", + SW_PERSONALIZATION_NOT_FINISHED : "Personalization is not finished.", + SW_INCORRECT_OFFSET : "Internal error: incorrect offset.", + SW_INCORRECT_PAYLOAD : "Internal error: incorrect payload value.", + SW_INCORRECT_PASSWORD_FOR_CARD_AUTHENICATION : "Incorrect password for card authentication.", + SW_INCORRECT_PASSWORD_CARD_IS_BLOCKED : "Incorrect password, card is locked.", + SW_SET_COIN_TYPE_FAILED : "Set coin type failed.", + SW_SET_CURVE_FAILED : "Set curve failed.", + SW_GET_COIN_PUB_DATA_FAILED : "Get coin pub data failed.", + SW_SIGN_DATA_FAILED : "Sign data failed.", + SW_INCORRECT_PIN : "Incorrect PIN.", + SW_COIN_MANAGER_INCORRECT_PIN : "Incorrect PIN.", + SW_COIN_MANAGER_UPDATE_PIN_ERROR : "Update PIN error (for CHANGE_PIN) or wallet status not support to export (for GENERATE SEED).", + SW_PIN_TRIES_EXPIRED : "PIN tries expired.", + SW_LOAD_SEED_ERROR : "Load seed error.", + SW_INCORRECT_KEY_INDEX : "Incorrect key index.", + SW_INCORRECT_KEY_CHUNK_START_OR_LEN : "Incorrect key chunk start or length.", + SW_INCORRECT_KEY_CHUNK_LEN : "Incorrect key chunk length.", + SW_NOT_ENOUGH_SPACE : "Not enough space.", + SW_KEY_SIZE_UNKNOWN : "Key size unknown.", + SW_KEY_LEN_INCORRECT : "Key length incorrect.", + SW_HMAC_EXISTS : "Hmac exists already.", + SW_INCORRECT_KEY_INDEX_TO_CHANGE: "Incorrect key index to change.", + SW_MAX_KEYS_NUMBER_EXCEEDED : "Max number of keys (1023) is exceeded.", + SW_DELETE_KEY_CHUNK_IS_NOT_FINISHED : "Delete key chunk is not finished.", + SW_INCORRECT_SAULT : "Incorrect sault.", + SW_DATA_INTEGRITY_CORRUPTED : "Data integrity corrupted.", + SW_INCORRECT_APDU_HMAC : "Incorrect apdu hmac. ", + SW_HMAC_VERIFICATION_TRIES_EXPIRED : "Apdu Hmac verification tries expired.", + SW_RECOVERY_DATA_TOO_LONG : "Too big length of recovery data.", + SW_INCORRECT_START_POS_OR_LE : "Incorrect start or length of recovery data piece in internal buffer.", + SW_INTEGRITY_OF_RECOVERY_DATA_CORRUPTED : "Hash of recovery data is incorrect. ", + SW_RECOVERY_DATA_ALREADY_EXISTS : "Recovery data already exists.", + SW_RECOVERY_DATA_IS_NOT_SET : "Recovery data does not exist" + ] + + static func getErrorMsg(sw : UInt16) -> String? { + return CARD_ERROR_MSGS[sw] + } + +} diff --git a/Emoney Info/Classes/smartCard/CommonConstants.swift b/Emoney Info/Classes/smartCard/CommonConstants.swift new file mode 100755 index 0000000..4c223ce --- /dev/null +++ b/Emoney Info/Classes/smartCard/CommonConstants.swift @@ -0,0 +1,39 @@ +/* +* Copyright 2018-2020 TON DEV SOLUTIONS LTD. +* +* Licensed under the SOFTWARE EVALUATION License (the "License"); you may not use +* this file except in compliance with the License. +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific TON DEV software governing permissions and +* limitations under the License. +*/ + +import Foundation + +func debugLog( + _ items: Any..., + separator: String = " ", + terminator: String = "\n" +) { +#if DEBUG + let output = items.map { String(describing: $0) }.joined(separator: separator) + Swift.print(output, terminator: terminator) +#endif +} + +/** + Here there are some contants related to all APDU commands + */ +public class CommonConstants { + static let CLA_SELECT: UInt8 = 0x00 + static let INS_SELECT : UInt8 = 0xA4 + static let SELECT_P1 : UInt8 = 0x04 + static let SELECT_P2 : UInt8 = 0x00 + static let LE_NO_RESPONSE_DATA = -1 // Use this LE if you do not wait any response data from card (except of status word) + static let LE_GET_ALL_RESPONSE_DATA = 256 // This is standard 0x00 value of LE. Use this LE if you want to take all response bytes from applet that it produced + + +} diff --git a/Emoney Info/Classes/smartCard/EmoneyApduCommands.swift b/Emoney Info/Classes/smartCard/EmoneyApduCommands.swift new file mode 100755 index 0000000..9298d1d --- /dev/null +++ b/Emoney Info/Classes/smartCard/EmoneyApduCommands.swift @@ -0,0 +1,72 @@ +// +// EmoneyApduCommands.swift +// Emoney Info +// +// Created by Wira Irawan on 23/07/24. +// + +import Foundation +import CoreNFC + + +@available(iOS 13.0, *) +class EmoneyApduCommands{ + static let TAPCASH_INIT_DATA : [UInt8] = [0xA0, 0x00, 0x42, 0x4e, 0x49, 0x10, 0x00, 0x01] + static let BRIZZI_INIT_DATA : [UInt8] = [0x01, 0x00, 0x00] + static let EMONEY_INIT_DATA : [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01] + static let FLAZZ_INIT_DATA : [UInt8] = [0xA0, 0x00, 0x00, 0x00, 0x18, 0x0F, 0x00, 0x00, 0x01, 0x80, 0x01] + static let JACKCARD_INIT_DATA : [UInt8] = [0xA0, 0x00, 0x00, 0x05, 0x71, 0x4e, 0x4a, 0x43] + static let MEGA_DATA : [UInt8] = [0x01, 0x00, 0x00, 0x00, 0x0A, 0x00, 0x00] + static let BRI_DATA_01 : [UInt8] = [0x00, 0x00, 0x00, 0x00, 0x17, 0x00, 0x00] + + static let TAPCASH_INIT_APDU = NFCISO7816APDU(instructionClass : CommonConstants.CLA_SELECT, instructionCode : CommonConstants.INS_SELECT, p1Parameter : CommonConstants.SELECT_P1, p2Parameter : CommonConstants.SELECT_P2, data : Data(_ : TAPCASH_INIT_DATA), expectedResponseLength : CommonConstants.LE_NO_RESPONSE_DATA) + + static let BRIZZI_INIT_APDU = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x5A, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_ : BRIZZI_INIT_DATA), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + + static let EMONEY_INIT_APDU = NFCISO7816APDU(instructionClass : CommonConstants.CLA_SELECT, instructionCode : CommonConstants.INS_SELECT, p1Parameter : CommonConstants.SELECT_P1, p2Parameter : CommonConstants.SELECT_P2, data : Data(_ : EMONEY_INIT_DATA), expectedResponseLength : CommonConstants.LE_NO_RESPONSE_DATA) + + static let FLAZZ_INIT_APDU = NFCISO7816APDU(instructionClass : CommonConstants.CLA_SELECT, instructionCode : CommonConstants.INS_SELECT, p1Parameter : CommonConstants.SELECT_P1, p2Parameter : CommonConstants.SELECT_P2, data : Data(_ : FLAZZ_INIT_DATA), expectedResponseLength : CommonConstants.LE_NO_RESPONSE_DATA) + + static let JACKCARD_INIT_APDU = NFCISO7816APDU(instructionClass : CommonConstants.CLA_SELECT, instructionCode : CommonConstants.INS_SELECT, p1Parameter : CommonConstants.SELECT_P1, p2Parameter : CommonConstants.SELECT_P2, data : Data(_ : JACKCARD_INIT_DATA), expectedResponseLength : CommonConstants.LE_NO_RESPONSE_DATA) + + static let TAPCASH_APDU01 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x32, p1Parameter : 0x03, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //00 a4 01 00 02 02 00 + static let BCA_APDU01 = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xA4, p1Parameter : 0x01, p2Parameter : 0x00, data : Data(_ : [0x02, 0x00]), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //00 b0 81 00 8e + static let BCA_APDU02 = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xb0, p1Parameter : 0x81, p2Parameter : 0x00, data : Data(), expectedResponseLength : 142) + //80 32 00 03 04 00 00 00 00 //00 b0 81 00 00 + static let BCA_APDU03 = NFCISO7816APDU(instructionClass : 0x80, instructionCode : 0x32, p1Parameter : 0x00, p2Parameter : 0x03, data : Data(_ : [0x00, 0x00, 0x00, 0x00]), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //00 b0 81 00 00 + static let BCA_APDU04 = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB0, p1Parameter : 0x81, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + + + + //00 b3 00 00 3f + static let MANDIRI_APDU01 = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB3, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(), expectedResponseLength : 63) + //00 b5 00 00 0a + static let MANDIRI_APDU02 = NFCISO7816APDU(instructionClass : 0x00, instructionCode : 0xB5, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(), expectedResponseLength : 10) + //90 4c 00 00 04 + static let DKI_APDU01 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x4C, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(), expectedResponseLength : 4) + //90 bd 00 00 07 01 00 00 00 0a 00 00 00 + static let MEGA_APDU01 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0xBD, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_ : MEGA_DATA), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 6c 00 00 01 02 00 + static let MEGA_APDU02 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x6C, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_ : [0x02]), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 60 00 00 00 + static let BRI_UID01 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x60, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 AF 00 00 00 + static let BRI_UID02 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0xAF, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + + + //90 bd 00 00 07 00 00 00 00 17 00 00 00 + static let BRI_APDU01 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0xBD, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_: BRI_DATA_01), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 5A 00 00 03 03 00 00 00 + static let BRI_APDU02 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x5A, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_: [0x03, 0x00, 0x00]), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 0a 00 00 01 00 00 + static let BRI_APDU03 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x0A, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_: [0x00]), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 6C 00 00 01 00 00 + static let BRI_APDU05 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0x6C, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_: [0x00]), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 BB 00 00 07 01 00 00 00 00 00 00 00 + static let BRI_LOG01 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0xBB, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(_: [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) + //90 AF 00 00 00 + static let BRI_LOG02 = NFCISO7816APDU(instructionClass : 0x90, instructionCode : 0xAF, p1Parameter : 0x00, p2Parameter : 0x00, data : Data(), expectedResponseLength : CommonConstants.LE_GET_ALL_RESPONSE_DATA) +} diff --git a/Emoney Info/Classes/smartCard/TapCashData.swift b/Emoney Info/Classes/smartCard/TapCashData.swift new file mode 100755 index 0000000..6f6a6ec --- /dev/null +++ b/Emoney Info/Classes/smartCard/TapCashData.swift @@ -0,0 +1,174 @@ +// +// TapCashData.swift +// Emoney Info +// +// Created by Wira Irawan on 26/07/24. +// + +import Foundation + +class TapCashData { + var purseData: [UInt8]? + var purseStatus: [UInt8]? + var purseBalance: [UInt8]? + var CAN: [UInt8]? + var CSN: [UInt8]? + var purseExpiry: [UInt8]? + var lastCredirTRP: [UInt8]? + var lastCreditHeader: [UInt8]? + var lastTxnTRP: [UInt8]? + var lastTxnRecord: [UInt8]? + var BDC: [UInt8]? + var keySet: [UInt8]? + var maxCardBalance: [UInt8]? + var eData: [UInt8]? + var crcb: [UInt8]? + var lastTransactionSignCert: [UInt8]? + var lastCounterData: [UInt8]? + var version: [UInt8]? + var lastDebitOption: [UInt8]? + var totalRecords: [UInt8]? + + func getPurseData() -> [UInt8]? { + return purseData + } + + func setPurseData(_ purseData: [UInt8]) { + self.purseData = purseData + unpackPurseData(data: purseData) + } + + func getPurseStatus() -> [UInt8]? { + return purseStatus + } + + func setPurseStatus(_ purseStatus: [UInt8]) { + self.purseStatus = purseStatus + } + + func getPurseBalance() -> [UInt8]? { + return purseBalance + } + + func setPurseBalance(_ purseBalance: [UInt8]) { + self.purseBalance = purseBalance + } + + func getCAN() -> [UInt8]? { + return CAN + } + + func setCAN(_ CAN: [UInt8]) { + self.CAN = CAN + } + + func getCSN() -> [UInt8]? { + return CSN + } + + func setCSN(_ CSN: [UInt8]) { + self.CSN = CSN + } + + func getPurseExpiry() -> [UInt8]? { + return purseExpiry + } + + func setPurseExpiry(_ purseExpiry: [UInt8]) { + self.purseExpiry = purseExpiry + } + + func getLastCredirTRP() -> [UInt8]? { + return lastCredirTRP + } + + func setLastCredirTRP(_ lastCredirTRP: [UInt8]) { + self.lastCredirTRP = lastCredirTRP + } + + func getLastCreditHeader() -> [UInt8]? { + return lastCreditHeader + } + + func setLastCreditHeader(_ lastCreditHeader: [UInt8]) { + self.lastCreditHeader = lastCreditHeader + } + + func getLastTxnTRP() -> [UInt8]? { + return lastTxnTRP + } + + func setLastTxnTRP(_ lastTxnTRP: [UInt8]) { + self.lastTxnTRP = lastTxnTRP + } + + func getLastTxnRecord() -> [UInt8]? { + return lastTxnRecord + } + + func setLastTxnRecord(_ lastTxnRecord: [UInt8]) { + self.lastTxnRecord = lastTxnRecord + } + + func getBDC() -> [UInt8]? { + return BDC + } + + func setBDC(_ BDC: [UInt8]) { + self.BDC = BDC + } + + func getKeySet() -> [UInt8]? { + return keySet + } + + func setKeySet(_ keySet: [UInt8]) { + self.keySet = keySet + } + + func getMaxCardBalance() -> [UInt8]? { + return maxCardBalance + } + + func setMaxCardBalance(_ maxCardBalance: [UInt8]) { + self.maxCardBalance = maxCardBalance + } + + func geteData() -> [UInt8]? { + return eData + } + + func seteData(_ eData: [UInt8]) { + self.eData = eData + } + + func getCrcb() -> [UInt8]? { + return crcb + } + + func setCrcb(_ crcb: [UInt8]) { + self.crcb = crcb + } + + func getTotalRecords() -> [UInt8]? { + return totalRecords + } + + private func unpackPurseData(data: [UInt8]) { + version = Array(data[0..<1]) + purseStatus = Array(data[1..<2]) + purseBalance = Array(data[2..<5]) + CAN = Array(data[8..<16]) + CSN = Array(data[16..<24]) + purseExpiry = Array(data[24..<26]) + lastCredirTRP = Array(data[28..<32]) + lastCreditHeader = Array(data[32..<40]) + totalRecords = Array(data[40..<41]) + lastTxnTRP = Array(data[42..<46]) + lastTxnRecord = Array(data[46..<62]) + BDC = Array(data[63..<64]) + keySet = Array(data[71..<72]) + maxCardBalance = Array(data[78..<81]) + lastDebitOption = Array(data[94..<95]) + } +} diff --git a/Emoney Info/Classes/utils/GradientView.swift b/Emoney Info/Classes/utils/GradientView.swift new file mode 100755 index 0000000..30acc28 --- /dev/null +++ b/Emoney Info/Classes/utils/GradientView.swift @@ -0,0 +1,19 @@ +// +// GradientView.swift +// Emoney Info +// +// Created by Wira Irawan on 29/07/24. +// + +import Foundation +import UIKit + +class GradientView: UIView { + override class var layerClass: AnyClass { + return CAGradientLayer.self + } + + var gradientLayer: CAGradientLayer { + return self.layer as! CAGradientLayer + } +} diff --git a/Emoney Info/Classes/utils/L10n.swift b/Emoney Info/Classes/utils/L10n.swift new file mode 100644 index 0000000..6f6e2d4 --- /dev/null +++ b/Emoney Info/Classes/utils/L10n.swift @@ -0,0 +1,177 @@ +import Foundation + +/// Type-safe localization helper. +/// Usage: L10n.checkBalance → returns localized String automatically +/// Works in both UIKit (as String) and SwiftUI (via Text(L10n.xxx)) +enum L10n { + + // MARK: - Home + + static var availableBalance: String { s("availableBalance") } + static var cardTapInstruction: String { s("cardTapInstruction") } + static var cardTypeDefault: String { s("cardTypeDefault") } + static var tapCardHere: String { s("tapCardHere") } + static var tapCardHint: String { s("tapCardHint") } + static var checkBalance: String { s("checkBalance") } + static var lastTransaction: String { s("lastTransaction") } + static var viewFullHistory: String { s("viewFullHistory") } + static var copiedToClipboard: String { s("copiedToClipboard") } + static var transactionDefault: String { s("transactionDefault") } + static var transactionStatusSuccess: String { s("transactionStatusSuccess") } + static var noCard: String { s("noCard") } + + // MARK: - Card / NFC + + static var cardType: String { s("cardType") } + static var cardNumber: String { s("cardNumber") } + static var balance: String { s("balance") } + static var scanMessage: String { s("scanMessage") } + static var readFinish: String { s("readFinish") } + static var readFailed: String { s("readFailed") } + static var updateBalance: String { s("updateBalance") } + static var payment: String { s("payment") } + static var topup: String { s("topup") } + static var unknown: String { s("unknown") } + static var void: String { s("void") } + static var reactivation: String { s("reactivation") } + static var statementFee: String { s("statementFee") } + static var gracePeriod: String { s("gracePeriod") } + static var refund: String { s("refund") } + static var close: String { s("close") } + static var atu: String { s("atu") } + + // MARK: - History + + static var historyTitle: String { s("historyTitle") } + static var recentActivity: String { s("recentActivity") } + static var filterAllTime: String { s("filterAllTime") } + static var filterToday: String { s("filterToday") } + static var filterThisMonth: String { s("filterThisMonth") } + static var filterThisWeek: String { s("filterThisWeek") } + static var noTransactionsFound: String { s("noTransactionsFound") } + static var exportPDF: String { s("exportPDF") } + static var transactionHistory: String { s("transactionHistory") } + + // MARK: - Settings + + static var settingsTitle: String { s("settingsTitle") } + static var premiumBadge: String { s("premiumBadge") } + static var premiumTitle: String { s("premiumTitle") } + static var premiumDesc: String { s("premiumDesc") } + static var upgradeNow: String { s("upgradeNow") } + static var sectionGeneral: String { s("sectionGeneral") } + static var languageTitle: String { s("languageTitle") } + static var languageValue: String { s("languageValue") } + static var showCardNumberTitle: String { s("showCardNumberTitle") } + static var showCardNumberDesc: String { s("showCardNumberDesc") } + static var sectionApp: String { s("sectionApp") } + static var notificationsTitle: String { s("notificationsTitle") } + static var notificationsDesc: String { s("notificationsDesc") } + static var helpCenterTitle: String { s("helpCenterTitle") } + static var helpCenterDesc: String { s("helpCenterDesc") } + static var aboutAppTitle: String { s("aboutAppTitle") } + static var aboutAppDesc: String { s("aboutAppDesc") } + // MARK: - Terms & Conditions + static var termsLastUpdated: String { s("termsLastUpdated") } + static var termsTitleRegular: String { s("termsTitleRegular") } + static var termsTitleBold: String { s("termsTitleBold") } + static var termsSubtitle: String { s("termsSubtitle") } + static var termsSec1Title: String { s("termsSec1Title") } + static var termsSec1Body: String { s("termsSec1Body") } + static var termsSec2Title: String { s("termsSec2Title") } + static var termsSec2Body: String { s("termsSec2Body") } + static var termsSec2Bullet1: String { s("termsSec2Bullet1") } + static var termsSec2Bullet2: String { s("termsSec2Bullet2") } + static var termsSec2Bullet3: String { s("termsSec2Bullet3") } + static var termsSec3Title: String { s("termsSec3Title") } + static var termsSec3Body: String { s("termsSec3Body") } + static var termsSec3Bullet1: String { s("termsSec3Bullet1") } + static var termsSec3Bullet2: String { s("termsSec3Bullet2") } + static var termsContactTitle: String { s("termsContactTitle") } + static var termsContactDesc: String { s("termsContactDesc") } + static var termsContactButton: String { s("termsContactButton") } + + // MARK: - Privacy Policy + static var privacyLastUpdated: String { s("privacyLastUpdated") } + static var privacySectionNfcTitle: String { s("privacySectionNfcTitle") } + static var privacySectionNfcBody: String { s("privacySectionNfcBody") } + static var privacySectionNoStorageTitle: String { s("privacySectionNoStorageTitle") } + static var privacySectionNoStorageBody: String { s("privacySectionNoStorageBody") } + static var privacySectionReadOnlyTitle: String { s("privacySectionReadOnlyTitle") } + static var privacySectionReadOnlyBody: String { s("privacySectionReadOnlyBody") } + static var privacyContactTitle: String { s("privacyContactTitle") } + static var privacyContactDesc: String { s("privacyContactDesc") } + static var privacyContactButton: String { s("privacyContactButton") } + + // MARK: - About + static var aboutAppDescription: String { s("aboutAppDescription") } + static var aboutChipNfc: String { s("aboutChipNfc") } + static var aboutChipRealtime: String { s("aboutChipRealtime") } + static var aboutChipMulti: String { s("aboutChipMulti") } + static var aboutTerms: String { s("aboutTerms") } + static var aboutPrivacy: String { s("aboutPrivacy") } + static var aboutConnectTitle: String { s("aboutConnectTitle") } + static var aboutConnectDesc: String { s("aboutConnectDesc") } + + static var maskTitle: String { s("maskTitle") } + static var maskDesc: String { s("maskDesc") } + static var supportCardTitle: String { s("supportCardTitle") } + static var supportCardDesc: String { s("supportCardDesc") } + static var aboutTitle: String { s("aboutTitle") } + static var version: String { s("versi") } + static var footerCopyright: String { s("footerCopyright") } + static var reportIssue: String { s("reportIssue") } + + // MARK: - FAQ + + static var faqHeaderTitle: String { s("faqHeaderTitle") } + static var faqSearchPlaceholder: String { s("faqSearchPlaceholder") } + static var faqFilterAll: String { s("faqFilterAll") } + static var faqNoResults: String { s("faqNoResults") } + static var faqHelpCardTitle: String { s("faqHelpCardTitle") } + static var faqHelpCardDesc: String { s("faqHelpCardDesc") } + static var faqEmailSupport: String { s("faqEmailSupport") } + + // FAQ Categories + static var faqCategoryCard: String { s("faqCategoryCard") } + static var faqCategoryTransaction: String { s("faqCategoryTransaction") } + static var faqCategoryBalance: String { s("faqCategoryBalance") } + static var faqCategoryApp: String { s("faqCategoryApp") } + + // FAQ Questions & Answers — Card + static var faqQ_cardCompatible: String { s("faqQ_cardCompatible") } + static var faqA_cardCompatible: String { s("faqA_cardCompatible") } + static var faqQ_cardNotDetected: String { s("faqQ_cardNotDetected") } + static var faqA_cardNotDetected: String { s("faqA_cardNotDetected") } + static var faqQ_cardReadFailed: String { s("faqQ_cardReadFailed") } + static var faqA_cardReadFailed: String { s("faqA_cardReadFailed") } + + // FAQ Questions & Answers — Transaction + static var faqQ_txNotShown: String { s("faqQ_txNotShown") } + static var faqA_txNotShown: String { s("faqA_txNotShown") } + static var faqQ_txExportPDF: String { s("faqQ_txExportPDF") } + static var faqA_txExportPDF: String { s("faqA_txExportPDF") } + + // FAQ Questions & Answers — Balance + static var faqQ_balanceWrong: String { s("faqQ_balanceWrong") } + static var faqA_balanceWrong: String { s("faqA_balanceWrong") } + static var faqQ_balanceTopup: String { s("faqQ_balanceTopup") } + static var faqA_balanceTopup: String { s("faqA_balanceTopup") } + + // FAQ Questions & Answers — App + static var faqQ_appLanguage: String { s("faqQ_appLanguage") } + static var faqA_appLanguage: String { s("faqA_appLanguage") } + static var faqQ_appMaskNumber: String { s("faqQ_appMaskNumber") } + static var faqA_appMaskNumber: String { s("faqA_appMaskNumber") } + + // MARK: - Tab Bar + + static var tabEmoney: String { s("tabEmoney") } + static var tabSettings: String { s("tabSettings") } + + // MARK: - Private + + private static func s(_ key: String) -> String { + NSLocalizedString(key, comment: "") + } +} diff --git a/Emoney Info/Classes/utils/Station.swift b/Emoney Info/Classes/utils/Station.swift new file mode 100644 index 0000000..1b038b4 --- /dev/null +++ b/Emoney Info/Classes/utils/Station.swift @@ -0,0 +1,15 @@ +// +// Station.swift +// Emoney Info +// +// Created by Wira Basalamah on 05/04/26. +// + + +struct Station { + let id: Int + let name: String + let subName: String + let latitude: String + let longitude: String +} \ No newline at end of file diff --git a/Emoney Info/Classes/utils/Theme.swift b/Emoney Info/Classes/utils/Theme.swift new file mode 100644 index 0000000..1fde07a --- /dev/null +++ b/Emoney Info/Classes/utils/Theme.swift @@ -0,0 +1,59 @@ +import UIKit + +enum Theme { + + // MARK: - Colors + + enum Color { + static let primary = UIColor(hex: "#7AD4D1") + static let secondary = UIColor(hex: "#5D7D7B") + static let success = UIColor(hex: "#34C759") + static let background = UIColor(hex: "#F3F3F8") + static let card = UIColor.white + static let textPrimary = UIColor(hex: "#1A1A2E") + static let textSecondary = UIColor(hex: "#8E8E93") + } + + // MARK: - Font Sizes + + enum FontSize { + static let title: CGFloat = 24 + static let subtitle: CGFloat = 18 + static let body: CGFloat = 16 + static let caption: CGFloat = 12 + } + + // MARK: - Fonts + + enum Font { + static func title(weight: UIFont.Weight = .bold) -> UIFont { + .systemFont(ofSize: FontSize.title, weight: weight) + } + static func subtitle(weight: UIFont.Weight = .semibold) -> UIFont { + .systemFont(ofSize: FontSize.subtitle, weight: weight) + } + static func body(weight: UIFont.Weight = .regular) -> UIFont { + .systemFont(ofSize: FontSize.body, weight: weight) + } + static func caption(weight: UIFont.Weight = .regular) -> UIFont { + .systemFont(ofSize: FontSize.caption, weight: weight) + } + } +} + +// MARK: - UIColor hex initializer + +private extension UIColor { + convenience init(hex: String) { + var sanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if sanitized.hasPrefix("#") { sanitized.removeFirst() } + + var rgb: UInt64 = 0 + Scanner(string: sanitized).scanHexInt64(&rgb) + + let r = CGFloat((rgb >> 16) & 0xFF) / 255 + let g = CGFloat((rgb >> 8) & 0xFF) / 255 + let b = CGFloat( rgb & 0xFF) / 255 + self.init(red: r, green: g, blue: b, alpha: 1) + } +} diff --git a/Emoney Info/Classes/utils/ToastHelper.swift b/Emoney Info/Classes/utils/ToastHelper.swift new file mode 100755 index 0000000..0a4b377 --- /dev/null +++ b/Emoney Info/Classes/utils/ToastHelper.swift @@ -0,0 +1,50 @@ +// +// ToastHelper.swift +// NewTonNfcCardLib +// +// Created by Alina Alinovna on 23.10.2020. +// Copyright © 2020 Facebook. All rights reserved. +// + +import Foundation +import UIKit + +class ToastHelper { + static func showToast(message : String) { + let toastView = UILabel() + toastView.backgroundColor = UIColor.black.withAlphaComponent(0.7) + toastView.textColor = UIColor.white + toastView.textAlignment = .center + toastView.font = UIFont.preferredFont(forTextStyle: .caption1) + toastView.layer.cornerRadius = 25 + toastView.layer.masksToBounds = true + toastView.text = message + toastView.numberOfLines = 0 + toastView.alpha = 0 + toastView.translatesAutoresizingMaskIntoConstraints = false + + let window = UIApplication.shared.delegate?.window! + window?.addSubview(toastView) + + let horizontalCenterContraint: NSLayoutConstraint = NSLayoutConstraint(item: toastView, attribute: .centerX, relatedBy: .equal, toItem: window, attribute: .centerX, multiplier: 1, constant: 0) + + let widthContraint: NSLayoutConstraint = NSLayoutConstraint(item: toastView, attribute: .width, relatedBy: .equal, toItem: nil, attribute: .width, multiplier: 1, constant: 275) + + let verticalContraint: [NSLayoutConstraint] = NSLayoutConstraint.constraints(withVisualFormat: "V:|-(>=200)-[loginView(==50)]-68-|", options: [.alignAllCenterX, .alignAllCenterY], metrics: nil, views: ["loginView": toastView]) + + NSLayoutConstraint.activate([horizontalCenterContraint, widthContraint]) + NSLayoutConstraint.activate(verticalContraint) + + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseIn, animations: { + toastView.alpha = 1 + }, completion: nil) + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + Double((Int64)(2 * NSEC_PER_SEC)) / Double(NSEC_PER_SEC), execute: { + UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseIn, animations: { + toastView.alpha = 0 + }, completion: { finished in + toastView.removeFromSuperview() + }) + }) +} +} diff --git a/Emoney Info/Design/about.png b/Emoney Info/Design/about.png new file mode 100644 index 0000000..ccced70 Binary files /dev/null and b/Emoney Info/Design/about.png differ diff --git a/Emoney Info/Design/faq.png b/Emoney Info/Design/faq.png new file mode 100644 index 0000000..c2ac2fb Binary files /dev/null and b/Emoney Info/Design/faq.png differ diff --git a/Emoney Info/Design/history.png b/Emoney Info/Design/history.png new file mode 100644 index 0000000..a926659 Binary files /dev/null and b/Emoney Info/Design/history.png differ diff --git a/Emoney Info/Design/home.png b/Emoney Info/Design/home.png new file mode 100644 index 0000000..1d5448c Binary files /dev/null and b/Emoney Info/Design/home.png differ diff --git a/Emoney Info/Design/kebijakan.png b/Emoney Info/Design/kebijakan.png new file mode 100644 index 0000000..fc3603d Binary files /dev/null and b/Emoney Info/Design/kebijakan.png differ diff --git a/Emoney Info/Design/settings.png b/Emoney Info/Design/settings.png new file mode 100644 index 0000000..7bea0cd Binary files /dev/null and b/Emoney Info/Design/settings.png differ diff --git a/Emoney Info/Design/syarat.png b/Emoney Info/Design/syarat.png new file mode 100644 index 0000000..61c6923 Binary files /dev/null and b/Emoney Info/Design/syarat.png differ diff --git a/Emoney Info/Emoney Info.entitlements b/Emoney Info/Emoney Info.entitlements new file mode 100755 index 0000000..467c964 --- /dev/null +++ b/Emoney Info/Emoney Info.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.nfc.readersession.formats + + TAG + + + diff --git a/Emoney Info/FAQData.swift b/Emoney Info/FAQData.swift new file mode 100644 index 0000000..776de03 --- /dev/null +++ b/Emoney Info/FAQData.swift @@ -0,0 +1,94 @@ +// FAQData.swift +// Emoney Info +// +// Model data untuk halaman FAQ. +// Untuk menambah/mengubah pertanyaan, cukup edit array `FAQData.all` di bawah. + +import Foundation + +// MARK: - Model + +struct FAQCategory { + let id: String // unik, dipakai untuk filter + let icon: String // SF Symbol name + let title: String // judul dalam bahasa aktif + let items: [FAQItem] +} + +struct FAQItem { + let question: String + let answer: String +} + +// MARK: - Content + +enum FAQData { + + /// Semua kategori FAQ. Tambah/ubah konten di sini. + static var all: [FAQCategory] {[ + FAQCategory( + id: "card", + icon: "creditcard.fill", + title: L10n.faqCategoryCard, + items: [ + FAQItem( + question: L10n.faqQ_cardCompatible, + answer: L10n.faqA_cardCompatible + ), + FAQItem( + question: L10n.faqQ_cardNotDetected, + answer: L10n.faqA_cardNotDetected + ), + FAQItem( + question: L10n.faqQ_cardReadFailed, + answer: L10n.faqA_cardReadFailed + ), + ] + ), + FAQCategory( + id: "transaction", + icon: "arrow.left.arrow.right", + title: L10n.faqCategoryTransaction, + items: [ + FAQItem( + question: L10n.faqQ_txNotShown, + answer: L10n.faqA_txNotShown + ), + FAQItem( + question: L10n.faqQ_txExportPDF, + answer: L10n.faqA_txExportPDF + ), + ] + ), + FAQCategory( + id: "balance", + icon: "dollarsign.circle.fill", + title: L10n.faqCategoryBalance, + items: [ + FAQItem( + question: L10n.faqQ_balanceWrong, + answer: L10n.faqA_balanceWrong + ), + FAQItem( + question: L10n.faqQ_balanceTopup, + answer: L10n.faqA_balanceTopup + ), + ] + ), + FAQCategory( + id: "app", + icon: "info.circle.fill", + title: L10n.faqCategoryApp, + items: [ + FAQItem( + question: L10n.faqQ_appLanguage, + answer: L10n.faqA_appLanguage + ), + FAQItem( + question: L10n.faqQ_appMaskNumber, + answer: L10n.faqA_appMaskNumber + ), + ] + ), + ]} +} diff --git a/Emoney Info/FAQViewController.swift b/Emoney Info/FAQViewController.swift new file mode 100644 index 0000000..b622dbc --- /dev/null +++ b/Emoney Info/FAQViewController.swift @@ -0,0 +1,556 @@ +// FAQViewController.swift +// Emoney Info +// +// Halaman Pusat Bantuan / FAQ +// - Search bar berfungsi (filter pertanyaan real-time) +// - Filter chip per kategori berfungsi +// - Accordion: tap pertanyaan untuk expand/collapse jawaban + +import UIKit + +final class FAQViewController: UIViewController { + + // MARK: - State + + private var allCategories: [FAQCategory] = FAQData.all + private var activeFilter: String? = nil // nil = semua kategori + private var searchText: String = "" + private var expandedItems: Set = [] // key = "catID|qIndex" + + // Computed: filtered categories based on search + filter chip + private var displayedCategories: [FAQCategory] { + var source = allCategories + + // 1. Filter by category chip + if let filter = activeFilter { + source = source.filter { $0.id == filter } + } + + // 2. Filter by search text + if !searchText.isEmpty { + let q = searchText.lowercased() + source = source.compactMap { cat in + let filtered = cat.items.filter { + $0.question.lowercased().contains(q) || $0.answer.lowercased().contains(q) + } + if filtered.isEmpty { return nil } + return FAQCategory(id: cat.id, icon: cat.icon, title: cat.title, items: filtered) + } + } + + return source + } + + // MARK: - UI + + private let scrollView = UIScrollView() + private let contentView = UIView() + + private let headerView = UIView() + private let backButton = UIButton(type: .system) + private let titleLabel = UILabel() + private let subtitleLabel = UILabel() + private let searchBar = UISearchBar() + + // Category filter chips (horizontal scroll) + private let chipScrollView = UIScrollView() + private let chipStack = UIStackView() + + // FAQ accordion container + private let faqContainerStack = UIStackView() + + // "Still need help?" footer card + private let helpCard = UIView() + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Theme.Color.background + setupScrollView() + setupHeader() + setupChips() + setupFAQSection() + setupHelpCard() + reloadFAQ() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(true, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(false, animated: animated) + } + + // MARK: - Setup: ScrollView + + private func setupScrollView() { + scrollView.showsVerticalScrollIndicator = false + scrollView.keyboardDismissMode = .onDrag + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + } + + // MARK: - Setup: Header + + private func setupHeader() { + // Teal rounded-bottom header card + headerView.backgroundColor = Theme.Color.primary + headerView.layer.cornerRadius = 24 + headerView.layer.maskedCorners = [.layerMinXMaxYCorner, .layerMaxXMaxYCorner] + headerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(headerView) + + // Back button + let chevron = UIImage(systemName: "chevron.left", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)) + backButton.setImage(chevron, for: .normal) + backButton.tintColor = .white + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.translatesAutoresizingMaskIntoConstraints = false + + // Header nav label "Help Center" + let navLabel = UILabel() + navLabel.text = L10n.helpCenterTitle + navLabel.font = Theme.Font.caption(weight: .semibold) + navLabel.textColor = UIColor.white.withAlphaComponent(0.85) + navLabel.translatesAutoresizingMaskIntoConstraints = false + + // Title + titleLabel.text = L10n.faqHeaderTitle + titleLabel.font = Theme.Font.title(weight: .bold) + titleLabel.textColor = .white + titleLabel.numberOfLines = 0 + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + // Search bar + searchBar.placeholder = L10n.faqSearchPlaceholder + searchBar.searchBarStyle = .minimal + searchBar.backgroundColor = .white + searchBar.layer.cornerRadius = 12 + searchBar.clipsToBounds = true + searchBar.delegate = self + searchBar.translatesAutoresizingMaskIntoConstraints = false + if let tf = searchBar.value(forKey: "searchField") as? UITextField { + tf.backgroundColor = .white + tf.font = Theme.Font.body(weight: .regular) + } + + [backButton, navLabel, titleLabel, searchBar].forEach { headerView.addSubview($0) } + + NSLayoutConstraint.activate([ + headerView.topAnchor.constraint(equalTo: contentView.topAnchor), + headerView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + headerView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + + backButton.topAnchor.constraint(equalTo: headerView.safeAreaLayoutGuide.topAnchor, constant: 16), + backButton.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 20), + backButton.widthAnchor.constraint(equalToConstant: 32), + backButton.heightAnchor.constraint(equalToConstant: 32), + + navLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor), + navLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8), + + titleLabel.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 24), + titleLabel.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -24), + + searchBar.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 16), + searchBar.leadingAnchor.constraint(equalTo: headerView.leadingAnchor, constant: 16), + searchBar.trailingAnchor.constraint(equalTo: headerView.trailingAnchor, constant: -16), + searchBar.bottomAnchor.constraint(equalTo: headerView.bottomAnchor, constant: -20), + searchBar.heightAnchor.constraint(equalToConstant: 44), + ]) + } + + // MARK: - Setup: Category Chips + + private func setupChips() { + chipScrollView.showsHorizontalScrollIndicator = false + chipScrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(chipScrollView) + + chipStack.axis = .horizontal + chipStack.spacing = 10 + chipStack.alignment = .center + chipStack.translatesAutoresizingMaskIntoConstraints = false + chipScrollView.addSubview(chipStack) + + NSLayoutConstraint.activate([ + chipScrollView.topAnchor.constraint(equalTo: headerView.bottomAnchor, constant: 20), + chipScrollView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + chipScrollView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + chipScrollView.heightAnchor.constraint(equalToConstant: 44), + + chipStack.centerYAnchor.constraint(equalTo: chipScrollView.centerYAnchor), + chipStack.leadingAnchor.constraint(equalTo: chipScrollView.leadingAnchor, constant: 20), + chipStack.trailingAnchor.constraint(equalTo: chipScrollView.trailingAnchor, constant: -20), + ]) + + // "All" chip + one per category + buildChips() + } + + private func buildChips() { + chipStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let allChip = makeChip(id: nil, icon: "square.grid.2x2", title: L10n.faqFilterAll) + chipStack.addArrangedSubview(allChip) + + for cat in allCategories { + let chip = makeChip(id: cat.id, icon: cat.icon, title: cat.title) + chipStack.addArrangedSubview(chip) + } + } + + private func makeChip(id: String?, icon: String, title: String) -> UIView { + let isActive = (id == activeFilter) + + let container = UIControl() + container.backgroundColor = isActive ? Theme.Color.primary : .white + container.layer.cornerRadius = 18 + container.layer.borderWidth = isActive ? 0 : 1 + container.layer.borderColor = UIColor.systemGray5.cgColor + container.layer.shadowColor = UIColor.black.cgColor + container.layer.shadowOpacity = isActive ? 0.08 : 0.04 + container.layer.shadowOffset = CGSize(width: 0, height: 2) + container.layer.shadowRadius = 4 + + let iconView = UIImageView(image: UIImage(systemName: icon, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 12, weight: .medium))) + iconView.tintColor = isActive ? .white : Theme.Color.secondary + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + + let label = UILabel() + label.text = title + label.font = Theme.Font.caption(weight: .semibold) + label.textColor = isActive ? .white : Theme.Color.textPrimary + label.translatesAutoresizingMaskIntoConstraints = false + + let stack = UIStackView(arrangedSubviews: [iconView, label]) + stack.axis = .horizontal + stack.spacing = 5 + stack.alignment = .center + stack.isUserInteractionEnabled = false + stack.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(stack) + + NSLayoutConstraint.activate([ + iconView.widthAnchor.constraint(equalToConstant: 14), + iconView.heightAnchor.constraint(equalToConstant: 14), + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 14), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -14), + stack.centerYAnchor.constraint(equalTo: container.centerYAnchor), + container.heightAnchor.constraint(equalToConstant: 36), + ]) + + // Store filter ID in accessibilityIdentifier for lookup + container.accessibilityIdentifier = id ?? "__all__" + container.addTarget(self, action: #selector(chipTapped(_:)), for: .touchUpInside) + + return container + } + + // MARK: - Setup: FAQ Accordion Section + + private func setupFAQSection() { + faqContainerStack.axis = .vertical + faqContainerStack.spacing = 12 + faqContainerStack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(faqContainerStack) + + NSLayoutConstraint.activate([ + faqContainerStack.topAnchor.constraint(equalTo: chipScrollView.bottomAnchor, constant: 20), + faqContainerStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + faqContainerStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + ]) + } + + // MARK: - Setup: Help Card Footer + + private func setupHelpCard() { + helpCard.backgroundColor = Theme.Color.primary + helpCard.layer.cornerRadius = 20 + helpCard.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(helpCard) + + let iconView = UIImageView(image: UIImage(systemName: "questionmark.bubble.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 28, weight: .medium))) + iconView.tintColor = .white + iconView.translatesAutoresizingMaskIntoConstraints = false + + let helpTitle = UILabel() + helpTitle.text = L10n.faqHelpCardTitle + helpTitle.font = Theme.Font.subtitle(weight: .bold) + helpTitle.textColor = .white + helpTitle.numberOfLines = 0 + helpTitle.translatesAutoresizingMaskIntoConstraints = false + + let helpDesc = UILabel() + helpDesc.text = L10n.faqHelpCardDesc + helpDesc.font = Theme.Font.caption(weight: .regular) + helpDesc.textColor = UIColor.white.withAlphaComponent(0.85) + helpDesc.numberOfLines = 0 + helpDesc.translatesAutoresizingMaskIntoConstraints = false + + let emailButton = UIButton(type: .system) + emailButton.setTitle(L10n.faqEmailSupport, for: .normal) + emailButton.setImage(UIImage(systemName: "envelope.fill", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 14, weight: .medium)), for: .normal) + emailButton.tintColor = Theme.Color.primary + emailButton.backgroundColor = .white + emailButton.layer.cornerRadius = 14 + emailButton.titleLabel?.font = Theme.Font.body(weight: .semibold) + emailButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 20, bottom: 12, right: 20) + emailButton.imageEdgeInsets = UIEdgeInsets(top: 0, left: -6, bottom: 0, right: 0) + emailButton.addTarget(self, action: #selector(emailTapped), for: .touchUpInside) + emailButton.translatesAutoresizingMaskIntoConstraints = false + + [iconView, helpTitle, helpDesc, emailButton].forEach { helpCard.addSubview($0) } + + NSLayoutConstraint.activate([ + helpCard.topAnchor.constraint(equalTo: faqContainerStack.bottomAnchor, constant: 28), + helpCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + helpCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + helpCard.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -140), + + iconView.topAnchor.constraint(equalTo: helpCard.topAnchor, constant: 24), + iconView.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24), + iconView.widthAnchor.constraint(equalToConstant: 36), + iconView.heightAnchor.constraint(equalToConstant: 36), + + helpTitle.topAnchor.constraint(equalTo: iconView.bottomAnchor, constant: 12), + helpTitle.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24), + helpTitle.trailingAnchor.constraint(equalTo: helpCard.trailingAnchor, constant: -24), + + helpDesc.topAnchor.constraint(equalTo: helpTitle.bottomAnchor, constant: 8), + helpDesc.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24), + helpDesc.trailingAnchor.constraint(equalTo: helpCard.trailingAnchor, constant: -24), + + emailButton.topAnchor.constraint(equalTo: helpDesc.bottomAnchor, constant: 20), + emailButton.leadingAnchor.constraint(equalTo: helpCard.leadingAnchor, constant: 24), + emailButton.bottomAnchor.constraint(equalTo: helpCard.bottomAnchor, constant: -24), + ]) + } + + // MARK: - Reload FAQ Accordion + + private func reloadFAQ() { + faqContainerStack.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let categories = displayedCategories + + if categories.isEmpty { + let emptyLabel = UILabel() + emptyLabel.text = L10n.faqNoResults + emptyLabel.font = Theme.Font.body(weight: .regular) + emptyLabel.textColor = Theme.Color.textSecondary + emptyLabel.textAlignment = .center + emptyLabel.numberOfLines = 0 + faqContainerStack.addArrangedSubview(emptyLabel) + return + } + + for cat in categories { + // Category header label + let catLabel = UILabel() + catLabel.text = cat.title.uppercased() + catLabel.font = Theme.Font.caption(weight: .semibold) + catLabel.textColor = Theme.Color.textSecondary + faqContainerStack.addArrangedSubview(catLabel) + faqContainerStack.setCustomSpacing(8, after: catLabel) + + // White card holding all items for this category + let card = UIView() + card.backgroundColor = Theme.Color.card + card.layer.cornerRadius = 16 + card.layer.shadowColor = UIColor.black.cgColor + card.layer.shadowOpacity = 0.06 + card.layer.shadowOffset = CGSize(width: 0, height: 2) + card.layer.shadowRadius = 8 + card.translatesAutoresizingMaskIntoConstraints = false + + let itemStack = UIStackView() + itemStack.axis = .vertical + itemStack.spacing = 0 + itemStack.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(itemStack) + + NSLayoutConstraint.activate([ + itemStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4), + itemStack.leadingAnchor.constraint(equalTo: card.leadingAnchor), + itemStack.trailingAnchor.constraint(equalTo: card.trailingAnchor), + itemStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4), + ]) + + for (qIndex, item) in cat.items.enumerated() { + let key = "\(cat.id)|\(qIndex)" + let isExpanded = expandedItems.contains(key) + + let row = FAQItemRow( + item: item, + isExpanded: isExpanded, + onToggle: { [weak self] in + self?.toggleItem(key: key) + } + ) + itemStack.addArrangedSubview(row) + + if qIndex < cat.items.count - 1 { + let sep = UIView() + sep.backgroundColor = Theme.Color.background + sep.translatesAutoresizingMaskIntoConstraints = false + sep.heightAnchor.constraint(equalToConstant: 1).isActive = true + itemStack.addArrangedSubview(sep) + } + } + + faqContainerStack.addArrangedSubview(card) + faqContainerStack.setCustomSpacing(20, after: card) + } + } + + // MARK: - Toggle accordion item + + private func toggleItem(key: String) { + if expandedItems.contains(key) { + expandedItems.remove(key) + } else { + expandedItems.insert(key) + } + UIView.animate(withDuration: 0.25, delay: 0, + usingSpringWithDamping: 0.85, initialSpringVelocity: 0) { + self.reloadFAQ() + self.view.layoutIfNeeded() + } + } + + // MARK: - Actions + + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + + @objc private func chipTapped(_ sender: UIControl) { + let rawID = sender.accessibilityIdentifier + activeFilter = (rawID == "__all__") ? nil : rawID + expandedItems.removeAll() + buildChips() + reloadFAQ() + } + + @objc private func emailTapped() { + let address = "apps@indonesiainyourhand.com" + let subject = "Ask Support" + let encoded = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject + if let url = URL(string: "mailto:\(address)?subject=\(encoded)") { + UIApplication.shared.open(url) + } + } +} + +// MARK: - UISearchBarDelegate + +extension FAQViewController: UISearchBarDelegate { + func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + self.searchText = searchText + expandedItems.removeAll() + reloadFAQ() + } + + func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { + searchBar.resignFirstResponder() + } +} + +// MARK: - FAQItemRow (Accordion Row) + +private final class FAQItemRow: UIView { + + private let questionLabel = UILabel() + private let answerLabel = UILabel() + private let chevron = UIImageView() + + var onToggle: (() -> Void)? + + init(item: FAQItem, isExpanded: Bool, onToggle: @escaping () -> Void) { + self.onToggle = onToggle + super.init(frame: .zero) + setup(item: item, isExpanded: isExpanded) + } + + required init?(coder: NSCoder) { fatalError() } + + private func setup(item: FAQItem, isExpanded: Bool) { + // Chevron icon + let chevronConfig = UIImage.SymbolConfiguration(pointSize: 12, weight: .semibold) + chevron.image = UIImage(systemName: "chevron.down", withConfiguration: chevronConfig) + chevron.tintColor = Theme.Color.textSecondary + chevron.contentMode = .scaleAspectFit + chevron.transform = isExpanded + ? CGAffineTransform(rotationAngle: .pi) + : .identity + chevron.translatesAutoresizingMaskIntoConstraints = false + + // Question + questionLabel.text = item.question + questionLabel.font = Theme.Font.body(weight: .medium) + questionLabel.textColor = Theme.Color.textPrimary + questionLabel.numberOfLines = 0 + + // Answer + answerLabel.text = item.answer + answerLabel.font = Theme.Font.caption(weight: .regular) + answerLabel.textColor = Theme.Color.textSecondary + answerLabel.numberOfLines = 0 + answerLabel.isHidden = !isExpanded + + let questionStack = UIStackView(arrangedSubviews: [questionLabel, chevron]) + questionStack.axis = .horizontal + questionStack.spacing = 12 + questionStack.alignment = .top + questionStack.translatesAutoresizingMaskIntoConstraints = false + + let mainStack = UIStackView(arrangedSubviews: [questionStack, answerLabel]) + mainStack.axis = .vertical + mainStack.spacing = 10 + mainStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(mainStack) + + NSLayoutConstraint.activate([ + chevron.widthAnchor.constraint(equalToConstant: 16), + chevron.heightAnchor.constraint(equalToConstant: 16), + + mainStack.topAnchor.constraint(equalTo: topAnchor, constant: 16), + mainStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + mainStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + mainStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), + ]) + + let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped)) + addGestureRecognizer(tap) + isUserInteractionEnabled = true + } + + @objc private func rowTapped() { onToggle?() } +} diff --git a/Emoney Info/HistoryView.swift b/Emoney Info/HistoryView.swift new file mode 100644 index 0000000..d70b4a6 --- /dev/null +++ b/Emoney Info/HistoryView.swift @@ -0,0 +1,688 @@ +import SwiftUI +import GoogleMobileAds + +// MARK: - HistoryView + +struct HistoryView: View { + + let transactions: [RiwayatCard] + var onBack: (() -> Void)? + var onExportPDF: (([RiwayatCard]) -> Void)? + + @State private var selectedFilter: FilterOption = .allTime + @State private var adLoaded: Bool = false + + // TODO: Replace with real ad unit ID before release + private let adUnitID = "ca-app-pub-3389368171983845/5404549734" + + // Pre-compute banner size so the UIView always gets a valid height + private let bannerAdSize: GADAdSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth( + UIScreen.main.bounds.width - 48 + ) + private var bannerAdHeight: CGFloat { CGFloat(bannerAdSize.size.height) } + + enum FilterOption: CaseIterable { + case allTime, today, thisWeek, thisMonth + var label: String { + switch self { + case .allTime: return L10n.filterAllTime + case .today: return L10n.filterToday + case .thisWeek: return L10n.filterThisWeek + case .thisMonth: return L10n.filterThisMonth + } + } + } + + private var filtered: [RiwayatCard] { + guard selectedFilter != .allTime else { return transactions } + var calendar = Calendar.current + calendar.firstWeekday = 2 // Senin sebagai awal minggu + let now = Date() + return transactions.filter { rw in + guard let date = rw.getTransationTime() else { return false } + switch selectedFilter { + case .today: + return calendar.isDateInToday(date) + case .thisWeek: + guard let interval = calendar.dateInterval(of: .weekOfYear, for: now) else { return false } + return interval.contains(date) + case .thisMonth: + guard let interval = calendar.dateInterval(of: .month, for: now) else { return false } + return interval.contains(date) + case .allTime: + return true + } + } + } + + var body: some View { + ZStack(alignment: .bottom) { + Color(Theme.Color.background).ignoresSafeArea() + + ScrollView(showsIndicators: false) { + LazyVStack(spacing: 0) { + headerView + .padding(.horizontal, 24) + .padding(.top, 16) + + // Ad banner — inner frame always has proper height so Google can load it; + // outer frame collapses to 0 + clipped to hide it until loaded. + BannerAdView( + adUnitID: adUnitID, + adSize: bannerAdSize, + onAdLoaded: { adLoaded = true }, + onAdFailed: { adLoaded = false } + ) + .frame(height: bannerAdHeight) // proper height — always valid for GAD + .clipped() + .frame(height: adLoaded ? bannerAdHeight : 0) // collapse space when hidden + .padding(.horizontal, 24) + .padding(.top, adLoaded ? 16 : 0) + .opacity(adLoaded ? 1 : 0) + .animation(.easeInOut(duration: 0.3), value: adLoaded) + + sectionHeader + .padding(.horizontal, 24) + .padding(.top, adLoaded ? 20 : 28) + .padding(.bottom, 12) + .animation(.easeInOut(duration: 0.3), value: adLoaded) + + transactionList + + Spacer().frame(height: 100) + } + } + + exportButton + .padding(.horizontal, 24) + .padding(.bottom, 90) + } + } + + // MARK: - Header + + private var headerView: some View { + HStack { + Button(action: { onBack?() }) { + Image(systemName: "arrow.left") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(Color(Theme.Color.textPrimary)) + .frame(width: 36, height: 36) + .background(Color(Theme.Color.card)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.06), radius: 6, x: 0, y: 2) + } + + Spacer() + + Text(L10n.historyTitle) + .font(.system(size: Theme.FontSize.subtitle, weight: .bold)) + .foregroundColor(Color(Theme.Color.textPrimary)) + + Spacer() + + // Placeholder to balance the HStack (same width as back button) + Color.clear.frame(width: 36, height: 36) + } + } + + // MARK: - Section Header + + private var sectionHeader: some View { + HStack { + Text(L10n.recentActivity) + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + .foregroundColor(Color(Theme.Color.textSecondary)) + .kerning(1.2) + + Spacer() + + Menu { + ForEach(FilterOption.allCases, id: \.self) { option in + Button(option.label) { selectedFilter = option } + } + } label: { + HStack(spacing: 4) { + Text(selectedFilter.label) + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + } + .foregroundColor(Color(Theme.Color.secondary)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(Theme.Color.primary).opacity(0.15)) + .clipShape(Capsule()) + } + } + } + + // MARK: - Transaction List + + private var transactionList: some View { + LazyVStack(spacing: 10) { + if filtered.isEmpty { + emptyState + } else { + ForEach(Array(filtered.enumerated()), id: \.offset) { _, rw in + TransactionRow(riwayat: rw) + .padding(.horizontal, 24) + } + } + } + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "tray") + .font(.system(size: 40)) + .foregroundColor(Color(Theme.Color.textSecondary)) + Text(L10n.noTransactionsFound) + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundColor(Color(Theme.Color.textSecondary)) + } + .frame(maxWidth: .infinity) + .padding(.top, 48) + } + + // MARK: - Export Button + + private var exportButton: some View { + let hasData = !filtered.isEmpty + return Button(action: { onExportPDF?(filtered) }) { + HStack(spacing: 8) { + Image(systemName: "arrow.down.doc.fill") + .font(.system(size: 15, weight: .semibold)) + Text(L10n.exportPDF) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + } + .foregroundColor(hasData ? Color(Theme.Color.secondary) : Color(Theme.Color.textSecondary)) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color(Theme.Color.card)) + .overlay( + RoundedRectangle(cornerRadius: 14) + .stroke(hasData ? Color(Theme.Color.primary) : Color(Theme.Color.textSecondary).opacity(0.3), lineWidth: 1.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 14)) + .shadow(color: .black.opacity(hasData ? 0.06 : 0), radius: 8, x: 0, y: 2) + } + .disabled(!hasData) + .animation(.easeInOut(duration: 0.2), value: hasData) + } +} + +// MARK: - BannerAdView (UIViewRepresentable) + +private struct BannerAdView: UIViewRepresentable { + + let adUnitID: String + let adSize: GADAdSize + var onAdLoaded: (() -> Void)? + var onAdFailed: (() -> Void)? + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeUIView(context: Context) -> GADBannerView { + let banner = GADBannerView(adSize: adSize) + banner.adUnitID = adUnitID + banner.delegate = context.coordinator + banner.rootViewController = context.coordinator.findRootViewController() + banner.load(GADRequest()) + return banner + } + + func updateUIView(_ uiView: GADBannerView, context: Context) {} + + // MARK: Coordinator + + final class Coordinator: NSObject, GADBannerViewDelegate { + let parent: BannerAdView + init(_ parent: BannerAdView) { self.parent = parent } + + func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + DispatchQueue.main.async { self.parent.onAdLoaded?() } + } + + func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + DispatchQueue.main.async { self.parent.onAdFailed?() } + } + + func findRootViewController() -> UIViewController? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap { $0.windows } + .first { $0.isKeyWindow }? + .rootViewController + } + } +} + +// MARK: - TransactionRow + +private struct TransactionRow: View { + + let riwayat: RiwayatCard + + private var isCredit: Bool { riwayat.getProsesTipe() == 0 } + + private var place: String { + riwayat.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + } + + private var hasPlace: Bool { !place.isEmpty } + + private var title: String { + hasPlace ? place : (isCredit ? L10n.topup : L10n.payment) + } + + private var amount: String { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "id_ID") + formatter.numberStyle = .currency + formatter.currencyCode = "IDR" + formatter.currencySymbol = "Rp " + formatter.minimumFractionDigits = 0 + formatter.maximumFractionDigits = 0 + let value = riwayat.getAmount() + let formatted = formatter.string(for: value) ?? "Rp \(value)" + return isCredit ? "+\(formatted)" : "-\(formatted)" + } + + private var dateText: String { + guard let date = riwayat.getTransationTime() else { return "–" } + let fmt = DateFormatter() + fmt.dateFormat = "MMM d, yyyy · HH:mm" + return fmt.string(from: date) + } + + private var statusText: String { isCredit ? L10n.topup : L10n.payment } + + private var iconName: String { + let t = title.lowercased() + if t.contains("top") || t.contains("deposit") || t.contains("bank") { return "arrow.down.circle.fill" } + if t.contains("coffee") || t.contains("cafe") { return "cup.and.saucer.fill" } + if t.contains("transit") || t.contains("metro") || t.contains("mrt") || t.contains("krl") { return "tram.fill" } + if t.contains("cloud") || t.contains("storage") { return "cloud.fill" } + if t.contains("bistro") || t.contains("resto") || t.contains("food") { return "fork.knife" } + if t.contains("market") || t.contains("grocery") { return "cart.fill" } + return isCredit ? "arrow.down.circle.fill" : "creditcard.fill" + } + + private var amountColor: Color { + isCredit ? Color(Theme.Color.success) : Color(UIColor.systemRed) + } + + var body: some View { + HStack(spacing: 14) { + // Icon — grey background, green icon + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color(Theme.Color.background)) + .frame(width: 44, height: 44) + Image(systemName: iconName) + .font(.system(size: 18, weight: .medium)) + .foregroundColor(Color(Theme.Color.secondary)) + } + + // Title + Date + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundColor(Color(Theme.Color.textPrimary)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + Text(dateText) + .font(.system(size: Theme.FontSize.caption)) + .foregroundColor(Color(Theme.Color.textSecondary)) + } + + Spacer() + + // Amount + Status badge (only when place is available) + VStack(alignment: .trailing, spacing: 4) { + Text(amount) + .font(.system(size: Theme.FontSize.body, weight: .bold)) + .foregroundColor(amountColor) + .lineLimit(1) + + if hasPlace { + let statusColor = isCredit ? Color(Theme.Color.success) : Color(UIColor.systemOrange) + Text(isCredit ? L10n.topup : L10n.payment) + .font(.system(size: 9, weight: .bold)) + .foregroundColor(statusColor) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(statusColor.opacity(0.12)) + .clipShape(Capsule()) + } + } + } + .padding(16) + .background(Color(Theme.Color.card)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .shadow(color: .black.opacity(0.05), radius: 6, x: 0, y: 2) + } +} + +// MARK: - UIKit Bridge + +import UIKit + +final class HistoryHostingController: UIViewController { + + var riwayatList: [RiwayatCard] = [] + var cardLabel: String = "" + var balanceText: String = "" + var cardNumber: String = "" + + // MARK: - Interstitial Ad + + private let interstitialAdUnitID = "ca-app-pub-3389368171983845/1759422963" + private var interstitial: GADInterstitialAd? + private var pendingExportList: [RiwayatCard] = [] + + override func viewDidLoad() { + super.viewDidLoad() + + var historyView = HistoryView(transactions: riwayatList) + historyView.onBack = { [weak self] in self?.dismiss(animated: true) } + historyView.onExportPDF = { [weak self] filteredList in self?.handleExportPDFTapped(filteredList) } + + let hosting = UIHostingController(rootView: historyView) + addChild(hosting) + hosting.view.frame = view.bounds + hosting.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.addSubview(hosting.view) + hosting.didMove(toParent: self) + + loadInterstitial() + } + + private func loadInterstitial() { + GADInterstitialAd.load( + withAdUnitID: interstitialAdUnitID, + request: GADRequest() + ) { [weak self] ad, _ in + // Store ad if loaded; ignore error — fallback to direct export + self?.interstitial = ad + self?.interstitial?.fullScreenContentDelegate = self + } + } + + // MARK: - Export trigger + + private func handleExportPDFTapped(_ filteredList: [RiwayatCard]) { + pendingExportList = filteredList + if let interstitial { + interstitial.present(fromRootViewController: self) + } else { + exportPDF() + } + } + + // MARK: - PDF Export + + private func exportPDF() { + let data = buildPDF(from: pendingExportList) + let fileName = "emoney_history_\(Date().timeIntervalSince1970).pdf" + let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) + do { + try data.write(to: url) + } catch { + return + } + let activityVC = UIActivityViewController(activityItems: [url], applicationActivities: nil) + activityVC.popoverPresentationController?.sourceView = view + present(activityVC, animated: true) + } + + private func buildPDF(from list: [RiwayatCard]) -> Data { + let pageW: CGFloat = 595.2 + let pageH: CGFloat = 841.8 + let margin: CGFloat = 40 + let colW = pageW - margin * 2 + + let renderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: pageW, height: pageH)) + + return renderer.pdfData { ctx in + ctx.beginPage() + var y: CGFloat = margin + + // ── Formatters ──────────────────────────────────────────── + let dateFmt = DateFormatter() + dateFmt.locale = Locale(identifier: "id_ID") + dateFmt.dateFormat = "dd MMMM yyyy, HH:mm" + + let numFmt = NumberFormatter() + numFmt.locale = Locale(identifier: "id_ID") + numFmt.numberStyle = .currency + numFmt.currencySymbol = "Rp " + numFmt.groupingSeparator = "." + numFmt.decimalSeparator = "," + numFmt.maximumFractionDigits = 0 + + // ── Styles ──────────────────────────────────────────────── + let bodyFont = UIFont.systemFont(ofSize: 10) + let boldFont = UIFont.boldSystemFont(ofSize: 10) + let titleFont = UIFont.boldSystemFont(ofSize: 13) + let headerFont = UIFont.boldSystemFont(ofSize: 10) + let smallFont = UIFont.systemFont(ofSize: 9) + let green = UIColor(red: 0.36, green: 0.49, blue: 0.48, alpha: 1) + + func attrs(_ font: UIFont, + _ color: UIColor = .black, + align: NSTextAlignment = .left) -> [NSAttributedString.Key: Any] { + let para = NSMutableParagraphStyle() + para.alignment = align + return [.font: font, .foregroundColor: color, .paragraphStyle: para] + } + + func drawText(_ text: String, x: CGFloat, y: CGFloat, + width: CGFloat, font: UIFont, + color: UIColor = .black, + align: NSTextAlignment = .left) { + let rect = CGRect(x: x, y: y, width: width, height: 200) + (text as NSString).draw(in: rect, withAttributes: attrs(font, color, align: align)) + } + + func textHeight(_ text: String, font: UIFont, width: CGFloat) -> CGFloat { + let rect = CGRect(x: 0, y: 0, width: width, height: .greatestFiniteMagnitude) + let bounding = (text as NSString).boundingRect( + with: rect.size, + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font], context: nil) + return ceil(bounding.height) + } + + // ── Header image ─────────────────────────────────────────── + if let headerImg = UIImage(named: "header") { + let imgH: CGFloat = 30 + let imgW = headerImg.size.width * (imgH / headerImg.size.height) + let imgRect = CGRect(x: margin, y: y, width: imgW, height: imgH) + headerImg.draw(in: imgRect) + y += imgH + 12 + } else { + // Fallback: draw green square with "E" + let iconSize: CGFloat = 48 + green.setFill() + UIBezierPath(roundedRect: CGRect(x: margin, y: y, + width: iconSize, height: iconSize), + cornerRadius: 10).fill() + drawText("E", x: margin, y: y + 14, width: iconSize, + font: titleFont, color: .white, align: .center) + y += iconSize + 12 + } + + // ── Subtitle ────────────────────────────────────────────── + let subtitle = "Dibuat oleh aplikasi emoney Info: cek saldo dan riwayat uang elektronik." + drawText(subtitle, x: margin, y: y, width: colW, font: bodyFont) + y += textHeight(subtitle, font: bodyFont, width: colW) + 16 + + // ── Separator line ──────────────────────────────────────── + UIColor.lightGray.setStroke() + let linePath = UIBezierPath() + linePath.move(to: CGPoint(x: margin, y: y)) + linePath.addLine(to: CGPoint(x: pageW - margin, y: y)) + linePath.lineWidth = 0.5 + linePath.stroke() + y += 10 + + // ── Card Info (left-aligned, 3 fixed columns) ───────────── + // Label | : | Value + let labelW: CGFloat = 90 + let colonX: CGFloat = margin + labelW + let valueX: CGFloat = colonX + 14 + + func drawInfoRow(label: String, value: String) { + drawText(label, x: margin, y: y, width: labelW, font: boldFont) + drawText(":", x: colonX, y: y, width: 14, font: bodyFont) + drawText(value, x: valueX, y: y, width: colW - labelW - 14, font: bodyFont) + } + + drawInfoRow(label: "Kartu", value: cardLabel) + y += 16 + drawInfoRow(label: "Saldo", value: balanceText) + y += 16 + drawInfoRow(label: "Nomor Kartu", value: cardNumber.formatCardNumber()) + y += 24 + + // ── Separator ───────────────────────────────────────────── + UIColor.lightGray.setStroke() + let line2 = UIBezierPath() + line2.move(to: CGPoint(x: margin, y: y)) + line2.addLine(to: CGPoint(x: pageW - margin, y: y)) + line2.lineWidth = 0.5 + line2.stroke() + y += 10 + + // ── Table Header ────────────────────────────────────────── + // Cek apakah ada data lokasi di seluruh list + let hasLocation = list.contains { + let loc = $0.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !loc.isEmpty + } + + let dateColW: CGFloat = 130 + let typeColW: CGFloat = 70 + let locColW: CGFloat = hasLocation ? 120 : 0 + let amtColX = margin + dateColW + typeColW + locColW + + drawText("Tanggal", x: margin, y: y, width: dateColW, font: headerFont, color: green) + drawText("Transaksi", x: margin + dateColW, y: y, width: typeColW, font: headerFont, color: green) + if hasLocation { + drawText("Lokasi", x: margin + dateColW + typeColW, y: y, width: locColW, font: headerFont, color: green) + } + drawText("Jumlah", x: amtColX, y: y, + width: colW - dateColW - typeColW - locColW, font: headerFont, color: green, align: .right) + y += 14 + + // Header underline + green.withAlphaComponent(0.4).setStroke() + let headerLine = UIBezierPath() + headerLine.move(to: CGPoint(x: margin, y: y)) + headerLine.addLine(to: CGPoint(x: pageW - margin, y: y)) + headerLine.lineWidth = 0.5 + headerLine.stroke() + y += 6 + + // ── Table Rows ──────────────────────────────────────────── + for (i, rw) in list.enumerated() { + let location = rw.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let rowHeight: CGFloat = 16 + + // New page if near bottom + if y > pageH - margin - rowHeight { + ctx.beginPage() + y = margin + } + + // Alternating row background + if i % 2 == 0 { + UIColor(white: 0.97, alpha: 1).setFill() + UIBezierPath(rect: CGRect(x: margin - 4, y: y - 2, + width: colW + 8, height: rowHeight)).fill() + } + + let dateStr = rw.getTransationTime().map { dateFmt.string(from: $0) } ?? "–" + let typeStr = rw.getProsesTipe() == 0 ? "Top up" : "Payment" + let amtStr = numFmt.string(for: rw.getAmount()) ?? "Rp 0" + let amtColor: UIColor = rw.getProsesTipe() == 0 + ? UIColor(red: 0.13, green: 0.55, blue: 0.13, alpha: 1) + : .black + + drawText(dateStr, x: margin, y: y, width: dateColW, font: smallFont) + drawText(typeStr, x: margin + dateColW, y: y, width: typeColW, font: smallFont) + if hasLocation { + drawText(location.isEmpty ? "–" : location, + x: margin + dateColW + typeColW, y: y, width: locColW, font: smallFont) + } + drawText(amtStr, x: amtColX, y: y, + width: colW - dateColW - typeColW - locColW, font: smallFont, + color: amtColor, align: .right) + y += rowHeight + } + + // ── Footer ──────────────────────────────────────────────── + y += 10 + UIColor.lightGray.setStroke() + let footerLine = UIBezierPath() + footerLine.move(to: CGPoint(x: margin, y: y)) + footerLine.addLine(to: CGPoint(x: pageW - margin, y: y)) + footerLine.lineWidth = 0.5 + footerLine.stroke() + y += 6 + drawText("emoneyInfo © \(Calendar.current.component(.year, from: Date()))", + x: margin, y: y, width: colW, font: smallFont, + color: .lightGray, align: .center) + } + } +} + +// MARK: - GADFullScreenContentDelegate + +extension HistoryHostingController: GADFullScreenContentDelegate { + + // Called when the interstitial is dismissed — proceed with export + func adDidDismissFullScreenContent(_ ad: GADFullScreenPresentingAd) { + interstitial = nil + loadInterstitial() // pre-load for the next export attempt + exportPDF() + } + + // Called when the interstitial fails to present — fall back to direct export + func ad(_ ad: GADFullScreenPresentingAd, didFailToPresentFullScreenContentWithError error: Error) { + interstitial = nil + exportPDF() + } +} + +// MARK: - Preview + +#if DEBUG +struct HistoryView_Previews: PreviewProvider { + static var previews: some View { + let items: [RiwayatCard] = { + let data: [(String, Int, Int, String)] = [ + ("Whole Foods Market", 142500, 1, "2023-10-19 08:43:00"), + ("Blue Bottle Coffee", 6500, 1, "2023-10-19 09:15:00"), + ("Metropolitan Transit", 2750, 1, "2023-10-20 07:12:00"), + ("Deposit from Bank", 500000, 0, "2023-10-21 11:30:00"), + ("Cloud Storage Pro", 9990, 1, "2023-10-20 09:00:00"), + ("The GreenBistro", 34200, 1, "2023-10-19 20:15:00"), + ] + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd HH:mm:ss" + return data.map { (title, amount, tipe, dateStr) in + let rw = RiwayatCard() + rw.setTitle(title) + rw.setAmount(amount) + rw.setProsesTipe(tipe) + if let date = fmt.date(from: dateStr) { rw.setTransactionTime(date) } + return rw + } + }() + HistoryView(transactions: items) + } +} +#endif diff --git a/Emoney Info/HomeView.swift b/Emoney Info/HomeView.swift new file mode 100644 index 0000000..fa51350 --- /dev/null +++ b/Emoney Info/HomeView.swift @@ -0,0 +1,670 @@ +import UIKit +import GoogleMobileAds +import Toast_Swift + +final class HomeViewController: UIViewController { + + // MARK: - Public state + + var balanceText: String = "Rp 0" { + didSet { balanceLabel.text = balanceText } + } + var cardTypeText: String = "E-Money Card" { + didSet { cardTypeLabel.text = cardTypeText } + } + var lastTransaction: LastTransactionItem? { + didSet { configureLastTransaction() } + } + + // MARK: - Callbacks + + var onScanTapped: (() -> Void)? + var onViewHistoryTapped: (() -> Void)? + var onSettingsTapped: (() -> Void)? + + // Diisi setelah scan berhasil, dipakai oleh SceneDelegate untuk history + var latestRiwayatList: [RiwayatCard] = [] { + didSet { updateViewHistoryButtonState() } + } + var latestCardNumber: String = "" // raw card number for PDF export + + // MARK: - UI Elements + + private let scrollView = UIScrollView() + private let contentView = UIView() + + // Header + private let appNameLabel = UILabel() + private let settingsButton = UIButton(type: .system) + + // Balance + private let availableLabel = UILabel() + private let balanceLabel = UILabel() + + // Card + private let cardView = UIView() + private let cardGradient = CAGradientLayer() + private let nfcIconView = UIImageView() + private let tapCardLabel = UILabel() + private let cardNumberLabel = UILabel() + private let copyButton = UIButton(type: .system) + private let cardTypeLabel = UILabel() + + // Raw card number stored after scan for re-formatting on setting change + private var rawCardNumber: String = "" + + // Instruction + private let tapHereLabel = UILabel() + private let tapHintLabel = UILabel() + + // Scan button + private let scanButton = UIButton(type: .system) + + // Ads + private let promoCard = UIView() + private var bannerView = GADBannerView() + + // Dynamic layout: toggle based on ad load state + private var lastTxTopNoAd: NSLayoutConstraint! + private var lastTxTopWithAd: NSLayoutConstraint! + + // Last transaction + private let lastTxHeader = UILabel() + private let lastTxCard = UIView() + private let txIconView = UIView() + private let txIconImage = UIImageView() + private let txTitleLabel = UILabel() + private let txDateLabel = UILabel() + private let txAmountLabel = UILabel() + private let txStatusLabel = UILabel() + + // Footer link + private let viewHistoryButton = UIButton(type: .system) + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Theme.Color.background + setupScrollView() + setupHeader() + setupBalanceSection() + setupCard() + setupInstruction() + setupScanButton() + setupPromo() + setupLastTransaction() + setupViewHistoryButton() + setupConstraints() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + NotificationCenter.default.addObserver( + self, + selector: #selector(onSettingChanged), + name: Notification.Name("refreshScreen"), + object: nil + ) + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + NotificationCenter.default.removeObserver(self, name: Notification.Name("refreshScreen"), object: nil) + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + cardGradient.frame = cardView.bounds + } + + // MARK: - Setup + + private func setupScrollView() { + scrollView.showsVerticalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + scrollView.addSubview(contentView) + } + + private func setupHeader() { + appNameLabel.text = "Emoney Info" + appNameLabel.font = Theme.Font.title(weight: .bold) + appNameLabel.textColor = Theme.Color.textPrimary + + let gearImage = UIImage(systemName: "gearshape", withConfiguration: + UIImage.SymbolConfiguration(pointSize: 20, weight: .medium)) + settingsButton.setImage(gearImage, for: .normal) + settingsButton.tintColor = Theme.Color.textPrimary + settingsButton.addTarget(self, action: #selector(settingsTapped), for: .touchUpInside) + + [appNameLabel, settingsButton].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + } + + private func setupBalanceSection() { + availableLabel.text = L10n.availableBalance + availableLabel.font = Theme.Font.caption(weight: .semibold) + availableLabel.textColor = Theme.Color.textSecondary + availableLabel.letterSpacing(1.5) + + balanceLabel.text = "Rp 0" + balanceLabel.font = .systemFont(ofSize: 36, weight: .bold) + balanceLabel.textColor = Theme.Color.textPrimary + + [availableLabel, balanceLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + } + + private func setupCard() { + cardView.layer.cornerRadius = 24 + cardView.clipsToBounds = true + + cardGradient.colors = [ + UIColor(red: 0.48, green: 0.83, blue: 0.82, alpha: 1).cgColor, + UIColor(red: 0.36, green: 0.49, blue: 0.48, alpha: 1).cgColor + ] + cardGradient.startPoint = CGPoint(x: 0, y: 0) + cardGradient.endPoint = CGPoint(x: 1, y: 1) + cardView.layer.insertSublayer(cardGradient, at: 0) + + let nfcConfig = UIImage.SymbolConfiguration(pointSize: 48, weight: .medium) + nfcIconView.image = UIImage(systemName: "wave.3.right.circle.fill", withConfiguration: nfcConfig) + nfcIconView.tintColor = .white.withAlphaComponent(0.9) + nfcIconView.contentMode = .scaleAspectFit + + tapCardLabel.text = L10n.cardTapInstruction + tapCardLabel.font = Theme.Font.caption(weight: .bold) + tapCardLabel.textColor = .white.withAlphaComponent(0.85) + tapCardLabel.letterSpacing(2) + + cardTypeLabel.text = cardTypeText + cardTypeLabel.font = Theme.Font.caption(weight: .regular) + cardTypeLabel.textColor = .white.withAlphaComponent(0.7) + + cardNumberLabel.font = .systemFont(ofSize: 11, weight: .medium) + cardNumberLabel.textColor = .white.withAlphaComponent(0.85) + cardNumberLabel.isHidden = true + + let copyConfig = UIImage.SymbolConfiguration(pointSize: 11, weight: .medium) + copyButton.setImage(UIImage(systemName: "doc.on.doc", withConfiguration: copyConfig), for: .normal) + copyButton.tintColor = .white.withAlphaComponent(0.75) + copyButton.isHidden = true + copyButton.addTarget(self, action: #selector(copyCardNumber), for: .touchUpInside) + + [cardView, nfcIconView, tapCardLabel, cardNumberLabel, copyButton, cardTypeLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + contentView.addSubview(cardView) + cardView.addSubview(nfcIconView) + cardView.addSubview(tapCardLabel) + cardView.addSubview(cardNumberLabel) + cardView.addSubview(copyButton) + cardView.addSubview(cardTypeLabel) + } + + private func setupInstruction() { + tapHereLabel.isHidden = true + + tapHintLabel.text = L10n.tapCardHint + tapHintLabel.font = Theme.Font.body(weight: .regular) + tapHintLabel.textColor = Theme.Color.textSecondary + tapHintLabel.textAlignment = .center + tapHintLabel.numberOfLines = 2 + + [tapHereLabel, tapHintLabel].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview($0) + } + } + + private func setupScanButton() { + scanButton.setTitle(L10n.checkBalance, for: .normal) + scanButton.titleLabel?.font = Theme.Font.body(weight: .semibold) + scanButton.setTitleColor(.white, for: .normal) + scanButton.backgroundColor = Theme.Color.secondary + scanButton.layer.cornerRadius = 14 + scanButton.addTarget(self, action: #selector(scanTapped), for: .touchUpInside) + scanButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(scanButton) + } + + private func setupPromo() { + // TODO: Replace ad unit ID before release + let adUnitID = "ca-app-pub-3389368171983845/5892672309" + + let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth( + UIScreen.main.bounds.width - 48 // matches horizontal padding 24pt each side + ) + bannerView = GADBannerView(adSize: adSize) + bannerView.adUnitID = adUnitID + bannerView.rootViewController = self + bannerView.delegate = self + bannerView.translatesAutoresizingMaskIntoConstraints = false + + promoCard.backgroundColor = .clear + promoCard.translatesAutoresizingMaskIntoConstraints = false + promoCard.clipsToBounds = true + promoCard.isHidden = true // shown only when ad loads successfully + promoCard.addSubview(bannerView) + contentView.addSubview(promoCard) + + NSLayoutConstraint.activate([ + bannerView.topAnchor.constraint(equalTo: promoCard.topAnchor), + bannerView.leadingAnchor.constraint(equalTo: promoCard.leadingAnchor), + bannerView.trailingAnchor.constraint(equalTo: promoCard.trailingAnchor), + bannerView.bottomAnchor.constraint(equalTo: promoCard.bottomAnchor), + bannerView.heightAnchor.constraint(equalToConstant: CGFloat(adSize.size.height)) + ]) + + // Ad is loaded externally via loadBannerAd() after GADMobileAds SDK has started. + } + + func loadBannerAd() { + bannerView.load(GADRequest()) + } + + private func setupLastTransaction() { + lastTxHeader.text = L10n.lastTransaction + lastTxHeader.font = Theme.Font.subtitle(weight: .bold) + lastTxHeader.textColor = Theme.Color.textPrimary + lastTxHeader.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(lastTxHeader) + + lastTxCard.backgroundColor = Theme.Color.card + lastTxCard.layer.cornerRadius = 16 + lastTxCard.layer.shadowColor = UIColor.black.cgColor + lastTxCard.layer.shadowOpacity = 0.06 + lastTxCard.layer.shadowOffset = CGSize(width: 0, height: 2) + lastTxCard.layer.shadowRadius = 8 + lastTxCard.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(lastTxCard) + + // Transaction icon + txIconView.backgroundColor = Theme.Color.background + txIconView.layer.cornerRadius = 12 + + txIconImage.tintColor = Theme.Color.secondary + txIconImage.contentMode = .scaleAspectFit + txIconImage.translatesAutoresizingMaskIntoConstraints = false + txIconView.addSubview(txIconImage) + + txTitleLabel.font = Theme.Font.body(weight: .semibold) + txTitleLabel.textColor = Theme.Color.textPrimary + + txDateLabel.font = Theme.Font.caption(weight: .regular) + txDateLabel.textColor = Theme.Color.textSecondary + + txAmountLabel.font = Theme.Font.body(weight: .bold) + txAmountLabel.textColor = UIColor.systemRed + txAmountLabel.textAlignment = .right + + txStatusLabel.font = Theme.Font.caption(weight: .semibold) + txStatusLabel.textColor = Theme.Color.success + txStatusLabel.textAlignment = .right + + let leftStack = UIStackView(arrangedSubviews: [txTitleLabel, txDateLabel]) + leftStack.axis = .vertical + leftStack.spacing = 2 + + let rightStack = UIStackView(arrangedSubviews: [txAmountLabel, txStatusLabel]) + rightStack.axis = .vertical + rightStack.spacing = 2 + rightStack.alignment = .trailing + + let hStack = UIStackView(arrangedSubviews: [txIconView, leftStack, rightStack]) + hStack.axis = .horizontal + hStack.spacing = 12 + hStack.alignment = .center + + [txIconView, txIconImage, hStack, leftStack, rightStack].forEach { + $0.translatesAutoresizingMaskIntoConstraints = false + } + lastTxCard.addSubview(hStack) + + NSLayoutConstraint.activate([ + txIconImage.centerXAnchor.constraint(equalTo: txIconView.centerXAnchor), + txIconImage.centerYAnchor.constraint(equalTo: txIconView.centerYAnchor), + txIconImage.widthAnchor.constraint(equalToConstant: 20), + txIconImage.heightAnchor.constraint(equalToConstant: 20), + txIconView.widthAnchor.constraint(equalToConstant: 44), + txIconView.heightAnchor.constraint(equalToConstant: 44), + + hStack.topAnchor.constraint(equalTo: lastTxCard.topAnchor, constant: 16), + hStack.leadingAnchor.constraint(equalTo: lastTxCard.leadingAnchor, constant: 16), + hStack.trailingAnchor.constraint(equalTo: lastTxCard.trailingAnchor, constant: -16), + hStack.bottomAnchor.constraint(equalTo: lastTxCard.bottomAnchor, constant: -16), + + rightStack.widthAnchor.constraint(greaterThanOrEqualToConstant: 80) + ]) + + configureLastTransaction() + } + + private func setupViewHistoryButton() { + viewHistoryButton.setTitle("\(L10n.viewFullHistory) →", for: .normal) + viewHistoryButton.titleLabel?.font = Theme.Font.body(weight: .semibold) + viewHistoryButton.addTarget(self, action: #selector(viewHistoryTapped), for: .touchUpInside) + viewHistoryButton.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(viewHistoryButton) + updateViewHistoryButtonState() + } + + private func updateViewHistoryButtonState() { + let hasData = !latestRiwayatList.isEmpty + viewHistoryButton.isEnabled = hasData + viewHistoryButton.setTitleColor(hasData ? Theme.Color.secondary : Theme.Color.textSecondary, for: .normal) + viewHistoryButton.alpha = hasData ? 1.0 : 0.4 + } + + // MARK: - Constraints + + private func setupConstraints() { + NSLayoutConstraint.activate([ + + // ScrollView + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + // ContentView + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + + // Header + appNameLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + appNameLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + + settingsButton.centerYAnchor.constraint(equalTo: appNameLabel.centerYAnchor), + settingsButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + settingsButton.widthAnchor.constraint(equalToConstant: 32), + settingsButton.heightAnchor.constraint(equalToConstant: 32), + + // Balance + availableLabel.topAnchor.constraint(equalTo: appNameLabel.bottomAnchor, constant: 28), + availableLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + + balanceLabel.topAnchor.constraint(equalTo: availableLabel.bottomAnchor, constant: 4), + balanceLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + + // Card + cardView.topAnchor.constraint(equalTo: balanceLabel.bottomAnchor, constant: 20), + cardView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + cardView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + cardView.heightAnchor.constraint(equalToConstant: 160), + + nfcIconView.centerXAnchor.constraint(equalTo: cardView.centerXAnchor), + nfcIconView.centerYAnchor.constraint(equalTo: cardView.centerYAnchor, constant: -10), + nfcIconView.widthAnchor.constraint(equalToConstant: 60), + nfcIconView.heightAnchor.constraint(equalToConstant: 60), + + tapCardLabel.topAnchor.constraint(equalTo: nfcIconView.bottomAnchor, constant: 8), + tapCardLabel.centerXAnchor.constraint(equalTo: cardView.centerXAnchor), + + // card number + copy — bottom left + cardNumberLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -14), + cardNumberLabel.leadingAnchor.constraint(equalTo: cardView.leadingAnchor, constant: 16), + + copyButton.centerYAnchor.constraint(equalTo: cardNumberLabel.centerYAnchor), + copyButton.leadingAnchor.constraint(equalTo: cardNumberLabel.trailingAnchor, constant: 6), + copyButton.widthAnchor.constraint(equalToConstant: 20), + copyButton.heightAnchor.constraint(equalToConstant: 20), + + // card type — bottom right + cardTypeLabel.bottomAnchor.constraint(equalTo: cardView.bottomAnchor, constant: -14), + cardTypeLabel.trailingAnchor.constraint(equalTo: cardView.trailingAnchor, constant: -16), + + // Instruction + tapHereLabel.topAnchor.constraint(equalTo: cardView.bottomAnchor, constant: 20), + tapHereLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + + tapHintLabel.topAnchor.constraint(equalTo: tapHereLabel.bottomAnchor, constant: 6), + tapHintLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + tapHintLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 40), + tapHintLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -40), + + // Scan Button + scanButton.topAnchor.constraint(equalTo: tapHintLabel.bottomAnchor, constant: 20), + scanButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + scanButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + scanButton.heightAnchor.constraint(equalToConstant: 52), + + // Promo + promoCard.topAnchor.constraint(equalTo: scanButton.bottomAnchor, constant: 28), + promoCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + promoCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + // Last Transaction leading (top is toggled dynamically) + lastTxHeader.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + + lastTxCard.topAnchor.constraint(equalTo: lastTxHeader.bottomAnchor, constant: 12), + lastTxCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + lastTxCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + // View History + viewHistoryButton.topAnchor.constraint(equalTo: lastTxCard.bottomAnchor, constant: 12), + viewHistoryButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + viewHistoryButton.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -140) + ]) + + // Dynamic top constraints for Last Transaction — toggled by ad load state + lastTxTopNoAd = lastTxHeader.topAnchor.constraint(equalTo: scanButton.bottomAnchor, constant: 28) + lastTxTopWithAd = lastTxHeader.topAnchor.constraint(equalTo: promoCard.bottomAnchor, constant: 28) + lastTxTopNoAd.isActive = true // default: no ad + } + + // MARK: - Actions + + @objc private func scanTapped() { + latestRiwayatList = [] + latestCardNumber = "" + lastTransaction = nil + onScanTapped?() + } + + @objc private func viewHistoryTapped() { + onViewHistoryTapped?() + } + + @objc private func settingsTapped() { + onSettingsTapped?() + } + + @objc private func copyCardNumber() { + guard !rawCardNumber.isEmpty else { return } + UIPasteboard.general.string = rawCardNumber + + var style = ToastStyle() + style.backgroundColor = Theme.Color.primary + style.messageColor = .white + style.messageFont = Theme.Font.body(weight: .semibold) + style.cornerRadius = 12 + + view.makeToast(L10n.copiedToClipboard, duration: 2.0, position: .top, style: style) + } + + @objc private func onSettingChanged() { + guard !rawCardNumber.isEmpty else { return } + updateCardNumberDisplay() + } + + // MARK: - Card number display + + private func updateCardNumberDisplay() { + // "Show Card Number on Home" toggle maps to key "masked" + // isOn = true → show full number + // isOn = false → mask first 12 digits + let showFull = UserDefaults.standard.bool(forKey: "masked") + cardNumberLabel.text = showFull ? rawCardNumber.formatCardNumber() : rawCardNumber.maskFirst12() + cardNumberLabel.isHidden = false + copyButton.isHidden = false + } + + // MARK: - Data + + private func configureLastTransaction() { + guard let tx = lastTransaction else { + txTitleLabel.text = "–" + txDateLabel.text = "–" + txAmountLabel.text = "–" + txStatusLabel.text = "–" + txIconImage.image = UIImage(systemName: "creditcard.fill") + return + } + + let hasPlace = !tx.place.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + + // Title: gunakan place jika ada, fallback ke status topup/payment + txTitleLabel.text = hasPlace ? tx.place : (tx.isCredit ? L10n.topup : L10n.payment) + txTitleLabel.numberOfLines = 2 + + // Icon: tram hanya untuk KMT, selain itu pakai arrow.down/creditcard + let iconName: String + if tx.isKMT { + iconName = "tram.fill" + } else { + iconName = tx.isCredit ? "arrow.down.circle.fill" : "creditcard.fill" + } + txIconImage.image = UIImage(systemName: iconName) + + txDateLabel.text = tx.date + txAmountLabel.text = tx.amount + txStatusLabel.text = tx.status.uppercased() + txStatusLabel.textColor = tx.isCredit ? Theme.Color.success : UIColor.systemRed + txAmountLabel.textColor = tx.isCredit ? Theme.Color.success : UIColor.systemRed + if (!hasPlace){ + txStatusLabel.isHidden = true + } + } +} + +// MARK: - Data Model + +struct LastTransactionItem { + let place: String // locationName, bisa kosong + let date: String + let amount: String + let status: String + let isCredit: Bool + let isKMT: Bool +} + +// MARK: - GADBannerViewDelegate + +extension HomeViewController: GADBannerViewDelegate { + func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + lastTxTopNoAd.isActive = false + lastTxTopWithAd.isActive = true + UIView.animate(withDuration: 0.3) { + self.promoCard.isHidden = false + self.view.layoutIfNeeded() + } + } + + func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + lastTxTopWithAd.isActive = false + lastTxTopNoAd.isActive = true + UIView.animate(withDuration: 0.3) { + self.promoCard.isHidden = true + self.view.layoutIfNeeded() + } + } +} + +// MARK: - ApduCallback + +import CoreNFC + +extension HomeViewController: ApduCallback { + func felicaConnected(unifiedNfcApi: UnifiedNfcApi, tag:NFCFeliCaTag) { + debugLog("felica nih.") + unifiedNfcApi.checkFelicaCard(tag: tag) + } + + func connected(unifiedNfcApi: UnifiedNfcApi) { + debugLog("normal nih.") + unifiedNfcApi.checkCard() + } + + func complete(emoney: Emoney) { + DispatchQueue.main.async { + self.balanceText = Self.idrCurrencyFormatter.string(for: emoney.getBalance()) ?? "Rp 0" + self.cardTypeText = emoney.getCardLabel() + + self.rawCardNumber = emoney.getCardNumber() + self.latestCardNumber = emoney.getCardNumber() + self.updateCardNumberDisplay() + + if (emoney.isTampilRiwayat()){ + let riwayat = emoney.getRiwayatList() + if let first = riwayat.first { + let dateFmt = DateFormatter() + dateFmt.dateFormat = "dd MMM yyyy, HH:mm" + let dateStr = first.getTransationTime().map { dateFmt.string(from: $0) } ?? "–" + let isCredit = first.getProsesTipe() == 0 + let sign = isCredit ? "+" : "-" + let place = first.getLocationName()?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let isKMT = emoney.getCardLabel().uppercased().contains("KMT") + self.lastTransaction = LastTransactionItem( + place: place, + date: dateStr, + amount: "\(sign)\(Self.idrCurrencyFormatter.string(for: first.getAmount()) ?? "Rp 0")", + status: isCredit ? L10n.topup : L10n.payment, + isCredit: isCredit, + isKMT: isKMT + ) + } + + // Simpan list untuk history + self.latestRiwayatList = riwayat + } + } + } + + func failed(error: NSError) {} +} + +private extension HomeViewController { + static let idrCurrencyFormatter: NumberFormatter = { + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "id_ID") + formatter.numberStyle = .currency + formatter.currencyCode = "IDR" + formatter.currencySymbol = "Rp " + formatter.maximumFractionDigits = 0 + formatter.minimumFractionDigits = 0 + return formatter + }() +} + +// MARK: - UILabel letter spacing helper + +private extension UILabel { + func letterSpacing(_ spacing: CGFloat) { + guard let text = text else { return } + let attributed = NSMutableAttributedString(string: text) + attributed.addAttribute(.kern, value: spacing, range: NSRange(location: 0, length: text.count)) + attributedText = attributed + } +} + +// MARK: - String card masking + +private extension String { + /// Replaces the first 12 digits with * and shows the last 4, formatted as **** **** **** XXXX + func maskFirst12() -> String { + let digits = self.filter { $0.isNumber } + guard digits.count == 16 else { return self } + let last4 = String(digits.suffix(4)) + return "**** **** **** \(last4)" + } +} diff --git a/Emoney Info/Info.plist b/Emoney Info/Info.plist new file mode 100755 index 0000000..b6b3b31 --- /dev/null +++ b/Emoney Info/Info.plist @@ -0,0 +1,254 @@ + + + + + GADApplicationIdentifier + ca-app-pub-3389368171983845~2451242519 + SKAdNetworkItems + + + SKAdNetworkIdentifier + cstr6suwn9.skadnetwork + + + SKAdNetworkIdentifier + 4fzdc2evr5.skadnetwork + + + SKAdNetworkIdentifier + 2fnua5tdw4.skadnetwork + + + SKAdNetworkIdentifier + ydx93a7ass.skadnetwork + + + SKAdNetworkIdentifier + p78axxw29g.skadnetwork + + + SKAdNetworkIdentifier + v72qych5uu.skadnetwork + + + SKAdNetworkIdentifier + ludvb6z3bs.skadnetwork + + + SKAdNetworkIdentifier + cp8zw746q7.skadnetwork + + + SKAdNetworkIdentifier + 3sh42y64q3.skadnetwork + + + SKAdNetworkIdentifier + c6k4g5qg8m.skadnetwork + + + SKAdNetworkIdentifier + s39g8k73mm.skadnetwork + + + SKAdNetworkIdentifier + wg4vff78zm.skadnetwork + + + SKAdNetworkIdentifier + 3qy4746246.skadnetwork + + + SKAdNetworkIdentifier + f38h382jlk.skadnetwork + + + SKAdNetworkIdentifier + hs6bdukanm.skadnetwork + + + SKAdNetworkIdentifier + mlmmfzh3r3.skadnetwork + + + SKAdNetworkIdentifier + v4nxqhlyqp.skadnetwork + + + SKAdNetworkIdentifier + wzmmz9fp6w.skadnetwork + + + SKAdNetworkIdentifier + su67r6k2v3.skadnetwork + + + SKAdNetworkIdentifier + yclnxrl5pm.skadnetwork + + + SKAdNetworkIdentifier + t38b2kh725.skadnetwork + + + SKAdNetworkIdentifier + 7ug5zh24hu.skadnetwork + + + SKAdNetworkIdentifier + gta9lk7p23.skadnetwork + + + SKAdNetworkIdentifier + vutu7akeur.skadnetwork + + + SKAdNetworkIdentifier + y5ghdn5j9k.skadnetwork + + + SKAdNetworkIdentifier + v9wttpbfk9.skadnetwork + + + SKAdNetworkIdentifier + n38lu8286q.skadnetwork + + + SKAdNetworkIdentifier + 47vhws6wlr.skadnetwork + + + SKAdNetworkIdentifier + kbd757ywx3.skadnetwork + + + SKAdNetworkIdentifier + 9t245vhmpl.skadnetwork + + + SKAdNetworkIdentifier + a2p9lx4jpn.skadnetwork + + + SKAdNetworkIdentifier + 22mmun2rn5.skadnetwork + + + SKAdNetworkIdentifier + 44jx6755aq.skadnetwork + + + SKAdNetworkIdentifier + k674qkevps.skadnetwork + + + SKAdNetworkIdentifier + 4468km3ulz.skadnetwork + + + SKAdNetworkIdentifier + 2u9pt9hc89.skadnetwork + + + SKAdNetworkIdentifier + 8s468mfl3y.skadnetwork + + + SKAdNetworkIdentifier + klf5c3l5u5.skadnetwork + + + SKAdNetworkIdentifier + ppxm28t8ap.skadnetwork + + + SKAdNetworkIdentifier + kbmxgpxpgc.skadnetwork + + + SKAdNetworkIdentifier + uw77j35x4d.skadnetwork + + + SKAdNetworkIdentifier + 578prtvx9j.skadnetwork + + + SKAdNetworkIdentifier + 4dzt52r2t5.skadnetwork + + + SKAdNetworkIdentifier + tl55sbb4fm.skadnetwork + + + SKAdNetworkIdentifier + c3frkrj4fj.skadnetwork + + + SKAdNetworkIdentifier + e5fvkxwrpn.skadnetwork + + + SKAdNetworkIdentifier + 8c4e2ghe7u.skadnetwork + + + SKAdNetworkIdentifier + 3rd42ekr43.skadnetwork + + + SKAdNetworkIdentifier + 97r2b46745.skadnetwork + + + SKAdNetworkIdentifier + 3qcr597p9d.skadnetwork + + + UIAppFonts + + halter.ttf + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + com.apple.developer.nfc.readersession.felica.systemcodes + + 90b7 + 12FC + + com.apple.developer.nfc.readersession.formats + + NDEF + TAG + + com.apple.developer.nfc.readersession.iso7816.select-identifiers + + 0000000000000001 + A000424E49100001 + A0000005714E4A43 + D3600000030003 + A0000000180F0000018001 + D2760000850100 + D4100000030001 + D2760000850101 + D4100000030001 + + + diff --git a/Emoney Info/MainTabView.swift b/Emoney Info/MainTabView.swift new file mode 100644 index 0000000..2407d26 --- /dev/null +++ b/Emoney Info/MainTabView.swift @@ -0,0 +1,198 @@ +import UIKit + +// MARK: - MainTabBarController + +final class MainTabBarController: UITabBarController { + + private let customTabBar = CustomTabBar() + + override func viewDidLoad() { + super.viewDidLoad() + tabBar.isHidden = true + view.backgroundColor = Theme.Color.background + setupCustomTabBar() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + layoutCustomTabBar() + } + + private func setupCustomTabBar() { + customTabBar.translatesAutoresizingMaskIntoConstraints = false + customTabBar.onTabSelected = { [weak self] index in + self?.selectedIndex = index + } + view.addSubview(customTabBar) + + NSLayoutConstraint.activate([ + customTabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + customTabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + customTabBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12), + customTabBar.heightAnchor.constraint(equalToConstant: 64) + ]) + } + + private func layoutCustomTabBar() { + view.bringSubviewToFront(customTabBar) + } + + // Call this after setting viewControllers to sync selection + override var selectedIndex: Int { + didSet { customTabBar.setSelected(selectedIndex) } + } + + override func setTabBarHidden(_ hidden: Bool, animated: Bool = true) { + let duration = animated ? 0.25 : 0 + UIView.animate(withDuration: duration) { + self.customTabBar.alpha = hidden ? 0 : 1 + } + customTabBar.isUserInteractionEnabled = !hidden + } +} + +// MARK: - CustomTabBar + +private final class CustomTabBar: UIView { + + var onTabSelected: ((Int) -> Void)? + + private struct TabItem { + let icon: String // SF Symbol name + let label: String + } + + private var items: [TabItem] {[ + TabItem(icon: "wave.3.right.circle.fill", label: L10n.tabEmoney), + TabItem(icon: "gearshape.fill", label: L10n.tabSettings) + ]} + + private var itemViews: [TabItemView] = [] + private var selectedIndex: Int = 0 + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + backgroundColor = .white + layer.cornerRadius = 32 + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.10 + layer.shadowOffset = CGSize(width: 0, height: 4) + layer.shadowRadius = 16 + + let stack = UIStackView() + stack.axis = .horizontal + stack.distribution = .fillEqually + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + stack.topAnchor.constraint(equalTo: topAnchor, constant: 8), + stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8) + ]) + + for (index, item) in items.enumerated() { + let tabView = TabItemView(icon: item.icon, label: item.label) + tabView.isActive = (index == selectedIndex) + tabView.tag = index + let tap = UITapGestureRecognizer(target: self, action: #selector(tabTapped(_:))) + tabView.addGestureRecognizer(tap) + stack.addArrangedSubview(tabView) + itemViews.append(tabView) + } + } + + @objc private func tabTapped(_ gesture: UITapGestureRecognizer) { + guard let index = gesture.view?.tag else { return } + setSelected(index) + onTabSelected?(index) + } + + func setSelected(_ index: Int) { + guard index != selectedIndex else { return } + selectedIndex = index + UIView.animate(withDuration: 0.25, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) { + self.itemViews.enumerated().forEach { i, view in + view.isActive = (i == index) + view.layoutIfNeeded() + } + } + } +} + +// MARK: - TabItemView + +private final class TabItemView: UIView { + + var isActive: Bool = false { + didSet { applyState() } + } + + private let iconView = UIImageView() + private let labelView = UILabel() + private let pill = UIView() + + init(icon: String, label: String) { + super.init(frame: .zero) + iconView.image = UIImage(systemName: icon) + iconView.contentMode = .scaleAspectFit + labelView.text = label + labelView.font = Theme.Font.caption(weight: .semibold) + labelView.textAlignment = .center + setup() + } + + required init?(coder: NSCoder) { fatalError() } + + private func setup() { + pill.layer.cornerRadius = 20 + pill.translatesAutoresizingMaskIntoConstraints = false + addSubview(pill) + + let stack = UIStackView(arrangedSubviews: [iconView, labelView]) + stack.axis = .vertical + stack.spacing = 2 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + + NSLayoutConstraint.activate([ + pill.centerXAnchor.constraint(equalTo: centerXAnchor), + pill.centerYAnchor.constraint(equalTo: centerYAnchor), + pill.widthAnchor.constraint(equalTo: widthAnchor, multiplier: 0.85), + pill.heightAnchor.constraint(equalToConstant: 48), + + stack.centerXAnchor.constraint(equalTo: centerXAnchor), + stack.centerYAnchor.constraint(equalTo: centerYAnchor), + + iconView.widthAnchor.constraint(equalToConstant: 20), + iconView.heightAnchor.constraint(equalToConstant: 20) + ]) + + applyState() + } + + private func applyState() { + if isActive { + pill.backgroundColor = Theme.Color.primary + iconView.tintColor = .white + labelView.textColor = .white + pill.transform = .identity + } else { + pill.backgroundColor = .clear + iconView.tintColor = Theme.Color.textSecondary + labelView.textColor = Theme.Color.textSecondary + pill.transform = .identity + } + } +} diff --git a/Emoney Info/PrivacyPolicyViewController.swift b/Emoney Info/PrivacyPolicyViewController.swift new file mode 100644 index 0000000..146d68a --- /dev/null +++ b/Emoney Info/PrivacyPolicyViewController.swift @@ -0,0 +1,309 @@ +// PrivacyPolicyViewController.swift +// Emoney Info + +import UIKit + +final class PrivacyPolicyViewController: UIViewController { + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Theme.Color.background + setupUI() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(true, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(false, animated: animated) + } + + // MARK: - Setup + + private func setupUI() { + let scrollView = UIScrollView() + let contentView = UIView() + scrollView.showsVerticalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + + // MARK: Back + Title + let backButton = UIButton(type: .system) + let chevron = UIImage(systemName: "chevron.left", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)) + backButton.setImage(chevron, for: .normal) + backButton.tintColor = Theme.Color.textPrimary + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = UILabel() + titleLabel.text = L10n.aboutPrivacy + titleLabel.font = Theme.Font.title(weight: .bold) + titleLabel.textColor = Theme.Color.textPrimary + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Last Updated chip + let updatedLabel = UILabel() + updatedLabel.text = L10n.privacyLastUpdated + updatedLabel.font = Theme.Font.caption(weight: .semibold) + updatedLabel.textColor = Theme.Color.secondary + updatedLabel.backgroundColor = Theme.Color.primary.withAlphaComponent(0.15) + updatedLabel.layer.cornerRadius = 8 + updatedLabel.clipsToBounds = true + updatedLabel.textAlignment = .center + updatedLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Sections card + let sections = PrivacyData.sections + let sectionsCard = makeCard() + + let sectionsStack = UIStackView() + sectionsStack.axis = .vertical + sectionsStack.spacing = 0 + sectionsStack.translatesAutoresizingMaskIntoConstraints = false + sectionsCard.addSubview(sectionsStack) + + for (i, section) in sections.enumerated() { + let row = makeSectionRow(section: section) + sectionsStack.addArrangedSubview(row) + if i < sections.count - 1 { + let sep = UIView() + sep.backgroundColor = Theme.Color.background + sep.translatesAutoresizingMaskIntoConstraints = false + sep.heightAnchor.constraint(equalToConstant: 1).isActive = true + sectionsStack.addArrangedSubview(sep) + } + } + + // MARK: Contact card (teal) + let contactCard = UIView() + contactCard.backgroundColor = Theme.Color.primary + contactCard.layer.cornerRadius = 20 + contactCard.translatesAutoresizingMaskIntoConstraints = false + + let contactTitle = UILabel() + contactTitle.text = L10n.privacyContactTitle + contactTitle.font = Theme.Font.subtitle(weight: .bold) + contactTitle.textColor = .white + contactTitle.numberOfLines = 0 + contactTitle.translatesAutoresizingMaskIntoConstraints = false + + let contactDesc = UILabel() + contactDesc.text = L10n.privacyContactDesc + contactDesc.font = Theme.Font.caption(weight: .regular) + contactDesc.textColor = UIColor.white.withAlphaComponent(0.85) + contactDesc.numberOfLines = 0 + contactDesc.translatesAutoresizingMaskIntoConstraints = false + + let contactButton = UIButton(type: .system) + contactButton.setTitle(L10n.privacyContactButton, for: .normal) + contactButton.titleLabel?.font = Theme.Font.body(weight: .semibold) + contactButton.setTitleColor(Theme.Color.primary, for: .normal) + contactButton.backgroundColor = .white + contactButton.layer.cornerRadius = 14 + contactButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 28, bottom: 12, right: 28) + contactButton.addTarget(self, action: #selector(contactTapped), for: .touchUpInside) + contactButton.translatesAutoresizingMaskIntoConstraints = false + + [contactTitle, contactDesc, contactButton].forEach { contactCard.addSubview($0) } + + // MARK: Footer + let footerLabel = UILabel() + footerLabel.text = L10n.footerCopyright + footerLabel.font = Theme.Font.caption(weight: .regular) + footerLabel.textColor = Theme.Color.textSecondary + footerLabel.textAlignment = .center + footerLabel.translatesAutoresizingMaskIntoConstraints = false + + [backButton, titleLabel, updatedLabel, sectionsCard, contactCard, footerLabel] + .forEach { contentView.addSubview($0) } + + NSLayoutConstraint.activate([ + // Nav + backButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 56), + backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + backButton.widthAnchor.constraint(equalToConstant: 32), + backButton.heightAnchor.constraint(equalToConstant: 32), + + titleLabel.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + + updatedLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10), + updatedLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + updatedLabel.heightAnchor.constraint(equalToConstant: 26), + updatedLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 80), + + // Sections card + sectionsCard.topAnchor.constraint(equalTo: updatedLabel.bottomAnchor, constant: 24), + sectionsCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + sectionsCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + sectionsStack.topAnchor.constraint(equalTo: sectionsCard.topAnchor, constant: 8), + sectionsStack.leadingAnchor.constraint(equalTo: sectionsCard.leadingAnchor), + sectionsStack.trailingAnchor.constraint(equalTo: sectionsCard.trailingAnchor), + sectionsStack.bottomAnchor.constraint(equalTo: sectionsCard.bottomAnchor, constant: -8), + + // Contact card + contactCard.topAnchor.constraint(equalTo: sectionsCard.bottomAnchor, constant: 24), + contactCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + contactCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + contactTitle.topAnchor.constraint(equalTo: contactCard.topAnchor, constant: 24), + contactTitle.leadingAnchor.constraint(equalTo: contactCard.leadingAnchor, constant: 20), + contactTitle.trailingAnchor.constraint(equalTo: contactCard.trailingAnchor, constant: -20), + + contactDesc.topAnchor.constraint(equalTo: contactTitle.bottomAnchor, constant: 8), + contactDesc.leadingAnchor.constraint(equalTo: contactCard.leadingAnchor, constant: 20), + contactDesc.trailingAnchor.constraint(equalTo: contactCard.trailingAnchor, constant: -20), + + contactButton.topAnchor.constraint(equalTo: contactDesc.bottomAnchor, constant: 20), + contactButton.leadingAnchor.constraint(equalTo: contactCard.leadingAnchor, constant: 20), + contactButton.bottomAnchor.constraint(equalTo: contactCard.bottomAnchor, constant: -24), + + // Footer + footerLabel.topAnchor.constraint(equalTo: contactCard.bottomAnchor, constant: 24), + footerLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + footerLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -32), + ]) + + // updatedLabel padding via insets workaround + updatedLabel.setContentHuggingPriority(.required, for: .horizontal) + } + + // MARK: - Helpers + + private func makeCard() -> UIView { + let v = UIView() + v.backgroundColor = Theme.Color.card + v.layer.cornerRadius = 16 + v.layer.shadowColor = UIColor.black.cgColor + v.layer.shadowOpacity = 0.06 + v.layer.shadowOffset = CGSize(width: 0, height: 2) + v.layer.shadowRadius = 8 + v.translatesAutoresizingMaskIntoConstraints = false + return v + } + + private func makeSectionRow(section: PrivacySection) -> UIView { + let row = UIView() + row.translatesAutoresizingMaskIntoConstraints = false + + // Icon container + let iconContainer = UIView() + iconContainer.backgroundColor = Theme.Color.primary.withAlphaComponent(0.12) + iconContainer.layer.cornerRadius = 12 + iconContainer.translatesAutoresizingMaskIntoConstraints = false + + let iconView = UIImageView(image: UIImage(systemName: section.icon, + withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .medium))) + iconView.tintColor = Theme.Color.secondary + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + iconContainer.addSubview(iconView) + + // Text + let titleLabel = UILabel() + titleLabel.text = section.title + titleLabel.font = Theme.Font.body(weight: .semibold) + titleLabel.textColor = Theme.Color.textPrimary + titleLabel.numberOfLines = 0 + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + let bodyLabel = UILabel() + bodyLabel.text = section.body + bodyLabel.font = Theme.Font.caption(weight: .regular) + bodyLabel.textColor = Theme.Color.textSecondary + bodyLabel.numberOfLines = 0 + bodyLabel.translatesAutoresizingMaskIntoConstraints = false + + let textStack = UIStackView(arrangedSubviews: [titleLabel, bodyLabel]) + textStack.axis = .vertical + textStack.spacing = 4 + textStack.translatesAutoresizingMaskIntoConstraints = false + + [iconContainer, textStack].forEach { row.addSubview($0) } + + NSLayoutConstraint.activate([ + iconContainer.widthAnchor.constraint(equalToConstant: 44), + iconContainer.heightAnchor.constraint(equalToConstant: 44), + iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 20), + iconView.heightAnchor.constraint(equalToConstant: 20), + + iconContainer.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 16), + iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 16), + + textStack.leadingAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: 14), + textStack.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -16), + textStack.topAnchor.constraint(equalTo: row.topAnchor, constant: 16), + textStack.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -16), + ]) + + return row + } + + // MARK: - Actions + + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + + @objc private func contactTapped() { + let address = "apps@indonesiainyourhand.com" + let subject = "Ask Support" + let encoded = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject + if let url = URL(string: "mailto:\(address)?subject=\(encoded)") { + UIApplication.shared.open(url) + } + } +} + +// MARK: - Privacy Data Model + +struct PrivacySection { + let icon: String + let title: String + let body: String +} + +enum PrivacyData { + static var sections: [PrivacySection] {[ + PrivacySection( + icon: "wave.3.right.circle.fill", + title: L10n.privacySectionNfcTitle, + body: L10n.privacySectionNfcBody + ), + PrivacySection( + icon: "xmark.icloud.fill", + title: L10n.privacySectionNoStorageTitle, + body: L10n.privacySectionNoStorageBody + ), + PrivacySection( + icon: "eye.fill", + title: L10n.privacySectionReadOnlyTitle, + body: L10n.privacySectionReadOnlyBody + ), + ]} +} diff --git a/Emoney Info/Resources/halter.ttf b/Emoney Info/Resources/halter.ttf new file mode 100755 index 0000000..97a4bc7 Binary files /dev/null and b/Emoney Info/Resources/halter.ttf differ diff --git a/Emoney Info/Resources/header.png b/Emoney Info/Resources/header.png new file mode 100755 index 0000000..30995e3 Binary files /dev/null and b/Emoney Info/Resources/header.png differ diff --git a/Emoney Info/Resources/logo.png b/Emoney Info/Resources/logo.png new file mode 100755 index 0000000..30995e3 Binary files /dev/null and b/Emoney Info/Resources/logo.png differ diff --git a/Emoney Info/SceneDelegate.swift b/Emoney Info/SceneDelegate.swift new file mode 100755 index 0000000..db627e6 --- /dev/null +++ b/Emoney Info/SceneDelegate.swift @@ -0,0 +1,193 @@ +// +// SceneDelegate.swift +// Emoney Info +// +// Created by Wira Irawan on 22/07/24. +// + +import UIKit +import AppTrackingTransparency +import GoogleMobileAds + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + private var hasRequestedTrackingAuthorization = false + private var isTrackingAuthorizationRequestInFlight = false + private var pendingTrackingCompletions: [() -> Void] = [] + private var hasStartedAdMob = false + private weak var homeVC: HomeViewController? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + let tabBar = MainTabBarController() + tabBar.viewControllers = [ + makeHomeVC(tabBar: tabBar), + makeSettingsVC() + ] + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = tabBar + window.makeKeyAndVisible() + self.window = window + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + requestTrackingAuthorizationIfNeeded() + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } + + // MARK: - VC Factories + + private func makeHomeVC(tabBar: MainTabBarController) -> HomeViewController { + let vc = HomeViewController() + homeVC = vc + + vc.onScanTapped = { [weak vc] in + guard let vc, let callback = vc as? ApduCallback else { return } + self.startNfcScan(callback: callback) + } + + vc.onViewHistoryTapped = { [weak vc] in + let historyVC = HistoryHostingController() + historyVC.riwayatList = vc?.latestRiwayatList ?? [] + historyVC.cardLabel = vc?.cardTypeText ?? "" + historyVC.balanceText = vc?.balanceText ?? "" + historyVC.cardNumber = vc?.latestCardNumber ?? "" + historyVC.modalPresentationStyle = .fullScreen + vc?.present(historyVC, animated: true) + } + + vc.onSettingsTapped = { [weak tabBar] in + tabBar?.selectedIndex = 1 + } + + return vc + } + + private func makeSettingsVC() -> UINavigationController { + let vc = SettingsViewController() + + vc.onShowCardNumberChanged = { isOn in + UserDefaults.standard.set(isOn, forKey: "masked") + NotificationCenter.default.post(name: Notification.Name("refreshScreen"), object: nil) + } + + vc.onHelpCenterTapped = { [weak vc] in + let faqVC = FAQViewController() + vc?.navigationController?.pushViewController(faqVC, animated: true) + } + + vc.onAboutTapped = { [weak vc] in + let aboutVC = AboutViewController() + vc?.navigationController?.pushViewController(aboutVC, animated: true) + } + + let nav = UINavigationController(rootViewController: vc) + nav.setNavigationBarHidden(true, animated: false) + return nav + } + + private func requestTrackingAuthorizationIfNeeded(completion: (() -> Void)? = nil) { + if let completion { + pendingTrackingCompletions.append(completion) + } + + guard #available(iOS 14, *) else { + startAdMobSDKIfNeeded() + flushPendingTrackingCompletions(after: 0) + return + } + + guard ATTrackingManager.trackingAuthorizationStatus == .notDetermined else { + // User already responded to ATT in a previous session — start AdMob immediately. + startAdMobSDKIfNeeded() + flushPendingTrackingCompletions(after: 0) + return + } + + guard !hasRequestedTrackingAuthorization else { + flushPendingTrackingCompletions(after: 0) + return + } + + hasRequestedTrackingAuthorization = true + isTrackingAuthorizationRequestInFlight = true + // Short delay so the root view controller completes its appearance + // transition before iOS presents the ATT dialog. + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + ATTrackingManager.requestTrackingAuthorization { _ in + DispatchQueue.main.async { + self.isTrackingAuthorizationRequestInFlight = false + // Start AdMob only after the user has responded to ATT, + // ensuring the SDK never collects data before consent is given. + self.startAdMobSDKIfNeeded() + self.flushPendingTrackingCompletions(after: 1.2) + } + } + } + } + + private func startAdMobSDKIfNeeded() { + guard !hasStartedAdMob else { return } + hasStartedAdMob = true +// GADMobileAds.sharedInstance().requestConfiguration.testDeviceIdentifiers = ["7b42e513861d6b0c9f07529b748930c0"] + GADMobileAds.sharedInstance().start { [weak self] _ in + DispatchQueue.main.async { + self?.homeVC?.loadBannerAd() + } + } + } + + private func startNfcScan(callback: ApduCallback) { + let beginScan = { + let api = UnifiedNfcApi() + api.setCallback(apduCallback: callback) + api.searchCard() + } + + if #available(iOS 14, *), + ATTrackingManager.trackingAuthorizationStatus == .notDetermined, + isTrackingAuthorizationRequestInFlight { + pendingTrackingCompletions.append(beginScan) + return + } + + requestTrackingAuthorizationIfNeeded(completion: beginScan) + } + + private func flushPendingTrackingCompletions(after delay: TimeInterval) { + let completions = pendingTrackingCompletions + pendingTrackingCompletions.removeAll() + guard !completions.isEmpty else { return } + + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + completions.forEach { $0() } + } + } +} diff --git a/Emoney Info/SettingsView.swift b/Emoney Info/SettingsView.swift new file mode 100644 index 0000000..af13f11 --- /dev/null +++ b/Emoney Info/SettingsView.swift @@ -0,0 +1,442 @@ +import UIKit +import GoogleMobileAds + +// MARK: - SettingsViewController + +final class SettingsViewController: UIViewController { + + // MARK: - Callbacks + + var onLanguageTapped: (() -> Void)? + var onNotificationsTapped: (() -> Void)? + var onHelpCenterTapped: (() -> Void)? + var onAboutTapped: (() -> Void)? + var onShowCardNumberChanged: ((Bool) -> Void)? + + // MARK: - State + + private var showCardNumber: Bool = { + // First launch: key doesn't exist → default ON and persist it + if UserDefaults.standard.object(forKey: "masked") == nil { + UserDefaults.standard.set(true, forKey: "masked") + return true + } + return UserDefaults.standard.bool(forKey: "masked") + }() { + didSet { onShowCardNumberChanged?(showCardNumber) } + } + + // MARK: - UI + + private let scrollView = UIScrollView() + private let contentView = UIView() + + // Ad banner + private let adContainer = UIView() + private var bannerView = GADBannerView() + + // Dynamic constraints — toggled on ad load/fail + private var generalTopNoAd: NSLayoutConstraint! + private var generalTopWithAd: NSLayoutConstraint! + + // Stored anchors for chaining + private var headerBottomAnchor: NSLayoutYAxisAnchor! + private var lastBottomAnchor: NSLayoutYAxisAnchor! + + // TODO: Replace with real ad unit ID before release + private let adUnitID = "ca-app-pub-3389368171983845/7916200416" + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Theme.Color.background + setupScrollView() + setupHeader() + setupAdBanner() + setupGeneralSection() + setupAppSection() + setupFooter() + } + + // MARK: - ScrollView + + private func setupScrollView() { + scrollView.showsVerticalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + } + + // MARK: - Header (no search button) + + private func setupHeader() { + let titleLabel = UILabel() + titleLabel.text = L10n.settingsTitle + titleLabel.font = Theme.Font.title(weight: .bold) + titleLabel.textColor = Theme.Color.textPrimary + titleLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(titleLabel) + + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24) + ]) + + headerBottomAnchor = titleLabel.bottomAnchor + lastBottomAnchor = titleLabel.bottomAnchor + } + + // MARK: - Ad Banner + + private func setupAdBanner() { + let adSize = GADCurrentOrientationAnchoredAdaptiveBannerAdSizeWithWidth( + UIScreen.main.bounds.width - 48 + ) + bannerView = GADBannerView(adSize: adSize) + bannerView.adUnitID = adUnitID + bannerView.rootViewController = self + bannerView.delegate = self + bannerView.translatesAutoresizingMaskIntoConstraints = false + + adContainer.backgroundColor = .clear + adContainer.clipsToBounds = true + adContainer.isHidden = true + adContainer.translatesAutoresizingMaskIntoConstraints = false + adContainer.addSubview(bannerView) + contentView.addSubview(adContainer) + + NSLayoutConstraint.activate([ + adContainer.topAnchor.constraint(equalTo: headerBottomAnchor, constant: 16), + adContainer.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + adContainer.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + bannerView.topAnchor.constraint(equalTo: adContainer.topAnchor), + bannerView.leadingAnchor.constraint(equalTo: adContainer.leadingAnchor), + bannerView.trailingAnchor.constraint(equalTo: adContainer.trailingAnchor), + bannerView.bottomAnchor.constraint(equalTo: adContainer.bottomAnchor), + bannerView.heightAnchor.constraint(equalToConstant: CGFloat(adSize.size.height)) + ]) + + bannerView.load(GADRequest()) + lastBottomAnchor = adContainer.bottomAnchor + } + + // MARK: - General Section + + private func setupGeneralSection() { + let sectionLabel = makeSectionLabel(L10n.sectionGeneral) + contentView.addSubview(sectionLabel) + + // Two top constraints — only one active at a time + generalTopNoAd = sectionLabel.topAnchor.constraint(equalTo: headerBottomAnchor, constant: 28) + generalTopWithAd = sectionLabel.topAnchor.constraint(equalTo: adContainer.bottomAnchor, constant: 20) + generalTopNoAd.isActive = true // default: no ad + + NSLayoutConstraint.activate([ + sectionLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24) + ]) + + let card = makeCard() + contentView.addSubview(card) + NSLayoutConstraint.activate([ + card.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 10), + card.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + card.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24) + ]) + + let languageRow = SettingsRow( + icon: "globe", title: L10n.languageTitle, + detail: L10n.languageValue, accessory: .chevron + ) + languageRow.onTap = { [weak self] in self?.onLanguageTapped?() } + + let toggleRow = SettingsRow( + icon: "eye", title: L10n.showCardNumberTitle, + subtitle: L10n.showCardNumberDesc, accessory: .toggle(isOn: showCardNumber) + ) + toggleRow.onToggleChanged = { [weak self] isOn in self?.showCardNumber = isOn } + + let stack = UIStackView(arrangedSubviews: [languageRow, makeSeparator(), toggleRow]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4), + stack.leadingAnchor.constraint(equalTo: card.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: card.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4) + ]) + + lastBottomAnchor = card.bottomAnchor + } + + // MARK: - App Section + + private func setupAppSection() { + let sectionLabel = makeSectionLabel(L10n.sectionApp) + contentView.addSubview(sectionLabel) + NSLayoutConstraint.activate([ + sectionLabel.topAnchor.constraint(equalTo: lastBottomAnchor, constant: 24), + sectionLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24) + ]) + + let card = makeCard() + contentView.addSubview(card) + NSLayoutConstraint.activate([ + card.topAnchor.constraint(equalTo: sectionLabel.bottomAnchor, constant: 10), + card.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + card.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24) + ]) + + let helpRow = SettingsRow( + icon: "questionmark.circle", title: L10n.helpCenterTitle, + subtitle: L10n.helpCenterDesc, accessory: .chevron + ) + helpRow.onTap = { [weak self] in self?.onHelpCenterTapped?() } + + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "" + let versionSubtitle = "\(L10n.aboutAppDesc) \(appVersion) (Build \(buildNumber))" + let aboutRow = SettingsRow( + icon: "info.circle", title: L10n.aboutAppTitle, + subtitle: versionSubtitle, accessory: .chevron + ) + aboutRow.onTap = { [weak self] in self?.onAboutTapped?() } + + let stack = UIStackView(arrangedSubviews: [helpRow, makeSeparator(), aboutRow]) + stack.axis = .vertical + stack.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(stack) + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 4), + stack.leadingAnchor.constraint(equalTo: card.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: card.trailingAnchor), + stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -4) + ]) + + lastBottomAnchor = card.bottomAnchor + } + + // MARK: - Footer + + private func setupFooter() { + let appLabel = UILabel() + appLabel.text = L10n.footerCopyright + appLabel.font = Theme.Font.caption(weight: .semibold) + appLabel.textColor = Theme.Color.textSecondary + appLabel.textAlignment = .center + + let stack = UIStackView(arrangedSubviews: [appLabel]) + stack.axis = .vertical + stack.spacing = 4 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(stack) + + NSLayoutConstraint.activate([ + stack.topAnchor.constraint(equalTo: lastBottomAnchor, constant: 32), + stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -140) + ]) + } + + // MARK: - Helpers + + private func makeSectionLabel(_ text: String) -> UILabel { + let label = UILabel() + label.text = text + label.font = Theme.Font.caption(weight: .semibold) + label.textColor = Theme.Color.textSecondary + label.translatesAutoresizingMaskIntoConstraints = false + return label + } + + private func makeCard() -> UIView { + let card = UIView() + card.backgroundColor = Theme.Color.card + card.layer.cornerRadius = 16 + card.layer.shadowColor = UIColor.black.cgColor + card.layer.shadowOpacity = 0.06 + card.layer.shadowOffset = CGSize(width: 0, height: 2) + card.layer.shadowRadius = 8 + card.translatesAutoresizingMaskIntoConstraints = false + return card + } + + private func makeSeparator() -> UIView { + let line = UIView() + line.backgroundColor = Theme.Color.background + line.translatesAutoresizingMaskIntoConstraints = false + line.heightAnchor.constraint(equalToConstant: 1).isActive = true + return line + } +} + +// MARK: - GADBannerViewDelegate + +extension SettingsViewController: GADBannerViewDelegate { + func bannerViewDidReceiveAd(_ bannerView: GADBannerView) { + generalTopNoAd.isActive = false + generalTopWithAd.isActive = true + UIView.animate(withDuration: 0.3) { + self.adContainer.isHidden = false + self.view.layoutIfNeeded() + } + } + + func bannerView(_ bannerView: GADBannerView, didFailToReceiveAdWithError error: Error) { + generalTopWithAd.isActive = false + generalTopNoAd.isActive = true + UIView.animate(withDuration: 0.3) { + self.adContainer.isHidden = true + self.view.layoutIfNeeded() + } + } +} + +// MARK: - SettingsRow + +private enum SettingsAccessory { + case chevron + case toggle(isOn: Bool) + case detail(String) +} + +private final class SettingsRow: UIView { + + var onTap: (() -> Void)? + var onToggleChanged: ((Bool) -> Void)? + + init(icon: String, + title: String, + subtitle: String? = nil, + detail: String? = nil, + accessory: SettingsAccessory) { + super.init(frame: .zero) + + // Icon — grey background, green icon (same as HistoryView) + let iconContainer = UIView() + iconContainer.backgroundColor = Theme.Color.background + iconContainer.layer.cornerRadius = 10 + + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .medium) + let iconView = UIImageView(image: UIImage(systemName: icon, withConfiguration: config)) + iconView.tintColor = Theme.Color.secondary + iconView.contentMode = .scaleAspectFit + iconView.translatesAutoresizingMaskIntoConstraints = false + iconContainer.addSubview(iconView) + + // Labels + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = Theme.Font.body(weight: .medium) + titleLabel.textColor = Theme.Color.textPrimary + titleLabel.numberOfLines = 0 + + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = Theme.Font.caption(weight: .regular) + subtitleLabel.textColor = Theme.Color.textSecondary + subtitleLabel.isHidden = subtitle == nil + subtitleLabel.numberOfLines = 0 + + // Right accessory + let rightView: UIView + switch accessory { + case .chevron: + let chevronConfig = UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold) + let img = UIImageView(image: UIImage(systemName: "chevron.right", withConfiguration: chevronConfig)) + img.tintColor = Theme.Color.textSecondary + img.contentMode = .scaleAspectFit + rightView = img + case .toggle(let isOn): + let sw = UISwitch() + sw.isOn = isOn + sw.onTintColor = Theme.Color.primary + sw.addTarget(self, action: #selector(toggleChanged(_:)), for: .valueChanged) + rightView = sw + case .detail(let text): + let lbl = UILabel() + lbl.text = text + lbl.font = Theme.Font.body(weight: .regular) + lbl.textColor = Theme.Color.textSecondary + rightView = lbl + } + + let labelStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) + labelStack.axis = .vertical + labelStack.spacing = 2 + + let hStack = UIStackView(arrangedSubviews: [iconContainer, labelStack, rightView]) + hStack.axis = .horizontal + hStack.spacing = 14 + hStack.alignment = .center + hStack.translatesAutoresizingMaskIntoConstraints = false + addSubview(hStack) + + NSLayoutConstraint.activate([ + iconContainer.widthAnchor.constraint(equalToConstant: 36), + iconContainer.heightAnchor.constraint(equalToConstant: 36), + iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor), + iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor), + iconView.widthAnchor.constraint(equalToConstant: 18), + iconView.heightAnchor.constraint(equalToConstant: 18), + + hStack.topAnchor.constraint(equalTo: topAnchor, constant: 14), + hStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + hStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + hStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14) + ]) + + // labelStack selalu menyusut agar rightView tidak terdorong keluar + labelStack.setContentHuggingPriority(.defaultLow, for: .horizontal) + labelStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + rightView.setContentHuggingPriority(.required, for: .horizontal) + rightView.setContentCompressionResistancePriority(.required, for: .horizontal) + + if case .chevron = accessory { + let tap = UITapGestureRecognizer(target: self, action: #selector(rowTapped)) + addGestureRecognizer(tap) + isUserInteractionEnabled = true + } + + if let detail = detail, case .chevron = accessory { + let detailLabel = UILabel() + detailLabel.text = detail + detailLabel.font = Theme.Font.body(weight: .regular) + detailLabel.textColor = Theme.Color.textSecondary + detailLabel.setContentHuggingPriority(.required, for: .horizontal) + detailLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + hStack.removeArrangedSubview(rightView) + rightView.removeFromSuperview() + hStack.addArrangedSubview(detailLabel) + + let chevronConfig = UIImage.SymbolConfiguration(pointSize: 13, weight: .semibold) + let img = UIImageView(image: UIImage(systemName: "chevron.right", withConfiguration: chevronConfig)) + img.tintColor = Theme.Color.textSecondary + img.contentMode = .scaleAspectFit + hStack.addArrangedSubview(img) + } + } + + required init?(coder: NSCoder) { nil } + + @objc private func rowTapped() { onTap?() } + @objc private func toggleChanged(_ sender: UISwitch) { onToggleChanged?(sender.isOn) } +} diff --git a/Emoney Info/TermsViewController.swift b/Emoney Info/TermsViewController.swift new file mode 100644 index 0000000..d0514b3 --- /dev/null +++ b/Emoney Info/TermsViewController.swift @@ -0,0 +1,356 @@ +// TermsViewController.swift +// Emoney Info + +import UIKit + +final class TermsViewController: UIViewController { + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = Theme.Color.background + setupUI() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + navigationController?.setNavigationBarHidden(true, animated: animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(true, animated: animated) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + (tabBarController as? MainTabBarController)?.setTabBarHidden(false, animated: animated) + } + + // MARK: - Setup + + private func setupUI() { + let scrollView = UIScrollView() + let contentView = UIView() + scrollView.showsVerticalScrollIndicator = false + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + ]) + + // MARK: Back + nav label + let backButton = UIButton(type: .system) + let chevron = UIImage(systemName: "chevron.left", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)) + backButton.setImage(chevron, for: .normal) + backButton.tintColor = Theme.Color.textPrimary + backButton.addTarget(self, action: #selector(backTapped), for: .touchUpInside) + backButton.translatesAutoresizingMaskIntoConstraints = false + + let navLabel = UILabel() + navLabel.text = L10n.aboutTerms + navLabel.font = Theme.Font.caption(weight: .semibold) + navLabel.textColor = Theme.Color.textSecondary + navLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Last updated chip + let updatedLabel = makePaddedLabel(text: L10n.termsLastUpdated, + color: Theme.Color.secondary, + bg: Theme.Color.primary.withAlphaComponent(0.15)) + + // MARK: Hero title (mixed weight) + let heroLabel = UILabel() + let heroAttr = NSMutableAttributedString( + string: L10n.termsTitleRegular + " ", + attributes: [ + .font: UIFont.systemFont(ofSize: 32, weight: .regular), + .foregroundColor: Theme.Color.textPrimary, + ] + ) + heroAttr.append(NSAttributedString( + string: L10n.termsTitleBold, + attributes: [ + .font: UIFont.systemFont(ofSize: 32, weight: .bold), + .foregroundColor: Theme.Color.textPrimary, + ] + )) + heroLabel.attributedText = heroAttr + heroLabel.numberOfLines = 0 + heroLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Subtitle + let subtitleLabel = UILabel() + subtitleLabel.text = L10n.termsSubtitle + subtitleLabel.font = Theme.Font.body(weight: .regular) + subtitleLabel.textColor = Theme.Color.textSecondary + subtitleLabel.numberOfLines = 0 + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + + // MARK: Terms sections + let termsStack = UIStackView() + termsStack.axis = .vertical + termsStack.spacing = 16 + termsStack.translatesAutoresizingMaskIntoConstraints = false + + for (index, section) in TermsData.sections.enumerated() { + let row = makeSectionRow(number: index + 1, section: section) + termsStack.addArrangedSubview(row) + } + + // MARK: Contact card (teal) + let contactCard = UIView() + contactCard.backgroundColor = Theme.Color.primary + contactCard.layer.cornerRadius = 20 + contactCard.translatesAutoresizingMaskIntoConstraints = false + + let contactTitle = UILabel() + contactTitle.text = L10n.termsContactTitle + contactTitle.font = Theme.Font.subtitle(weight: .bold) + contactTitle.textColor = .white + contactTitle.numberOfLines = 0 + contactTitle.translatesAutoresizingMaskIntoConstraints = false + + let contactDesc = UILabel() + contactDesc.text = L10n.termsContactDesc + contactDesc.font = Theme.Font.caption(weight: .regular) + contactDesc.textColor = UIColor.white.withAlphaComponent(0.85) + contactDesc.numberOfLines = 0 + contactDesc.translatesAutoresizingMaskIntoConstraints = false + + let contactButton = UIButton(type: .system) + contactButton.setTitle(L10n.termsContactButton, for: .normal) + contactButton.titleLabel?.font = Theme.Font.body(weight: .semibold) + contactButton.setTitleColor(Theme.Color.primary, for: .normal) + contactButton.backgroundColor = .white + contactButton.layer.cornerRadius = 14 + contactButton.contentEdgeInsets = UIEdgeInsets(top: 12, left: 28, bottom: 12, right: 28) + contactButton.addTarget(self, action: #selector(contactTapped), for: .touchUpInside) + contactButton.translatesAutoresizingMaskIntoConstraints = false + + [contactTitle, contactDesc, contactButton].forEach { contactCard.addSubview($0) } + + // MARK: Footer + let footerLabel = UILabel() + footerLabel.text = L10n.footerCopyright + footerLabel.font = Theme.Font.caption(weight: .regular) + footerLabel.textColor = Theme.Color.textSecondary + footerLabel.textAlignment = .center + footerLabel.translatesAutoresizingMaskIntoConstraints = false + + [backButton, navLabel, updatedLabel, heroLabel, subtitleLabel, + termsStack, contactCard, footerLabel].forEach { contentView.addSubview($0) } + + NSLayoutConstraint.activate([ + // Nav row + backButton.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 56), + backButton.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + backButton.widthAnchor.constraint(equalToConstant: 32), + backButton.heightAnchor.constraint(equalToConstant: 32), + + navLabel.centerYAnchor.constraint(equalTo: backButton.centerYAnchor), + navLabel.leadingAnchor.constraint(equalTo: backButton.trailingAnchor, constant: 8), + + // Chip + updatedLabel.topAnchor.constraint(equalTo: backButton.bottomAnchor, constant: 20), + updatedLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + + // Hero + heroLabel.topAnchor.constraint(equalTo: updatedLabel.bottomAnchor, constant: 12), + heroLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + heroLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + // Subtitle + subtitleLabel.topAnchor.constraint(equalTo: heroLabel.bottomAnchor, constant: 12), + subtitleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 24), + subtitleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -24), + + // Terms stack + termsStack.topAnchor.constraint(equalTo: subtitleLabel.bottomAnchor, constant: 28), + termsStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + termsStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + // Contact card + contactCard.topAnchor.constraint(equalTo: termsStack.bottomAnchor, constant: 28), + contactCard.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + contactCard.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + contactTitle.topAnchor.constraint(equalTo: contactCard.topAnchor, constant: 24), + contactTitle.leadingAnchor.constraint(equalTo: contactCard.leadingAnchor, constant: 20), + contactTitle.trailingAnchor.constraint(equalTo: contactCard.trailingAnchor, constant: -20), + + contactDesc.topAnchor.constraint(equalTo: contactTitle.bottomAnchor, constant: 8), + contactDesc.leadingAnchor.constraint(equalTo: contactCard.leadingAnchor, constant: 20), + contactDesc.trailingAnchor.constraint(equalTo: contactCard.trailingAnchor, constant: -20), + + contactButton.topAnchor.constraint(equalTo: contactDesc.bottomAnchor, constant: 20), + contactButton.leadingAnchor.constraint(equalTo: contactCard.leadingAnchor, constant: 20), + contactButton.bottomAnchor.constraint(equalTo: contactCard.bottomAnchor, constant: -24), + + // Footer + footerLabel.topAnchor.constraint(equalTo: contactCard.bottomAnchor, constant: 24), + footerLabel.centerXAnchor.constraint(equalTo: contentView.centerXAnchor), + footerLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -32), + ]) + } + + // MARK: - Helpers + + private func makePaddedLabel(text: String, color: UIColor, bg: UIColor) -> UILabel { + let label = UILabel() + label.text = text + label.font = Theme.Font.caption(weight: .semibold) + label.textColor = color + label.backgroundColor = bg + label.layer.cornerRadius = 8 + label.clipsToBounds = true + label.textAlignment = .center + label.translatesAutoresizingMaskIntoConstraints = false + label.heightAnchor.constraint(equalToConstant: 26).isActive = true + return label + } + + private func makeSectionRow(number: Int, section: TermsSection) -> UIView { + let card = UIView() + card.backgroundColor = Theme.Color.card + card.layer.cornerRadius = 16 + card.layer.shadowColor = UIColor.black.cgColor + card.layer.shadowOpacity = 0.06 + card.layer.shadowOffset = CGSize(width: 0, height: 2) + card.layer.shadowRadius = 8 + card.translatesAutoresizingMaskIntoConstraints = false + + // Number badge + let badge = UILabel() + badge.text = "\(number)." + badge.font = Theme.Font.body(weight: .bold) + badge.textColor = Theme.Color.secondary + badge.translatesAutoresizingMaskIntoConstraints = false + + // Title + let titleLabel = UILabel() + titleLabel.text = section.title + titleLabel.font = Theme.Font.body(weight: .semibold) + titleLabel.textColor = Theme.Color.textPrimary + titleLabel.numberOfLines = 0 + titleLabel.translatesAutoresizingMaskIntoConstraints = false + + // Header row + let headerStack = UIStackView(arrangedSubviews: [badge, titleLabel]) + headerStack.axis = .horizontal + headerStack.spacing = 8 + headerStack.alignment = .top + headerStack.translatesAutoresizingMaskIntoConstraints = false + + // Body + let bodyLabel = UILabel() + bodyLabel.text = section.body + bodyLabel.font = Theme.Font.caption(weight: .regular) + bodyLabel.textColor = Theme.Color.textSecondary + bodyLabel.numberOfLines = 0 + bodyLabel.translatesAutoresizingMaskIntoConstraints = false + + // Bullet points + let bulletStack = UIStackView() + bulletStack.axis = .vertical + bulletStack.spacing = 6 + bulletStack.translatesAutoresizingMaskIntoConstraints = false + + for bullet in section.bullets { + let bulletRow = UIStackView() + bulletRow.axis = .horizontal + bulletRow.spacing = 8 + bulletRow.alignment = .top + + let dot = UILabel() + dot.text = "•" + dot.font = Theme.Font.caption(weight: .semibold) + dot.textColor = Theme.Color.secondary + + let bulletLabel = UILabel() + bulletLabel.text = bullet + bulletLabel.font = Theme.Font.caption(weight: .regular) + bulletLabel.textColor = Theme.Color.textSecondary + bulletLabel.numberOfLines = 0 + + bulletRow.addArrangedSubview(dot) + bulletRow.addArrangedSubview(bulletLabel) + bulletStack.addArrangedSubview(bulletRow) + } + + let mainStack = UIStackView(arrangedSubviews: [headerStack, bodyLabel]) + if !section.bullets.isEmpty { mainStack.addArrangedSubview(bulletStack) } + mainStack.axis = .vertical + mainStack.spacing = 8 + mainStack.translatesAutoresizingMaskIntoConstraints = false + card.addSubview(mainStack) + + NSLayoutConstraint.activate([ + badge.widthAnchor.constraint(equalToConstant: 24), + mainStack.topAnchor.constraint(equalTo: card.topAnchor, constant: 16), + mainStack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16), + mainStack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16), + mainStack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -16), + ]) + + return card + } + + // MARK: - Actions + + @objc private func backTapped() { + navigationController?.popViewController(animated: true) + } + + @objc private func contactTapped() { + let address = "apps@indonesiainyourhand.com" + let subject = "Ask Support" + let encoded = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? subject + if let url = URL(string: "mailto:\(address)?subject=\(encoded)") { + UIApplication.shared.open(url) + } + } +} + +// MARK: - Terms Data Model + +struct TermsSection { + let title: String + let body: String + let bullets: [String] +} + +enum TermsData { + static var sections: [TermsSection] {[ + TermsSection( + title: L10n.termsSec1Title, + body: L10n.termsSec1Body, + bullets: [] + ), + TermsSection( + title: L10n.termsSec2Title, + body: L10n.termsSec2Body, + bullets: [ + L10n.termsSec2Bullet1, + L10n.termsSec2Bullet2, + L10n.termsSec2Bullet3, + ] + ), + TermsSection( + title: L10n.termsSec3Title, + body: L10n.termsSec3Body, + bullets: [ + L10n.termsSec3Bullet1, + L10n.termsSec3Bullet2, + ] + ), + ]} +} diff --git a/Emoney Info/en.lproj/Localizable.strings b/Emoney Info/en.lproj/Localizable.strings new file mode 100755 index 0000000..b210b11 --- /dev/null +++ b/Emoney Info/en.lproj/Localizable.strings @@ -0,0 +1,159 @@ +/* Emoney Info — English */ + +// --- Existing --- +"cardType" = "CARD TYPE"; +"checkBalance" = "Check Balance"; +"cardNumber" = "Card Number"; +"balance" = "Balance"; +"transactionHistory" = "Last Ten Transactions"; +"maskTitle" = "Mask Card Number"; +"maskDesc" = "If this option is selected, the card number will be displayed with some parts hidden"; +"supportCardTitle" = "Supported Cards"; +"supportCardDesc" = "This application can read e-money cards as follows: \n - Mandiri e-money \n - BCA Flazz \n - BNI TapCash \n - BRI Brizzi \n - JackCard \n - MegaCash \n - KMT"; +"versi" = "Version "; +"aboutTitle" = "About"; +"lastTransaction" = "Last Transaction"; +"payment" = "PAYMENT"; +"topup" = "TOP UP"; +"unknown" = "Unknown"; +"scanMessage" = "Hold your e-money card near the NFC sensor"; +"readFinish" = "Card read successfully"; +"readFailed" = "Failed to read card"; +"updateBalance" = "Update Balance"; +"void" = "Void"; +"reactivation" = "Reactivation"; +"statementFee" = "Statement Fee"; +"gracePeriod" = "Grace Period Debit"; +"refund" = "Refund"; +"close" = "Refund Disable Purse"; +"atu" = "Change ATU Amount"; +"noCard" = "No card detected"; + +// --- Home --- +"availableBalance" = "AVAILABLE BALANCE"; +"cardTapInstruction" = "TAP CARD"; +"cardTypeDefault" = "E-Money Card"; +"tapCardHere" = "Tap Card Here"; +"tapCardHint" = "Hold the card in front of your iPhone\nto scan."; +"viewFullHistory" = "View Full History"; +"copiedToClipboard" = "Copied to clipboard"; +"transactionDefault" = "Transaction"; +"transactionStatusSuccess" = "SUCCESS"; + +// --- History --- +"historyTitle" = "Transaction History"; +"recentActivity" = "RECENT ACTIVITY"; +"filterAllTime" = "All Time"; +"filterToday" = "Today"; +"filterThisMonth" = "This Month"; +"filterThisWeek" = "This Week"; +"noTransactionsFound" = "No transactions found"; +"exportPDF" = "Export History to PDF"; + +// --- Settings --- +"settingsTitle" = "Settings"; +"premiumBadge" = "PREMIUM ACCESS"; +"premiumTitle" = "Unlock Advanced Ledger Analytics"; +"premiumDesc" = "Get deep insights into your spending habits with AI-powered forecasting and unlimited ledgers."; +"upgradeNow" = "Upgrade Now"; +"sectionGeneral" = "GENERAL"; +"languageTitle" = "Language"; +"languageValue" = "English (US)"; +"showCardNumberTitle" = "Show Card Number on Home"; +"showCardNumberDesc" = "Privacy focus for public spaces"; +"sectionApp" = "APP"; +"notificationsTitle" = "Notifications"; +"notificationsDesc" = "Manage alerts and news"; +"helpCenterTitle" = "Help Center"; +"helpCenterDesc" = "FAQs and Support tickets"; +"aboutAppTitle" = "About App"; +"aboutAppDesc" = "Version"; +"footerCopyright" = "EMONEY INFO © 2026"; +"reportIssue" = "Report an issue with emoneyInfo"; + +// --- Terms & Conditions --- +"termsLastUpdated" = " Last updated: 5 April 2026 "; +"termsTitleRegular" = "Terms &"; +"termsTitleBold" = "Conditions"; +"termsSubtitle" = "We value your transparency and provide a simple service."; +"termsSec1Title" = "Layanan"; +"termsSec1Body" = "Emoney Info is a free application that allows users to read the balance and transaction history of compatible NFC e-money cards using an iPhone's built-in NFC reader."; +"termsSec2Title" = "Tanpa Data Pengguna"; +"termsSec2Body" = "Privacy is our foundation. Emoney Info requires no registration or data collection. All information is read locally from your card chip."; +"termsSec2Bullet1" = "No account or login required"; +"termsSec2Bullet2" = "No analytics or tracking SDKs"; +"termsSec2Bullet3" = "No personal data is sent to any server"; +"termsSec3Title" = "Batasan Tanggung Jawab"; +"termsSec3Body" = "The app provides balance data as shown on the card chip at the time of scanning. We are not responsible for discrepancies caused by:"; +"termsSec3Bullet1" = "Pending top-ups not yet synced to the chip"; +"termsSec3Bullet2" = "Damaged or incompatible cards"; +"termsContactTitle" = "Need further assistance?"; +"termsContactDesc" = "Our team is happy to help answer any questions you may have about these terms."; +"termsContactButton" = "Hubungi Kami"; + +// --- Privacy Policy --- +"privacyLastUpdated" = " Last updated: 5 April 2026 "; +"privacySectionNfcTitle" = "NFC Data Handling"; +"privacySectionNfcBody" = "Emoney Info uses Near Field Communication (NFC) technology only to read your card balance and transaction history directly from the chip."; +"privacySectionNoStorageTitle" = "No Storage"; +"privacySectionNoStorageBody" = "Data read from your NFC card is never stored on our servers. All processing happens locally on your device."; +"privacySectionReadOnlyTitle" = "Read Only"; +"privacySectionReadOnlyBody" = "The app only reads data from your card chip. It cannot write, modify, or transfer any funds on your card."; +"privacyContactTitle" = "Have more questions?"; +"privacyContactDesc" = "Our privacy team is here to help. We will respond to all enquiries within 1–2 business days."; +"privacyContactButton" = "Contact Us"; + +// --- About --- +"aboutAppDescription" = "Emoney Info is your smart e-money companion for instant balance checks via NFC — supports Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JakCard, MegaCash, and KMT"; +"aboutChipNfc" = "Secure NFC"; +"aboutChipRealtime" = "Real-time"; +"aboutChipMulti" = "Multi-Provider"; +"aboutTerms" = "Terms & Conditions"; +"aboutPrivacy" = "Privacy Policy"; +"aboutConnectTitle" = "Connect with Ease"; +"aboutConnectDesc" = "Simply tap your e-money card to your iPhone and get your balance instantly — no internet required."; + +// --- FAQ --- +"faqHeaderTitle" = "What can we\nhelp you with?"; +"faqSearchPlaceholder" = "Search..."; +"faqFilterAll" = "All"; +"faqNoResults" = "No results found"; +"faqHelpCardTitle" = "Still need help?"; +"faqHelpCardDesc" = "Send us an email and we'll get back to you within 1–2 business days."; +"faqEmailSupport" = "Email Support"; + +"faqCategoryCard" = "Card"; +"faqCategoryTransaction" = "Transaction"; +"faqCategoryBalance" = "Balance"; +"faqCategoryApp" = "About App"; + +"faqQ_cardCompatible" = "What cards are supported?"; +"faqA_cardCompatible" = "The app supports Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash and KMT. Make sure your iPhone supports NFC (iPhone 7 or later)."; + +"faqQ_cardNotDetected" = "Why is my card not detected?"; +"faqA_cardNotDetected" = "Ensure NFC is enabled on your iPhone. Hold the card flat against the top-front of your phone and keep it still during scanning."; + +"faqQ_cardReadFailed" = "Card read keeps failing — what should I do?"; +"faqA_cardReadFailed" = "Try removing any thick phone case, clean the card surface, and try again. If the issue persists, the card may be damaged."; + +"faqQ_txNotShown" = "Why are my transactions not showing?"; +"faqA_txNotShown" = "The app reads the last 10 transactions stored on the card chip itself. Older transactions are not accessible via NFC."; + +"faqQ_txExportPDF" = "How do I export transactions to PDF?"; +"faqA_txExportPDF" = "After scanning your card, tap 'View Full History', then tap the 'Export History to PDF' button. You can then share via WhatsApp, email, or other apps."; + +"faqQ_balanceWrong" = "The balance shown doesn't match. Why?"; +"faqA_balanceWrong" = "The app reads balance directly from the card chip in real time. Discrepancies may occur if a recent top-up has not yet been synced to the chip."; + +"faqQ_balanceTopup" = "Can I top up my card through the app?"; +"faqA_balanceTopup" = "No, this app is a read-only reader. Top-up must be done via your bank's official app, ATM, or merchant."; + +"faqQ_appLanguage" = "How do I change the app language?"; +"faqA_appLanguage" = "Go to Settings → Language. The app follows your selection and changes all text immediately."; + +"faqQ_appMaskNumber" = "What does 'Show Card Number on Home' do?"; +"faqA_appMaskNumber" = "When enabled, the full card number is shown on the home screen. When disabled, the first 12 digits are masked (****) for privacy in public spaces."; + +// --- Tab Bar --- +"tabEmoney" = "E-money"; +"tabSettings" = "Settings"; diff --git a/Emoney Info/en.lproj/Main.strings b/Emoney Info/en.lproj/Main.strings new file mode 100755 index 0000000..b86c238 --- /dev/null +++ b/Emoney Info/en.lproj/Main.strings @@ -0,0 +1,87 @@ + +/* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "9in-JR-ktf"; */ +"9in-JR-ktf.title" = "Item"; + +/* Class = "UINavigationItem"; title = "About"; ObjectID = "F6N-yB-2oz"; */ +"F6N-yB-2oz.title" = "About"; + +/* Class = "UINavigationItem"; title = "Transaction History"; ObjectID = "ITp-Hk-vPE"; */ +"ITp-Hk-vPE.title" = "Transaction History"; + +/* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "OZl-bA-PjA"; */ +"OZl-bA-PjA.title" = "Item"; + +/* Class = "UILabel"; text = "Rp 1.000.000"; ObjectID = "Oso-wx-0jC"; */ +"Oso-wx-0jC.text" = "Rp 1.000.000"; + +/* Class = "UILabel"; text = "Jika opsi ini dipilih, maka nomor kartu akan ditampilkan dengan beberapa bagian disembunyikan"; ObjectID = "R7Q-1G-6O6"; */ +"R7Q-1G-6O6.text" = "If this option is selected, the card number will be displayed with some parts hidden"; + +/* Class = "UILabel"; text = "Payment"; ObjectID = "TRL-1w-NOn"; */ +"TRL-1w-NOn.text" = "Payment"; + +/* Class = "UILabel"; text = "Version 1.0.0"; ObjectID = "VJe-fQ-MBN"; */ +"VJe-fQ-MBN.text" = "Version 1.0.0"; + +/* Class = "UILabel"; text = "Date"; ObjectID = "W7C-md-5MW"; */ +"W7C-md-5MW.text" = "Date"; + +/* Class = "UIButton"; configuration.title = "Transaction History"; ObjectID = "XDg-u0-UMy"; */ +"XDg-u0-UMy.configuration.title" = "Transaction History"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "XDg-u0-UMy"; */ +"XDg-u0-UMy.normalTitle" = "Button"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "YcB-9G-CAF"; */ +"YcB-9G-CAF.normalTitle" = "Button"; + +/* Class = "UILabel"; text = "Last Transaction"; ObjectID = "Zoc-1N-tgA"; */ +"Zoc-1N-tgA.text" = "Last Transaction"; + +/* Class = "UILabel"; text = "Type"; ObjectID = "aQt-6M-Kmf"; */ +"aQt-6M-Kmf.text" = "Type"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "aa9-WL-TIz"; */ +"aa9-WL-TIz.normalTitle" = "Button"; + +/* Class = "UIButton"; configuration.title = "Check Balance"; ObjectID = "aaf-bC-dgI"; */ +"aaf-bC-dgI.configuration.title" = "Check Balance"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "aaf-bC-dgI"; */ +"aaf-bC-dgI.normalTitle" = "Button"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "abh-nj-y92"; */ +"abh-nj-y92.normalTitle" = "Button"; + +/* Class = "UILabel"; text = "Kartu yang Didukung"; ObjectID = "dE4-Jh-VkM"; */ +"dE4-Jh-VkM.text" = "Supported Cards"; + +/* Class = "UILabel"; text = "Balance"; ObjectID = "fTA-jE-Qna"; */ +"fTA-jE-Qna.text" = "Balance"; + +/* Class = "UILabel"; text = "Rp"; ObjectID = "fuw-eQ-7SN"; */ +"fuw-eQ-7SN.text" = "Rp"; + +/* Class = "UILabel"; text = "Emoney Info"; ObjectID = "gX1-Zu-C97"; */ +"gX1-Zu-C97.text" = "Emoney Info"; + +/* Class = "UILabel"; text = "Card Number"; ObjectID = "gmE-Wy-fPa"; */ +"gmE-Wy-fPa.text" = "Card Number"; + +/* Class = "UILabel"; text = "Samarkan Nomor Kartu"; ObjectID = "gsj-W5-R53"; */ +"gsj-W5-R53.text" = "Mask Card Number"; + +/* Class = "UILabel"; text = "CARD TYPE"; ObjectID = "gtr-Wv-eFh"; */ +"gtr-Wv-eFh.text" = "CARD TYPE"; + +/* Class = "UILabel"; text = "Aplikasi ini dapat membaca kartu emoney mandiri, flazz bca, bni tapcash, Brizzi, jackcard, dan megacash"; ObjectID = "hg4-vd-djz"; */ +"hg4-vd-djz.text" = "This application can read cards: \n - emoney mandiri\n - flazz bca\n - bni tapcash\n - Brizzi\n - jackcard\n - dan megacash"; + +/* Class = "UILabel"; text = "1111 3333 5555 7777"; ObjectID = "pef-Nc-fis"; */ +"pef-Nc-fis.text" = "1111 3333 5555 7777"; + +/* Class = "UILabel"; text = "25/05/24 11:11:00"; ObjectID = "qJe-Nr-n8g"; */ +"qJe-Nr-n8g.text" = "25/05/24 11:11:00"; + +/* Class = "UILabel"; text = "Rp. 0"; ObjectID = "s8F-Gt-8SO"; */ +"s8F-Gt-8SO.text" = "Rp. 0"; diff --git a/Emoney Info/id.lproj/LaunchScreen.storyboard b/Emoney Info/id.lproj/LaunchScreen.storyboard new file mode 100755 index 0000000..865e932 --- /dev/null +++ b/Emoney Info/id.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Emoney Info/id.lproj/LaunchScreen.strings b/Emoney Info/id.lproj/LaunchScreen.strings new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/Emoney Info/id.lproj/LaunchScreen.strings @@ -0,0 +1 @@ + diff --git a/Emoney Info/id.lproj/Localizable.strings b/Emoney Info/id.lproj/Localizable.strings new file mode 100755 index 0000000..e13bbe7 --- /dev/null +++ b/Emoney Info/id.lproj/Localizable.strings @@ -0,0 +1,159 @@ +/* Emoney Info — Bahasa Indonesia */ + +// --- Existing --- +"cardType" = "JENIS KARTU"; +"checkBalance" = "Cek Saldo"; +"cardNumber" = "Nomor Kartu"; +"balance" = "Saldo"; +"transactionHistory" = "Sepuluh Transaksi Terakhir"; +"maskTitle" = "Samarkan Nomor Kartu"; +"maskDesc" = "Jika opsi ini dipilih, maka nomor kartu akan ditampilkan dengan beberapa bagian disembunyikan"; +"supportCardTitle" = "Kartu yang didukung"; +"supportCardDesc" = "Aplikasi ini dapat membaca kartu emoney sebagai berikut: \n - Mandiri e-money \n - BCA Flazz \n - BNI TapCash \n - BRI Brizzi \n - JackCard \n - MegaCash \n - KMT"; +"versi" = "Versi "; +"aboutTitle" = "Tentang Aplikasi"; +"lastTransaction" = "Transaksi Terakhir"; +"payment" = "PEMBAYARAN"; +"topup" = "ISI ULANG"; +"unknown" = "Tidak diketahui"; +"scanMessage" = "Tempelkan kartu emoney ke sensor NFC"; +"readFinish" = "Proses membaca kartu selesai"; +"readFailed" = "Proses membaca kartu gagal"; +"updateBalance" = "Update Saldo"; +"void" = "Void"; +"reactivation" = "Reaktivasi"; +"statementFee" = "Biaya cetak history"; +"gracePeriod" = "Biaya masa tenggang"; +"refund" = "Pengembalian dana"; +"close" = "Pengembalian dana penutupan kartu"; +"atu" = "Perubahan nominal ATU"; +"noCard" = "Tidak ada kartu yang terdeteksi"; + +// --- Beranda --- +"availableBalance" = "SALDO TERSEDIA"; +"cardTapInstruction" = "TEMPEL KARTU"; +"cardTypeDefault" = "Kartu E-Money"; +"tapCardHere" = "Tempel Kartu di Sini"; +"tapCardHint" = "Tempelkan kartu pada bagian depan atas iPhone Anda\nuntuk memindai."; +"viewFullHistory" = "Lihat Semua Riwayat"; +"copiedToClipboard" = "Disalin ke clipboard"; +"transactionDefault" = "Transaksi"; +"transactionStatusSuccess" = "BERHASIL"; + +// --- Riwayat --- +"historyTitle" = "Riwayat Transaksi"; +"recentActivity" = "AKTIVITAS TERKINI"; +"filterAllTime" = "Semua Waktu"; +"filterToday" = "Hari Ini"; +"filterThisMonth" = "Bulan Ini"; +"filterThisWeek" = "Minggu Ini"; +"noTransactionsFound" = "Tidak ada transaksi"; +"exportPDF" = "Ekspor Riwayat ke PDF"; + +// --- Pengaturan --- +"settingsTitle" = "Pengaturan"; +"premiumBadge" = "AKSES PREMIUM"; +"premiumTitle" = "Buka Analitik Saldo Tingkat Lanjut"; +"premiumDesc" = "Dapatkan wawasan mendalam tentang kebiasaan pengeluaran Anda dengan prediksi berbasis AI dan riwayat tanpa batas."; +"upgradeNow" = "Tingkatkan Sekarang"; +"sectionGeneral" = "UMUM"; +"languageTitle" = "Bahasa"; +"languageValue" = "Indonesia"; +"showCardNumberTitle" = "Tampilkan Nomor Kartu di Beranda"; +"showCardNumberDesc" = "Fokus privasi untuk tempat umum"; +"sectionApp" = "APLIKASI"; +"notificationsTitle" = "Notifikasi"; +"notificationsDesc" = "Kelola notifikasi dan berita"; +"helpCenterTitle" = "Pusat Bantuan"; +"helpCenterDesc" = "FAQ dan tiket dukungan"; +"aboutAppTitle" = "Tentang Aplikasi"; +"aboutAppDesc" = "Versi"; +"footerCopyright" = "EMONEY INFO © 2026"; +"reportIssue" = "Laporkan masalah dengan emoneyInfo"; + +// --- Terms & Conditions --- +"termsLastUpdated" = " Terakhir diperbarui: 5 April 2026 "; +"termsTitleRegular" = "Syarat &"; +"termsTitleBold" = "Ketentuan"; +"termsSubtitle" = "Kami menjunjung transparansi dan menyediakan layanan yang sederhana."; +"termsSec1Title" = "Layanan"; +"termsSec1Body" = "Emoney Info adalah aplikasi gratis yang memungkinkan pengguna membaca saldo dan riwayat transaksi kartu e-money NFC yang kompatibel menggunakan NFC bawaan iPhone."; +"termsSec2Title" = "Tanpa Data Pengguna"; +"termsSec2Body" = "Privasi adalah fondasi kami. Emoney Info tidak memerlukan registrasi atau pengumpulan data. Semua informasi dibaca secara lokal dari chip kartu Anda."; +"termsSec2Bullet1" = "Tidak perlu akun atau login"; +"termsSec2Bullet2" = "Tidak ada analytics atau SDK pelacak"; +"termsSec2Bullet3" = "Tidak ada data pribadi yang dikirim ke server manapun"; +"termsSec3Title" = "Batasan Tanggung Jawab"; +"termsSec3Body" = "Aplikasi menampilkan data saldo sesuai chip kartu saat pemindaian. Kami tidak bertanggung jawab atas ketidaksesuaian yang disebabkan oleh:"; +"termsSec3Bullet1" = "Top-up yang belum tersinkron ke chip"; +"termsSec3Bullet2" = "Kartu rusak atau tidak kompatibel"; +"termsContactTitle" = "Butuh bantuan lebih lanjut?"; +"termsContactDesc" = "Tim kami siap membantu menjawab pertanyaan Anda mengenai syarat dan ketentuan ini."; +"termsContactButton" = "Hubungi Kami"; + +// --- Privacy Policy --- +"privacyLastUpdated" = " Terakhir diperbarui: 5 April 2026 "; +"privacySectionNfcTitle" = "Penanganan Data NFC"; +"privacySectionNfcBody" = "Emoney Info menggunakan teknologi Near Field Communication (NFC) hanya untuk membaca saldo dan informasi kartu secara langsung."; +"privacySectionNoStorageTitle" = "Tanpa Penyimpanan"; +"privacySectionNoStorageBody" = "Data yang dibaca dari kartu NFC Anda tidak pernah disimpan di server kami. Semua pemrosesan terjadi secara lokal di perangkat Anda."; +"privacySectionReadOnlyTitle" = "Hanya Baca"; +"privacySectionReadOnlyBody" = "Aplikasi hanya membaca data dari chip kartu. Tidak dapat menulis, mengubah, atau mentransfer dana pada kartu Anda."; +"privacyContactTitle" = "Ada pertanyaan lebih lanjut?"; +"privacyContactDesc" = "Tim privasi kami siap membantu menjawab dan detail keluhan Anda. Kami akan merespons dalam 1–2 hari kerja."; +"privacyContactButton" = "Hubungi Kami"; + +// --- About --- +"aboutAppDescription" = "Emoney Info adalah pendamping e-money cerdas untuk cek saldo instan via NFC — mendukung Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JakCard, MegaCash, serta KMT"; +"aboutChipNfc" = "NFC Aman"; +"aboutChipRealtime" = "Real-time"; +"aboutChipMulti" = "Multi-Provider"; +"aboutTerms" = "Syarat & Ketentuan"; +"aboutPrivacy" = "Kebijakan Privasi"; +"aboutConnectTitle" = "Terhubung Dengan Mudah"; +"aboutConnectDesc" = "Cukup tempelkan kartu e-money ke iPhone Anda dan dapatkan saldo secara instan — tanpa koneksi internet."; + +// --- FAQ --- +"faqHeaderTitle" = "Apa yang bisa kami\nbantu?"; +"faqSearchPlaceholder" = "Cari pertanyaan..."; +"faqFilterAll" = "Semua"; +"faqNoResults" = "Tidak ada hasil ditemukan"; +"faqHelpCardTitle" = "Masih butuh bantuan?"; +"faqHelpCardDesc" = "Kirim email ke kami dan kami akan membalas dalam 1–2 hari kerja."; +"faqEmailSupport" = "Email Support"; + +"faqCategoryCard" = "Kartu Saya"; +"faqCategoryTransaction" = "Transaksi"; +"faqCategoryBalance" = "Keuangan"; +"faqCategoryApp" = "Tentang Aplikasi"; + +"faqQ_cardCompatible" = "Bagaimana cara cek saldo?"; +"faqA_cardCompatible" = "Aplikasi mendukung Mandiri e-money, BCA Flazz, BNI TapCash, BRI Brizzi, JackCard, MegaCash, dan KMT. Pastikan iPhone Anda mendukung NFC (iPhone 7 ke atas)."; + +"faqQ_cardNotDetected" = "Kartu saya apa yang didukung?"; +"faqA_cardNotDetected" = "Pastikan NFC aktif di iPhone Anda. Tempelkan kartu secara rata di bagian atas depan iPhone dan tahan diam selama pemindaian."; + +"faqQ_cardReadFailed" = "Kartu gagal dibaca terus, apa yang harus dilakukan?"; +"faqA_cardReadFailed" = "Coba lepas casing tebal, bersihkan permukaan kartu, lalu coba lagi. Jika masalah berlanjut, kartu mungkin rusak."; + +"faqQ_txNotShown" = "Mengapa transaksi saya tidak muncul?"; +"faqA_txNotShown" = "Aplikasi membaca transaksi-transaksi yang tersimpan di chip kartu. Transaksi yang lebih lama tidak dapat diakses melalui NFC."; + +"faqQ_txExportPDF" = "Cara ekspor riwayat ke PDF?"; +"faqA_txExportPDF" = "Setelah scan kartu, tekan 'Lihat Semua Riwayat', lalu tekan tombol 'Ekspor Riwayat ke PDF'. Anda dapat membagikannya via WhatsApp, email, dan lainnya."; + +"faqQ_balanceWrong" = "Saldo yang ditampilkan tidak sesuai, kenapa?"; +"faqA_balanceWrong" = "Aplikasi membaca saldo langsung dari chip kartu secara real time. Ketidaksesuaian dapat terjadi jika top-up terbaru belum tersinkron ke chip."; + +"faqQ_balanceTopup" = "Apakah bisa isi ulang saldo lewat aplikasi ini?"; +"faqA_balanceTopup" = "Tidak, aplikasi ini hanya bisa membaca saldo. Isi ulang harus dilakukan melalui aplikasi resmi bank, ATM, atau merchant."; + +"faqQ_appLanguage" = "Bagaimana cara ganti bahasa aplikasi?"; +"faqA_appLanguage" = "Buka Pengaturan → Bahasa. Aplikasi mengikuti pilihan Anda dan mengubah semua teks secara langsung."; + +"faqQ_appMaskNumber" = "Apa fungsi 'Tampilkan Nomor Kartu di Beranda'?"; +"faqA_appMaskNumber" = "Jika diaktifkan, nomor kartu penuh ditampilkan di beranda. Jika dimatikan, 12 digit pertama disembunyikan (****) untuk privasi di tempat umum."; + +// --- Tab Bar --- +"tabEmoney" = "E-money"; +"tabSettings" = "Pengaturan"; diff --git a/Emoney Info/id.lproj/Main.strings b/Emoney Info/id.lproj/Main.strings new file mode 100755 index 0000000..72c8a36 --- /dev/null +++ b/Emoney Info/id.lproj/Main.strings @@ -0,0 +1,60 @@ + +/* Class = "UIBarButtonItem"; title = "Item"; ObjectID = "9in-JR-ktf"; */ +"9in-JR-ktf.title" = "Item"; + +/* Class = "UILabel"; text = "Emoney Info"; ObjectID = "BcP-BK-ZTT"; */ +"BcP-BK-ZTT.text" = "Emoney Info"; + +/* Class = "UILabel"; text = "Version 1.0.0"; ObjectID = "Bmn-F1-M8I"; */ +"Bmn-F1-M8I.text" = "Versi 1.0.0"; + +/* Class = "UILabel"; text = "Aplikasi ini dapat membaca emoney dari beberapa bank, seperti BCA Flazz, BNI TapCash"; ObjectID = "CUd-zr-ayF"; */ +"CUd-zr-ayF.text" = "Aplikasi ini dapat membaca emoney dari beberapa bank, seperti BCA Flazz, BNI TapCash"; + +/* Class = "UINavigationItem"; title = "About"; ObjectID = "F6N-yB-2oz"; */ +"F6N-yB-2oz.title" = "About"; + +/* Class = "UILabel"; text = "Date"; ObjectID = "W7C-md-5MW"; */ +"W7C-md-5MW.text" = "Date"; + +/* Class = "UIButton"; configuration.title = "Riwayat Transaksi"; ObjectID = "XDg-u0-UMy"; */ +"XDg-u0-UMy.configuration.title" = "Riwayat Transaksi"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "XDg-u0-UMy"; */ +"XDg-u0-UMy.normalTitle" = "Button"; + +/* Class = "UILabel"; text = "Transaksi Terakhir"; ObjectID = "Zoc-1N-tgA"; */ +"Zoc-1N-tgA.text" = "Transaksi Terakhir"; + +/* Class = "UILabel"; text = "Type"; ObjectID = "aQt-6M-Kmf"; */ +"aQt-6M-Kmf.text" = "Type"; + +/* Class = "UIButton"; configuration.title = "Cek Saldo"; ObjectID = "aaf-bC-dgI"; */ +"aaf-bC-dgI.configuration.title" = "Cek Saldo"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "aaf-bC-dgI"; */ +"aaf-bC-dgI.normalTitle" = "Button"; + +/* Class = "UIButton"; normalTitle = "Button"; ObjectID = "abh-nj-y92"; */ +"abh-nj-y92.normalTitle" = "Button"; + +/* Class = "UILabel"; text = "Saldo"; ObjectID = "fTA-jE-Qna"; */ +"fTA-jE-Qna.text" = "Saldo"; + +/* Class = "UILabel"; text = "Rp"; ObjectID = "fuw-eQ-7SN"; */ +"fuw-eQ-7SN.text" = "Rp"; + +/* Class = "UILabel"; text = "Nomor Kartu"; ObjectID = "gmE-Wy-fPa"; */ +"gmE-Wy-fPa.text" = "Nomor Kartu"; + +/* Class = "UILabel"; text = "CARD LABEL"; ObjectID = "gtr-Wv-eFh"; */ +"gtr-Wv-eFh.text" = "JENIS KARTU"; + +/* Class = "UILabel"; text = "Description"; ObjectID = "mvJ-kI-RaF"; */ +"mvJ-kI-RaF.text" = "Description"; + +/* Class = "UILabel"; text = "1111 3333 5555 7777"; ObjectID = "pef-Nc-fis"; */ +"pef-Nc-fis.text" = "1111 3333 5555 7777"; + +/* Class = "UILabel"; text = "Rp. 0"; ObjectID = "s8F-Gt-8SO"; */ +"s8F-Gt-8SO.text" = "Rp. 0"; diff --git a/Podfile b/Podfile new file mode 100755 index 0000000..0cc0717 --- /dev/null +++ b/Podfile @@ -0,0 +1,12 @@ +# Uncomment the next line to define a global platform for your project +# platform :ios, '9.0' + +target 'Emoney Info' do + # Comment the next line if you don't want to use dynamic frameworks + use_frameworks! + + # Pods for Emoney Info +# pod "PromiseKit", "~> 8" +pod 'Google-Mobile-Ads-SDK' +pod 'Toast-Swift', '~> 5.0.0' +end diff --git a/Podfile.lock b/Podfile.lock new file mode 100755 index 0000000..92340e6 --- /dev/null +++ b/Podfile.lock @@ -0,0 +1,24 @@ +PODS: + - Google-Mobile-Ads-SDK (11.7.0): + - GoogleUserMessagingPlatform (>= 1.1) + - GoogleUserMessagingPlatform (2.5.0) + - Toast-Swift (5.0.1) + +DEPENDENCIES: + - Google-Mobile-Ads-SDK + - Toast-Swift (~> 5.0.0) + +SPEC REPOS: + trunk: + - Google-Mobile-Ads-SDK + - GoogleUserMessagingPlatform + - Toast-Swift + +SPEC CHECKSUMS: + Google-Mobile-Ads-SDK: 204b517c9765169144cf39763c7f5d23c57a9db0 + GoogleUserMessagingPlatform: 6b4f48a370e77ce121d034c908cc6ee4fdafaf13 + Toast-Swift: 9b6a70f28b3bf0b96c40d46c0c4b9d6639846711 + +PODFILE CHECKSUM: a8fb23d684c8043574d80ffe022008c9b1ff5b41 + +COCOAPODS: 1.16.2