diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.jazzy.yaml b/.jazzy.yaml new file mode 100644 index 0000000..9e9b5a6 --- /dev/null +++ b/.jazzy.yaml @@ -0,0 +1,39 @@ +--- +xcodebuild_arguments: +- "-workspace" +- GiniMobile.xcworkspace +- "-scheme" +- GiniMerchantSDK +- "-destination" +- platform=iOS Simulator,OS=17.2,name=iPhone 14 +author: Gini GmbH +author_url: https://gini.net +module: GiniMerchantSDK +readme: Documentation/Sections/Documentation.md +theme: Documentation/jazzy-theme/ +output: Documentation/Api +github_url: https://github.com/gini/gini-mobile-ios.git +hide_documentation_coverage: 'true' +documentation: MerchantSDK/GiniMerchantSDK/Documentation/source/*.md +abstract: MerchantSDK/GiniMerchantSDK/Documentation/Sections/*.md +custom_categories: +- name: Documentation + children: + - Installation + - Integration + - Customization guide + - Migration guide + - Event tracking guide + - Testing + - License +- name: Classes + children: [] +- name: Enums + children: [] +- name: Protocols + children: [] +- name: Structs + children: [] +- name: Typealiases + children: [] + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GiniMerchantSDK.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GiniMerchantSDK.xcscheme new file mode 100644 index 0000000..2cfca0b --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GiniMerchantSDK.xcscheme @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/GiniMerchantSDKTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/GiniMerchantSDKTests.xcscheme new file mode 100644 index 0000000..8d98d6e --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/GiniMerchantSDKTests.xcscheme @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Documentation/jazzy-theme/assets/css/highlight.css.scss b/Documentation/jazzy-theme/assets/css/highlight.css.scss new file mode 100755 index 0000000..7bc1f29 --- /dev/null +++ b/Documentation/jazzy-theme/assets/css/highlight.css.scss @@ -0,0 +1,63 @@ +/* Credit to https://gist.github.com/wataru420/2048287 */ + +.highlight { + .c { color: #999988; font-style: italic } /* Comment */ + .err { color: #a61717; background-color: #e3d2d2 } /* Error */ + .k { color: #000000; font-weight: bold } /* Keyword */ + .o { color: #000000; font-weight: bold } /* Operator */ + .cm { color: #999988; font-style: italic } /* Comment.Multiline */ + .cp { color: #999999; font-weight: bold } /* Comment.Preproc */ + .c1 { color: #999988; font-style: italic } /* Comment.Single */ + .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ + .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ + .gd .x { color: #000000; background-color: #ffaaaa } /* Generic.Deleted.Specific */ + .ge { color: #000000; font-style: italic } /* Generic.Emph */ + .gr { color: #aa0000 } /* Generic.Error */ + .gh { color: #999999 } /* Generic.Heading */ + .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ + .gi .x { color: #000000; background-color: #aaffaa } /* Generic.Inserted.Specific */ + .go { color: #888888 } /* Generic.Output */ + .gp { color: #555555 } /* Generic.Prompt */ + .gs { font-weight: bold } /* Generic.Strong */ + .gu { color: #aaaaaa } /* Generic.Subheading */ + .gt { color: #aa0000 } /* Generic.Traceback */ + .kc { color: #000000; font-weight: bold } /* Keyword.Constant */ + .kd { color: #000000; font-weight: bold } /* Keyword.Declaration */ + .kp { color: #000000; font-weight: bold } /* Keyword.Pseudo */ + .kr { color: #000000; font-weight: bold } /* Keyword.Reserved */ + .kt { color: #445588; } /* Keyword.Type */ + .m { color: #009999 } /* Literal.Number */ + .s { color: #d14 } /* Literal.String */ + .na { color: #008080 } /* Name.Attribute */ + .nb { color: #0086B3 } /* Name.Builtin */ + .nc { color: #445588; font-weight: bold } /* Name.Class */ + .no { color: #008080 } /* Name.Constant */ + .ni { color: #800080 } /* Name.Entity */ + .ne { color: #990000; font-weight: bold } /* Name.Exception */ + .nf { color: #990000; } /* Name.Function */ + .nn { color: #555555 } /* Name.Namespace */ + .nt { color: #000080 } /* Name.Tag */ + .nv { color: #008080 } /* Name.Variable */ + .ow { color: #000000; font-weight: bold } /* Operator.Word */ + .w { color: #bbbbbb } /* Text.Whitespace */ + .mf { color: #009999 } /* Literal.Number.Float */ + .mh { color: #009999 } /* Literal.Number.Hex */ + .mi { color: #009999 } /* Literal.Number.Integer */ + .mo { color: #009999 } /* Literal.Number.Oct */ + .sb { color: #d14 } /* Literal.String.Backtick */ + .sc { color: #d14 } /* Literal.String.Char */ + .sd { color: #d14 } /* Literal.String.Doc */ + .s2 { color: #d14 } /* Literal.String.Double */ + .se { color: #d14 } /* Literal.String.Escape */ + .sh { color: #d14 } /* Literal.String.Heredoc */ + .si { color: #d14 } /* Literal.String.Interpol */ + .sx { color: #d14 } /* Literal.String.Other */ + .sr { color: #009926 } /* Literal.String.Regex */ + .s1 { color: #d14 } /* Literal.String.Single */ + .ss { color: #990073 } /* Literal.String.Symbol */ + .bp { color: #999999 } /* Name.Builtin.Pseudo */ + .vc { color: #008080 } /* Name.Variable.Class */ + .vg { color: #008080 } /* Name.Variable.Global */ + .vi { color: #008080 } /* Name.Variable.Instance */ + .il { color: #009999 } /* Literal.Number.Integer.Long */ +} diff --git a/Documentation/jazzy-theme/assets/css/jazzy.css.scss b/Documentation/jazzy-theme/assets/css/jazzy.css.scss new file mode 100755 index 0000000..943d45a --- /dev/null +++ b/Documentation/jazzy-theme/assets/css/jazzy.css.scss @@ -0,0 +1,622 @@ +// =========================================================================== +// +// Variables +// +// =========================================================================== + +$body_background: #fff; +$body_font: 16px/1.7 'Helvetica Neue', Helvetica, Arial, sans-serif; +$text_color: #333; +$gray_border: 1px solid #ddd; + +$heading_weight: 700; +$light_heading_color: #777; + +$quote_color: #858585; +$quote_border: 4px solid #e5e5e5; + +$link_color: #4183c4; + +$table_alt_row_color: #fbfbfb; +$table_border_color: #ddd; + +$code_bg_color: #f7f7f7; +$code_font: Consolas, "Liberation Mono", Menlo, Courier, monospace; + + +// ----- Layout + +$gutter: 16px; +$navigation_max_width: 300px; + + +// ----- Header + +$header_bg_color: #009edc; +$header_link_color: #fff; +$doc_coverage_color: #999; + + +// ----- Breadcrumbs + +$breadcrumbs_bg_color: #fbfbfb; +$breadcrumbs_border_color: #ddd; + + +// ----- Navigation + +$navigation_max_width: 300px; +$navigation_bg_color: #fbfbfb; +$navigation_border_color: #ddd; +$navigation_title_color: #009edc; +$navigation_task_color: #808080; + +// ----- Content + +$declaration_title_language_color: #4183c4; +$declaration_language_border: 5px solid #cde9f4; +$declaration_bg_color: #fff; +$declaration_border_color: #ddd; + +$aside_color: #aaa; +$aside_border: 5px solid lighten($aside_color, 20%); +$aside_warning_color: #ff0000; +$aside_warning_border: 5px solid lighten($aside_warning_color, 20%); + +// ----- Footer + +$footer_bg_color: #444; +$footer_text_color: #ddd; +$footer_link_color: #fff; + + +// =========================================================================== +// +// Base +// +// =========================================================================== + +*, *:before, *:after { + box-sizing: inherit; +} + +body { + margin: 0; + background: $body_background; + color: $text_color; + font: $body_font; + letter-spacing: .2px; + -webkit-font-smoothing: antialiased; + box-sizing: border-box; +} + +// ----- Block elements + +@mixin heading($font-size: 1rem, $margin: 1.275em 0 0.85em) { + font-size: $font-size; + font-weight: $heading_weight; + margin: $margin; +} + +h1 { + @include heading(2rem, 1.275em 0 0.6em); +} + +h2 { + @include heading(1.75rem, 1.275em 0 0.3em); +} + +h3 { + @include heading(1.5rem, 1em 0 0.3em); +} + +h4 { + @include heading(1.25rem); +} + +h5 { + @include heading; +} + +h6 { + @include heading; + color: $light_heading_color; +} + +p { + margin: 0 0 1em; +} + +ul, ol { + padding: 0 0 0 2em; + margin: 0 0 0.85em; +} + +blockquote { + margin: 0 0 0.85em; + padding: 0 15px; + color: $quote_color; + border-left: $quote_border; +} + + +// ----- Inline elements + +img { + max-width: 100%; +} + +a { + color: $link_color; + text-decoration: none; + + &:hover, &:focus { + outline: 0; + text-decoration: underline; + } +} + + +// ----- Tables + +table { + background: $body_background; + width: 100%; + border-collapse: collapse; + border-spacing: 0; + overflow: auto; + margin: 0 0 0.85em; +} + +tr { + &:nth-child(2n) { + background-color: $table_alt_row_color; + } +} + +th, td { + padding: 6px 13px; + border: 1px solid $table_border_color; +} + + +// ----- Code + +pre { + margin: 0 0 1.275em; + padding: .85em 1em; + overflow: auto; + background: $code_bg_color; + font-size: .85em; + font-family: $code_font; +} + +code { + font-family: $code_font; +} + +p, li { + > code { + background: $code_bg_color; + padding: .2em; + &:before, &:after { + letter-spacing: -.2em; + content: "\00a0"; + } + } +} + +pre code { + padding: 0; + white-space: pre; +} + + +// =========================================================================== +// +// Layout +// +// =========================================================================== + +.content-wrapper { + display: flex; + flex-direction: column; + @media (min-width: 768px) { + flex-direction: row; + } +} + + +// =========================================================================== +// +// Header +// +// =========================================================================== + +.header { + display: flex; + padding: $gutter/2; + font-size: 0.875em; + background: $header_bg_color; + color: $doc_coverage_color; + align-items: flex-end +} + +.header-col { + margin: 0; + padding: 0 $gutter/2 +} + +.header-col--primary { + flex: 1; +} + +.header-link { + color: $header_link_color; +} + +.header-icon { + padding-right: 6px; + vertical-align: -4px; + height: 16px; +} + +.logo-icon { +padding-right: 6px; +vertical-align: -4px; +height: 60px; +} + + + +// =========================================================================== +// +// Breadcrumbs +// +// =========================================================================== + +.breadcrumbs { + font-size: 0.875em; + padding: $gutter / 2 $gutter; + margin: 0; + background: $breadcrumbs_bg_color; + border-bottom: 1px solid $breadcrumbs_border_color; +} + +.carat { + height: 10px; + margin: 0 5px; +} + + +// =========================================================================== +// +// Navigation +// +// =========================================================================== + +.navigation { + order: 2; + + @media (min-width: 768px) { + order: 1; + width: 25%; + max-width: $navigation_max_width; + padding-bottom: $gutter*4; + overflow: hidden; + word-wrap: normal; + background: $navigation_bg_color; + border-right: 1px solid $navigation_border_color; + } +} + +.nav-groups { + list-style-type: none; + padding-left: 0; +} + +.nav-group-name { + border-bottom: 1px solid $navigation_border_color; + padding: $gutter/2 0 $gutter/2 $gutter; +} + +.nav-group-name-link { + color: $navigation_title_color; +} + +.nav-group-tasks { + margin: $gutter/2 0; + padding: 0 0 0 $gutter/2; +} + +.nav-group-task { + font-size: 1em; + list-style-type: none; + white-space: nowrap; +} + +.nav-group-task-link { + color: $navigation_task_color; +} + +// =========================================================================== +// +// Content +// +// =========================================================================== + +.main-content { + order: 1; + @media (min-width: 768px) { + order: 2; + flex: 1; + padding-bottom: 60px; + } +} + +.section { + padding: 0 $gutter * 2; + border-bottom: 1px solid $navigation_border_color; +} + +.section-content { + max-width: 834px; + margin: 0 auto; + padding: $gutter 0; +} + +.section-name { + color: #666; + display: block; +} + +.declaration .highlight { + overflow-x: initial; // This allows the scrollbar to show up inside declarations + padding: $gutter/2 0; + margin: 0; + background-color: transparent; + border: none; +} + +.task-group-section { + border-top: $gray_border; +} + +.task-group { + padding-top: 0px; +} + +.task-name-container { + a[name] { + &:before { + content: ""; + display: block; + } + } +} + +.item-container { + padding: 0; +} + +.item { + padding-top: 8px; + width: 100%; + list-style-type: none; + + a[name] { + &:before { + content: ""; + display: block; + } + } + + .token { + padding-left: 3px; + margin-left: 0px; + font-size: 1rem; + } + + .declaration-note { + font-size: .85em; + color: #808080; + font-style: italic; + } +} + +.pointer-container { + border-bottom: $gray_border; + left: -23px; + padding-bottom: 13px; + position: relative; + width: 110%; +} + +.pointer { + left: 21px; + top: 7px; + display: block; + position: absolute; + width: 12px; + height: 12px; + border-left: 1px solid $declaration_border_color; + border-top: 1px solid $declaration_border_color; + background: $declaration_bg_color; + transform: rotate(45deg); +} + +.height-container { + display: none; + position: relative; + width: 100%; + overflow: hidden; + .section { + background: $declaration_bg_color; + border: $gray_border; + border-top-width: 0; + padding-top: 10px; + padding-bottom: 5px; + padding: $gutter / 2 $gutter; + } +} + +.aside, .language { + padding: 6px 12px; + margin: 12px 0; + border-left: $aside_border; + overflow-y: hidden; + .aside-title { + font-size: 9px; + letter-spacing: 2px; + text-transform: uppercase; + padding-bottom: 0; + margin: 0; + color: $aside_color; + -webkit-user-select: none; + } + p:last-child { + margin-bottom: 0; + } +} + +.language { + border-left: $declaration_language_border; + .aside-title { + color: $declaration_title_language_color; + } +} + +.aside-warning { + border-left: $aside_warning_border; + .aside-title { + color: $aside_warning_color; + } +} + +.graybox { + border-collapse: collapse; + width: 100%; + p { + margin: 0; + word-break: break-word; + min-width: 50px; + } + td { + border: $gray_border; + padding: 5px 25px 5px 10px; + vertical-align: middle; + } + tr td:first-of-type { + text-align: right; + padding: 7px; + vertical-align: top; + word-break: normal; + width: 40px; + } +} + +.slightly-smaller { + font-size: 0.9em; +} + + +// =========================================================================== +// +// Footer +// +// =========================================================================== + +.footer { + padding: $gutter/2 $gutter; + background: $footer_bg_color; + color: $footer_text_color; + font-size: 0.8em; + + p { + margin: $gutter/2 0; + } + + a { + color: $footer_link_color; + } +} + + +// =========================================================================== +// +// Dash +// +// =========================================================================== + +html.dash { + + .header, .breadcrumbs, .navigation { + display: none; + } + + .height-container { + display: block; + } +} + +// =========================================================================== +// +// Search +// +// =========================================================================== +form[role=search] { + input { + font: $body_font; + font-size: 14px; + line-height: 24px; + padding: 0 10px; + margin: 0; + border: none; + border-radius: 1em; + .loading & { + background: white url(../img/spinner.gif) center right 4px no-repeat; + } + } + + // Typeahead elements + + .tt-menu { + margin: 0; + min-width: 300px; + background: $navigation_bg_color; + color: $text_color; + border: 1px solid $navigation_border_color; + } + + .tt-highlight { + font-weight: bold; + } + + .tt-suggestion { + font: $body_font; + padding: 0 $gutter/2; + span { + display: table-cell; + white-space: nowrap; + } + .doc-parent-name { + width: 100%; + text-align: right; + font-weight: normal; + font-size: 0.9em; + padding-left: $gutter; + } + } + + .tt-suggestion:hover, + .tt-suggestion.tt-cursor { + cursor: pointer; + background-color: $link_color; + color: #fff; + } + + .tt-suggestion:hover .doc-parent-name, + .tt-suggestion.tt-cursor .doc-parent-name { + color: #fff; + } +} diff --git a/Documentation/jazzy-theme/assets/img/Integration guide/BankSelectionBottomSheet.png b/Documentation/jazzy-theme/assets/img/Integration guide/BankSelectionBottomSheet.png new file mode 100644 index 0000000..6bfa524 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/Integration guide/BankSelectionBottomSheet.png differ diff --git a/Documentation/jazzy-theme/assets/img/Integration guide/OrderDetailWithPaymentComponent.png b/Documentation/jazzy-theme/assets/img/Integration guide/OrderDetailWithPaymentComponent.png new file mode 100644 index 0000000..74ea4bc Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/Integration guide/OrderDetailWithPaymentComponent.png differ diff --git a/Documentation/jazzy-theme/assets/img/Integration guide/PaymentFeatureInformationScreen.png b/Documentation/jazzy-theme/assets/img/Integration guide/PaymentFeatureInformationScreen.png new file mode 100644 index 0000000..8f29399 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/Integration guide/PaymentFeatureInformationScreen.png differ diff --git a/Documentation/jazzy-theme/assets/img/Integration guide/PaymentReviewBottomSheet.png b/Documentation/jazzy-theme/assets/img/Integration guide/PaymentReviewBottomSheet.png new file mode 100644 index 0000000..d002080 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/Integration guide/PaymentReviewBottomSheet.png differ diff --git a/Documentation/jazzy-theme/assets/img/Integration guide/ReviewScreenAfterResolvingPayment.PNG b/Documentation/jazzy-theme/assets/img/Integration guide/ReviewScreenAfterResolvingPayment.PNG new file mode 100644 index 0000000..24a0c68 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/Integration guide/ReviewScreenAfterResolvingPayment.PNG differ diff --git a/Documentation/jazzy-theme/assets/img/Integration guide/ReviewScreenBeforeResolvingPayment.PNG b/Documentation/jazzy-theme/assets/img/Integration guide/ReviewScreenBeforeResolvingPayment.PNG new file mode 100644 index 0000000..820d8af Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/Integration guide/ReviewScreenBeforeResolvingPayment.PNG differ diff --git a/Documentation/jazzy-theme/assets/img/Integration guide/SchemeExample.png b/Documentation/jazzy-theme/assets/img/Integration guide/SchemeExample.png new file mode 100644 index 0000000..e9301ff Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/Integration guide/SchemeExample.png differ diff --git a/Documentation/jazzy-theme/assets/img/carat.png b/Documentation/jazzy-theme/assets/img/carat.png new file mode 100755 index 0000000..29d2f7f Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/carat.png differ diff --git a/Documentation/jazzy-theme/assets/img/credentials.png b/Documentation/jazzy-theme/assets/img/credentials.png new file mode 100644 index 0000000..6d8f4ad Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/credentials.png differ diff --git a/Documentation/jazzy-theme/assets/img/credentials_plist_format.png b/Documentation/jazzy-theme/assets/img/credentials_plist_format.png new file mode 100644 index 0000000..6d8f4ad Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/credentials_plist_format.png differ diff --git a/Documentation/jazzy-theme/assets/img/dash.png b/Documentation/jazzy-theme/assets/img/dash.png new file mode 100755 index 0000000..6f694c7 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/dash.png differ diff --git a/Documentation/jazzy-theme/assets/img/gh.png b/Documentation/jazzy-theme/assets/img/gh.png new file mode 100755 index 0000000..628da97 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/gh.png differ diff --git a/Documentation/jazzy-theme/assets/img/logo.png b/Documentation/jazzy-theme/assets/img/logo.png new file mode 100644 index 0000000..28f0c36 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/logo.png differ diff --git a/Documentation/jazzy-theme/assets/img/repo-logo.png b/Documentation/jazzy-theme/assets/img/repo-logo.png new file mode 100644 index 0000000..28f0c36 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/repo-logo.png differ diff --git a/Documentation/jazzy-theme/assets/img/spinner.gif b/Documentation/jazzy-theme/assets/img/spinner.gif new file mode 100755 index 0000000..e3038d0 Binary files /dev/null and b/Documentation/jazzy-theme/assets/img/spinner.gif differ diff --git a/Documentation/jazzy-theme/assets/js/jazzy.js b/Documentation/jazzy-theme/assets/js/jazzy.js new file mode 100755 index 0000000..009c80d --- /dev/null +++ b/Documentation/jazzy-theme/assets/js/jazzy.js @@ -0,0 +1,43 @@ +window.jazzy = {'docset': false} +if (typeof window.dash != 'undefined') { + document.documentElement.className += ' dash' + window.jazzy.docset = true +} +if (navigator.userAgent.match(/xcode/i)) { + document.documentElement.className += ' xcode' + window.jazzy.docset = true +} + +// On doc load, toggle the URL hash discussion if present +$(document).ready(function() { + if (!window.jazzy.docset) { + var linkToHash = $('a[href="' + window.location.hash +'"]'); + linkToHash.trigger("click"); + } +}); + +// On token click, toggle its discussion and animate token.marginLeft +$(".token").click(function(event) { + if (window.jazzy.docset) { + return; + } + var link = $(this); + var animationDuration = 300; + $content = link.parent().parent().next(); + $content.slideToggle(animationDuration); + + // Keeps the document from jumping to the hash. + var href = $(this).attr('href'); + if (history.pushState) { + history.pushState({}, '', href); + } else { + location.hash = href; + } + event.preventDefault(); +}); + +// Dumb down quotes within code blocks that delimit strings instead of quotations +// https://github.com/realm/jazzy/issues/714 +$("code q").replaceWith(function () { + return ["\"", $(this).contents(), "\""]; +}); diff --git a/Documentation/jazzy-theme/assets/js/jazzy.search.js b/Documentation/jazzy-theme/assets/js/jazzy.search.js new file mode 100755 index 0000000..54be83c --- /dev/null +++ b/Documentation/jazzy-theme/assets/js/jazzy.search.js @@ -0,0 +1,62 @@ +$(function(){ + var searchIndex = lunr(function() { + this.ref('url'); + this.field('name'); + }); + + var $typeahead = $('[data-typeahead]'); + var $form = $typeahead.parents('form'); + var searchURL = $form.attr('action'); + + function displayTemplate(result) { + return result.name; + } + + function suggestionTemplate(result) { + var t = '
'; + t += '' + result.name + ''; + if (result.parent_name) { + t += '' + result.parent_name + ''; + } + t += '
'; + return t; + } + + $typeahead.one('focus', function() { + $form.addClass('loading'); + + $.getJSON(searchURL).then(function(searchData) { + $.each(searchData, function (url, doc) { + searchIndex.add({url: url, name: doc.name}); + }); + + $typeahead.typeahead( + { + highlight: true, + minLength: 3 + }, + { + limit: 10, + display: displayTemplate, + templates: { suggestion: suggestionTemplate }, + source: function(query, sync) { + var results = searchIndex.search(query).map(function(result) { + var doc = searchData[result.ref]; + doc.url = result.ref; + return doc; + }); + sync(results); + } + } + ); + $form.removeClass('loading'); + $typeahead.trigger('focus'); + }); + }); + + var baseURL = searchURL.slice(0, -"search.json".length); + + $typeahead.on('typeahead:select', function(e, result) { + window.location = baseURL + result.url; + }); +}); diff --git a/Documentation/jazzy-theme/assets/js/jquery.min.js b/Documentation/jazzy-theme/assets/js/jquery.min.js new file mode 100755 index 0000000..ab28a24 --- /dev/null +++ b/Documentation/jazzy-theme/assets/js/jquery.min.js @@ -0,0 +1,4 @@ +/*! jQuery v1.11.1 | (c) 2005, 2014 jQuery Foundation, Inc. | jquery.org/license */ +!function(a,b){"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){var c=[],d=c.slice,e=c.concat,f=c.push,g=c.indexOf,h={},i=h.toString,j=h.hasOwnProperty,k={},l="1.11.1",m=function(a,b){return new m.fn.init(a,b)},n=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,o=/^-ms-/,p=/-([\da-z])/gi,q=function(a,b){return b.toUpperCase()};m.fn=m.prototype={jquery:l,constructor:m,selector:"",length:0,toArray:function(){return d.call(this)},get:function(a){return null!=a?0>a?this[a+this.length]:this[a]:d.call(this)},pushStack:function(a){var b=m.merge(this.constructor(),a);return b.prevObject=this,b.context=this.context,b},each:function(a,b){return m.each(this,a,b)},map:function(a){return this.pushStack(m.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(d.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(0>a?b:0);return this.pushStack(c>=0&&b>c?[this[c]]:[])},end:function(){return this.prevObject||this.constructor(null)},push:f,sort:c.sort,splice:c.splice},m.extend=m.fn.extend=function(){var a,b,c,d,e,f,g=arguments[0]||{},h=1,i=arguments.length,j=!1;for("boolean"==typeof g&&(j=g,g=arguments[h]||{},h++),"object"==typeof g||m.isFunction(g)||(g={}),h===i&&(g=this,h--);i>h;h++)if(null!=(e=arguments[h]))for(d in e)a=g[d],c=e[d],g!==c&&(j&&c&&(m.isPlainObject(c)||(b=m.isArray(c)))?(b?(b=!1,f=a&&m.isArray(a)?a:[]):f=a&&m.isPlainObject(a)?a:{},g[d]=m.extend(j,f,c)):void 0!==c&&(g[d]=c));return g},m.extend({expando:"jQuery"+(l+Math.random()).replace(/\D/g,""),isReady:!0,error:function(a){throw new Error(a)},noop:function(){},isFunction:function(a){return"function"===m.type(a)},isArray:Array.isArray||function(a){return"array"===m.type(a)},isWindow:function(a){return null!=a&&a==a.window},isNumeric:function(a){return!m.isArray(a)&&a-parseFloat(a)>=0},isEmptyObject:function(a){var b;for(b in a)return!1;return!0},isPlainObject:function(a){var b;if(!a||"object"!==m.type(a)||a.nodeType||m.isWindow(a))return!1;try{if(a.constructor&&!j.call(a,"constructor")&&!j.call(a.constructor.prototype,"isPrototypeOf"))return!1}catch(c){return!1}if(k.ownLast)for(b in a)return j.call(a,b);for(b in a);return void 0===b||j.call(a,b)},type:function(a){return null==a?a+"":"object"==typeof a||"function"==typeof a?h[i.call(a)]||"object":typeof a},globalEval:function(b){b&&m.trim(b)&&(a.execScript||function(b){a.eval.call(a,b)})(b)},camelCase:function(a){return a.replace(o,"ms-").replace(p,q)},nodeName:function(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()},each:function(a,b,c){var d,e=0,f=a.length,g=r(a);if(c){if(g){for(;f>e;e++)if(d=b.apply(a[e],c),d===!1)break}else for(e in a)if(d=b.apply(a[e],c),d===!1)break}else if(g){for(;f>e;e++)if(d=b.call(a[e],e,a[e]),d===!1)break}else for(e in a)if(d=b.call(a[e],e,a[e]),d===!1)break;return a},trim:function(a){return null==a?"":(a+"").replace(n,"")},makeArray:function(a,b){var c=b||[];return null!=a&&(r(Object(a))?m.merge(c,"string"==typeof a?[a]:a):f.call(c,a)),c},inArray:function(a,b,c){var d;if(b){if(g)return g.call(b,a,c);for(d=b.length,c=c?0>c?Math.max(0,d+c):c:0;d>c;c++)if(c in b&&b[c]===a)return c}return-1},merge:function(a,b){var c=+b.length,d=0,e=a.length;while(c>d)a[e++]=b[d++];if(c!==c)while(void 0!==b[d])a[e++]=b[d++];return a.length=e,a},grep:function(a,b,c){for(var d,e=[],f=0,g=a.length,h=!c;g>f;f++)d=!b(a[f],f),d!==h&&e.push(a[f]);return e},map:function(a,b,c){var d,f=0,g=a.length,h=r(a),i=[];if(h)for(;g>f;f++)d=b(a[f],f,c),null!=d&&i.push(d);else for(f in a)d=b(a[f],f,c),null!=d&&i.push(d);return e.apply([],i)},guid:1,proxy:function(a,b){var c,e,f;return"string"==typeof b&&(f=a[b],b=a,a=f),m.isFunction(a)?(c=d.call(arguments,2),e=function(){return a.apply(b||this,c.concat(d.call(arguments)))},e.guid=a.guid=a.guid||m.guid++,e):void 0},now:function(){return+new Date},support:k}),m.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(a,b){h["[object "+b+"]"]=b.toLowerCase()});function r(a){var b=a.length,c=m.type(a);return"function"===c||m.isWindow(a)?!1:1===a.nodeType&&b?!0:"array"===c||0===b||"number"==typeof b&&b>0&&b-1 in a}var s=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+-new Date,v=a.document,w=0,x=0,y=gb(),z=gb(),A=gb(),B=function(a,b){return a===b&&(l=!0),0},C="undefined",D=1<<31,E={}.hasOwnProperty,F=[],G=F.pop,H=F.push,I=F.push,J=F.slice,K=F.indexOf||function(a){for(var b=0,c=this.length;c>b;b++)if(this[b]===a)return b;return-1},L="checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped",M="[\\x20\\t\\r\\n\\f]",N="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=N.replace("w","w#"),P="\\["+M+"*("+N+")(?:"+M+"*([*^$|!~]?=)"+M+"*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|("+O+"))|)"+M+"*\\]",Q=":("+N+")(?:\\((('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|((?:\\\\.|[^\\\\()[\\]]|"+P+")*)|.*)\\)|)",R=new RegExp("^"+M+"+|((?:^|[^\\\\])(?:\\\\.)*)"+M+"+$","g"),S=new RegExp("^"+M+"*,"+M+"*"),T=new RegExp("^"+M+"*([>+~]|"+M+")"+M+"*"),U=new RegExp("="+M+"*([^\\]'\"]*?)"+M+"*\\]","g"),V=new RegExp(Q),W=new RegExp("^"+O+"$"),X={ID:new RegExp("^#("+N+")"),CLASS:new RegExp("^\\.("+N+")"),TAG:new RegExp("^("+N.replace("w","w*")+")"),ATTR:new RegExp("^"+P),PSEUDO:new RegExp("^"+Q),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+L+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/^(?:input|select|textarea|button)$/i,Z=/^h\d$/i,$=/^[^{]+\{\s*\[native \w/,_=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ab=/[+~]/,bb=/'|\\/g,cb=new RegExp("\\\\([\\da-f]{1,6}"+M+"?|("+M+")|.)","ig"),db=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:0>d?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)};try{I.apply(F=J.call(v.childNodes),v.childNodes),F[v.childNodes.length].nodeType}catch(eb){I={apply:F.length?function(a,b){H.apply(a,J.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function fb(a,b,d,e){var f,h,j,k,l,o,r,s,w,x;if((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,d=d||[],!a||"string"!=typeof a)return d;if(1!==(k=b.nodeType)&&9!==k)return[];if(p&&!e){if(f=_.exec(a))if(j=f[1]){if(9===k){if(h=b.getElementById(j),!h||!h.parentNode)return d;if(h.id===j)return d.push(h),d}else if(b.ownerDocument&&(h=b.ownerDocument.getElementById(j))&&t(b,h)&&h.id===j)return d.push(h),d}else{if(f[2])return I.apply(d,b.getElementsByTagName(a)),d;if((j=f[3])&&c.getElementsByClassName&&b.getElementsByClassName)return I.apply(d,b.getElementsByClassName(j)),d}if(c.qsa&&(!q||!q.test(a))){if(s=r=u,w=b,x=9===k&&a,1===k&&"object"!==b.nodeName.toLowerCase()){o=g(a),(r=b.getAttribute("id"))?s=r.replace(bb,"\\$&"):b.setAttribute("id",s),s="[id='"+s+"'] ",l=o.length;while(l--)o[l]=s+qb(o[l]);w=ab.test(a)&&ob(b.parentNode)||b,x=o.join(",")}if(x)try{return I.apply(d,w.querySelectorAll(x)),d}catch(y){}finally{r||b.removeAttribute("id")}}}return i(a.replace(R,"$1"),b,d,e)}function gb(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function hb(a){return a[u]=!0,a}function ib(a){var b=n.createElement("div");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function jb(a,b){var c=a.split("|"),e=a.length;while(e--)d.attrHandle[c[e]]=b}function kb(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&(~b.sourceIndex||D)-(~a.sourceIndex||D);if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function lb(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function mb(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function nb(a){return hb(function(b){return b=+b,hb(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function ob(a){return a&&typeof a.getElementsByTagName!==C&&a}c=fb.support={},f=fb.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return b?"HTML"!==b.nodeName:!1},m=fb.setDocument=function(a){var b,e=a?a.ownerDocument||a:v,g=e.defaultView;return e!==n&&9===e.nodeType&&e.documentElement?(n=e,o=e.documentElement,p=!f(e),g&&g!==g.top&&(g.addEventListener?g.addEventListener("unload",function(){m()},!1):g.attachEvent&&g.attachEvent("onunload",function(){m()})),c.attributes=ib(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ib(function(a){return a.appendChild(e.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=$.test(e.getElementsByClassName)&&ib(function(a){return a.innerHTML="
",a.firstChild.className="i",2===a.getElementsByClassName("i").length}),c.getById=ib(function(a){return o.appendChild(a).id=u,!e.getElementsByName||!e.getElementsByName(u).length}),c.getById?(d.find.ID=function(a,b){if(typeof b.getElementById!==C&&p){var c=b.getElementById(a);return c&&c.parentNode?[c]:[]}},d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){return a.getAttribute("id")===b}}):(delete d.find.ID,d.filter.ID=function(a){var b=a.replace(cb,db);return function(a){var c=typeof a.getAttributeNode!==C&&a.getAttributeNode("id");return c&&c.value===b}}),d.find.TAG=c.getElementsByTagName?function(a,b){return typeof b.getElementsByTagName!==C?b.getElementsByTagName(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){return typeof b.getElementsByClassName!==C&&p?b.getElementsByClassName(a):void 0},r=[],q=[],(c.qsa=$.test(e.querySelectorAll))&&(ib(function(a){a.innerHTML="",a.querySelectorAll("[msallowclip^='']").length&&q.push("[*^$]="+M+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+M+"*(?:value|"+L+")"),a.querySelectorAll(":checked").length||q.push(":checked")}),ib(function(a){var b=e.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+M+"*[*^$|!~]?="),a.querySelectorAll(":enabled").length||q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=$.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ib(function(a){c.disconnectedMatch=s.call(a,"div"),s.call(a,"[s!='']:x"),r.push("!=",Q)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=$.test(o.compareDocumentPosition),t=b||$.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===e||a.ownerDocument===v&&t(v,a)?-1:b===e||b.ownerDocument===v&&t(v,b)?1:k?K.call(k,a)-K.call(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,f=a.parentNode,g=b.parentNode,h=[a],i=[b];if(!f||!g)return a===e?-1:b===e?1:f?-1:g?1:k?K.call(k,a)-K.call(k,b):0;if(f===g)return kb(a,b);c=a;while(c=c.parentNode)h.unshift(c);c=b;while(c=c.parentNode)i.unshift(c);while(h[d]===i[d])d++;return d?kb(h[d],i[d]):h[d]===v?-1:i[d]===v?1:0},e):n},fb.matches=function(a,b){return fb(a,null,null,b)},fb.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(U,"='$1']"),!(!c.matchesSelector||!p||r&&r.test(b)||q&&q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return fb(b,n,null,[a]).length>0},fb.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},fb.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&E.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},fb.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},fb.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=fb.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=fb.selectors={cacheLength:50,createPseudo:hb,match:X,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(cb,db),a[3]=(a[3]||a[4]||a[5]||"").replace(cb,db),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||fb.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&fb.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return X.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&V.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(cb,db).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+M+")"+a+"("+M+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||typeof a.getAttribute!==C&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=fb.attr(d,a);return null==e?"!="===b:b?(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e+" ").indexOf(c)>-1:"|="===b?e===c||e.slice(0,c.length+1)===c+"-":!1):!0}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h;if(q){if(f){while(p){l=b;while(l=l[p])if(h?l.nodeName.toLowerCase()===r:1===l.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){k=q[u]||(q[u]={}),j=k[a]||[],n=j[0]===w&&j[1],m=j[0]===w&&j[2],l=n&&q.childNodes[n];while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if(1===l.nodeType&&++m&&l===b){k[a]=[w,n,m];break}}else if(s&&(j=(b[u]||(b[u]={}))[a])&&j[0]===w)m=j[1];else while(l=++n&&l&&l[p]||(m=n=0)||o.pop())if((h?l.nodeName.toLowerCase()===r:1===l.nodeType)&&++m&&(s&&((l[u]||(l[u]={}))[a]=[w,m]),l===b))break;return m-=e,m===d||m%d===0&&m/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||fb.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?hb(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=K.call(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:hb(function(a){var b=[],c=[],d=h(a.replace(R,"$1"));return d[u]?hb(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),!c.pop()}}),has:hb(function(a){return function(b){return fb(a,b).length>0}}),contains:hb(function(a){return function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:hb(function(a){return W.test(a||"")||fb.error("unsupported lang: "+a),a=a.replace(cb,db).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:function(a){return a.disabled===!1},disabled:function(a){return a.disabled===!0},checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return Z.test(a.nodeName)},input:function(a){return Y.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:nb(function(){return[0]}),last:nb(function(a,b){return[b-1]}),eq:nb(function(a,b,c){return[0>c?c+b:c]}),even:nb(function(a,b){for(var c=0;b>c;c+=2)a.push(c);return a}),odd:nb(function(a,b){for(var c=1;b>c;c+=2)a.push(c);return a}),lt:nb(function(a,b,c){for(var d=0>c?c+b:c;--d>=0;)a.push(d);return a}),gt:nb(function(a,b,c){for(var d=0>c?c+b:c;++db;b++)d+=a[b].value;return d}function rb(a,b,c){var d=b.dir,e=c&&"parentNode"===d,f=x++;return b.first?function(b,c,f){while(b=b[d])if(1===b.nodeType||e)return a(b,c,f)}:function(b,c,g){var h,i,j=[w,f];if(g){while(b=b[d])if((1===b.nodeType||e)&&a(b,c,g))return!0}else while(b=b[d])if(1===b.nodeType||e){if(i=b[u]||(b[u]={}),(h=i[d])&&h[0]===w&&h[1]===f)return j[2]=h[2];if(i[d]=j,j[2]=a(b,c,g))return!0}}}function sb(a){return a.length>1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function tb(a,b,c){for(var d=0,e=b.length;e>d;d++)fb(a,b[d],c);return c}function ub(a,b,c,d,e){for(var f,g=[],h=0,i=a.length,j=null!=b;i>h;h++)(f=a[h])&&(!c||c(f,d,e))&&(g.push(f),j&&b.push(h));return g}function vb(a,b,c,d,e,f){return d&&!d[u]&&(d=vb(d)),e&&!e[u]&&(e=vb(e,f)),hb(function(f,g,h,i){var j,k,l,m=[],n=[],o=g.length,p=f||tb(b||"*",h.nodeType?[h]:h,[]),q=!a||!f&&b?p:ub(p,m,a,h,i),r=c?e||(f?a:o||d)?[]:g:q;if(c&&c(q,r,h,i),d){j=ub(r,n),d(j,[],h,i),k=j.length;while(k--)(l=j[k])&&(r[n[k]]=!(q[n[k]]=l))}if(f){if(e||a){if(e){j=[],k=r.length;while(k--)(l=r[k])&&j.push(q[k]=l);e(null,r=[],j,i)}k=r.length;while(k--)(l=r[k])&&(j=e?K.call(f,l):m[k])>-1&&(f[j]=!(g[j]=l))}}else r=ub(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):I.apply(g,r)})}function wb(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=rb(function(a){return a===b},h,!0),l=rb(function(a){return K.call(b,a)>-1},h,!0),m=[function(a,c,d){return!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d))}];f>i;i++)if(c=d.relative[a[i].type])m=[rb(sb(m),c)];else{if(c=d.filter[a[i].type].apply(null,a[i].matches),c[u]){for(e=++i;f>e;e++)if(d.relative[a[e].type])break;return vb(i>1&&sb(m),i>1&&qb(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(R,"$1"),c,e>i&&wb(a.slice(i,e)),f>e&&wb(a=a.slice(e)),f>e&&qb(a))}m.push(c)}return sb(m)}function xb(a,b){var c=b.length>0,e=a.length>0,f=function(f,g,h,i,k){var l,m,o,p=0,q="0",r=f&&[],s=[],t=j,u=f||e&&d.find.TAG("*",k),v=w+=null==t?1:Math.random()||.1,x=u.length;for(k&&(j=g!==n&&g);q!==x&&null!=(l=u[q]);q++){if(e&&l){m=0;while(o=a[m++])if(o(l,g,h)){i.push(l);break}k&&(w=v)}c&&((l=!o&&l)&&p--,f&&r.push(l))}if(p+=q,c&&q!==p){m=0;while(o=b[m++])o(r,s,g,h);if(f){if(p>0)while(q--)r[q]||s[q]||(s[q]=G.call(i));s=ub(s)}I.apply(i,s),k&&!f&&s.length>0&&p+b.length>1&&fb.uniqueSort(i)}return k&&(w=v,j=t),r};return c?hb(f):f}return h=fb.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=wb(b[c]),f[u]?d.push(f):e.push(f);f=A(a,xb(e,d)),f.selector=a}return f},i=fb.select=function(a,b,e,f){var i,j,k,l,m,n="function"==typeof a&&a,o=!f&&g(a=n.selector||a);if(e=e||[],1===o.length){if(j=o[0]=o[0].slice(0),j.length>2&&"ID"===(k=j[0]).type&&c.getById&&9===b.nodeType&&p&&d.relative[j[1].type]){if(b=(d.find.ID(k.matches[0].replace(cb,db),b)||[])[0],!b)return e;n&&(b=b.parentNode),a=a.slice(j.shift().value.length)}i=X.needsContext.test(a)?0:j.length;while(i--){if(k=j[i],d.relative[l=k.type])break;if((m=d.find[l])&&(f=m(k.matches[0].replace(cb,db),ab.test(j[0].type)&&ob(b.parentNode)||b))){if(j.splice(i,1),a=f.length&&qb(j),!a)return I.apply(e,f),e;break}}}return(n||h(a,o))(f,b,!p,e,ab.test(a)&&ob(b.parentNode)||b),e},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ib(function(a){return 1&a.compareDocumentPosition(n.createElement("div"))}),ib(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||jb("type|href|height|width",function(a,b,c){return c?void 0:a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ib(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||jb("value",function(a,b,c){return c||"input"!==a.nodeName.toLowerCase()?void 0:a.defaultValue}),ib(function(a){return null==a.getAttribute("disabled")})||jb(L,function(a,b,c){var d;return c?void 0:a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),fb}(a);m.find=s,m.expr=s.selectors,m.expr[":"]=m.expr.pseudos,m.unique=s.uniqueSort,m.text=s.getText,m.isXMLDoc=s.isXML,m.contains=s.contains;var t=m.expr.match.needsContext,u=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,v=/^.[^:#\[\.,]*$/;function w(a,b,c){if(m.isFunction(b))return m.grep(a,function(a,d){return!!b.call(a,d,a)!==c});if(b.nodeType)return m.grep(a,function(a){return a===b!==c});if("string"==typeof b){if(v.test(b))return m.filter(b,a,c);b=m.filter(b,a)}return m.grep(a,function(a){return m.inArray(a,b)>=0!==c})}m.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?m.find.matchesSelector(d,a)?[d]:[]:m.find.matches(a,m.grep(b,function(a){return 1===a.nodeType}))},m.fn.extend({find:function(a){var b,c=[],d=this,e=d.length;if("string"!=typeof a)return this.pushStack(m(a).filter(function(){for(b=0;e>b;b++)if(m.contains(d[b],this))return!0}));for(b=0;e>b;b++)m.find(a,d[b],c);return c=this.pushStack(e>1?m.unique(c):c),c.selector=this.selector?this.selector+" "+a:a,c},filter:function(a){return this.pushStack(w(this,a||[],!1))},not:function(a){return this.pushStack(w(this,a||[],!0))},is:function(a){return!!w(this,"string"==typeof a&&t.test(a)?m(a):a||[],!1).length}});var x,y=a.document,z=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/,A=m.fn.init=function(a,b){var c,d;if(!a)return this;if("string"==typeof a){if(c="<"===a.charAt(0)&&">"===a.charAt(a.length-1)&&a.length>=3?[null,a,null]:z.exec(a),!c||!c[1]&&b)return!b||b.jquery?(b||x).find(a):this.constructor(b).find(a);if(c[1]){if(b=b instanceof m?b[0]:b,m.merge(this,m.parseHTML(c[1],b&&b.nodeType?b.ownerDocument||b:y,!0)),u.test(c[1])&&m.isPlainObject(b))for(c in b)m.isFunction(this[c])?this[c](b[c]):this.attr(c,b[c]);return this}if(d=y.getElementById(c[2]),d&&d.parentNode){if(d.id!==c[2])return x.find(a);this.length=1,this[0]=d}return this.context=y,this.selector=a,this}return a.nodeType?(this.context=this[0]=a,this.length=1,this):m.isFunction(a)?"undefined"!=typeof x.ready?x.ready(a):a(m):(void 0!==a.selector&&(this.selector=a.selector,this.context=a.context),m.makeArray(a,this))};A.prototype=m.fn,x=m(y);var B=/^(?:parents|prev(?:Until|All))/,C={children:!0,contents:!0,next:!0,prev:!0};m.extend({dir:function(a,b,c){var d=[],e=a[b];while(e&&9!==e.nodeType&&(void 0===c||1!==e.nodeType||!m(e).is(c)))1===e.nodeType&&d.push(e),e=e[b];return d},sibling:function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c}}),m.fn.extend({has:function(a){var b,c=m(a,this),d=c.length;return this.filter(function(){for(b=0;d>b;b++)if(m.contains(this,c[b]))return!0})},closest:function(a,b){for(var c,d=0,e=this.length,f=[],g=t.test(a)||"string"!=typeof a?m(a,b||this.context):0;e>d;d++)for(c=this[d];c&&c!==b;c=c.parentNode)if(c.nodeType<11&&(g?g.index(c)>-1:1===c.nodeType&&m.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?m.unique(f):f)},index:function(a){return a?"string"==typeof a?m.inArray(this[0],m(a)):m.inArray(a.jquery?a[0]:a,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(m.unique(m.merge(this.get(),m(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function D(a,b){do a=a[b];while(a&&1!==a.nodeType);return a}m.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return m.dir(a,"parentNode")},parentsUntil:function(a,b,c){return m.dir(a,"parentNode",c)},next:function(a){return D(a,"nextSibling")},prev:function(a){return D(a,"previousSibling")},nextAll:function(a){return m.dir(a,"nextSibling")},prevAll:function(a){return m.dir(a,"previousSibling")},nextUntil:function(a,b,c){return m.dir(a,"nextSibling",c)},prevUntil:function(a,b,c){return m.dir(a,"previousSibling",c)},siblings:function(a){return m.sibling((a.parentNode||{}).firstChild,a)},children:function(a){return m.sibling(a.firstChild)},contents:function(a){return m.nodeName(a,"iframe")?a.contentDocument||a.contentWindow.document:m.merge([],a.childNodes)}},function(a,b){m.fn[a]=function(c,d){var e=m.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=m.filter(d,e)),this.length>1&&(C[a]||(e=m.unique(e)),B.test(a)&&(e=e.reverse())),this.pushStack(e)}});var E=/\S+/g,F={};function G(a){var b=F[a]={};return m.each(a.match(E)||[],function(a,c){b[c]=!0}),b}m.Callbacks=function(a){a="string"==typeof a?F[a]||G(a):m.extend({},a);var b,c,d,e,f,g,h=[],i=!a.once&&[],j=function(l){for(c=a.memory&&l,d=!0,f=g||0,g=0,e=h.length,b=!0;h&&e>f;f++)if(h[f].apply(l[0],l[1])===!1&&a.stopOnFalse){c=!1;break}b=!1,h&&(i?i.length&&j(i.shift()):c?h=[]:k.disable())},k={add:function(){if(h){var d=h.length;!function f(b){m.each(b,function(b,c){var d=m.type(c);"function"===d?a.unique&&k.has(c)||h.push(c):c&&c.length&&"string"!==d&&f(c)})}(arguments),b?e=h.length:c&&(g=d,j(c))}return this},remove:function(){return h&&m.each(arguments,function(a,c){var d;while((d=m.inArray(c,h,d))>-1)h.splice(d,1),b&&(e>=d&&e--,f>=d&&f--)}),this},has:function(a){return a?m.inArray(a,h)>-1:!(!h||!h.length)},empty:function(){return h=[],e=0,this},disable:function(){return h=i=c=void 0,this},disabled:function(){return!h},lock:function(){return i=void 0,c||k.disable(),this},locked:function(){return!i},fireWith:function(a,c){return!h||d&&!i||(c=c||[],c=[a,c.slice?c.slice():c],b?i.push(c):j(c)),this},fire:function(){return k.fireWith(this,arguments),this},fired:function(){return!!d}};return k},m.extend({Deferred:function(a){var b=[["resolve","done",m.Callbacks("once memory"),"resolved"],["reject","fail",m.Callbacks("once memory"),"rejected"],["notify","progress",m.Callbacks("memory")]],c="pending",d={state:function(){return c},always:function(){return e.done(arguments).fail(arguments),this},then:function(){var a=arguments;return m.Deferred(function(c){m.each(b,function(b,f){var g=m.isFunction(a[b])&&a[b];e[f[1]](function(){var a=g&&g.apply(this,arguments);a&&m.isFunction(a.promise)?a.promise().done(c.resolve).fail(c.reject).progress(c.notify):c[f[0]+"With"](this===d?c.promise():this,g?[a]:arguments)})}),a=null}).promise()},promise:function(a){return null!=a?m.extend(a,d):d}},e={};return d.pipe=d.then,m.each(b,function(a,f){var g=f[2],h=f[3];d[f[1]]=g.add,h&&g.add(function(){c=h},b[1^a][2].disable,b[2][2].lock),e[f[0]]=function(){return e[f[0]+"With"](this===e?d:this,arguments),this},e[f[0]+"With"]=g.fireWith}),d.promise(e),a&&a.call(e,e),e},when:function(a){var b=0,c=d.call(arguments),e=c.length,f=1!==e||a&&m.isFunction(a.promise)?e:0,g=1===f?a:m.Deferred(),h=function(a,b,c){return function(e){b[a]=this,c[a]=arguments.length>1?d.call(arguments):e,c===i?g.notifyWith(b,c):--f||g.resolveWith(b,c)}},i,j,k;if(e>1)for(i=new Array(e),j=new Array(e),k=new Array(e);e>b;b++)c[b]&&m.isFunction(c[b].promise)?c[b].promise().done(h(b,k,c)).fail(g.reject).progress(h(b,j,i)):--f;return f||g.resolveWith(k,c),g.promise()}});var H;m.fn.ready=function(a){return m.ready.promise().done(a),this},m.extend({isReady:!1,readyWait:1,holdReady:function(a){a?m.readyWait++:m.ready(!0)},ready:function(a){if(a===!0?!--m.readyWait:!m.isReady){if(!y.body)return setTimeout(m.ready);m.isReady=!0,a!==!0&&--m.readyWait>0||(H.resolveWith(y,[m]),m.fn.triggerHandler&&(m(y).triggerHandler("ready"),m(y).off("ready")))}}});function I(){y.addEventListener?(y.removeEventListener("DOMContentLoaded",J,!1),a.removeEventListener("load",J,!1)):(y.detachEvent("onreadystatechange",J),a.detachEvent("onload",J))}function J(){(y.addEventListener||"load"===event.type||"complete"===y.readyState)&&(I(),m.ready())}m.ready.promise=function(b){if(!H)if(H=m.Deferred(),"complete"===y.readyState)setTimeout(m.ready);else if(y.addEventListener)y.addEventListener("DOMContentLoaded",J,!1),a.addEventListener("load",J,!1);else{y.attachEvent("onreadystatechange",J),a.attachEvent("onload",J);var c=!1;try{c=null==a.frameElement&&y.documentElement}catch(d){}c&&c.doScroll&&!function e(){if(!m.isReady){try{c.doScroll("left")}catch(a){return setTimeout(e,50)}I(),m.ready()}}()}return H.promise(b)};var K="undefined",L;for(L in m(k))break;k.ownLast="0"!==L,k.inlineBlockNeedsLayout=!1,m(function(){var a,b,c,d;c=y.getElementsByTagName("body")[0],c&&c.style&&(b=y.createElement("div"),d=y.createElement("div"),d.style.cssText="position:absolute;border:0;width:0;height:0;top:0;left:-9999px",c.appendChild(d).appendChild(b),typeof b.style.zoom!==K&&(b.style.cssText="display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1",k.inlineBlockNeedsLayout=a=3===b.offsetWidth,a&&(c.style.zoom=1)),c.removeChild(d))}),function(){var a=y.createElement("div");if(null==k.deleteExpando){k.deleteExpando=!0;try{delete a.test}catch(b){k.deleteExpando=!1}}a=null}(),m.acceptData=function(a){var b=m.noData[(a.nodeName+" ").toLowerCase()],c=+a.nodeType||1;return 1!==c&&9!==c?!1:!b||b!==!0&&a.getAttribute("classid")===b};var M=/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/,N=/([A-Z])/g;function O(a,b,c){if(void 0===c&&1===a.nodeType){var d="data-"+b.replace(N,"-$1").toLowerCase();if(c=a.getAttribute(d),"string"==typeof c){try{c="true"===c?!0:"false"===c?!1:"null"===c?null:+c+""===c?+c:M.test(c)?m.parseJSON(c):c}catch(e){}m.data(a,b,c)}else c=void 0}return c}function P(a){var b;for(b in a)if(("data"!==b||!m.isEmptyObject(a[b]))&&"toJSON"!==b)return!1;return!0}function Q(a,b,d,e){if(m.acceptData(a)){var f,g,h=m.expando,i=a.nodeType,j=i?m.cache:a,k=i?a[h]:a[h]&&h; +if(k&&j[k]&&(e||j[k].data)||void 0!==d||"string"!=typeof b)return k||(k=i?a[h]=c.pop()||m.guid++:h),j[k]||(j[k]=i?{}:{toJSON:m.noop}),("object"==typeof b||"function"==typeof b)&&(e?j[k]=m.extend(j[k],b):j[k].data=m.extend(j[k].data,b)),g=j[k],e||(g.data||(g.data={}),g=g.data),void 0!==d&&(g[m.camelCase(b)]=d),"string"==typeof b?(f=g[b],null==f&&(f=g[m.camelCase(b)])):f=g,f}}function R(a,b,c){if(m.acceptData(a)){var d,e,f=a.nodeType,g=f?m.cache:a,h=f?a[m.expando]:m.expando;if(g[h]){if(b&&(d=c?g[h]:g[h].data)){m.isArray(b)?b=b.concat(m.map(b,m.camelCase)):b in d?b=[b]:(b=m.camelCase(b),b=b in d?[b]:b.split(" ")),e=b.length;while(e--)delete d[b[e]];if(c?!P(d):!m.isEmptyObject(d))return}(c||(delete g[h].data,P(g[h])))&&(f?m.cleanData([a],!0):k.deleteExpando||g!=g.window?delete g[h]:g[h]=null)}}}m.extend({cache:{},noData:{"applet ":!0,"embed ":!0,"object ":"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000"},hasData:function(a){return a=a.nodeType?m.cache[a[m.expando]]:a[m.expando],!!a&&!P(a)},data:function(a,b,c){return Q(a,b,c)},removeData:function(a,b){return R(a,b)},_data:function(a,b,c){return Q(a,b,c,!0)},_removeData:function(a,b){return R(a,b,!0)}}),m.fn.extend({data:function(a,b){var c,d,e,f=this[0],g=f&&f.attributes;if(void 0===a){if(this.length&&(e=m.data(f),1===f.nodeType&&!m._data(f,"parsedAttrs"))){c=g.length;while(c--)g[c]&&(d=g[c].name,0===d.indexOf("data-")&&(d=m.camelCase(d.slice(5)),O(f,d,e[d])));m._data(f,"parsedAttrs",!0)}return e}return"object"==typeof a?this.each(function(){m.data(this,a)}):arguments.length>1?this.each(function(){m.data(this,a,b)}):f?O(f,a,m.data(f,a)):void 0},removeData:function(a){return this.each(function(){m.removeData(this,a)})}}),m.extend({queue:function(a,b,c){var d;return a?(b=(b||"fx")+"queue",d=m._data(a,b),c&&(!d||m.isArray(c)?d=m._data(a,b,m.makeArray(c)):d.push(c)),d||[]):void 0},dequeue:function(a,b){b=b||"fx";var c=m.queue(a,b),d=c.length,e=c.shift(),f=m._queueHooks(a,b),g=function(){m.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return m._data(a,c)||m._data(a,c,{empty:m.Callbacks("once memory").add(function(){m._removeData(a,b+"queue"),m._removeData(a,c)})})}}),m.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.lengthh;h++)b(a[h],c,g?d:d.call(a[h],h,b(a[h],c)));return e?a:j?b.call(a):i?b(a[0],c):f},W=/^(?:checkbox|radio)$/i;!function(){var a=y.createElement("input"),b=y.createElement("div"),c=y.createDocumentFragment();if(b.innerHTML="
a",k.leadingWhitespace=3===b.firstChild.nodeType,k.tbody=!b.getElementsByTagName("tbody").length,k.htmlSerialize=!!b.getElementsByTagName("link").length,k.html5Clone="<:nav>"!==y.createElement("nav").cloneNode(!0).outerHTML,a.type="checkbox",a.checked=!0,c.appendChild(a),k.appendChecked=a.checked,b.innerHTML="",k.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue,c.appendChild(b),b.innerHTML="",k.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,k.noCloneEvent=!0,b.attachEvent&&(b.attachEvent("onclick",function(){k.noCloneEvent=!1}),b.cloneNode(!0).click()),null==k.deleteExpando){k.deleteExpando=!0;try{delete b.test}catch(d){k.deleteExpando=!1}}}(),function(){var b,c,d=y.createElement("div");for(b in{submit:!0,change:!0,focusin:!0})c="on"+b,(k[b+"Bubbles"]=c in a)||(d.setAttribute(c,"t"),k[b+"Bubbles"]=d.attributes[c].expando===!1);d=null}();var X=/^(?:input|select|textarea)$/i,Y=/^key/,Z=/^(?:mouse|pointer|contextmenu)|click/,$=/^(?:focusinfocus|focusoutblur)$/,_=/^([^.]*)(?:\.(.+)|)$/;function ab(){return!0}function bb(){return!1}function cb(){try{return y.activeElement}catch(a){}}m.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m._data(a);if(r){c.handler&&(i=c,c=i.handler,e=i.selector),c.guid||(c.guid=m.guid++),(g=r.events)||(g=r.events={}),(k=r.handle)||(k=r.handle=function(a){return typeof m===K||a&&m.event.triggered===a.type?void 0:m.event.dispatch.apply(k.elem,arguments)},k.elem=a),b=(b||"").match(E)||[""],h=b.length;while(h--)f=_.exec(b[h])||[],o=q=f[1],p=(f[2]||"").split(".").sort(),o&&(j=m.event.special[o]||{},o=(e?j.delegateType:j.bindType)||o,j=m.event.special[o]||{},l=m.extend({type:o,origType:q,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&m.expr.match.needsContext.test(e),namespace:p.join(".")},i),(n=g[o])||(n=g[o]=[],n.delegateCount=0,j.setup&&j.setup.call(a,d,p,k)!==!1||(a.addEventListener?a.addEventListener(o,k,!1):a.attachEvent&&a.attachEvent("on"+o,k))),j.add&&(j.add.call(a,l),l.handler.guid||(l.handler.guid=c.guid)),e?n.splice(n.delegateCount++,0,l):n.push(l),m.event.global[o]=!0);a=null}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,n,o,p,q,r=m.hasData(a)&&m._data(a);if(r&&(k=r.events)){b=(b||"").match(E)||[""],j=b.length;while(j--)if(h=_.exec(b[j])||[],o=q=h[1],p=(h[2]||"").split(".").sort(),o){l=m.event.special[o]||{},o=(d?l.delegateType:l.bindType)||o,n=k[o]||[],h=h[2]&&new RegExp("(^|\\.)"+p.join("\\.(?:.*\\.|)")+"(\\.|$)"),i=f=n.length;while(f--)g=n[f],!e&&q!==g.origType||c&&c.guid!==g.guid||h&&!h.test(g.namespace)||d&&d!==g.selector&&("**"!==d||!g.selector)||(n.splice(f,1),g.selector&&n.delegateCount--,l.remove&&l.remove.call(a,g));i&&!n.length&&(l.teardown&&l.teardown.call(a,p,r.handle)!==!1||m.removeEvent(a,o,r.handle),delete k[o])}else for(o in k)m.event.remove(a,o+b[j],c,d,!0);m.isEmptyObject(k)&&(delete r.handle,m._removeData(a,"events"))}},trigger:function(b,c,d,e){var f,g,h,i,k,l,n,o=[d||y],p=j.call(b,"type")?b.type:b,q=j.call(b,"namespace")?b.namespace.split("."):[];if(h=l=d=d||y,3!==d.nodeType&&8!==d.nodeType&&!$.test(p+m.event.triggered)&&(p.indexOf(".")>=0&&(q=p.split("."),p=q.shift(),q.sort()),g=p.indexOf(":")<0&&"on"+p,b=b[m.expando]?b:new m.Event(p,"object"==typeof b&&b),b.isTrigger=e?2:3,b.namespace=q.join("."),b.namespace_re=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=d),c=null==c?[b]:m.makeArray(c,[b]),k=m.event.special[p]||{},e||!k.trigger||k.trigger.apply(d,c)!==!1)){if(!e&&!k.noBubble&&!m.isWindow(d)){for(i=k.delegateType||p,$.test(i+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),l=h;l===(d.ownerDocument||y)&&o.push(l.defaultView||l.parentWindow||a)}n=0;while((h=o[n++])&&!b.isPropagationStopped())b.type=n>1?i:k.bindType||p,f=(m._data(h,"events")||{})[b.type]&&m._data(h,"handle"),f&&f.apply(h,c),f=g&&h[g],f&&f.apply&&m.acceptData(h)&&(b.result=f.apply(h,c),b.result===!1&&b.preventDefault());if(b.type=p,!e&&!b.isDefaultPrevented()&&(!k._default||k._default.apply(o.pop(),c)===!1)&&m.acceptData(d)&&g&&d[p]&&!m.isWindow(d)){l=d[g],l&&(d[g]=null),m.event.triggered=p;try{d[p]()}catch(r){}m.event.triggered=void 0,l&&(d[g]=l)}return b.result}},dispatch:function(a){a=m.event.fix(a);var b,c,e,f,g,h=[],i=d.call(arguments),j=(m._data(this,"events")||{})[a.type]||[],k=m.event.special[a.type]||{};if(i[0]=a,a.delegateTarget=this,!k.preDispatch||k.preDispatch.call(this,a)!==!1){h=m.event.handlers.call(this,a,j),b=0;while((f=h[b++])&&!a.isPropagationStopped()){a.currentTarget=f.elem,g=0;while((e=f.handlers[g++])&&!a.isImmediatePropagationStopped())(!a.namespace_re||a.namespace_re.test(e.namespace))&&(a.handleObj=e,a.data=e.data,c=((m.event.special[e.origType]||{}).handle||e.handler).apply(f.elem,i),void 0!==c&&(a.result=c)===!1&&(a.preventDefault(),a.stopPropagation()))}return k.postDispatch&&k.postDispatch.call(this,a),a.result}},handlers:function(a,b){var c,d,e,f,g=[],h=b.delegateCount,i=a.target;if(h&&i.nodeType&&(!a.button||"click"!==a.type))for(;i!=this;i=i.parentNode||this)if(1===i.nodeType&&(i.disabled!==!0||"click"!==a.type)){for(e=[],f=0;h>f;f++)d=b[f],c=d.selector+" ",void 0===e[c]&&(e[c]=d.needsContext?m(c,this).index(i)>=0:m.find(c,this,null,[i]).length),e[c]&&e.push(d);e.length&&g.push({elem:i,handlers:e})}return h]","i"),hb=/^\s+/,ib=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,jb=/<([\w:]+)/,kb=/\s*$/g,rb={option:[1,""],legend:[1,"
","
"],area:[1,"",""],param:[1,"",""],thead:[1,"","
"],tr:[2,"","
"],col:[2,"","
"],td:[3,"","
"],_default:k.htmlSerialize?[0,"",""]:[1,"X
","
"]},sb=db(y),tb=sb.appendChild(y.createElement("div"));rb.optgroup=rb.option,rb.tbody=rb.tfoot=rb.colgroup=rb.caption=rb.thead,rb.th=rb.td;function ub(a,b){var c,d,e=0,f=typeof a.getElementsByTagName!==K?a.getElementsByTagName(b||"*"):typeof a.querySelectorAll!==K?a.querySelectorAll(b||"*"):void 0;if(!f)for(f=[],c=a.childNodes||a;null!=(d=c[e]);e++)!b||m.nodeName(d,b)?f.push(d):m.merge(f,ub(d,b));return void 0===b||b&&m.nodeName(a,b)?m.merge([a],f):f}function vb(a){W.test(a.type)&&(a.defaultChecked=a.checked)}function wb(a,b){return m.nodeName(a,"table")&&m.nodeName(11!==b.nodeType?b:b.firstChild,"tr")?a.getElementsByTagName("tbody")[0]||a.appendChild(a.ownerDocument.createElement("tbody")):a}function xb(a){return a.type=(null!==m.find.attr(a,"type"))+"/"+a.type,a}function yb(a){var b=pb.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function zb(a,b){for(var c,d=0;null!=(c=a[d]);d++)m._data(c,"globalEval",!b||m._data(b[d],"globalEval"))}function Ab(a,b){if(1===b.nodeType&&m.hasData(a)){var c,d,e,f=m._data(a),g=m._data(b,f),h=f.events;if(h){delete g.handle,g.events={};for(c in h)for(d=0,e=h[c].length;e>d;d++)m.event.add(b,c,h[c][d])}g.data&&(g.data=m.extend({},g.data))}}function Bb(a,b){var c,d,e;if(1===b.nodeType){if(c=b.nodeName.toLowerCase(),!k.noCloneEvent&&b[m.expando]){e=m._data(b);for(d in e.events)m.removeEvent(b,d,e.handle);b.removeAttribute(m.expando)}"script"===c&&b.text!==a.text?(xb(b).text=a.text,yb(b)):"object"===c?(b.parentNode&&(b.outerHTML=a.outerHTML),k.html5Clone&&a.innerHTML&&!m.trim(b.innerHTML)&&(b.innerHTML=a.innerHTML)):"input"===c&&W.test(a.type)?(b.defaultChecked=b.checked=a.checked,b.value!==a.value&&(b.value=a.value)):"option"===c?b.defaultSelected=b.selected=a.defaultSelected:("input"===c||"textarea"===c)&&(b.defaultValue=a.defaultValue)}}m.extend({clone:function(a,b,c){var d,e,f,g,h,i=m.contains(a.ownerDocument,a);if(k.html5Clone||m.isXMLDoc(a)||!gb.test("<"+a.nodeName+">")?f=a.cloneNode(!0):(tb.innerHTML=a.outerHTML,tb.removeChild(f=tb.firstChild)),!(k.noCloneEvent&&k.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||m.isXMLDoc(a)))for(d=ub(f),h=ub(a),g=0;null!=(e=h[g]);++g)d[g]&&Bb(e,d[g]);if(b)if(c)for(h=h||ub(a),d=d||ub(f),g=0;null!=(e=h[g]);g++)Ab(e,d[g]);else Ab(a,f);return d=ub(f,"script"),d.length>0&&zb(d,!i&&ub(a,"script")),d=h=e=null,f},buildFragment:function(a,b,c,d){for(var e,f,g,h,i,j,l,n=a.length,o=db(b),p=[],q=0;n>q;q++)if(f=a[q],f||0===f)if("object"===m.type(f))m.merge(p,f.nodeType?[f]:f);else if(lb.test(f)){h=h||o.appendChild(b.createElement("div")),i=(jb.exec(f)||["",""])[1].toLowerCase(),l=rb[i]||rb._default,h.innerHTML=l[1]+f.replace(ib,"<$1>")+l[2],e=l[0];while(e--)h=h.lastChild;if(!k.leadingWhitespace&&hb.test(f)&&p.push(b.createTextNode(hb.exec(f)[0])),!k.tbody){f="table"!==i||kb.test(f)?""!==l[1]||kb.test(f)?0:h:h.firstChild,e=f&&f.childNodes.length;while(e--)m.nodeName(j=f.childNodes[e],"tbody")&&!j.childNodes.length&&f.removeChild(j)}m.merge(p,h.childNodes),h.textContent="";while(h.firstChild)h.removeChild(h.firstChild);h=o.lastChild}else p.push(b.createTextNode(f));h&&o.removeChild(h),k.appendChecked||m.grep(ub(p,"input"),vb),q=0;while(f=p[q++])if((!d||-1===m.inArray(f,d))&&(g=m.contains(f.ownerDocument,f),h=ub(o.appendChild(f),"script"),g&&zb(h),c)){e=0;while(f=h[e++])ob.test(f.type||"")&&c.push(f)}return h=null,o},cleanData:function(a,b){for(var d,e,f,g,h=0,i=m.expando,j=m.cache,l=k.deleteExpando,n=m.event.special;null!=(d=a[h]);h++)if((b||m.acceptData(d))&&(f=d[i],g=f&&j[f])){if(g.events)for(e in g.events)n[e]?m.event.remove(d,e):m.removeEvent(d,e,g.handle);j[f]&&(delete j[f],l?delete d[i]:typeof d.removeAttribute!==K?d.removeAttribute(i):d[i]=null,c.push(f))}}}),m.fn.extend({text:function(a){return V(this,function(a){return void 0===a?m.text(this):this.empty().append((this[0]&&this[0].ownerDocument||y).createTextNode(a))},null,a,arguments.length)},append:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.appendChild(a)}})},prepend:function(){return this.domManip(arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=wb(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return this.domManip(arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},remove:function(a,b){for(var c,d=a?m.filter(a,this):this,e=0;null!=(c=d[e]);e++)b||1!==c.nodeType||m.cleanData(ub(c)),c.parentNode&&(b&&m.contains(c.ownerDocument,c)&&zb(ub(c,"script")),c.parentNode.removeChild(c));return this},empty:function(){for(var a,b=0;null!=(a=this[b]);b++){1===a.nodeType&&m.cleanData(ub(a,!1));while(a.firstChild)a.removeChild(a.firstChild);a.options&&m.nodeName(a,"select")&&(a.options.length=0)}return this},clone:function(a,b){return a=null==a?!1:a,b=null==b?a:b,this.map(function(){return m.clone(this,a,b)})},html:function(a){return V(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a)return 1===b.nodeType?b.innerHTML.replace(fb,""):void 0;if(!("string"!=typeof a||mb.test(a)||!k.htmlSerialize&&gb.test(a)||!k.leadingWhitespace&&hb.test(a)||rb[(jb.exec(a)||["",""])[1].toLowerCase()])){a=a.replace(ib,"<$1>");try{for(;d>c;c++)b=this[c]||{},1===b.nodeType&&(m.cleanData(ub(b,!1)),b.innerHTML=a);b=0}catch(e){}}b&&this.empty().append(a)},null,a,arguments.length)},replaceWith:function(){var a=arguments[0];return this.domManip(arguments,function(b){a=this.parentNode,m.cleanData(ub(this)),a&&a.replaceChild(b,this)}),a&&(a.length||a.nodeType)?this:this.remove()},detach:function(a){return this.remove(a,!0)},domManip:function(a,b){a=e.apply([],a);var c,d,f,g,h,i,j=0,l=this.length,n=this,o=l-1,p=a[0],q=m.isFunction(p);if(q||l>1&&"string"==typeof p&&!k.checkClone&&nb.test(p))return this.each(function(c){var d=n.eq(c);q&&(a[0]=p.call(this,c,d.html())),d.domManip(a,b)});if(l&&(i=m.buildFragment(a,this[0].ownerDocument,!1,this),c=i.firstChild,1===i.childNodes.length&&(i=c),c)){for(g=m.map(ub(i,"script"),xb),f=g.length;l>j;j++)d=i,j!==o&&(d=m.clone(d,!0,!0),f&&m.merge(g,ub(d,"script"))),b.call(this[j],d,j);if(f)for(h=g[g.length-1].ownerDocument,m.map(g,yb),j=0;f>j;j++)d=g[j],ob.test(d.type||"")&&!m._data(d,"globalEval")&&m.contains(h,d)&&(d.src?m._evalUrl&&m._evalUrl(d.src):m.globalEval((d.text||d.textContent||d.innerHTML||"").replace(qb,"")));i=c=null}return this}}),m.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(a,b){m.fn[a]=function(a){for(var c,d=0,e=[],g=m(a),h=g.length-1;h>=d;d++)c=d===h?this:this.clone(!0),m(g[d])[b](c),f.apply(e,c.get());return this.pushStack(e)}});var Cb,Db={};function Eb(b,c){var d,e=m(c.createElement(b)).appendTo(c.body),f=a.getDefaultComputedStyle&&(d=a.getDefaultComputedStyle(e[0]))?d.display:m.css(e[0],"display");return e.detach(),f}function Fb(a){var b=y,c=Db[a];return c||(c=Eb(a,b),"none"!==c&&c||(Cb=(Cb||m(" + +## Bank Selection Bottom Sheet + +You can also view the UI customisation guide in Figma [here](https://www.figma.com/design/ww461LDHx2u6NY9dAr9gsl/iOS-Gini-Merchant-SDK-1.0-UI-Customisation?node-id=12922-11246&t=7NfLZSvYFs3UI2BV-1). + +**Note:** +To copy text from Figma you need to have a Figma account. If you don't have one, you can create one for free. + + + +## Payment Feature Info Screen + +You can also view the UI customisation guide in Figma [here](https://www.figma.com/design/ww461LDHx2u6NY9dAr9gsl/iOS-Gini-Merchant-SDK-1.0-UI-Customisation?node-id=12922-11379&t=7NfLZSvYFs3UI2BV-1). + +**Note:** +To copy text from Figma you need to have a Figma account. If you don't have one, you can create one for free. + + + +## Payment Review Bottom Sheet + +You can also view the UI customisation guide in Figma [here](https://www.figma.com/design/ww461LDHx2u6NY9dAr9gsl/iOS-Gini-Merchant-SDK-1.0-UI-Customisation?node-id=12922-10422&t=7NfLZSvYFs3UI2BV-1). + +**Note:** +To copy text from Figma you need to have a Figma account. If you don't have one, you can create one for free. + + + +> **Note:** +> - PaymentReviewViewController Bottom Sheet contains the following configuration options: +> - showPaymentReviewScreen: If set to true, a Payment Review Bottom Sheet will be shown. +Default value is true. + +For disabling `showPaymentReviewScreen`: + +```swift +let giniConfiguration = GiniMerchantConfiguration() +config.showPaymentReviewScreen = false +merchantSDK.setConfiguration(config) +``` + diff --git a/Documentation/source/Event tracking guide.md b/Documentation/source/Event tracking guide.md new file mode 100644 index 0000000..cab8a52 --- /dev/null +++ b/Documentation/source/Event tracking guide.md @@ -0,0 +1,36 @@ +Event Tracking +============================= + +The Gini Merchant SDK exposes protocols for tracking events. + +# Global events + +Implement the `GiniMerchantDelegate` protocol and supply the delegate to the `GiniMerchant` instance: + +```swift +let merchantSDK = GiniMerchant(with: giniApiLib) +merchantSDK.delegate = self // where self conforms to the GiniMerchantDelegate protocol +```` +## Events + +| Event | Additional info | Comment | +| --- | --- | --- | +| `didCreatePaymentRequest` | `paymentRequestID`| A payment request had been created | +| `shouldHandleErrorInternally` | `error` | An error occurred. Return `false` to prevent the SDK from showing an error message. | + + +# Screen events + +Implement the `GiniMerchantTrackingDelegate` protocol and supply the delegate when initializing `PaymentReviewViewController`. For example: + +```swift +let viewController = paymentComponentsController.loadPaymentReviewScreenFor(documentID: documentId, paymentInfo: paymentInfo, trackingDelegate: self) +``` + +## Events + +Event types are partitioned into different domains according to the screens that they appear at. Each domain has a number of event types. + +| Domain | Event type | Additional info keys | Comment | +| --- | --- | --- | --- | +| Payment Review Screen | `onToTheBankButtonClicked` |`"paymentProvider"`| User tapped "To the banking app" button from the payment review screen | diff --git a/Documentation/source/Installation.md b/Documentation/source/Installation.md new file mode 100644 index 0000000..c39e086 --- /dev/null +++ b/Documentation/source/Installation.md @@ -0,0 +1,15 @@ +Installation +============================= + +Gini Merchant SDK can either be installed by using Swift Package Manager or by manually dragging the required files to your project. + +## Swift Package Manager + +The [Swift Package Manager](https://swift.org/package-manager/) is a tool for managing the distribution of Swift code. +Once you have your Swift package set up, adding `GiniMerchantSDK` as a dependency is as easy as adding it to the dependencies value of your `Package.swift` + +```swift +dependencies: [ + .package(url: "https://github.com/gini/merchant-sdk-ios.git", .exact("1.0.0")) +] +``` diff --git a/Documentation/source/Integration.md b/Documentation/source/Integration.md new file mode 100644 index 0000000..808abb5 --- /dev/null +++ b/Documentation/source/Integration.md @@ -0,0 +1,171 @@ +Integration +============================= + +The Gini Merchant SDK for iOS provides all the UI and functionality needed to use the Gini API in your app to create payment. The payment information can be reviewed and then the invoice can be paid using any available payment provider app (e.g., banking app). + +> ⚠️ **Important:** +For supporting each payment provider you need to specify `LSApplicationQueriesSchemes` in your `Info.plist` file. App schemes for specification will be provided by Gini. + + +## GiniMerchant initialization + +> ⚠️ **Important:** +You should have received Gini API client credentials from us. Please get in touch with us in case you don't have them. + +You can easy initialize `GiniMerchant` with the client credentials: + +```swift +private lazy var merchant = GiniMerchant(id: clientID, secret: clientPassword, domain: clientDomain) +``` + +## Certificate pinning (optional) + +If you want to use _Certificate pinning_, provide metadata for the upload process, you can pass both your public key pinning configuration for more information) +```swift + private lazy var mechant = GiniMerchant(id: clientID, secret: clientPassword, domain: clientDomain, pinningConfig: ["PinnedDomains" : ["PublicKeyHashes"]]) +``` + + +## Integrate the Payment component + +We provide a custom payment component view to help users pay. +Please follow the steps below for the payment component integration. + +### 1. Create an instance of the `PaymentComponentsController`. + +```swift +let paymentComponentsController = PaymentComponentsController(giniMerchant: merchant) +``` + +### 2. Load the payment providers + +You will load the list of the payment providers by calling the `loadPaymentProviders` function from the `PaymentComponentsController` and conform to the `PaymentComponentsControllerProtocol`. + +```swift +paymentComponentsController.delegate = self // where self is your viewController +paymentComponentsController.loadPaymentProviders() +``` + +* `PaymentComponentsControllerProtocol` provides information when the `PaymentComponentsController` is loading. +You can show/hide an `UIActivityIndicator` based on that. + +* `PaymentComponentsControllerProtocol` provides completion handlers when `PaymentComponentsController` fetched successfully payment providers or when it failed with an error. + +> **Note:** +It should be sufficient to call paymentComponentsController.loadPaymentProviderApps() only once when your app starts. + +> - We effectively handle situations where there are no payment providers available. +> - Based on the payment provider's colors, the `UIView` will automatically change its color. + +### 3. Show the PaymentComponentBottomView + +In this step you will show the `PaymentComponentBottomView` in a bottom sheet. +This function provides the UIViewController and you cand present it anywhere. + +```swift +public func paymentViewBottomSheet(documentID: String?) -> UIViewController +``` + +``` +let paymentViewBottomSheet = paymentComponentsController.paymentViewBottomSheet(documentID: nil) +paymentViewBottomSheet.modalPresentationStyle = .overFullScreen +present(paymentViewBottomSheet, animated: false) +``` + +> **Note:** +> - We strongly recommend to present `PaymentComponentBottomView` modally with a `.overFullScreen` presentation style. +> - Based on the payment provider's colors, the `UIView` will automatically change its color. + +* `PaymentComponentViewProtocol` is the view protocol and provides events handlers when the user tapped on various areas on the payment component view (more information icon, bank/payment provider picker, the pay invoice button and etc.). + +> - Make sure you properly link `PaymentComponentsControllerProtocol` and `PaymentComponentViewProtocol` delegates to get notified. + +## Show PaymentInfoViewController + +The `PaymentInfoViewController` displays information and an FAQ section about the payment feature. +It requires a `PaymentComponentsController` instance (see `Integrate the Payment component` step 1). + +> **Note:** +> - The `PaymentInfoViewController` can be presented modally, used in a container view or pushed to a navigation view controller. Make sure to add your own navigation around the provided views. +> - Please make sure to dismiss presentedViewController (usually `PaymentComponentBottomView`) before you push the PaymentInfoViewController. + +> ⚠️ **Important:** +> - The `PaymentInfoViewController` presentation should happen in `func didTapOnMoreInformation(documentId: String?)` inside +`PaymentComponentViewProtocol` implementation.(`Integrate the Payment component` step 3). + +```swift +func didTapOnMoreInformation(documentId: String?) { + let paymentInfoViewController = paymentComponentsController.paymentInfoViewController() + if let presentedViewController = self.presentedViewController { + presentedViewController.dismiss(animated: true) { + self.navigationController?.pushViewController(paymentInfoViewController, animated: true) + } + } else { + self.navigationController?.pushViewController(paymentInfoViewController, animated: true) + } +} + ``` + +## Show BankSelectionBottomSheet + +The `BankSelectionBottomSheet` displays a list of available banks for the user to choose from. +If a banking app is not installed we will provide an `InstallAppBottomView` view on the `PaymentReviewScreen` and you will be able to install that missing app from AppStore. +The `BankSelectionBottomSheet` presentation requires a `PaymentComponentsController` instance from the `Integrate the Payment component` step 1. + +> **Note:** +> - We strongly recommend to present `BankSelectionBottomSheet` modally with a `.overFullScreen` presentation style. + +> ⚠️ **Important:** +> - The `BankSelectionBottomSheet` presentation should happen in `func didTapOnBankPicker(documentId: String?)` inside +`PaymentComponentViewProtocol` implementation (see `Integrate the Payment component` step 3). +> - Please make sure to dismiss presentedViewController (usually `PaymentComponentBottomView`) before you push the BankSelectionBottomSheet. + +```swift +func didTapOnBankPicker(documentId: String?) { + let bankSelectionBottomSheet = paymentComponentsController.bankSelectionBottomSheet() + bankSelectionBottomSheet.modalPresentationStyle = .overFullScreen + if let presentedViewController = self.presentedViewController { + presentedViewController.dismiss(animated: true) { + self.present(bankSelectionBottomSheet, animated: animated) + } + } else { + self.present(bankSelectionBottomSheet, animated: animated) + } +} + ``` + +## Show PaymentReviewViewController Bottom Sheet + +The `PaymentReviewViewController` displays payment's details. It also lets users pay the invoice with the bank they selected in the `BankSelectionBottomSheet`. In here you will be able to revise the amount field of the payment and proceed with the payment. + +The `PaymentReviewViewController` presentation requires a `PaymentComponentsController` instance from the `Integrate the Payment component` step 1 and `documentId`. + +> **Note:** +> - The `PaymentReviewViewController` can be presented modally, used in a container view or pushed to a navigation view controller. Make sure to add your own navigation around the provided views. +> - Please make sure to dismiss presentedViewController (usually `PaymentComponentBottomView`) before you push the PaymentReviewViewController. + +> ⚠️ **Important:** +> - The `PaymentReviewViewController` presentation should happen in `func didTapOnPayInvoice(documentId: String?)` inside +`PaymentComponentViewProtocol` implementation (see `Integrate the Payment component` step 3). + +```swift + func didTapOnPayInvoice(documentId: String?) { + guard let documentId else { return } + paymentComponentsController.loadPaymentReviewScreenFor(documentID: documentId, paymentInfo: obtainPaymentInfo(), trackingDelegate: self) { [weak self] viewController, error in + if let error { + self?.errors.append(error.localizedDescription) + self?.showErrorsIfAny() + } else if let viewController { + viewController.modalTransitionStyle = .coverVertical + viewController.modalPresentationStyle = .overCurrentContext + if let presentedViewController = self?.presentedViewController { + presentedViewController.dismiss(animated: true) { + self?.present(viewController, animated: animated) + } + } else { + self?.present(viewController, animated: animated) + } + } + } +} +``` diff --git a/Documentation/source/License.md b/Documentation/source/License.md new file mode 100644 index 0000000..4ea28cb --- /dev/null +++ b/Documentation/source/License.md @@ -0,0 +1,18 @@ +License +======= +Always make sure to ship all license notices and permissions with your application. + +## The Gini Merchant SDK for iOS is licensed under a Private License. + + Copyright (c) 2024, Gini GmbH + All rights reserved. + + The Gini Merchant SDK is licensed through Gini GmbH ("Gini") and may not be + used, altered or copied in any way without explicit permission by Gini. The + terms of usage are defined in a separate usage agreement between Gini and the + licensee, where the licensee can gain access to a non-exclusive, + non-transferable usage right which is restricted for the time of a contractual + relationship between Gini and the licensee. + + For license related inquiries contact Gini via the email address + technical-support@gini.net. \ No newline at end of file diff --git a/Documentation/source/Testing.md b/Documentation/source/Testing.md new file mode 100644 index 0000000..6707c41 --- /dev/null +++ b/Documentation/source/Testing.md @@ -0,0 +1,110 @@ +Testing +============================= + +## Gini Pay Deep Link For Your App + +In order for banking apps to be able to return the user to your app after the payment has been resolved you need register a scheme for your app to respond to a deep link scheme known by the Gini Bank API. + +> **Info:** +You should already have a scheme and host from us. Please contact us in case you don't have them. + +The following is an example for the deep link `gini-pay://payment-requester`: + +
+
+
+ +## Testing + +An example banking app is available in the [Gini Mobile Monorepo iOS](https://github.com/gini/gini-mobile-ios/blob/main/BankSDK/GiniBankSDKExample/GiniBankSDKExampleBank) repository. + +In order to test using our example banking app you need to use development client credentials. This will make sure +the Gini Merchant SDK uses a test payment provider which will open our example banking app. To inject your API credentials into the Bank example app you need to fill in your credentials in [Credentials.plist](https://github.com/gini/gini-mobile-ios/blob/main/BankSDK/GiniBankSDKExample/GiniBankSDKExampleBank/Credentials.plist). + +### End to end testing + +The app scheme in our banking example app: `ginipay-bank://`. Please, specify this scheme `LSApplicationQueriesSchemes` in your app in `Info.plist` file. + +After you've set the client credentials in the example banking app and installed it on your device you can run your app. + +#### Payment component + +After following the integration steps above you'll arrive at the `Orders list screen`. Then, you can navigate to `Order detail screen`. Tapping on `Pay` will show you the `Payment Component`. +The following screenshot shows an order detail screen with the `PaymentComponent` shown in a bottom sheet. + +
+
+
+ +#### Bank Selection Bottom sheet + +By clicking the picker you should see the `BankSelectionBottomSheet` with the list of available banking apps (including `Gini-Test-Payment-Provider` and other testing and production apps). + +
+
+
+ +#### More information and FAQ + +By clicking either the more information or the info icon on the `Payment Component` view you should see the `Payment feature Info screen` with information about the payment feature and an FAQ section. + +
+
+
+ +#### Payment Review + +By clicking the `Continue to overview` button on a `Payment Component` view you should see the `Payment Review Bottom Sheet`, which shows the the payment information. It also allows editing the payment amount information. The `To the banking app` button should have the icon and colors of the banking app, which was selected in the payment component view. + +Check that the details are shown and then press the `To the banking app` button: + +
+
+
+ +#### Execute payment + +When clicking the `To the banking app` button on the payment review you should be redirected to the example banking app where the payment information will be fetched from Gini (including any changes you made on the payment review). Press the "Pay" button to execute a test payment which will mark the payment as paid in the [Gini Merchant API](https://merchant-api.gini.net/documentation/#gini-health-api-documentation). +You should be redirected to the example banking app where the final payment details are shown: + +
+
+
+ +After you press the `Pay` button the Gini Bank SDK resolves the payment and allows you to return to your app: + +
+
+
+ +#### Return to your app + +After the test payment has been executed, the example banking app should show a "Return back" button which should take you back to your app. + +For handling incoming url in your app after redirecting back from the banking app you need to implement to handle the incoming url: +The following is an example for the url `gini-pay://payment-requester`: + +```swift + func application(_ app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + if url.host == "payment-requester" { + // hadle incoming url from the banking app + } + return true + } +``` + +With these steps completed you have verified that your app, the Gini Health API, the Gini Merchant SDK and the Gini +Bank SDK work together correctly. + +### Testing in production + +The steps are the same but instead of the development client credentials you will need to use production client +credentials. This will make sure the Gini Merchant SDK receives real payment providers which open real banking apps. + +You will also need to install a banking app which uses the Gini Bank SDK. Please contact us in case you don't know +which banking app(s) to install. + +Lastly make sure that for production you register the scheme we provided you for deep linking and you are not using +`gini-pay://payment-requester`. diff --git a/GiniMerchant_Logo.png b/GiniMerchant_Logo.png new file mode 100644 index 0000000..28f0c36 Binary files /dev/null and b/GiniMerchant_Logo.png differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..32c0437 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright (c) 2021-2023, Gini GmbH +All rights reserved. + +The Gini Merchant SDK is licensed through Gini GmbH ("Gini") and may not be +used, altered or copied in any way without explicit permission by Gini. The +terms of usage are defined in a separate usage agreement between Gini and the +licensee, where the licensee can gain access to a non-exclusive, +non-transferable usage right which is restricted for the time of a contractual +relationship between Gini and the licensee. + +For license related inquiries contact Gini via the email address +technical-support@gini.net. \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..eb22604 --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version:5.5 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "GiniMerchantSDK", + defaultLocalization: "en", + platforms: [.iOS(.v13)], + products: [ + // Products define the executables and libraries a package produces, and make them visible to other packages. + .library( + name: "GiniMerchantSDK", + targets: ["GiniMerchantSDK"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + .package(name: "GiniHealthAPILibrary", url: "https://github.com/gini/health-api-library-ios.git", .exact("4.3.0")), + .package(name: "GiniUtilites", url: "https://github.com/gini/utilites-ios.git", .exact("1.0.0")), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages this package depends on. + + .target( + name: "GiniMerchantSDK", + dependencies: ["GiniHealthAPILibrary"]), + .testTarget( + name: "GiniMerchantSDKTests", + dependencies: ["GiniMerchantSDK"]), + ] +) diff --git a/README.md b/README.md index 33484aa..9dc1a08 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,47 @@ -# merchant-sdk-ios -Release repo for Gini Merchant SDK +

+ +

+ +# Gini Merchant SDK for iOS + +[![Platform](https://img.shields.io/badge/platform-iOS-lightgrey.svg)]() +[![Devices](https://img.shields.io/badge/devices-iPhone%20%7C%20iPad-blue.svg)]() +[![Swift version](https://img.shields.io/badge/swift-5.0-orange.svg)]() +[![Swift package manager](https://img.shields.io/badge/Swift_Package_Manager-compatible-orange?style=flat-square)]() + +The Gini Merchant SDK provides components for uploading, reviewing and analyzing photos of invoices and remittance slips. + +By integrating this SDK into your application you can allow your users to easily upload a picture of a document, review it and get analysis results from the Gini backend, create a payment and send it to the prefferable payment provider. + +## Documentation + +Further documentation with installation, integration or customization guides can be found in our [website](https://developer.gini.net/gini-mobile-ios/GiniMerchantSDK/index.html). + +## Example apps + +We are providing example app for Swift. This app demonstrates how to integrate the Gini Merchant SDK with the [Gini Capture SDK](https://gini.atlassian.net/wiki/spaces/ICSV/overview). + +An example banking app is available in the [Gini Mobile iOS Monorepo](https://github.com/gini/gini-mobile-ios/tree/main/BankSDK/GiniBankSDKExample) repository. +To check the redirection to the Banking app please run Bank example before the Merchant example. You can use the same Gini Merchant API client credentials in the example banking app as in your app, if not otherwise specified. +To inject your API credentials into the Merchant and Bank example apps you need to fill in your credentials in [Credentials.plist](https://github.com/gini/gini-mobile-ios/blob/main/BankSDK/GiniBankSDKExample/GiniBankSDKExampleBank/Credentials.plist) and [Credentials.plist](https://github.com/gini/gini-mobile-ios/blob/main/MerchantSDK/GiniMerchantSDKExample/GiniMerchantSDKExample/Credentials.plist), respectively. + +## Requirements + +- iOS 13+ +- Xcode 15+ + +**Note:** +In order to have better analysis results it is highly recommended to enable only devices with 8MP camera and flash. These devices would be: + +* iPhones with iOS 13 or higher. +* iPad Pro devices (iPad Air 2 and iPad Mini 4 have 8MP camera but no flash). + +## Author + +Gini GmbH, hello@gini.net + +## License + +The Gini Merchant SDK for iOS is licensed under a Private License. See [the license](https://developer.gini.net/gini-mobile-ios/GiniMerchantSDK/license.html) for more info. + +**Important:** Always make sure to ship all license notices and permissions with your application. diff --git a/Sources/GiniMerchantSDK/Core/Adapter/CompositeDocument.swift b/Sources/GiniMerchantSDK/Core/Adapter/CompositeDocument.swift new file mode 100644 index 0000000..e873932 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/CompositeDocument.swift @@ -0,0 +1,21 @@ +// +// CompositeDocument.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/// Composite document information +public struct CompositeDocument { + + /// Composite document URL. Similar to this: https://api.gini.net/documents/12345678-9123-11e2-bfd6-000000000000 + public let document: URL + + /// The composite document’s unique identifier. + public var id: String? { + guard let id = document.absoluteString.split(separator: "/").last else { return nil } + return String(id) + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/CompositeDocumentInfo.swift b/Sources/GiniMerchantSDK/Core/Adapter/CompositeDocumentInfo.swift new file mode 100644 index 0000000..c1c86e3 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/CompositeDocumentInfo.swift @@ -0,0 +1,25 @@ +// +// CompositeDocumentInfo.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/// Information used to create a composite document +public struct CompositeDocumentInfo { + + /// Array containing all the partial documents used to create a composite document. + public let partialDocuments: [PartialDocumentInfo] + + public init(partialDocuments: [PartialDocumentInfo]) { + self.partialDocuments = partialDocuments + } +} + +// MARK: - Decodable + +extension CompositeDocumentInfo: Encodable { + +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/Document+Layout.swift b/Sources/GiniMerchantSDK/Core/Adapter/Document+Layout.swift new file mode 100644 index 0000000..9507f0a --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/Document+Layout.swift @@ -0,0 +1,67 @@ +// +// Document+Layout.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +extension Document.Layout { + /// A document's page layout, indicating its size, textZones, regions and its page number + public struct Page: Decodable { + /// Page number + public let number: Int + /// Page width + public let sizeX: Double + /// Page height + public let sizeY: Double + /// Page textZones + public let textZones: [TextZone] + /// Page regions + public let regions: [Region]? + } + + /// A document's page layout region, indicating its origin, size, type, lines and words + public struct Region: Decodable { + /// Top-Left X origin + public let l: Double + /// Top-Left Y origin + public let t: Double + /// Region width + public let w: Double + /// Region height + public let h: Double + /// Region type + public let type: String? + /// (Optional) Amount of lines in that region + public let lines: [Region]? + /// (Optional) Amount of words in that region + public let wds: [Word]? + } + + /// A document's page text zone, containing an array of regions + public struct TextZone: Decodable { + public let paragraphs: [Region] + } + + /// Word contained within a text region + public struct Word: Decodable { + /// Top-Left X origin + public let l: Double + /// Top-Left Y origin + public let t: Double + /// Word width + public let w: Double + /// Word height + public let h: Double + /// Word font size + public let fontSize: Double + /// Word font family + public let fontFamily: String + /// Indicates if the font style is bold + public let bold: Bool + /// Text contained in the word + public let text: String + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/Document.swift b/Sources/GiniMerchantSDK/Core/Adapter/Document.swift new file mode 100644 index 0000000..14506da --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/Document.swift @@ -0,0 +1,223 @@ +// +// Document.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import GiniHealthAPILibrary + +/// Data model that represents a Document entity +public struct Document { + + /// (Optional) Array containing the path of every composite document + public let compositeDocuments: [CompositeDocument]? + /// The document's creation date. + public let creationDate: Date + /// The document's unique identifier. + public let id: String + /// The document's file name. + public let name: String + /// The document's origin. + public let origin: Origin + /// The number of pages. + public let pageCount: Int + /// The document's pages. + public let pages: [Page]? + /// Links to related resources, such as extractions, document, processed, layout or pages. + public let links: Links + /// (Optional) Array containing the path of every partial document info + public let partialDocuments: [PartialDocumentInfo]? + /// The processing state of the document. + public let progress: Progress + /// The document's source classification. + public let sourceClassification: SourceClassification + /// The document's expiration date. + public let expirationDate: Date? +} + +extension Document { + /** + It's the easiest way to initialize a `Document` if you are receiving a customized JSON structure from your proxy backend. + + - parameter creationDate: The document's creation date. + - parameter id: The document's unique identifier. + - parameter name: The document's file name. + - parameter links: Links to related resources, such as extractions, document, processed, layout or pages. + - parameter sourceClassification: The document's source classification. We recommend to use `scanned` or `composite`. + - parameter expirationDate: The document's expiration date. + + - note: Custom networking only. + */ + public init(creationDate: Date, + id: String, + name: String, + links: Links, + sourceClassification: SourceClassification, + expirationDate: Date?) { + self.init(compositeDocuments: [], + creationDate: creationDate, + id: id, + name: name, + origin: .upload, + pageCount: 1, + pages: [], + links: links, + partialDocuments: [], + progress: .completed, + sourceClassification: sourceClassification, + expirationDate: expirationDate) + } +} + +extension Document { + /** + * The possible states of documents. The availability of a document's extractions, layout and preview images are + * depending on the document's progress. + */ + public enum Progress: String, Decodable { + /// Indicates that the document is fully processed. Preview images, extractions and the layout are available. + case completed = "COMPLETED" + + /// Indicates that the document is not fully processed yet. + /// There are no extractions, layout or preview images available. + case pending = "PENDING" + + /// The document is processed, but there was an error during processing, so it is very likely that neither the + /// extractions, layout or preview images are available + case error = "ERROR" + } + + /// The origin of an uploaded document. + public enum Origin: String, Decodable { + /// When a document comes from an upload + case upload = "UPLOAD" + + /// Unknown origin + case unknown = "UNKNOWN" + } + + /// The possible source classifications of a document. + public enum SourceClassification: String, Decodable { + /// A composite document created by one or several partial documents + case composite = "COMPOSITE" + /// A "native" document, usually a PDF document. + case native = "NATIVE" + /// A scanned document, usually the result of a photographed or scanned document. + case scanned = "SCANNED" + /// A scanned document with the ocr information on top. + case sandwich = "SANDWICH" + /// A text document. + case text = "TEXT" + } + + /// A document's page, consisting of an array of number and its page number + public struct Page { + /// Page number + public let number: Int + /// Page image urls array, along with their sizes + public let images: [(size: Size, url: URL)] + + //swiftlint:disable nesting + enum CodingKeys: String, CodingKey { + case number = "pageNumber" + case images + } + + /// Page size + public enum Size: String, Decodable { + /// 750x900 + case small = "750x900" + + /// 1280x1810 + case big = "1280x1810" + } + + } + + /// Links to related resources, such as extractions, document, processed or layout. + public struct Links { + /** + An initializer for a `Links` structure if you are receiving a customized JSON structure from your proxy backend. + For this particular case all links will be pointed to the document's link. + + - parameter giniAPIDocumentURL: The document's link received from the Gini API. This must be the same URL that you received in the `Location` header from the Gini API. For example "https://pay-api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000000". + + - note: Custom networking only. + */ + public init(giniAPIDocumentURL: URL) { + self.extractions = giniAPIDocumentURL + self.layout = giniAPIDocumentURL + self.processed = giniAPIDocumentURL + self.document = giniAPIDocumentURL + self.pages = nil + } + + public let extractions: URL + public let layout: URL + public let processed: URL + public let document: URL + public let pages: URL? + } + + /// The document's layout, formed by an array of pages + public struct Layout { + /// Layout pages + public let pages: [Page] + } + + /// The document types, used as a hint during the analysis. + public enum DocType: String, Codable { + case bankStatement = "BankStatement" + case contract = "Contract" + case invoice = "Invoice" + case receipt = "Receipt" + case reminder = "Reminder" + case remittanceSlip = "RemittanceSlip" + case travelExpenseReport = "TravelExpenseReport" + case other = "Other" + } + + /// The V2 document's type. Used when creating documents in multipage mode. + public enum TypeV2 { + /// Partial document, consisting of pdf/image/qrCode data + case partial(Data) + /// Composite document, made of partial documents + case composite(CompositeDocumentInfo) + + var name: String { + switch self { + case .partial: + return "partial" + case .composite: + return "composite" + } + } + } + + /** + * The metadata contains any custom information regarding the upload (used later for reporting), + * creating HTTP headers with an specific format. + */ + public struct Metadata { + internal let healthMeta: GiniHealthAPILibrary.Document.Metadata + + /** + * The document metadata initializer with the branch ID (i.e: the BLZ of a Bank in Germany) and additional + * headers. + * + * - Parameter branchId: The branch id (i.e: the BLZ of a Bank in Germany) + * - Parameter additionalHeaders: Additional headers for the metadata. i.e: ["customerId":"123456"] + */ + public init(branchId: String? = nil, additionalHeaders: [String: String]? = nil) { + healthMeta = GiniHealthAPILibrary.Document.Metadata(branchId: branchId, additionalHeaders: additionalHeaders) + } + } +} + +extension Document: Equatable { + public static func == (lhs: Document, rhs: Document) -> Bool { + lhs.id == rhs.id + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/DocumentService.swift b/Sources/GiniMerchantSDK/Core/Adapter/DocumentService.swift new file mode 100644 index 0000000..9908ddc --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/DocumentService.swift @@ -0,0 +1,252 @@ +// +// DefaultDocumentService.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import GiniHealthAPILibrary + +/// The default document service. By default interacts with the `APIDomain.default` api. +public final class DefaultDocumentService { + + private let docService: GiniHealthAPILibrary.DefaultDocumentService + + init(docService: GiniHealthAPILibrary.DefaultDocumentService) { + self.docService = docService + self.docService.apiDomain = .merchant + self.docService.apiVersion = GiniMerchant.Constants.merchantVersionAPI + } + + /** + * Creates a partial document from a given image `Data` or a composite document for given partial documents. + * + * - Parameter fileName: The document's filename + * - Parameter docType: The document's docType + * - Parameter type: The V2 document's type. It could be either partial or composite type. + * - Parameter metadata: The document's metadata + * - Parameter completion: A completion callback, returning the created document on success + */ + public func createDocument(fileName: String?, + docType: Document.DocType?, + type: Document.TypeV2, + metadata: Document.Metadata?, + completion: @escaping CompletionResult) { + docService.createDocument(fileName: fileName, + docType: GiniHealthAPILibrary.Document.DocType(rawValue: docType?.rawValue ?? ""), + type: type.toHealthType(), + metadata: metadata?.healthMeta, + completion: { result in + switch result { + case .success(let document): + completion(.success(Document(healthDocument: document))) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + + } + + /** + * Deletes a document + * + * - Parameter document: Document to be deleted + * - Parameter completion: A completion callback + */ + public func delete(_ document: Document, completion: @escaping CompletionResult) { + docService.delete(document.toHealthDocument(), + completion: { result in + switch result { + case .success(let item): + completion(.success(item)) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + /** + * Fetches the user documents, with the possibility to retrieve them paginated + * + * - Parameter limit: Limit of documents to retrieve + * - Parameter offset: Document's offset + * - Parameter completion: A completion callback, returning the document list on success + */ + public func documents(limit: Int?, offset: Int?, completion: @escaping CompletionResult<[Document]>) { + docService.documents(limit: limit, + offset: offset, + completion: { result in + switch result { + case .success(let documents): + completion(.success(documents.compactMap { Document(healthDocument: $0) })) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + /** + * Retrieves a document for a given document id + * + * - Parameter id: The document's unique identifier + * - Parameter completion: A completion callback, returning the requested document on success + */ + public func fetchDocument(with id: String, completion: @escaping CompletionResult) { + docService.fetchDocument(with: id, + completion: { result in + switch result { + case .success(let item): + completion(.success(Document(healthDocument: item))) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + /** + * Retrieves the extractions for a given document. + * + * - Parameter document: Document to get the extractions for + * - Parameter cancellationToken: Token use to stopped the analysis when a user cancels it + * - Parameter completion: A completion callback, returning the extraction list on success + */ + public func extractions(for document: Document, + cancellationToken: CancellationToken, + completion: @escaping CompletionResult) { + docService.extractions(for: document.toHealthDocument(), + cancellationToken: cancellationToken.healthToken, + completion: { result in + switch result { + case .success(let healthExtractionResult): + completion(.success(ExtractionResult(healthExtractionResult: healthExtractionResult))) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + /** + * Retrieves the layout of a given document + * + * - Parameter id: The document's unique identifier + * - Parameter completion: A completion callback, returning the requested document layout on success + */ + public func layout(for document: Document, completion: @escaping CompletionResult) { + docService.layout(for: document.toHealthDocument(), + completion: { result in + switch result { + case .success(let item): + completion(.success(Document.Layout(healthLayout: item))) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + /** + * Retrieves the pages of a given document + * + * - Parameter id: The document's unique identifier + * - Parameter completion: A completion callback, returning the requested document layout on success + */ + public func pages(in document: Document, completion: @escaping CompletionResult<[Document.Page]>) { + docService.pages(in: document.toHealthDocument(), + completion: { result in + switch result { + case .success(let pages): + completion(.success(pages.compactMap { Document.Page(healthPage: $0) })) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + /** + * Submits the analysis feedback for a given document. + * + * - Parameter document: The document for which feedback should be sent + * - Parameter extractions: The document's updated extractions + * - Parameter completion: A completion callback + */ + public func submitFeedback(for document: Document, + with extractions: [Extraction], + completion: @escaping CompletionResult) { + let healthExtractions = extractions.map { $0.toHealthExtraction() } + docService.submitFeedback(for: document.toHealthDocument(), + with: healthExtractions, + completion: { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + /** + * Submits the analysis feedback with compound extractions (e.g., "line items") for a given document. + * + * - Parameter document: The document for which feedback should be sent + * - Parameter extractions: The document's updated extractions + * - Parameter compoundExtractions: The document's updated compound extractions + * - Parameter completion: A completion callback + */ + public func submitFeedback(for document: Document, + with extractions: [Extraction], + and compoundExtractions: [String: [[Extraction]]], + completion: @escaping CompletionResult) { + let healthExtractions = extractions.map { $0.toHealthExtraction() } + let healthCompoundExtractions = compoundExtractions.mapValues { mapCompoundExtraction($0) } + docService.submitFeedback(for: document.toHealthDocument(), + with: healthExtractions, + and: healthCompoundExtractions, + completion: { result in + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + private func mapCompoundExtraction(_ compoundExtraction: [[Extraction]]) -> [[GiniHealthAPILibrary.Extraction]] { + return compoundExtraction.map { $0.map { $0.toHealthExtraction() } } + } + + /** + * Retrieves the page preview of a document for a given page + * + * - Parameter documentId: Document id to get the preview for + * - Parameter pageNumber: The document's page number starting from 1 + * - Parameter completion: A completion callback, returning the requested page preview as Data on success + */ + public func preview(for documentId: String, + pageNumber: Int, + completion: @escaping CompletionResult) { + docService.preview(for: documentId, + pageNumber: pageNumber, + completion: { result in + switch result { + case .success(let data): + completion(.success(data)) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } + + public func file(urlString: String, completion: @escaping CompletionResult){ + docService.file(urlString: urlString, + completion: { result in + switch result { + case .success(let data): + completion(.success(data)) + case .failure(let error): + completion(.failure(GiniError.decorator(error))) + } + }) + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/Extraction.swift b/Sources/GiniMerchantSDK/Core/Adapter/Extraction.swift new file mode 100644 index 0000000..10459fd --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/Extraction.swift @@ -0,0 +1,119 @@ +// +// Extraction.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/** + * Data model for a document extraction. + */ +@objcMembers final public class Extraction: NSObject { + + /// The extraction's box. Only available for some extractions. + public let box: Box? + /// The available candidates for this extraction. + public let candidates: String? + /// The extraction's entity. + public let entity: String + /// The extraction's value + public var value: String + /// The extraction's name + public var name: String? + + /// The extraction's box attributes. + @objcMembers final public class Box: NSObject { + public let height: Double + public let left: Double + public let page: Int + public let top: Double + public let width: Double + + public init(height: Double, left: Double, page: Int, top: Double, width: Double) { + self.height = height + self.left = left + self.page = page + self.top = top + self.width = width + } + } + + /// A extraction candidate, containing a box, an entity and a its value. + @objcMembers final public class Candidate: NSObject { + public let box: Box? + public let entity: String + public let value: String + + public init(box: Box?, entity: String, value: String) { + self.box = box + self.entity = entity + self.value = value + } + } + + public init(box: Box?, candidates: String?, entity: String, value: String, name: String?) { + self.box = box + self.candidates = candidates + self.entity = entity + self.value = value + self.name = name + } + +} + +// MARK: - Decodable + +extension Extraction: Decodable {} +extension Extraction.Box: Decodable {} +extension Extraction.Candidate: Decodable {} + +// MARK: - isEqual + +extension Extraction { + + public override func isEqual(_ object: Any?) -> Bool { + + guard let other = object as? Extraction else { return false } + + return self.box == other.box && + self.candidates == other.candidates && + self.entity == other.entity && + self.name == other.name && + self.value == other.value + } +} + +extension Extraction { + + public override var debugDescription: String { + return "(\(name ?? "") : \(value))" + } +} + +extension Extraction.Box { + + public override func isEqual(_ object: Any?) -> Bool { + + guard let other = object as? Extraction.Box else { return false } + + return self.height == other.height && + self.left == other.left && + self.page == other.page && + self.top == other.top && + self.width == other.width + } +} + +extension Extraction.Candidate { + + public override func isEqual(_ object: Any?) -> Bool { + + guard let other = object as? Extraction.Candidate else { return false } + + return self.box == other.box && + self.entity == other.entity && + self.value == other.value + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/ExtractionResult.swift b/Sources/GiniMerchantSDK/Core/Adapter/ExtractionResult.swift new file mode 100644 index 0000000..13589ac --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/ExtractionResult.swift @@ -0,0 +1,55 @@ +// +// ExtractionResult.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/** + Payment State types from payment state from extraction result + */ +public enum PaymentState: String { + case payable = "Payable" + case other = "Other" +} +/** + Extraction Types for extraction result + */ +public enum ExtractionType: String { + case paymentState = "payment_state" + case paymentDueDate = "payment_due_date" + case amountToPay = "amount_to_pay" + case paymentRecipient = "payment_recipient" + case iban = "iban" + case paymentPurpose = "payment_purpose" +} + +/** +* Data model for a document extraction result. +*/ +@objcMembers final public class ExtractionResult: NSObject { + + /// The specific extractions. + public let extractions: [Extraction] + + /// The payment compound extractions. + public var payment: [[Extraction]]? + + /// The line item compound extractions. + public var lineItems: [[Extraction]]? + + public init(extractions: [Extraction], payment: [[Extraction]]?, lineItems: [[Extraction]]?) { + self.extractions = extractions + self.payment = payment + self.lineItems = lineItems + super.init() + } + + convenience init(extractionsContainer: ExtractionsContainer) { + self.init(extractions: extractionsContainer.extractions, + payment: extractionsContainer.compoundExtractions?["payment"], + lineItems: extractionsContainer.compoundExtractions?["lineItems"]) + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/ExtractionsContainer.swift b/Sources/GiniMerchantSDK/Core/Adapter/ExtractionsContainer.swift new file mode 100644 index 0000000..9589c6b --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/ExtractionsContainer.swift @@ -0,0 +1,56 @@ +// +// ExtractionsContainer.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public struct ExtractionsContainer { + let extractions: [Extraction] + let compoundExtractions: [String : [[Extraction]]]? + let candidates: [Extraction.Candidate] + + enum CodingKeys: String, CodingKey { + case extractions + case compoundExtractions + case candidates + } +} + +// MARK: - Decodable + +extension ExtractionsContainer: Decodable { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let decodedExtractions = try container.decode([String : Extraction].self, + forKey: .extractions) + let decodedCompoundExtractions = try container.decodeIfPresent([String : [[String : Extraction]]].self, + forKey: .compoundExtractions) + let decodedCandidates = try container.decodeIfPresent([String : [Extraction.Candidate]].self, + forKey: .candidates) ?? [:] + + extractions = decodedExtractions.map(ExtractionsContainer.mapExtraction) + + compoundExtractions = decodedCompoundExtractions?.mapValues(ExtractionsContainer.mapCompoundExtractions) + + candidates = decodedCandidates.flatMap { $0.value } + } +} + +private extension ExtractionsContainer { + private static func mapExtraction(key: String, value: Extraction) -> Extraction { + let extraction = value + extraction.name = key + return extraction + } + + private static func mapCompoundExtractions(extractionDictionaries: [[String : Extraction]]) -> [[Extraction]] { + return extractionDictionaries.map { extractionsDictionary in + extractionsDictionary.map(mapExtraction) + } + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/GiniError.swift b/Sources/GiniMerchantSDK/Core/Adapter/GiniError.swift new file mode 100644 index 0000000..f7bfd3c --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/GiniError.swift @@ -0,0 +1,40 @@ +// +// GiniError.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import GiniHealthAPILibrary + +public protocol GiniErrorProtocol { + var message: String { get } + var response: HTTPURLResponse? { get } + var data: Data? { get } +} + +public enum GiniError: Error, GiniErrorProtocol, Equatable { + case decorator(GiniHealthAPILibrary.GiniError) + + public var message: String { + switch self { + case .decorator(let giniError): + return giniError.message + } + } + + public var response: HTTPURLResponse? { + switch self { + case .decorator(let giniError): + return giniError.response + } + } + + public var data: Data? { + switch self { + case .decorator(let giniError): + return giniError.data + } + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/GiniLog.swift b/Sources/GiniMerchantSDK/Core/Adapter/GiniLog.swift new file mode 100644 index 0000000..c05d667 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/GiniLog.swift @@ -0,0 +1,13 @@ +// +// GiniLog.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +public enum LogLevel { + case none + case debug +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/Mapping.swift b/Sources/GiniMerchantSDK/Core/Adapter/Mapping.swift new file mode 100644 index 0000000..ccf93e5 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/Mapping.swift @@ -0,0 +1,208 @@ +// +// Mapping.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import GiniHealthAPILibrary + +//MARK: - Mapping Extraction +extension Extraction { + convenience init(healthExtraction: GiniHealthAPILibrary.Extraction) { + self.init(box: nil, + candidates: healthExtraction.candidates, + entity: healthExtraction.entity, + value: healthExtraction.value, + name: healthExtraction.name) + } + + func toHealthExtraction() -> GiniHealthAPILibrary.Extraction { + return GiniHealthAPILibrary.Extraction(box: nil, + candidates: candidates, + entity: entity, + value: value, + name: name) + } +} + +extension ExtractionResult { + convenience init(healthExtractionResult: GiniHealthAPILibrary.ExtractionResult) { + let extractions = healthExtractionResult.extractions.map { Extraction(healthExtraction: $0) } + let payment = healthExtractionResult.payment?.map { $0.map { Extraction(healthExtraction: $0) } } + let lineItems = healthExtractionResult.lineItems?.map { $0.map { Extraction(healthExtraction: $0) } } + + self.init(extractions: extractions, + payment: payment, + lineItems: lineItems) + } + + func toHealthExtractionResult() -> GiniHealthAPILibrary.ExtractionResult { + let healthExtractions = extractions.map { $0.toHealthExtraction() } + let healthPayment = payment?.map { $0.map { $0.toHealthExtraction() } } + let healthLineItems = lineItems?.map { $0.map { $0.toHealthExtraction() } } + return GiniHealthAPILibrary.ExtractionResult(extractions: healthExtractions, + payment: healthPayment, + lineItems: healthLineItems) + } +} + +//MARK: - PaymentProvider + +extension PaymentProvider { + init(healthPaymentProvider: GiniHealthAPILibrary.PaymentProvider) { + let openWithPlatforms = healthPaymentProvider.openWithSupportedPlatforms.compactMap { PlatformSupported(rawValue: $0.rawValue) } + let gpcSupportedPlatforms = healthPaymentProvider.gpcSupportedPlatforms.compactMap { PlatformSupported(rawValue: $0.rawValue) } + let colors = ProviderColors(healthProviderColors: healthPaymentProvider.colors) + let minAppVersions: MinAppVersions? + if let healthMinAppVersions = healthPaymentProvider.minAppVersion { + minAppVersions = MinAppVersions(healthMinAppVersions: healthMinAppVersions) + } else { + minAppVersions = nil + } + + self.init(id: healthPaymentProvider.id, + name: healthPaymentProvider.name, + appSchemeIOS: healthPaymentProvider.appSchemeIOS, + minAppVersion: minAppVersions, + colors: colors, + iconData: healthPaymentProvider.iconData, + appStoreUrlIOS: healthPaymentProvider.appStoreUrlIOS, + universalLinkIOS: healthPaymentProvider.universalLinkIOS, + index: healthPaymentProvider.index, + gpcSupportedPlatforms: gpcSupportedPlatforms, + openWithSupportedPlatforms: openWithPlatforms) + } +} + +extension ProviderColors { + init(healthProviderColors: GiniHealthAPILibrary.ProviderColors) { + self.init(background: healthProviderColors.background, + text: healthProviderColors.text) + } +} + +extension MinAppVersions { + init(healthMinAppVersions: GiniHealthAPILibrary.MinAppVersions) { + self.healthMinAppVersions = healthMinAppVersions + } +} + +//MARK: - Document + +extension Document { + init(healthDocument: GiniHealthAPILibrary.Document) { + self.init(compositeDocuments: healthDocument.compositeDocuments?.compactMap { CompositeDocument(document: $0.document) }, + creationDate: healthDocument.creationDate, + id: healthDocument.id, + name: healthDocument.name, + origin: Origin(rawValue: healthDocument.origin.rawValue) ?? .unknown, + pageCount: healthDocument.pageCount, + pages: healthDocument.pages?.compactMap { Document.Page(healthPage: $0) }, + links: Links(giniAPIDocumentURL: healthDocument.links.extractions), + partialDocuments: healthDocument.partialDocuments?.compactMap { PartialDocumentInfo(document: $0.document, rotationDelta: $0.rotationDelta) }, + progress: Progress(rawValue: healthDocument.progress.rawValue) ?? .completed, + sourceClassification: SourceClassification(rawValue: healthDocument.sourceClassification.rawValue) ?? .scanned, + expirationDate: healthDocument.expirationDate) + } + + func toHealthDocument() -> GiniHealthAPILibrary.Document { + GiniHealthAPILibrary.Document(creationDate: creationDate, + id: id, + name: name, + links: GiniHealthAPILibrary.Document.Links(giniAPIDocumentURL: links.extractions), + sourceClassification: GiniHealthAPILibrary.Document.SourceClassification(rawValue: sourceClassification.rawValue) ?? .scanned, + expirationDate: expirationDate) + } +} + +extension Document.Page { + init(healthPage: GiniHealthAPILibrary.Document.Page) { + let images = healthPage.images.compactMap { (size: Document.Page.Size(healthSize: $0.size), url: $0.url) } + self.init(number: healthPage.number, images: images) + } +} + +extension Document.Page.Size { + init(healthSize: GiniHealthAPILibrary.Document.Page.Size) { + self.init(rawValue: healthSize.rawValue)! + } +} + +extension Document.Layout { + init(healthLayout: GiniHealthAPILibrary.Document.Layout) { + self.init(pages: healthLayout.pages.compactMap { Document.Layout.Page(healthPage: $0) }) + } +} + +extension Document.Layout.Page { + init(healthPage: GiniHealthAPILibrary.Document.Layout.Page) { + self.init(number: healthPage.number, + sizeX: healthPage.sizeX, + sizeY: healthPage.sizeY, + textZones: healthPage.textZones.compactMap { Document.Layout.TextZone(healthTextZone: $0) }, + regions: healthPage.regions?.compactMap { Document.Layout.Region(healthRegion: $0) }) + } +} + +extension Document.Layout.Region { + init(healthRegion: GiniHealthAPILibrary.Document.Layout.Region) { + self.init(l: healthRegion.l, + t: healthRegion.t, + w: healthRegion.w, + h: healthRegion.h, + type: healthRegion.type, + lines: healthRegion.lines?.compactMap { Document.Layout.Region.init(healthRegion: $0) }, + wds: healthRegion.wds?.compactMap { Document.Layout.Word.init(healthWord: $0) }) + } +} + +extension Document.Layout.Word { + init(healthWord: GiniHealthAPILibrary.Document.Layout.Word) { + self.init(l: healthWord.l, + t: healthWord.t, + w: healthWord.w, + h: healthWord.h, + fontSize: healthWord.fontSize, + fontFamily: healthWord.fontFamily, + bold: healthWord.bold, + text: healthWord.text) + } +} + +extension Document.Layout.TextZone { + init(healthTextZone: GiniHealthAPILibrary.Document.Layout.TextZone) { + self.init(paragraphs: healthTextZone.paragraphs.compactMap { Document.Layout.Region(healthRegion: $0) }) + } +} + +extension CompositeDocumentInfo { + func toHealthCompositeDocumentInfo() -> GiniHealthAPILibrary.CompositeDocumentInfo { + GiniHealthAPILibrary.CompositeDocumentInfo(partialDocuments: partialDocuments.map { GiniHealthAPILibrary.PartialDocumentInfo(document: $0.document) }) + } +} + +extension Document.TypeV2 { + func toHealthType() -> GiniHealthAPILibrary.Document.TypeV2 { + switch self { + case .partial(let data): + return .partial(data) + case .composite(let info): + return .composite(info.toHealthCompositeDocumentInfo()) + } + } +} + +//MARK: - Log + +extension LogLevel { + func toHealthLogLevel() -> GiniHealthAPILibrary.LogLevel { + switch self { + case .debug: + return .debug + case .none: + return .none + } + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/PartialDocumentInfo.swift b/Sources/GiniMerchantSDK/Core/Adapter/PartialDocumentInfo.swift new file mode 100644 index 0000000..24843e2 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/PartialDocumentInfo.swift @@ -0,0 +1,43 @@ +// +// PartialDocumentInfo.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/// Partial document info used to create a composite document +public struct PartialDocumentInfo { + /// Partial document url + public var document: URL? + /// Partial document rotation delta [0-360º]. + public var rotationDelta: Int + + /// The partial document’s unique identifier. + public var id: String? { + guard let id = document?.absoluteString.split(separator: "/").last else { return nil } + return String(id) + } + + enum CodingKeys: String, CodingKey { + case document + case rotationDelta + } + + public init(document: URL?, rotationDelta: Int = 0) { + self.document = document + self.rotationDelta = rotationDelta + } +} + +// MARK: - Decodable + +extension PartialDocumentInfo: Codable { + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + document = try container.decodeIfPresent(URL.self, forKey: .document) + rotationDelta = try container.decodeIfPresent(Int.self, forKey: .rotationDelta) ?? 0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/PaymentProvider.swift b/Sources/GiniMerchantSDK/Core/Adapter/PaymentProvider.swift new file mode 100644 index 0000000..12c5b1b --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/PaymentProvider.swift @@ -0,0 +1,77 @@ +// +// PaymentProvider.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import GiniHealthAPILibrary +/** + Struct for payment provider + */ +public struct PaymentProvider: Codable { + public var id: String + public var name: String + public var appSchemeIOS: String + public var colors: ProviderColors + public var minAppVersion: MinAppVersions? + public var iconData: Data + public var appStoreUrlIOS: String? + public var universalLinkIOS: String + public var index: Int? + public var gpcSupportedPlatforms: [PlatformSupported] + public var openWithSupportedPlatforms: [PlatformSupported] + + public init(id: String, name: String, appSchemeIOS: String, minAppVersion: MinAppVersions?, colors: ProviderColors, iconData: Data, appStoreUrlIOS: String?, universalLinkIOS: String, index: Int?, gpcSupportedPlatforms: [PlatformSupported], openWithSupportedPlatforms: [PlatformSupported]) { + self.id = id + self.name = name + self.appSchemeIOS = appSchemeIOS + self.minAppVersion = minAppVersion + self.colors = colors + self.iconData = iconData + self.appStoreUrlIOS = appStoreUrlIOS + self.universalLinkIOS = universalLinkIOS + self.index = index + self.gpcSupportedPlatforms = gpcSupportedPlatforms + self.openWithSupportedPlatforms = openWithSupportedPlatforms + } +} +public typealias PaymentProviders = [PaymentProvider] + +extension PaymentProvider: Equatable { + public static func == (lhs: PaymentProvider, rhs: PaymentProvider) -> Bool { + lhs.id == rhs.id + } +} + +/** + Struct for MinAppVersions in payment provider response + */ +public struct MinAppVersions: Codable { + internal let healthMinAppVersions: GiniHealthAPILibrary.MinAppVersions + + public init(ios: String?, android: String?) { + self.healthMinAppVersions = GiniHealthAPILibrary.MinAppVersions(ios: ios, android: android) + } +} + +/** + Struct for payment provider colors in payment provider response + */ +public struct ProviderColors: Codable { + public var background: String + public var text: String + public init(background: String, text: String) { + self.background = background + self.text = text + } +} + +/** + Enum for platforms supported by payment providers. We now support iOS and Android + */ +public enum PlatformSupported: String, Codable { + case ios + case android +} diff --git a/Sources/GiniMerchantSDK/Core/Adapter/SessionManager.swift b/Sources/GiniMerchantSDK/Core/Adapter/SessionManager.swift new file mode 100644 index 0000000..2e48c02 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Adapter/SessionManager.swift @@ -0,0 +1,35 @@ +// +// SessionManager.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +import GiniHealthAPILibrary + +public typealias CompletionResult = (Result) -> Void + +/// Cancellation token needed during the analysis process +public final class CancellationToken { + internal let healthToken: GiniHealthAPILibrary.CancellationToken + + /// Indicates if the analysis has been cancelled + public var isCancelled: Bool { + get { healthToken.isCancelled } + set { healthToken.isCancelled = newValue } + } + + public init() { + self.healthToken = GiniHealthAPILibrary.CancellationToken() + } + + public init(healthToken: GiniHealthAPILibrary.CancellationToken) { + self.healthToken = healthToken + } + + /// Cancels the current task + public func cancel() { + healthToken.cancel() + } +} diff --git a/Sources/GiniMerchantSDK/Core/BottomSheetController.swift b/Sources/GiniMerchantSDK/Core/BottomSheetController.swift new file mode 100644 index 0000000..69497ba --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/BottomSheetController.swift @@ -0,0 +1,220 @@ +// +// BottomSheetController.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +public class BottomSheetController: UIViewController { + // MARK: - UI + /// Main bottom sheet container view + private lazy var mainContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = backgroundColor + view.roundCorners(corners: [.topLeft, .topRight], radius: Constants.cornerRadiusView) + view.layer.cornerRadius = Constants.cornerRadiusView + view.clipsToBounds = true + return view + }() + + /// View to hold dynamic content + private let contentView = EmptyView() + + /// Top bar view that draggable to dismiss + private let topBarView = EmptyView() + + /// Top view bar + private lazy var barLineView: UIView = { + let view = UIView() + view.backgroundColor = rectangleColor + view.layer.cornerRadius = Constants.cornerRadiusTopRectangle + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// Dimmed background view + private lazy var dimmedView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = dimmingBackgroundColor + view.alpha = 0 + return view + }() + + let backgroundColor: UIColor = GiniColor.standard7.uiColor() + let rectangleColor: UIColor = GiniColor.standard5.uiColor() + let dimmingBackgroundColor: UIColor = GiniColor(lightModeColor: UIColor.black, + darkModeColor: UIColor.white).uiColor().withAlphaComponent(0.4) + var minHeight: CGFloat = 0 + + // MARK: - View Setup + public override func viewDidLoad() { + super.viewDidLoad() + setupViews() + setupGestures() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + animatePresent() + } + + private func setupViews() { + view.backgroundColor = .clear + view.addSubview(dimmedView) + NSLayoutConstraint.activate([ + // Set dimmedView edges to superview + dimmedView.topAnchor.constraint(equalTo: view.topAnchor), + dimmedView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + dimmedView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dimmedView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + // Container View + view.addSubview(mainContainerView) + NSLayoutConstraint.activate([ + mainContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mainContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + mainContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + if minHeight > 0 { + mainContainerView.topAnchor.constraint(lessThanOrEqualTo: view.topAnchor, constant: obtainTopAnchorMinHeightConstraint()).isActive = true + } else { + mainContainerView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor, constant: Constants.minTopSpacing).isActive = true + } + + // Top draggable bar view + mainContainerView.addSubview(topBarView) + NSLayoutConstraint.activate([ + topBarView.topAnchor.constraint(equalTo: mainContainerView.topAnchor), + topBarView.leadingAnchor.constraint(equalTo: mainContainerView.leadingAnchor), + topBarView.trailingAnchor.constraint(equalTo: mainContainerView.trailingAnchor), + topBarView.heightAnchor.constraint(equalToConstant: Constants.heightTopBarView) + ]) + topBarView.addSubview(barLineView) + NSLayoutConstraint.activate([ + barLineView.centerXAnchor.constraint(equalTo: topBarView.centerXAnchor), + barLineView.topAnchor.constraint(equalTo: topBarView.topAnchor, constant: Constants.topAnchorTopRectangle), + barLineView.widthAnchor.constraint(equalToConstant: Constants.widthTopRectangle), + barLineView.heightAnchor.constraint(equalToConstant: Constants.heightTopRectangle) + ]) + + // Content View + mainContainerView.addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: mainContainerView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: mainContainerView.trailingAnchor), + contentView.topAnchor.constraint(equalTo: topBarView.bottomAnchor), + contentView.bottomAnchor.constraint(equalTo: mainContainerView.bottomAnchor, constant: -Constants.bottomPaddingConstraint) + ]) + } + + private func obtainTopAnchorMinHeightConstraint() -> CGFloat { + let window = UIApplication.shared.windows.filter {$0.isKeyWindow}.first + let extraBottomSafeAreaConstant = window?.safeAreaInsets.bottom == 0 ? Constants.safeAreaBottomPadding : 0 // fix for small devices + let topAnchorWithMinHeightConstant = view.frame.height - minHeight + extraBottomSafeAreaConstant + return topAnchorWithMinHeightConstant + } + + private func setupGestures() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapDimmedView)) + dimmedView.addGestureRecognizer(tapGesture) + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + panGesture.delaysTouchesBegan = false + panGesture.delaysTouchesEnded = false + topBarView.addGestureRecognizer(panGesture) + } + + @objc private func handleTapDimmedView() { + dismissBottomSheet() + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + // get drag direction + let isDraggingDown = translation.y > 0 + guard isDraggingDown else { return } + let pannedHeight = translation.y + let currentY = self.view.frame.height - self.mainContainerView.frame.height + // handle gesture state + switch gesture.state { + case .changed: + // This state will occur when user is dragging + self.mainContainerView.frame.origin.y = currentY + pannedHeight + case .ended: + // When user stop dragging + // if fulfil the condition dismiss it, else move to original position + if pannedHeight >= Constants.minDismissiblePanHeight { + dismissBottomSheet() + } else { + self.mainContainerView.frame.origin.y = currentY + } + default: + break + } + } + + private func animatePresent() { + dimmedView.alpha = 0 + // add more animation duration for smoothness + UIView.animate(withDuration: 0.2) { [weak self] in + self?.dimmedView.alpha = Constants.maxDimmedAlpha + } + } + + func dismissBottomSheet() { + UIView.animate(withDuration: 0.2, animations: { [weak self] in + guard let self = self else { return } + self.dimmedView.alpha = Constants.maxDimmedAlpha + self.mainContainerView.frame.origin.y = self.view.frame.height + }, completion: { [weak self] _ in + self?.dismiss(animated: false) + }) + } + + // sub-view controller will call this function to set content + func setContent(content: UIView) { + contentView.addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + content.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + content.topAnchor.constraint(equalTo: contentView.topAnchor), + content.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + view.layoutIfNeeded() + } +} + +extension BottomSheetController { + enum Constants { + /// Maximum alpha for dimmed view + static let maxDimmedAlpha: CGFloat = 0.8 + /// Minimum drag vertically that enable bottom sheet to dismiss + static let minDismissiblePanHeight: CGFloat = 20 + /// Minimum spacing between the top edge and bottom sheet + static var minTopSpacing: CGFloat = 80 + /// Minimum bottom sheet height + static let heightTopBarView = 32.0 + static let cornerRadiusTopRectangle = 2.0 + static let cornerRadiusView = 12.0 + static let topAnchorTopRectangle = 16.0 + static let widthTopRectangle = 48.0 + static let heightTopRectangle = 4.0 + static let bottomPaddingConstraint = 34.0 + static let safeAreaBottomPadding = 32.0 + } +} + +extension UIViewController { + func presentBottomSheet(viewController: BottomSheetController) { + viewController.modalPresentationStyle = .overFullScreen + present(viewController, animated: false, completion: nil) + } +} diff --git a/Sources/GiniMerchantSDK/Core/ButtonConfiguration.swift b/Sources/GiniMerchantSDK/Core/ButtonConfiguration.swift new file mode 100644 index 0000000..8671965 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/ButtonConfiguration.swift @@ -0,0 +1,48 @@ +// +// ButtonConfiguration.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit + +public struct ButtonConfiguration { + let backgroundColor: UIColor + let borderColor: UIColor + let titleColor: UIColor + let shadowColor: UIColor + let cornerRadius: CGFloat + let borderWidth: CGFloat + let shadowRadius: CGFloat + + let withBlurEffect: Bool + + /// Button configuration initalizer + /// - Parameters: + /// - backgroundColor: the button's background color + /// - borderColor: the button's border color + /// - titleColor: the button's title color + /// - shadowColor: the button's color of the shadow + /// - cornerRadius: the button's corner radius + /// - borderWidth: the button's border width + /// - shadowRadius: the button's shadow radius + /// - withBlurEffect: adds a blur effect on the button ignoring the background color and making it translucent + public init(backgroundColor: UIColor, + borderColor: UIColor, + titleColor: UIColor, + shadowColor: UIColor, + cornerRadius: CGFloat, + borderWidth: CGFloat, + shadowRadius: CGFloat, + withBlurEffect: Bool) { + self.backgroundColor = backgroundColor + self.borderColor = borderColor + self.titleColor = titleColor + self.shadowColor = shadowColor + self.cornerRadius = cornerRadius + self.borderWidth = borderWidth + self.shadowRadius = shadowRadius + self.withBlurEffect = withBlurEffect + } +} diff --git a/Sources/GiniMerchantSDK/Core/Extensions/GiniMerchantColors.swift b/Sources/GiniMerchantSDK/Core/Extensions/GiniMerchantColors.swift new file mode 100644 index 0000000..53a35e4 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Extensions/GiniMerchantColors.swift @@ -0,0 +1,86 @@ +// +// GiniMerchantColors.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniUtilites + +enum GiniMerchantColorPalette: String { + case accent1 = "Accent01" + case accent2 = "Accent02" + case accent3 = "Accent03" + case accent4 = "Accent04" + case accent5 = "Accent05" + + case dark1 = "Dark01" + case dark2 = "Dark02" + case dark3 = "Dark03" + case dark4 = "Dark04" + case dark5 = "Dark05" + case dark6 = "Dark06" + case dark7 = "Dark07" + + case light1 = "Light01" + case light2 = "Light02" + case light3 = "Light03" + case light4 = "Light04" + case light5 = "Light05" + case light6 = "Light06" + case light7 = "Light07" + + case feedback1 = "Feedback01" + case feedback2 = "Feedback02" + case feedback3 = "Feedback03" + case feedback4 = "Feedback04" + + case success1 = "Success01" + case success2 = "Success02" + case success3 = "Success03" + case success4 = "Success04" +} + +extension GiniMerchantColorPalette { + func preferredColor() -> UIColor { + let name = self.rawValue + if let mainBundleColor = UIColor(named: name, in: Bundle.main, compatibleWith: nil) { + return mainBundleColor + } + + guard let color = UIColor(named: name, in: giniMerchantBundleResource(), compatibleWith: nil) else { + fatalError("The color named '\(name)' does not exist.") + } + + return color + } +} + +extension GiniColor { + static let standard1 = GiniColor(lightModeColorName: .dark1, darkModeColorName: .light1) + static let standard2 = GiniColor(lightModeColorName: .dark2, darkModeColorName: .light2) + static let standard3 = GiniColor(lightModeColorName: .dark3, darkModeColorName: .light3) + static let standard4 = GiniColor(lightModeColorName: .dark4, darkModeColorName: .light4) + static let standard5 = GiniColor(lightModeColorName: .dark5, darkModeColorName: .light5) + static let standard6 = GiniColor(lightModeColorName: .dark6, darkModeColorName: .light6) + static let standard7 = GiniColor(lightModeColorName: .dark7, darkModeColorName: .light7) + + static let accent1 = GiniColor(lightModeColorName: .accent1, darkModeColorName: .accent1) + + static let feedback1 = GiniColor(lightModeColorName: .feedback1, darkModeColorName: .feedback1) +} + +extension GiniColor { + /** + Creates a GiniColor with the color names for the light and dark modes + + - parameter lightModeColorName: color name for the light mode + - parameter darkModeColorName: color name for the dark mode + */ + convenience init(lightModeColorName: GiniMerchantColorPalette, darkModeColorName: GiniMerchantColorPalette) { + let lightColor = lightModeColorName.preferredColor() + let darkColor = darkModeColorName.preferredColor() + self.init(lightModeColor: lightColor, darkModeColor: darkColor) + } +} diff --git a/Sources/GiniMerchantSDK/Core/Extensions/NotificationsName.swift b/Sources/GiniMerchantSDK/Core/Extensions/NotificationsName.swift new file mode 100644 index 0000000..3742333 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Extensions/NotificationsName.swift @@ -0,0 +1,12 @@ +// +// NotificationsName.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import Foundation + +extension Notification.Name { + static let paymentInfoDissapeared = Notification.Name("paymentInfoDissapeared") +} diff --git a/Sources/GiniMerchantSDK/Core/GiniMerchant.swift b/Sources/GiniMerchantSDK/Core/GiniMerchant.swift new file mode 100644 index 0000000..04e0ded --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/GiniMerchant.swift @@ -0,0 +1,428 @@ +// +// GiniMerchant.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniHealthAPILibrary +import GiniUtilites + +/** + Delegate to inform about the current status of the Gini Merchant SDK. + Makes use of callback for handling payment request creation. + + */ +public protocol GiniMerchantDelegate: AnyObject { + + /** + Called when the payment request was successfully created + + - parameter paymentRequestID: Id of created payment request. + */ + func didCreatePaymentRequest(paymentRequestID: String) + + /** + Error handling. If delegate is set and error is going to be handled internally the method should return true. + If error hadling is planned to be custom return false for specific error case. + + - parameter error: error which will be handled. + */ + func shouldHandleErrorInternally(error: GiniMerchantError) -> Bool +} +/** + Errors thrown with Gini Merchant SDK. + */ +public enum GiniMerchantError: Error { + /// Error thrown when there are no apps which supports Gini Pay Connect installed. + case noInstalledApps + /// Error thrown when api returns failure. + case apiError(GiniError) + /// Error thrown when api didn't returns payment extractions. + case noPaymentDataExtracted +} + +extension GiniMerchantError: Equatable {} +/** + Data structure for Payment Review Screen initialization. + */ +public struct DataForReview { + public let document: Document + public let extractions: [Extraction] + public init(document: Document, extractions: [Extraction]) { + self.document = document + self.extractions = extractions + } +} +/** + Core class for Gini Merchant SDK. + */ +@objc public final class GiniMerchant: NSObject { + /// reponsible for interaction with Gini Health backend . + public var giniApiLib: GiniHealthAPI + /// reponsible for the whole document processing. + public var documentService: DefaultDocumentService + /// reponsible for the payment processing. + public var paymentService: PaymentService + /// delegate to inform about the current status of the Gini Merchant SDK. + public weak var delegate: GiniMerchantDelegate? + + private var bankProviders: [PaymentProvider] = [] + + /** + Initializes a new instance of GiniMerchant. + + This initializer creates a GiniMerchant instance by first constructing a Client object with the provided client credentials (id, secret, domain) + + - Parameters: + - id: The client ID provided by Gini when you register your application. This is a unique identifier for your application. + - secret: The client secret provided by Gini alongside the client ID. This is used to authenticate your application to the Gini API. + - domain: The domain associated with your client credentials. This is used to scope the client credentials to a specific domain. + - logLevel: The log level. `LogLevel.none` by default. + */ + public init(id: String, + secret: String, + domain: String, + apiVersion: Int = Constants.merchantVersionAPI, + logLevel: LogLevel = .none) { + let client = Client(id: id, secret: secret, domain: domain, apiVersion: apiVersion) + self.giniApiLib = GiniHealthAPI.Builder(client: client, api: .merchant, logLevel: logLevel.toHealthLogLevel()).build() + self.documentService = DefaultDocumentService(docService: giniApiLib.documentService()) + self.paymentService = giniApiLib.paymentService(apiDomain: APIDomain.merchant, apiVersion: apiVersion) + } + + /** + Initializes a new instance of GiniMerchant. + + This initializer creates a GiniMerchant instance by first constructing a Client object with the provided client credentials (id, secret, domain) + + - Parameters: + - id: The client ID provided by Gini when you register your application. This is a unique identifier for your application. + - secret: The client secret provided by Gini alongside the client ID. This is used to authenticate your application to the Gini API. + - domain: The domain associated with your client credentials. This is used to scope the client credentials to a specific domain. + - pinningConfig: Configuration for certificate pinning. Format ["PinnedDomains" : ["PublicKeyHashes"]] + - logLevel: The log level. `LogLevel.none` by default. + */ + public init(id: String, + secret: String, + domain: String, + apiVersion: Int = Constants.defaultVersionAPI, + pinningConfig: [String: [String]], + logLevel: LogLevel = .none) { + let client = Client(id: id, secret: secret, domain: domain, apiVersion: apiVersion) + self.giniApiLib = GiniHealthAPI.Builder(client: client, + logLevel: logLevel.toHealthLogLevel(), + sessionDelegate: GiniSessionDelegate(pinningConfig: pinningConfig)).build() + self.documentService = DefaultDocumentService(docService: giniApiLib.documentService()) + self.paymentService = giniApiLib.paymentService(apiDomain: APIDomain.merchant, apiVersion: apiVersion) + } + + //For Testing + internal init(giniApiLib: GiniHealthAPI) { + self.giniApiLib = giniApiLib + self.documentService = DefaultDocumentService(docService: giniApiLib.documentService()) + self.paymentService = giniApiLib.paymentService(apiDomain: APIDomain.merchant, apiVersion: Constants.merchantVersionAPI) + } + + /** + Getting a list of the installed banking apps which support Gini Pay Connect functionality. + + - Parameters: + - completion: An action for processing asynchronous data received from the service with Result type as a paramater. + Result is a value that represents either a success or a failure, including an associated value in each case. + Completion block called on main thread. + In success case it includes array of payment providers, which are represebt the installed on the phone apps. + In case of failure error that there are no supported banking apps installed. + + */ + private func fetchInstalledBankingApps(completion: @escaping (Result) -> Void) { + fetchBankingApps { result in + DispatchQueue.main.async { + switch result { + case .success(let providers): + self.updateBankProviders(providers: providers) + + if self.bankProviders.count > 0 { + completion(.success(self.bankProviders)) + } else { + completion(.failure(.noInstalledApps)) + } + case let .failure(error): + + completion(.failure(GiniMerchantError.apiError(error))) + } + } + } + } + + private func updateBankProviders(providers: PaymentProviders) { + for provider in providers { + if let url = URL(string:provider.appSchemeIOS), UIApplication.shared.canOpenURL(url) { + self.bankProviders.append(provider) + } + } + } + + /** + Getting a list of the banking apps supported by SDK + + - Parameters: + - completion: An action for processing asynchronous data received from the service with Result type as a paramater. + Result is a value that represents either a success or a failure, including an associated value in each case. + In success case it includes array of payment providers supported by SDK. + In case of failure error provided by API. + */ + + public func fetchBankingApps(completion: @escaping (Result) -> Void) { + paymentService.paymentProviders { result in + switch result { + case let .success(providers): + self.bankProviders = providers.map { PaymentProvider(healthPaymentProvider: $0) } + completion(.success(self.bankProviders)) + case let .failure(error): + completion(.failure(GiniError.decorator(error))) + } + } + } + + /** + Sets a configuration which is used to customize the look of the Gini Merchant SDK, + for example to change texts and colors displayed to the user. + + - Parameters: + - configuration: The configuration to set. + + */ + public func setConfiguration(_ configuration: GiniMerchantConfiguration) { + GiniMerchantConfiguration.shared = configuration + } + + /** + Checks if the document is payable, looks for iban extraction. + + - Parameters: + - docId: Id of uploaded document. + - completion: An action for processing asynchronous data received from the service with Result type as a paramater. Result is a value that represents either a success or a failure, including an associated value in each case. Completion block called on main thread. + In success case it includes a boolean value and returns true if iban was extracted. + In case of failure in case of failure error from the server side. + + */ + public func checkIfDocumentIsPayable(docId: String, completion: @escaping (Result) -> Void) { + documentService.fetchDocument(with: docId) { result in + switch result { + case let .success(createdDocument): + self.documentService.extractions(for: createdDocument, + cancellationToken: CancellationToken()) { result in + DispatchQueue.main.async { + switch result { + case let .success(extractionResult): + if let paymentExtractions = extractionResult.payment?.first, let iban = paymentExtractions.first(where: { $0.name == "iban" })?.value, !iban.isEmpty { + completion(.success(true)) + } else if let ibanExtraction = extractionResult.extractions.first(where: { $0.name == "iban"})?.value, !ibanExtraction.isEmpty { + completion(.success(true)) + } else { + completion(.success(false)) + } + case .failure(let error): + completion(.failure(.apiError(error))) + } + } + } + case .failure(let error): + completion(.failure(.apiError(error))) + } + } + } + + /** + Polls the document via document id. + + - Parameters: + - docId: Id of uploaded document. + - completion: An action for processing asynchronous data received from the service with Result type as a paramater. Result is a value that represents either a success or a failure, including an associated value in each case. + Completion block called on main thread. + In success returns the polled document. + In case of failure error from the server side. + + */ + public func pollDocument(docId: String, completion: @escaping (Result) -> Void){ + documentService.fetchDocument(with: docId) { result in + DispatchQueue.main.async { + switch result { + case let .success(document): + completion(.success(document)) + case let .failure(error): + completion(.failure(.apiError(error))) + } + } + } + } + + /** + Get extractions for the document. + + - parameter docId: Id of the uploaded document. + - parameter completion: An action for processing asynchronous data received from the service with Result type as a paramater. Result is a value that represents either a success or a failure, including an associated value in each case. + Completion block called on main thread. + In success case it includes array of extractions. + In case of failure in case of failure error from the server side. + + */ + public func getExtractions(docId: String, completion: @escaping (Result<[Extraction], GiniMerchantError>) -> Void){ + documentService.fetchDocument(with: docId) { result in + switch result { + case let .success(createdDocument): + self.documentService + .extractions(for: createdDocument, + cancellationToken: CancellationToken()) { result in + DispatchQueue.main.async { + switch result { + case let .success(extractionResult): + if let paymentExtractionsContainer = extractionResult.payment, let paymentExtractions = paymentExtractionsContainer.first { + completion(.success(paymentExtractions)) + } else { + completion(.failure(.noPaymentDataExtracted)) + } + case let .failure(error): + completion(.failure(.apiError(error))) + } + } + } + case let .failure(error): + DispatchQueue.main.async { + completion(.failure(.apiError(error))) + } + } + } + } + + /** + Creates a payment request + + - Parameters: + - paymentInfo: Model object for payment information. + - completion: An action for processing asynchronous data received from the service with Result type as a paramater. Result is a value that represents either a success or a failure, including an associated value in each case. + Completion block called on main thread. + In success it includes the id of created payment request. + In case of failure error from the server side. + + */ + public func createPaymentRequest(paymentInfo: PaymentInfo, completion: @escaping (Result) -> Void) { + paymentService.createPaymentRequest(sourceDocumentLocation: "", paymentProvider: paymentInfo.paymentProviderId, recipient: paymentInfo.recipient, iban: paymentInfo.iban, bic: "", amount: paymentInfo.amount, purpose: paymentInfo.purpose) { result in + DispatchQueue.main.async { + switch result { + case let .success(requestID): + completion(.success(requestID)) + self.delegate?.didCreatePaymentRequest(paymentRequestID: requestID) + case let .failure(error): + completion(.failure(GiniError.decorator(error))) + } + } + } + } + + /** + Opens an app of selected payment provider. + openUrl called on main thread. + + - Parameters: + - requestID: Id of the created payment request. + - universalLink: Universal link for the selected payment provider + */ + public func openPaymentProviderApp(requestID: String, universalLink: String, urlOpener: URLOpener = URLOpener(UIApplication.shared), completion: ((Bool) -> Void)? = nil) { + let queryItems = [URLQueryItem(name: "id", value: requestID)] + let urlString = universalLink + "://payment" + var urlComponents = URLComponents(string: urlString)! + urlComponents.queryItems = queryItems + let resultUrl = urlComponents.url! + DispatchQueue.main.async { + urlOpener.openLink(url: resultUrl, completion: completion) + } + } + + /** + Sets a data for payment review screen + + - Parameters: + - documentId: Id of uploaded document. + - completion: An action for processing asynchronous data received from the service with Result type as a paramater. + Result is a value that represents either a success or a failure, including an associated value in each case. + Completion block called on main thread. + In success it includes array of extractions. + In case of failure error from the server side. + + */ + public func setDocumentForReview(documentId: String, completion: @escaping (Result<[Extraction], GiniMerchantError>) -> Void) { + documentService.fetchDocument(with: documentId) { result in + switch result { + case .success(let document): + self.getExtractions(docId: document.id) { result in + switch result{ + case .success(let extractions): + completion(.success(extractions)) + case .failure(let error): + completion(.failure(error)) + } + } + case .failure(let error): + DispatchQueue.main.async { + completion(.failure(.apiError(error))) + } + } + } + } + + /** + Fetches document and extractions for payment review screen + + - Parameters: + - documentId: Id of uploaded document. + - completion: An action for processing asynchronous data received from the service with Result type as a paramater. + Result is a value that represents either a success or a failure, including an associated value in each case. + Completion block called on main thread. + In success returns DataForReview structure. It includes document and array of extractions. + In case of failure error from the server side and nil instead of document . + + */ + public func fetchDataForReview(documentId: String, completion: @escaping (Result) -> Void) { + documentService.fetchDocument(with: documentId) { result in + switch result { + case let .success(document): + self.documentService + .extractions(for: document, + cancellationToken: CancellationToken()) { result in + DispatchQueue.main.async { + switch result { + case let .success(extractionResult): + if let paymentExtractionsContainer = extractionResult.payment, let paymentExtractions = paymentExtractionsContainer.first { + let fetchedData = DataForReview(document: document, extractions: paymentExtractions) + completion(.success(fetchedData)) + } else { + completion(.failure(.noPaymentDataExtracted)) + } + case let .failure(error): + completion(.failure(.apiError(error))) + } + } + } + case let .failure(error): + DispatchQueue.main.async { + completion(.failure(.apiError(error))) + } + } + } + } + + public static var versionString: String { + return GiniMerchantSDKVersion + } +} + +extension GiniMerchant { + public enum Constants { + public static let defaultVersionAPI = 4 + public static let merchantVersionAPI = 1 + } +} diff --git a/Sources/GiniMerchantSDK/Core/GiniMerchantConfiguration.swift b/Sources/GiniMerchantSDK/Core/GiniMerchantConfiguration.swift new file mode 100644 index 0000000..357ab53 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/GiniMerchantConfiguration.swift @@ -0,0 +1,138 @@ +// +// GiniMerchantConfiguration.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniUtilites + +/** + The `GiniMerchantConfiguration` class allows customizations to the look of the Gini Merchant SDK. + If there are limitations regarding which API can be used, this is clearly stated for the specific attribute. + + - note: Text can also be set by using the appropriate keys in a `Localizable.strings` file in the projects bundle. + The library will prefer whatever value is set in the following order: attribute in configuration, + key in strings file in project bundle, key in strings file in `GiniMerchant` bundle. + - note: Images can only be set by providing images with the same filename in an assets file or as individual files + in the projects bundle. The library will prefer whatever value is set in the following order: asset file + in project bundle, asset file in `GiniMerchant` bundle. See the avalible images for overriding in `GiniImages.xcassets`. + */ +public final class GiniMerchantConfiguration: NSObject { + private let fontProvider = FontProvider() + + /** + Singleton to make configuration internally accessible in all classes of the Gini Merchant SDK. + */ + public static var shared = GiniMerchantConfiguration() + + /** + Returns a `GiniMerchantConfiguration` instance which allows to set individual configurations + to change the look and feel of the Gini Merchant SDK. + + - returns: Instance of `GiniMerchantConfiguration`. + */ + public override init() { + super.init() + } + + // MARK: - Payment component view + + /** + Height of the buttons from the Payment Component View + */ + public var paymentComponentButtonsHeight: CGFloat = Constants.defaultButtonsHeight { + didSet { + if paymentComponentButtonsHeight < Constants.minimumButtonsHeight { + paymentComponentButtonsHeight = Constants.minimumButtonsHeight + } + } + } + + // MARK: - Payment review screen + + /** + Set to `false` to hide the payment review screen and jump straight to payment + */ + public var showPaymentReviewScreen = true + + // MARK: - Button configuration options + /** + A configuration that defines the appearance of the primary button, including its background color, border color, title color, shadow color, corner radius, border width, shadow radius, and whether to apply a blur effect. It is used for buttons on different UI elements: Payment Component View, Payment Review Screen. + */ + public lazy var primaryButtonConfiguration = ButtonConfiguration(backgroundColor: GiniMerchantColorPalette.accent1.preferredColor().withAlphaComponent(0.4), + borderColor: .clear, + titleColor: .white, + shadowColor: .clear, + cornerRadius: 12, + borderWidth: 0, + shadowRadius: 0, + withBlurEffect: false) + /** + A configuration that defines the appearance of the secondary button, including its background color, border color, title color, shadow color, corner radius, border width, shadow radius, and whether to apply a blur effect. It is used for buttons on different UI elements: Payment Component View. + */ + public lazy var secondaryButtonConfiguration = ButtonConfiguration(backgroundColor: GiniColor.standard6.uiColor(), + borderColor: GiniColor.standard5.uiColor(), + titleColor: GiniColor.standard1.uiColor(), + shadowColor: .clear, + cornerRadius: 12, + borderWidth: 1, + shadowRadius: 0, + withBlurEffect: true) + + // MARK: - Shared properties + + /** + A default style configuration that defines the appearance of the text field, including its background color, border color, text color, corner radius, border width and the placeholder foreground color. It is used for input text fields on Payment Review Screen. + */ + public lazy var defaultStyleInputFieldConfiguration = TextFieldConfiguration(backgroundColor: GiniColor.standard6.uiColor(), + borderColor: GiniColor.standard5.uiColor(), + textColor: GiniColor.standard1.uiColor(), + cornerRadius: 12.0, + borderWidth: 1.0, + placeholderForegroundColor: GiniColor.standard4.uiColor()) + /** + A error style configuration that defines the appearance of the text field, including its background color, border color, text color, corner radius, border width and the placeholder foreground color. It is used for input text fields on Payment Review Screen. + */ + public lazy var errorStyleInputFieldConfiguration = TextFieldConfiguration(backgroundColor: GiniColor.standard6.uiColor(), + borderColor: GiniColor(lightModeColorName: .feedback1, darkModeColorName: .feedback1).uiColor(), + textColor: GiniColor.standard1.uiColor(), + cornerRadius: 12.0, + borderWidth: 1.0, + placeholderForegroundColor: GiniColor.standard4.uiColor()) + /** + A selection style configuration that defines the appearance of the text field, including its background color, border color, text color, corner radius, border width and the placeholder foreground color. It is used for input text fields on Payment Review Screen. + */ + public lazy var selectionStyleInputFieldConfiguration = TextFieldConfiguration(backgroundColor: GiniColor.standard6.uiColor(), + borderColor: GiniColor.accent1.uiColor(), + textColor: GiniColor.standard1.uiColor(), + cornerRadius: 12.0, + borderWidth: 1.0, + placeholderForegroundColor: GiniColor.standard4.uiColor()) + + // MARK: - Update to custom font + /** + Allows setting a custom font for specific text styles. The change will affect all screens where a specific text style was used. + + - parameter font: Font that is going to be assosiated with specific text style. You can use scaled font or scale your font with our util method `UIFont.scaledFont(_ font: UIFont, textStyle: UIFont.TextStyle)` + - parameter textStyle: Constants that describe the preferred styles for fonts. Please, find additional information [here](https://developer.apple.com/documentation/uikit/uifont/textstyle) + */ + public func updateFont(_ font: UIFont, for textStyle: UIFont.TextStyle) { + fontProvider.updateFont(font, for: textStyle) + } + + public func font(for textStyle: UIFont.TextStyle) -> UIFont { + return fontProvider.font(for: textStyle) + } + + // We will switch this option internally to stil handle documents with extractions on GiniHealthSDK and still handle invoices without document on GiniMerchantSDK + var useInvoiceWithoutDocument: Bool = false +} + +extension GiniMerchantConfiguration { + private enum Constants { + static let defaultButtonsHeight = 56.0 + static let minimumButtonsHeight = 44.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/GiniMerchantImage.swift b/Sources/GiniMerchantSDK/Core/GiniMerchantImage.swift new file mode 100644 index 0000000..f363b62 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/GiniMerchantImage.swift @@ -0,0 +1,51 @@ +// +// GiniImage.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit + +/** + The GiniMerchantImage enumeration provides a convenient way to manage image assets within the Gini SDK, supporting customization for both light and dark modes. Each case in the enumeration represents a specific image asset used by the SDK. + + - Note: The raw values for each case correspond to the image asset names in the asset catalog. + */ + +public enum GiniMerchantImage: String { + case logo = "gm.giniLogo" + case info = "gm.infoCircle" + case close = "gm.close" + case more = "gm.more" + case plus = "gm.plus" + case minus = "gm.minus" + case appStore = "gm.appStoreIcon" + case chevronDown = "gm.iconChevronDown" + case selectionIndicator = "gm.selectionIndicator" + case paymentReviewClose = "gm.paymentReviewClose" + case lock = "gm.iconInputLock" + + /** + Retrieves an image corresponding to the enumeration case, prioritizing the client's bundle. If the image is not found in the client's bundle, it attempts to load the image from the Gini Merchant SDK bundle. + + - Returns: An UIImage instance corresponding to the enumeration case. If the image cannot be found in either the client's bundle or the Gini Merchant SDK bundle, the method triggers a runtime error. + */ + public func preferredUIImage() -> UIImage { + return UIImage(named: self.rawValue) ?? defaultImage() + } +} + +//MARK: - Private +private extension GiniMerchantImage { + func defaultImage() -> UIImage { + guard let image = UIImage(named: self.rawValue, in: giniMerchantBundle(), compatibleWith: nil) else { + fatalError("Merchant SDK: Image \(self.rawValue) not found") + } + return image + } +} + + + diff --git a/Sources/GiniMerchantSDK/Core/GiniMerchantUtils.swift b/Sources/GiniMerchantSDK/Core/GiniMerchantUtils.swift new file mode 100644 index 0000000..b05322a --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/GiniMerchantUtils.swift @@ -0,0 +1,82 @@ +// +// GiniMerchantUtils.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +/** + Returns the GiniMerchant bundle. + + */ +public func giniMerchantBundle() -> Bundle { + Bundle.module +} + +/** + Returns a localized string resource preferably from the client's bundle. + + - parameter key: The key to search for in the strings file. + - parameter comment: The corresponding comment. + + - returns: String resource for the given key. + */ +func NSLocalizedStringPreferredFormat(_ key: String, + fallbackKey: String = "", + comment: String, + isCustomizable: Bool = true) -> String { + let clientString = NSLocalizedString(key, comment: comment) + let fallbackClientString = NSLocalizedString(fallbackKey, comment: comment) + let format: String + if (clientString.lowercased() != key.lowercased() || fallbackClientString.lowercased() != fallbackKey.lowercased()) + && isCustomizable { + format = clientString + } else { + let bundle = giniMerchantBundle() + + var defaultFormat = NSLocalizedString(key, bundle: bundle, comment: comment) + + if defaultFormat.lowercased() == key.lowercased() { + defaultFormat = NSLocalizedString(fallbackKey, bundle: bundle, comment: comment) + } + + format = defaultFormat + } + + return format +} + +func giniMerchantBundleResource() -> Bundle { + Bundle.resource +} + +extension Foundation.Bundle { + /** + The resource bundle associated with the current module. + - important: When `GiniMerchantSDK` is distributed via Swift Package Manager, it will be synthesized automatically in the name of `Bundle.module`. + */ + static var resource: Bundle = { + let moduleName = "GiniMerchantSDK" + let bundleName = "\(moduleName)_\(moduleName)" + let candidates = [ + // Bundle should be present here when the package is linked into an App. + Bundle.main.resourceURL, + + // Bundle should be present here when the package is linked into a framework. + Bundle(for: MerchantSDKBundleFinder.self).resourceURL, + + // For command-line tools. + Bundle.main.bundleURL] + + for candidate in candidates { + let bundlePath = candidate?.appendingPathComponent(bundleName + ".bundle") + if let bundle = bundlePath.flatMap(Bundle.init(url:)) { + return bundle + } + } + return Bundle(for: GiniMerchant.self) + }() +} + +private class MerchantSDKBundleFinder {} diff --git a/Sources/GiniMerchantSDK/Core/PageCollectionViewCell.swift b/Sources/GiniMerchantSDK/Core/PageCollectionViewCell.swift new file mode 100644 index 0000000..abce285 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PageCollectionViewCell.swift @@ -0,0 +1,33 @@ +// +// PageCollectionViewCell.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniUtilites + +class PageCollectionViewCell: UICollectionViewCell, ReusableView { + + var pageImageView: ZoomedImageView = { + let iv = ZoomedImageView() + iv.setup() + iv.clipsToBounds = true + return iv + }() + + fileprivate func addImageView() { + contentView.addSubview(pageImageView) + } + + override init(frame: CGRect) { + super.init(frame: .zero) + addImageView() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + addImageView() + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/BankSelectionTableViewCell.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/BankSelectionTableViewCell.swift new file mode 100644 index 0000000..779207b --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/BankSelectionTableViewCell.swift @@ -0,0 +1,112 @@ +// +// BankSelectionTableViewCell.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +class BankSelectionTableViewCell: UITableViewCell, ReusableView { + private let cellView = UIView() + private let bankNameLabel = UILabel() + private let bankImageView = UIImageView() + private let selectionIndicatorImageView = UIImageView() + + var cellViewModel: BankSelectionTableViewCellModel? { + didSet { updateCell(cellViewModel) } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + setupViews() + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Private + +private extension BankSelectionTableViewCell { + enum Constants { + static let viewCornerRadius = 8.0 + static let selectedBorderWidth = 3.0 + static let notSelectedBorderWidth = 1.0 + static let bankIconBorderWidth = 1.0 + static let bankIconCornerRadius = 6.0 + static let bankIconSide = 32.0 + static let sectionIconSide = 24.0 + static let paddingHorizontal = 16.0 + static let paddingVertical = 4.0 + } +} + +private extension BankSelectionTableViewCell { + func setupViews() { + addSubview(cellView) + backgroundColor = .clear + selectionStyle = .none + + cellView.addSubview(bankImageView) + cellView.addSubview(bankNameLabel) + cellView.addSubview(selectionIndicatorImageView) + cellView.layer.cornerRadius = Constants.viewCornerRadius + + bankImageView.layer.cornerRadius = Constants.bankIconCornerRadius + bankImageView.layer.borderWidth = Constants.bankIconBorderWidth + bankImageView.clipsToBounds = true + } + + func setupConstraints() { + cellView.translatesAutoresizingMaskIntoConstraints = false + bankNameLabel.translatesAutoresizingMaskIntoConstraints = false + bankImageView.translatesAutoresizingMaskIntoConstraints = false + selectionIndicatorImageView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + cellView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.paddingVertical), + cellView.leadingAnchor.constraint(equalTo: leadingAnchor), + cellView.trailingAnchor.constraint(equalTo: trailingAnchor), + cellView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.paddingVertical), + + bankImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + bankImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.paddingHorizontal), + bankImageView.widthAnchor.constraint(equalToConstant: Constants.bankIconSide), + bankImageView.heightAnchor.constraint(equalToConstant: Constants.bankIconSide), + + bankNameLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + bankNameLabel.leadingAnchor.constraint(equalTo: bankImageView.trailingAnchor, constant: Constants.paddingHorizontal), + bankNameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.paddingHorizontal), + + selectionIndicatorImageView.centerYAnchor.constraint(equalTo: centerYAnchor), + selectionIndicatorImageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.paddingHorizontal), + selectionIndicatorImageView.widthAnchor.constraint(equalToConstant: Constants.sectionIconSide), + selectionIndicatorImageView.heightAnchor.constraint(equalToConstant: Constants.sectionIconSide) + ]) + } + + func updateCell(_ cellViewModel: BankSelectionTableViewCellModel?) { + guard let cellViewModel else { return } + + let isSelected = cellViewModel.shouldShowSelectionIcon + + bankImageView.image = cellViewModel.bankImageIcon + bankImageView.layer.borderColor = cellViewModel.bankIconBorderColor.cgColor + + bankNameLabel.text = cellViewModel.bankName + bankNameLabel.font = cellViewModel.bankNameLabelFont + bankNameLabel.textColor = cellViewModel.bankNameLabelAccentColor + + cellView.backgroundColor = cellViewModel.backgroundColor + cellView.layer.borderWidth = isSelected ? Constants.selectedBorderWidth : Constants.notSelectedBorderWidth + cellView.layer.borderColor = isSelected ? cellViewModel.selectedBankBorderColor.cgColor : cellViewModel.notSelectedBankBorderColor.cgColor + + selectionIndicatorImageView.image = cellViewModel.selectionIndicatorImage + selectionIndicatorImageView.isHidden = !isSelected + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/BankSelectionTableViewCellModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/BankSelectionTableViewCellModel.swift new file mode 100644 index 0000000..c7bf928 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/BankSelectionTableViewCellModel.swift @@ -0,0 +1,47 @@ +// +// BankSelectionTableViewCellModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites +import GiniHealthAPILibrary + +final class BankSelectionTableViewCellModel { + + private var isSelected: Bool = false + + var shouldShowSelectionIcon: Bool { + isSelected + } + + let backgroundColor: UIColor = GiniColor.standard7.uiColor() + + private var bankImageIconData: Data? + var bankImageIcon: UIImage { + if let bankImageIconData { + return UIImage(data: bankImageIconData) ?? UIImage() + } + return UIImage() + } + var bankIconBorderColor = GiniColor.standard5.uiColor() + + var bankName: String + var bankNameLabelFont: UIFont + let bankNameLabelAccentColor: UIColor = GiniColor.standard1.uiColor() + + let selectedBankBorderColor: UIColor = GiniColor.accent1.uiColor() + let notSelectedBankBorderColor: UIColor = GiniColor.standard5.uiColor() + + let selectionIndicatorImage: UIImage = GiniMerchantImage.selectionIndicator.preferredUIImage() + + init(paymentProvider: PaymentProviderAdditionalInfo) { + self.isSelected = paymentProvider.isSelected + self.bankImageIconData = paymentProvider.paymentProvider.iconData + self.bankName = paymentProvider.paymentProvider.name + self.bankNameLabelFont = GiniMerchantConfiguration.shared.font(for: .body1) + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/BanksBottomView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/BanksBottomView.swift new file mode 100644 index 0000000..ee74d4b --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/BanksBottomView.swift @@ -0,0 +1,227 @@ +// +// PaymentProvidersBottomView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +class BanksBottomView: BottomSheetViewController { + + var viewModel: BanksBottomViewModel + + private let contentStackView = EmptyStackView(orientation: .vertical) + + private lazy var titleView: UIView = { + let view = EmptyView() + view.frame = CGRect(x: 0, y: 0, width: .greatestFiniteMagnitude, height: Constants.heightTitleView) + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.selectBankTitleText + label.textColor = viewModel.selectBankLabelAccentColor + label.font = viewModel.selectBankLabelFont + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var closeTitleIconImageView: UIImageView = { + let imageView = UIImageView(image: viewModel.closeTitleIcon.withRenderingMode(.alwaysTemplate)) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = viewModel.closeIconAccentColor + imageView.isUserInteractionEnabled = true + imageView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapOnCloseIcon))) + imageView.isHidden = true + return imageView + }() + + private let descriptionView = EmptyView() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.descriptionText + label.textColor = viewModel.descriptionLabelAccentColor + label.font = viewModel.descriptionLabelFont + label.numberOfLines = 0 + return label + }() + + private let paymentProvidersView = EmptyView() + + private lazy var paymentProvidersTableView: UITableView = { + let tableView = UITableView(frame: .zero, style: .plain) + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.delegate = self + tableView.dataSource = self + tableView.register(cellType: BankSelectionTableViewCell.self) + tableView.estimatedRowHeight = viewModel.rowHeight + tableView.rowHeight = viewModel.rowHeight + tableView.separatorStyle = .none + tableView.tableFooterView = UIView() + tableView.backgroundColor = .clear + tableView.showsVerticalScrollIndicator = false + return tableView + }() + + private let bottomView = EmptyView() + + private let bottomStackView = EmptyStackView(orientation: .horizontal) + + private lazy var moreInformationView: MoreInformationView = { + let view = MoreInformationView() + let viewModel = MoreInformationViewModel() + viewModel.delegate = self + view.viewModel = viewModel + return view + }() + + private lazy var poweredByGiniView: PoweredByGiniView = { + let view = PoweredByGiniView() + view.viewModel = PoweredByGiniViewModel() + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + } + + init(viewModel: BanksBottomViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + setupViewHierarchy() + setupViewAttributes() + setupLayout() + } + + private func setupViewHierarchy() { + titleView.addSubview(titleLabel) + titleView.addSubview(closeTitleIconImageView) + contentStackView.addArrangedSubview(titleView) + descriptionView.addSubview(descriptionLabel) + contentStackView.addArrangedSubview(descriptionView) + paymentProvidersView.addSubview(paymentProvidersTableView) + contentStackView.addArrangedSubview(paymentProvidersView) + bottomStackView.addArrangedSubview(moreInformationView) + bottomStackView.addArrangedSubview(UIView()) + bottomStackView.addArrangedSubview(poweredByGiniView) + bottomView.addSubview(bottomStackView) + contentStackView.addArrangedSubview(bottomView) + self.setContent(content: contentStackView) + } + + private func setupViewAttributes() { + let isFullScreen = viewModel.bottomViewHeight >= viewModel.maximumViewHeight + paymentProvidersTableView.isScrollEnabled = isFullScreen + } + + private func setupLayout() { + setupTitleViewConstraints() + setupDescriptionConstraints() + setupTableViewConstraints() + setupPoweredByGiniConstraints() + } + + private func setupTitleViewConstraints() { + NSLayoutConstraint.activate([ + titleView.heightAnchor.constraint(equalToConstant: Constants.heightTitleView), + titleLabel.leadingAnchor.constraint(equalTo: titleView.leadingAnchor, constant: Constants.viewPaddingConstraint), + titleLabel.centerYAnchor.constraint(equalTo: titleView.centerYAnchor), + closeTitleIconImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + closeTitleIconImageView.heightAnchor.constraint(equalToConstant: Constants.closeIconSize), + closeTitleIconImageView.widthAnchor.constraint(equalToConstant: Constants.closeIconSize), + closeTitleIconImageView.trailingAnchor.constraint(equalTo: titleView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + closeTitleIconImageView.leadingAnchor.constraint(greaterThanOrEqualTo: titleLabel.trailingAnchor, constant: Constants.titleViewTitleIconSpacing) + ]) + } + + private func setupDescriptionConstraints() { + NSLayoutConstraint.activate([ + descriptionLabel.topAnchor.constraint(equalTo: descriptionView.topAnchor, constant: Constants.descriptionTopPadding), + descriptionLabel.leadingAnchor.constraint(equalTo: descriptionView.leadingAnchor, constant: Constants.viewPaddingConstraint), + descriptionLabel.trailingAnchor.constraint(equalTo: descriptionView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + descriptionLabel.bottomAnchor.constraint(equalTo: descriptionView.bottomAnchor, constant: -Constants.viewPaddingConstraint) + ]) + } + + private func setupTableViewConstraints() { + NSLayoutConstraint.activate([ + paymentProvidersTableView.topAnchor.constraint(equalTo: paymentProvidersView.topAnchor), + paymentProvidersTableView.leadingAnchor.constraint(equalTo: paymentProvidersView.leadingAnchor, constant: Constants.viewPaddingConstraint), + paymentProvidersTableView.trailingAnchor.constraint(equalTo: paymentProvidersView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + paymentProvidersTableView.bottomAnchor.constraint(equalTo: paymentProvidersView.bottomAnchor), + paymentProvidersTableView.heightAnchor.constraint(equalToConstant: viewModel.heightTableView) + ]) + } + + private func setupPoweredByGiniConstraints() { + NSLayoutConstraint.activate([ + bottomStackView.leadingAnchor.constraint(equalTo: bottomView.leadingAnchor, constant: Constants.viewPaddingConstraint), + bottomStackView.trailingAnchor.constraint(equalTo: bottomView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + bottomStackView.topAnchor.constraint(equalTo: bottomView.topAnchor, constant: Constants.topAnchorPoweredByGiniConstraint), + bottomStackView.bottomAnchor.constraint(equalTo: bottomView.bottomAnchor), + bottomStackView.heightAnchor.constraint(equalToConstant: Constants.bottomViewHeight) + ]) + } + + @objc + private func tapOnCloseIcon() { + viewModel.didTapOnClose() + } + +} + +extension BanksBottomView { + enum Constants { + static let heightTitleView = 19.0 + static let descriptionTopPadding = 4.0 + static let viewPaddingConstraint = 16.0 + static let topAnchorTitleView = 32.0 + static let closeIconSize = 24.0 + static let titleViewTitleIconSpacing = 10.0 + static let topAnchorPoweredByGiniConstraint = 5.0 + static let bottomViewHeight = 44.0 + } +} + +extension BanksBottomView: MoreInformationViewProtocol { + func didTapOnMoreInformation() { + viewModel.didTapOnMoreInformation() + } +} + +extension BanksBottomView: UITableViewDataSource, UITableViewDelegate { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + viewModel.paymentProviders.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: BankSelectionTableViewCell = tableView.dequeueReusableCell(for: indexPath) + let invoiceTableViewCellModel = viewModel.paymentProvidersViewModel(paymentProvider: viewModel.paymentProviders[indexPath.row]) + cell.cellViewModel = invoiceTableViewCellModel + return cell + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + viewModel.rowHeight + } + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + viewModel.viewDelegate?.didSelectPaymentProvider(paymentProvider: viewModel.paymentProviders[indexPath.row].paymentProvider) + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/BanksBottomViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/BanksBottomViewModel.swift new file mode 100644 index 0000000..90535b6 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/BanksBottomViewModel.swift @@ -0,0 +1,114 @@ +// +// BanksBottomViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +public protocol PaymentProvidersBottomViewProtocol: AnyObject { + func didSelectPaymentProvider(paymentProvider: PaymentProvider) + func didTapOnClose() + func didTapOnMoreInformation() + func didTapOnContinueOnShareBottomSheet() + func didTapForwardOnInstallBottomSheet() + func didTapOnPayButton() +} + +struct PaymentProviderAdditionalInfo { + var isSelected: Bool + var isInstalled: Bool + let paymentProvider: PaymentProvider +} + +final class BanksBottomViewModel { + + weak var viewDelegate: PaymentProvidersBottomViewProtocol? + + var paymentProviders: [PaymentProviderAdditionalInfo] = [] + private var selectedPaymentProvider: PaymentProvider? + + let maximumViewHeight: CGFloat = UIScreen.main.bounds.height - Constants.topPaddingView + let rowHeight: CGFloat = Constants.cellSizeHeight + var bottomViewHeight: CGFloat = 0 + var heightTableView: CGFloat = 0 + + let backgroundColor: UIColor = GiniColor.standard7.uiColor() + let rectangleColor: UIColor = GiniColor.standard5.uiColor() + let dimmingBackgroundColor: UIColor = GiniColor(lightModeColor: UIColor.black, + darkModeColor: UIColor.white).uiColor().withAlphaComponent(0.4) + + let selectBankTitleText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.select.bank.label", + comment: "Select bank text from the top label on payment providers bottom sheet") + let selectBankLabelAccentColor: UIColor = GiniColor(lightModeColorName: .dark1, darkModeColorName: .light2).uiColor() + var selectBankLabelFont: UIFont + + let closeTitleIcon: UIImage = GiniMerchantImage.close.preferredUIImage() + let closeIconAccentColor: UIColor = GiniColor.standard2.uiColor() + + let descriptionText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.providers.list.description", + comment: "Top description text on payment providers bottom sheet") + let descriptionLabelAccentColor: UIColor = GiniColor.standard3.uiColor() + var descriptionLabelFont: UIFont + + private var urlOpener: URLOpener + + init(paymentProviders: PaymentProviders, selectedPaymentProvider: PaymentProvider?, urlOpener: URLOpener = URLOpener(UIApplication.shared)) { + self.selectedPaymentProvider = selectedPaymentProvider + self.urlOpener = urlOpener + + self.selectBankLabelFont = GiniMerchantConfiguration.shared.font(for: .subtitle2) + self.descriptionLabelFont = GiniMerchantConfiguration.shared.font(for: .captions1) + + self.paymentProviders = paymentProviders + .map({ PaymentProviderAdditionalInfo(isSelected: $0.id == selectedPaymentProvider?.id, + isInstalled: isPaymentProviderInstalled(paymentProvider: $0), + paymentProvider: $0)}) + .filter { $0.paymentProvider.gpcSupportedPlatforms.contains(.ios) || $0.paymentProvider.openWithSupportedPlatforms.contains(.ios) } + .sorted(by: { ($0.paymentProvider.index ?? 0 < $1.paymentProvider.index ?? 0) }) + .sorted(by: { ($0.isInstalled && !$1.isInstalled) }) + self.calculateHeights() + } + + private func calculateHeights() { + let totalTableViewHeight = CGFloat(paymentProviders.count) * Constants.cellSizeHeight + let totalBottomViewHeight = Constants.blankBottomViewHeight + totalTableViewHeight + if totalBottomViewHeight > maximumViewHeight { + self.heightTableView = maximumViewHeight - Constants.blankBottomViewHeight + self.bottomViewHeight = maximumViewHeight + } else { + self.heightTableView = totalTableViewHeight + self.bottomViewHeight = totalTableViewHeight + Constants.blankBottomViewHeight + } + } + + func paymentProvidersViewModel(paymentProvider: PaymentProviderAdditionalInfo) -> BankSelectionTableViewCellModel { + BankSelectionTableViewCellModel(paymentProvider: paymentProvider) + } + + func didTapOnClose() { + viewDelegate?.didTapOnClose() + } + + func didTapOnMoreInformation() { + viewDelegate?.didTapOnMoreInformation() + } + + private func isPaymentProviderInstalled(paymentProvider: PaymentProvider) -> Bool { + if let urlAppScheme = URL(string: paymentProvider.appSchemeIOS) { + return urlOpener.canOpenLink(url: urlAppScheme) + } + return false + } +} + +extension BanksBottomViewModel { + enum Constants { + static let blankBottomViewHeight: CGFloat = 200.0 + static let cellSizeHeight: CGFloat = 64.0 + static let topPaddingView: CGFloat = 100.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/BottomSheetViewController.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/BottomSheetViewController.swift new file mode 100644 index 0000000..3f45c44 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/BottomSheetViewController.swift @@ -0,0 +1,220 @@ +// +// BottomSheetViewController.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +class BottomSheetViewController: UIViewController { + // MARK: - UI + /// Main bottom sheet container view + private lazy var mainContainerView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = backgroundColor + view.roundCorners(corners: [.topLeft, .topRight], radius: Constants.cornerRadiusView) + view.layer.cornerRadius = Constants.cornerRadiusView + view.clipsToBounds = true + return view + }() + + /// View to hold dynamic content + private let contentView = EmptyView() + + /// Top bar view that draggable to dismiss + private let topBarView = EmptyView() + + /// Top view bar + private lazy var barLineView: UIView = { + let view = UIView() + view.backgroundColor = rectangleColor + view.layer.cornerRadius = Constants.cornerRadiusTopRectangle + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + /// Dimmed background view + private lazy var dimmedView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = dimmingBackgroundColor + view.alpha = 0 + return view + }() + + let backgroundColor: UIColor = GiniColor.standard7.uiColor() + let rectangleColor: UIColor = GiniColor.standard5.uiColor() + let dimmingBackgroundColor: UIColor = GiniColor(lightModeColor: UIColor.black, + darkModeColor: UIColor.white).uiColor().withAlphaComponent(0.4) + var minHeight: CGFloat = 0 + + // MARK: - View Setup + override func viewDidLoad() { + super.viewDidLoad() + setupViews() + setupGestures() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + animatePresent() + } + + private func setupViews() { + view.backgroundColor = .clear + view.addSubview(dimmedView) + NSLayoutConstraint.activate([ + // Set dimmedView edges to superview + dimmedView.topAnchor.constraint(equalTo: view.topAnchor), + dimmedView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + dimmedView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + dimmedView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + + // Container View + view.addSubview(mainContainerView) + NSLayoutConstraint.activate([ + mainContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mainContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + mainContainerView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + if minHeight > 0 { + mainContainerView.topAnchor.constraint(lessThanOrEqualTo: view.topAnchor, constant: obtainTopAnchorMinHeightConstraint()).isActive = true + } else { + mainContainerView.topAnchor.constraint(greaterThanOrEqualTo: view.topAnchor, constant: Constants.minTopSpacing).isActive = true + } + + // Top draggable bar view + mainContainerView.addSubview(topBarView) + NSLayoutConstraint.activate([ + topBarView.topAnchor.constraint(equalTo: mainContainerView.topAnchor), + topBarView.leadingAnchor.constraint(equalTo: mainContainerView.leadingAnchor), + topBarView.trailingAnchor.constraint(equalTo: mainContainerView.trailingAnchor), + topBarView.heightAnchor.constraint(equalToConstant: Constants.heightTopBarView) + ]) + topBarView.addSubview(barLineView) + NSLayoutConstraint.activate([ + barLineView.centerXAnchor.constraint(equalTo: topBarView.centerXAnchor), + barLineView.topAnchor.constraint(equalTo: topBarView.topAnchor, constant: Constants.topAnchorTopRectangle), + barLineView.widthAnchor.constraint(equalToConstant: Constants.widthTopRectangle), + barLineView.heightAnchor.constraint(equalToConstant: Constants.heightTopRectangle) + ]) + + // Content View + mainContainerView.addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: mainContainerView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: mainContainerView.trailingAnchor), + contentView.topAnchor.constraint(equalTo: topBarView.bottomAnchor), + contentView.bottomAnchor.constraint(equalTo: mainContainerView.bottomAnchor, constant: -Constants.bottomPaddingConstraint) + ]) + } + + private func obtainTopAnchorMinHeightConstraint() -> CGFloat { + let window = UIApplication.shared.windows.filter {$0.isKeyWindow}.first + let extraBottomSafeAreaConstant = window?.safeAreaInsets.bottom == 0 ? Constants.safeAreaBottomPadding : 0 // fix for small devices + let topAnchorWithMinHeightConstant = view.frame.height - minHeight + extraBottomSafeAreaConstant + return topAnchorWithMinHeightConstant + } + + private func setupGestures() { + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTapDimmedView)) + dimmedView.addGestureRecognizer(tapGesture) + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) + panGesture.delaysTouchesBegan = false + panGesture.delaysTouchesEnded = false + topBarView.addGestureRecognizer(panGesture) + } + + @objc private func handleTapDimmedView() { + dismissBottomSheet() + } + + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + // get drag direction + let isDraggingDown = translation.y > 0 + guard isDraggingDown else { return } + let pannedHeight = translation.y + let currentY = self.view.frame.height - self.mainContainerView.frame.height + // handle gesture state + switch gesture.state { + case .changed: + // This state will occur when user is dragging + self.mainContainerView.frame.origin.y = currentY + pannedHeight + case .ended: + // When user stop dragging + // if fulfil the condition dismiss it, else move to original position + if pannedHeight >= Constants.minDismissiblePanHeight { + dismissBottomSheet() + } else { + self.mainContainerView.frame.origin.y = currentY + } + default: + break + } + } + + private func animatePresent() { + dimmedView.alpha = 0 + // add more animation duration for smoothness + UIView.animate(withDuration: 0.2) { [weak self] in + self?.dimmedView.alpha = Constants.maxDimmedAlpha + } + } + + func dismissBottomSheet() { + UIView.animate(withDuration: 0.2, animations: { [weak self] in + guard let self = self else { return } + self.dimmedView.alpha = Constants.maxDimmedAlpha + self.mainContainerView.frame.origin.y = self.view.frame.height + }, completion: { [weak self] _ in + self?.dismiss(animated: false) + }) + } + + // sub-view controller will call this function to set content + func setContent(content: UIView) { + contentView.addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + content.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + content.topAnchor.constraint(equalTo: contentView.topAnchor), + content.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + view.layoutIfNeeded() + } +} + +extension BottomSheetViewController { + enum Constants { + /// Maximum alpha for dimmed view + static let maxDimmedAlpha: CGFloat = 0.8 + /// Minimum drag vertically that enable bottom sheet to dismiss + static let minDismissiblePanHeight: CGFloat = 20 + /// Minimum spacing between the top edge and bottom sheet + static var minTopSpacing: CGFloat = 80 + /// Minimum bottom sheet height + static let heightTopBarView = 32.0 + static let cornerRadiusTopRectangle = 2.0 + static let cornerRadiusView = 12.0 + static let topAnchorTopRectangle = 16.0 + static let widthTopRectangle = 48.0 + static let heightTopRectangle = 4.0 + static let bottomPaddingConstraint = 34.0 + static let safeAreaBottomPadding = 32.0 + } +} + +extension UIViewController { + func presentBottomSheet(viewController: BottomSheetViewController) { + viewController.modalPresentationStyle = .overFullScreen + present(viewController, animated: false, completion: nil) + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/InstallAppBottomView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/InstallAppBottomView.swift new file mode 100644 index 0000000..ec58f88 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/InstallAppBottomView.swift @@ -0,0 +1,270 @@ +// +// InstallAppBottomView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +class InstallAppBottomView: BottomSheetViewController { + + var viewModel: InstallAppBottomViewModel + + private let contentStackView = EmptyStackView(orientation: .vertical) + + private let titleView = EmptyView() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.titleText + label.textColor = viewModel.titleLabelAccentColor + label.font = viewModel.titleLabelFont + label.numberOfLines = 0 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let bankView = EmptyView() + + private lazy var bankIconImageView: UIImageView = { + let imageView = UIImageView(image: viewModel.bankImageIcon) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.roundCorners(corners: .allCorners, radius: Constants.bankIconCornerRadius) + imageView.layer.borderWidth = Constants.bankIconBorderWidth + imageView.layer.borderColor = viewModel.bankIconBorderColor.cgColor + return imageView + }() + + private let moreInformationView = EmptyView() + + private lazy var moreInformationStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .horizontal) + stackView.spacing = Constants.viewPaddingConstraint + stackView.distribution = .fillProportionally + return stackView + }() + + private lazy var moreInformationLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = viewModel.moreInformationLabelTextColor + label.font = viewModel.moreInformationLabelFont + label.numberOfLines = 0 + label.text = viewModel.moreInformationLabelText + return label + }() + + private lazy var moreInformationButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(viewModel.moreInformationIcon, for: .normal) + button.tintColor = viewModel.moreInformationAccentColor + button.isUserInteractionEnabled = false + return button + }() + + private lazy var continueButton: PaymentPrimaryButton = { + let button = PaymentPrimaryButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.configure(with: viewModel.giniMerchantConfiguration.primaryButtonConfiguration) + button.customConfigure(paymentProviderColors: viewModel.paymentProviderColors, + text: viewModel.continueLabelText) + return button + }() + + private lazy var appStoreImageView: UIButton = { + let button = UIButton(type: .custom) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(viewModel.appStoreIcon, for: .normal) + button.imageView?.contentMode = .scaleAspectFit + button.addTarget(self, action: #selector(tapOnAppStoreButton), for: .touchUpInside) + return button + }() + + private let buttonsView: UIView = EmptyView() + + private let bottomView = EmptyView() + + private let bottomStackView = EmptyStackView(orientation: .horizontal) + + private lazy var poweredByGiniView: PoweredByGiniView = { + let view = PoweredByGiniView() + view.viewModel = PoweredByGiniViewModel() + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + } + + init(viewModel: InstallAppBottomViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + setupViewHierarchy() + setupLayout() + setupListeners() + setButtonsState() + } + + private func setupViewHierarchy() { + titleView.addSubview(titleLabel) + contentStackView.addArrangedSubview(titleView) + bankView.addSubview(bankIconImageView) + contentStackView.addArrangedSubview(bankView) + moreInformationStackView.addArrangedSubview(moreInformationButton) + moreInformationStackView.addArrangedSubview(moreInformationLabel) + moreInformationView.addSubview(moreInformationStackView) + contentStackView.addArrangedSubview(moreInformationView) + buttonsView.addSubview(continueButton) + buttonsView.addSubview(appStoreImageView) + contentStackView.addArrangedSubview(buttonsView) + contentStackView.addArrangedSubview(UIView()) + bottomStackView.addArrangedSubview(UIView()) + bottomStackView.addArrangedSubview(poweredByGiniView) + bottomView.addSubview(bottomStackView) + contentStackView.addArrangedSubview(bottomView) + self.setContent(content: contentStackView) + } + + private func setupLayout() { + setupTitleViewConstraints() + setupBankImageConstraints() + setupMoreInformationConstraints() + setupContinueButtonConstraints() + setupAppStoreButtonConstraints() + setupPoweredByGiniConstraints() + } + + private func setupListeners() { + NotificationCenter.default.addObserver(self, + selector: #selector(willEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + @objc private func willEnterForeground() { + setButtonsState() + } + + private func setButtonsState() { + appStoreImageView.isHidden = viewModel.isBankInstalled + continueButton.isHidden = !viewModel.isBankInstalled + moreInformationLabel.text = viewModel.moreInformationLabelText + + continueButton.didTapButton = { [weak self] in + self?.tapOnContinueButton() + } + } + + private func setupTitleViewConstraints() { + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: titleView.leadingAnchor, constant: Constants.viewPaddingConstraint), + titleLabel.trailingAnchor.constraint(equalTo: titleView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + titleLabel.topAnchor.constraint(equalTo: titleView.topAnchor, constant: Constants.topBottomPaddingConstraint), + titleLabel.bottomAnchor.constraint(equalTo: titleView.bottomAnchor, constant: -Constants.topBottomPaddingConstraint) + ]) + } + + private func setupBankImageConstraints() { + NSLayoutConstraint.activate([ + bankIconImageView.heightAnchor.constraint(equalToConstant: Constants.bankIconSize), + bankIconImageView.widthAnchor.constraint(equalToConstant: Constants.bankIconSize), + bankIconImageView.topAnchor.constraint(equalTo: bankView.topAnchor), + bankIconImageView.bottomAnchor.constraint(equalTo: bankView.bottomAnchor), + bankIconImageView.centerXAnchor.constraint(equalTo: bankView.centerXAnchor), + + ]) + } + + private func setupMoreInformationConstraints() { + NSLayoutConstraint.activate([ + moreInformationStackView.leadingAnchor.constraint(equalTo: moreInformationView.leadingAnchor, constant: Constants.viewPaddingConstraint), + moreInformationStackView.trailingAnchor.constraint(equalTo: moreInformationView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + moreInformationStackView.topAnchor.constraint(equalTo: moreInformationView.topAnchor, constant: Constants.viewPaddingConstraint), + moreInformationStackView.bottomAnchor.constraint(equalTo: moreInformationView.bottomAnchor, constant: Constants.moreInformationBottomAnchorConstraint), + moreInformationButton.widthAnchor.constraint(equalToConstant: Constants.infoIconSize) + ]) + } + + private func setupContinueButtonConstraints() { + NSLayoutConstraint.activate([ + continueButton.leadingAnchor.constraint(equalTo: buttonsView.leadingAnchor, constant: Constants.viewPaddingConstraint), + continueButton.trailingAnchor.constraint(equalTo: buttonsView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + continueButton.heightAnchor.constraint(equalToConstant: Constants.continueButtonViewHeight), + continueButton.topAnchor.constraint(equalTo: buttonsView.topAnchor, constant: Constants.continueButtonTopAnchor), + continueButton.bottomAnchor.constraint(equalTo: buttonsView.bottomAnchor, constant: -Constants.continueButtonBottomAnchor) + ]) + } + + private func setupAppStoreButtonConstraints() { + NSLayoutConstraint.activate([ + appStoreImageView.leadingAnchor.constraint(equalTo: buttonsView.leadingAnchor, constant: Constants.viewPaddingConstraint), + appStoreImageView.trailingAnchor.constraint(equalTo: buttonsView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + appStoreImageView.heightAnchor.constraint(equalToConstant: Constants.appStoreImageViewHeight), + appStoreImageView.topAnchor.constraint(equalTo: buttonsView.topAnchor, constant: Constants.continueButtonTopAnchor), + appStoreImageView.centerXAnchor.constraint(equalTo: buttonsView.centerXAnchor), + appStoreImageView.bottomAnchor.constraint(equalTo: buttonsView.bottomAnchor, constant: -Constants.appStoreBottomAnchor) + ]) + } + + private func setupPoweredByGiniConstraints() { + NSLayoutConstraint.activate([ + bottomStackView.leadingAnchor.constraint(equalTo: bottomView.leadingAnchor, constant: Constants.viewPaddingConstraint), + bottomStackView.trailingAnchor.constraint(equalTo: bottomView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + bottomStackView.topAnchor.constraint(equalTo: bottomView.topAnchor, constant: Constants.topAnchorPoweredByGiniConstraint), + bottomStackView.bottomAnchor.constraint(equalTo: bottomView.bottomAnchor), + bottomStackView.heightAnchor.constraint(equalToConstant: Constants.bottomViewHeight) + ]) + } + + @objc + private func tapOnContinueButton() { + viewModel.didTapOnContinue() + } + + @objc + private func tapOnAppStoreButton() { + openPaymentProvidersAppStoreLink(urlString: viewModel.selectedPaymentProvider?.appStoreUrlIOS) + } + + private func openPaymentProvidersAppStoreLink(urlString: String?) { + guard let urlString = urlString else { + print("AppStore link unavailable for this payment provider") + return + } + if let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } +} + +extension InstallAppBottomView { + enum Constants { + static let viewPaddingConstraint = 16.0 + static let bankIconSize = 36.0 + static let bankIconCornerRadius = 6.0 + static let bankIconBorderWidth = 1.0 + static let continueButtonViewHeight = 56.0 + static let continueButtonTopAnchor = 16.0 + static let continueButtonBottomAnchor = 4.0 + static let appStoreBottomAnchor = 16.0 + static let appStoreImageViewHeight = 44.0 + static let topBottomPaddingConstraint = 10.0 + static let topAnchorPoweredByGiniConstraint = 5.0 + static let moreInformationBottomAnchorConstraint = 8.0 + static let infoIconSize = 24.0 + static let bottomViewHeight = 44.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/InstallAppBottomViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/InstallAppBottomViewModel.swift new file mode 100644 index 0000000..7f1396c --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/InstallAppBottomViewModel.swift @@ -0,0 +1,88 @@ +// +// InstallAppBottomViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites +import GiniHealthAPILibrary + +protocol InstallAppBottomViewProtocol: AnyObject { + func didTapOnContinue() +} + +final class InstallAppBottomViewModel { + + let giniMerchantConfiguration = GiniMerchantConfiguration.shared + + var selectedPaymentProvider: PaymentProvider? + // Payment provider colors + var paymentProviderColors: ProviderColors? + + weak var viewDelegate: InstallAppBottomViewProtocol? + + let backgroundColor: UIColor = GiniColor.standard7.uiColor() + let rectangleColor: UIColor = GiniColor.standard5.uiColor() + let dimmingBackgroundColor: UIColor = GiniColor(lightModeColor: UIColor.black, + darkModeColor: UIColor.white).uiColor().withAlphaComponent(0.4) + + var titleText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.install.app.bottom.sheet.title", + comment: "Install App Bottom sheet title") + let titleLabelAccentColor: UIColor = GiniColor.standard2.uiColor() + var titleLabelFont: UIFont + + private var bankImageIconData: Data? + var bankImageIcon: UIImage { + if let bankImageIconData { + return UIImage(data: bankImageIconData) ?? UIImage() + } + return UIImage() + } + var bankIconBorderColor = GiniColor.standard5.uiColor() + + // More information part + let moreInformationLabelTextColor: UIColor = GiniColor.standard3.uiColor() + let moreInformationAccentColor: UIColor = GiniColor.standard3.uiColor() + var moreInformationLabelText: String { + isBankInstalled ? + NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.install.app.bottom.sheet.tip.description", + comment: "Text for tip information label").replacingOccurrences(of: bankToReplaceString, + with: selectedPaymentProvider?.name ?? "") : + NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.install.app.bottom.sheet.notes.description", + comment: "Text for notes information label").replacingOccurrences(of: bankToReplaceString, + with: selectedPaymentProvider?.name ?? "") + } + + + var moreInformationLabelFont: UIFont + let moreInformationIcon: UIImage = GiniMerchantImage.info.preferredUIImage() + + // Pay invoice label + let continueLabelText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.install.app.bottom.sheet.continue.button.text", + comment: "Title label used for the Continue button") + + var appStoreIcon: UIImage = GiniMerchantImage.appStore.preferredUIImage() + let bankToReplaceString = "[BANK]" + + var isBankInstalled: Bool { + selectedPaymentProvider?.appSchemeIOS.canOpenURLString() == true + } + + init(selectedPaymentProvider: PaymentProvider?) { + self.selectedPaymentProvider = selectedPaymentProvider + self.bankImageIconData = selectedPaymentProvider?.iconData + self.paymentProviderColors = selectedPaymentProvider?.colors + + titleText = titleText.replacingOccurrences(of: bankToReplaceString, with: selectedPaymentProvider?.name ?? "") + + self.titleLabelFont = giniMerchantConfiguration.font(for: .subtitle1) + self.moreInformationLabelFont = giniMerchantConfiguration.font(for: .captions1) + } + + func didTapOnContinue() { + viewDelegate?.didTapOnContinue() + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/MoreInformationView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/MoreInformationView.swift new file mode 100644 index 0000000..e98db5b --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/MoreInformationView.swift @@ -0,0 +1,106 @@ +// +// MoreInformationView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class MoreInformationView: UIView { + var viewModel: MoreInformationViewModel! { + didSet { + setupView() + } + } + + private let mainContainer = EmptyView() + + private lazy var moreInformationLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 0 + + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: viewModel.moreInformationLabelTextColor, + .underlineStyle: NSUnderlineStyle.single.rawValue, + .font: viewModel.moreInformationLabelLinkFont + ] + let moreInformationActionableAttributtedString = NSMutableAttributedString(string: viewModel.moreInformationActionablePartText, attributes: attributes) + label.attributedText = moreInformationActionableAttributtedString + + let tapOnMoreInformation = UITapGestureRecognizer(target: self, + action: #selector(tapOnMoreInformationLabelAction(gesture:))) + label.isUserInteractionEnabled = true + label.addGestureRecognizer(tapOnMoreInformation) + + label.attributedText = moreInformationActionableAttributtedString + return label + }() + + private lazy var moreInformationButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(viewModel.moreInformationIcon, for: .normal) + button.tintColor = viewModel.moreInformationAccentColor + button.addTarget(self, action: #selector(tapOnMoreInformationButtonAction), for: .touchUpInside) + return button + }() + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + self.translatesAutoresizingMaskIntoConstraints = false + + mainContainer.addSubview(moreInformationButton) + mainContainer.addSubview(moreInformationLabel) + self.addSubview(mainContainer) + + setupConstraints() + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + moreInformationButton.leadingAnchor.constraint(equalTo: mainContainer.leadingAnchor), + moreInformationButton.centerYAnchor.constraint(equalTo: mainContainer.centerYAnchor), + moreInformationButton.widthAnchor.constraint(equalToConstant: Constants.infoIconSize), + moreInformationButton.heightAnchor.constraint(equalToConstant: Constants.infoIconSize), + moreInformationLabel.leadingAnchor.constraint(equalTo: moreInformationButton.trailingAnchor, constant: Constants.spacingPadding), + moreInformationLabel.centerYAnchor.constraint(equalTo: moreInformationButton.centerYAnchor), + moreInformationLabel.trailingAnchor.constraint(greaterThanOrEqualTo: mainContainer.trailingAnchor), + mainContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + mainContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + mainContainer.topAnchor.constraint(equalTo: topAnchor), + mainContainer.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + @objc + private func tapOnMoreInformationLabelAction(gesture: UITapGestureRecognizer) { + if gesture.didTapAttributedTextInLabel(label: moreInformationLabel, + targetText: viewModel.moreInformationActionablePartText) { + viewModel.tapOnMoreInformation() + } + } + + @objc + private func tapOnMoreInformationButtonAction(gesture: UITapGestureRecognizer) { + viewModel.tapOnMoreInformation() + } +} + +extension MoreInformationView { + private enum Constants { + static let buttonPadding = 10.0 + static let spacingPadding = 8.0 + static let infoIconSize = 24.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/MoreInformationViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/MoreInformationViewModel.swift new file mode 100644 index 0000000..b3b5c32 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/MoreInformationViewModel.swift @@ -0,0 +1,34 @@ +// +// MoreInformationViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +protocol MoreInformationViewProtocol: AnyObject { + func didTapOnMoreInformation() +} + +final class MoreInformationViewModel { + + weak var delegate: MoreInformationViewProtocol? + // More information part + let moreInformationAccentColor: UIColor = GiniColor.standard2.uiColor() + let moreInformationLabelTextColor: UIColor = GiniColor.standard4.uiColor() + let moreInformationActionablePartText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.more.information.underlined.part", + comment: "Text for more information actionable part from the label") + var moreInformationLabelLinkFont: UIFont + let moreInformationIcon: UIImage = GiniMerchantImage.info.preferredUIImage() + + init() { + moreInformationLabelLinkFont = GiniMerchantConfiguration.shared.font(for: .captions2) + } + + func tapOnMoreInformation() { + delegate?.didTapOnMoreInformation() + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/OnboardingShareInvoiceScreenCount.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/OnboardingShareInvoiceScreenCount.swift new file mode 100644 index 0000000..4df435b --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/OnboardingShareInvoiceScreenCount.swift @@ -0,0 +1,51 @@ +// +// OnboardingShareInvoiceScreenCount.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import Foundation + +struct OnboardingShareInvoiceScreenCount: Codable { + var providerCounts: [String: Int] // Dictionary to store count for each provider +} + +extension OnboardingShareInvoiceScreenCount { + // UserDefaults key for storing onboarding presentation counts + private static let onboardingShareScreenCountKey = "OnboardingShareInvoiceScreenCount" + + // Load onboarding presentation counts from UserDefaults + static func load() -> OnboardingShareInvoiceScreenCount { + if let data = UserDefaults.standard.data(forKey: onboardingShareScreenCountKey), + let counts = try? JSONDecoder().decode(OnboardingShareInvoiceScreenCount.self, from: data) { + return counts + } + return OnboardingShareInvoiceScreenCount(providerCounts: [:]) + } + + // Save onboarding presentation counts to UserDefaults + func save() { + if let data = try? JSONEncoder().encode(self) { + UserDefaults.standard.set(data, forKey: OnboardingShareInvoiceScreenCount.onboardingShareScreenCountKey) + } + } + + // Get presentation count for a specific provider + func presentationCount(forProvider providerID: String?) -> Int { + guard let providerID else { return 0 } + return providerCounts[providerID] ?? 0 + } + + // Increment presentation count for a specific provider + mutating func incrementPresentationCount(forProvider providerID: String?) { + guard let providerID else { return } + if let count = providerCounts[providerID] { + providerCounts[providerID] = count + 1 + } else { + providerCounts[providerID] = 1 + } + save() // Save updated counts to UserDefaults + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentBottomView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentBottomView.swift new file mode 100644 index 0000000..a6ff6c3 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentBottomView.swift @@ -0,0 +1,48 @@ +// +// PaymentComponentBottomView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniUtilites + +class PaymentComponentBottomView: BottomSheetViewController { + + private var paymentView: UIView + + private let contentView = EmptyView() + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + } + + init(paymentView: UIView) { + self.paymentView = paymentView + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + contentView.addSubview(paymentView) + self.setContent(content: contentView) + + NSLayoutConstraint.activate([ + paymentView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.padding), + paymentView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.padding), + paymentView.topAnchor.constraint(equalTo: contentView.topAnchor), + paymentView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor) + ]) + } +} + +extension PaymentComponentBottomView { + private enum Constants { + static let padding = 16.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentConfiguration.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentConfiguration.swift new file mode 100644 index 0000000..ede2367 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentConfiguration.swift @@ -0,0 +1,31 @@ +// +// PaymentComponentConfiguration.swift +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import Foundation + +public struct PaymentComponentConfiguration { + /** + * Please contact a Gini representative before changing this configuration option. + */ + public var isPaymentComponentBranded: Bool + + /** + Set to `true` to make see payment component in 1 row instead of 2 + */ + var showPaymentComponentInOneRow: Bool + + /** + Set to `true` to hide information like select your bank title label and more information view if user is returning and used component multiple times + */ + var hideInfoForReturningUser: Bool + + public init(isPaymentComponentBranded: Bool = true) { + self.isPaymentComponentBranded = isPaymentComponentBranded + self.showPaymentComponentInOneRow = false + self.hideInfoForReturningUser = false + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentView.swift new file mode 100644 index 0000000..fde2c00 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentView.swift @@ -0,0 +1,208 @@ +// +// PaymentComponentView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class PaymentComponentView: UIView { + + var viewModel: PaymentComponentViewModel! { + didSet { + setupView() + } + } + + private let contentStackView = EmptyStackView(orientation: .vertical) + + private let selectYourBankView = EmptyView() + + private lazy var selectYourBankLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.selectYourBankLabelText + label.textColor = viewModel.selectYourBankAccentColor + label.font = viewModel.selectYourBankLabelFont + label.numberOfLines = 0 + return label + }() + + private let buttonsView = EmptyView() + + private lazy var buttonsStackView: UIStackView = { + let stackView = EmptyStackView(orientation: viewModel.showPaymentComponentInOneRow ? .horizontal : .vertical) + stackView.spacing = Constants.buttonsSpacing + return stackView + }() + + private lazy var selectBankButton: PaymentSecondaryButton = { + let button = PaymentSecondaryButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.configure(with: viewModel.giniMerchantConfiguration.secondaryButtonConfiguration) + return button + }() + + private lazy var payInvoiceButton: PaymentPrimaryButton = { + let button = PaymentPrimaryButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.configure(with: viewModel.giniMerchantConfiguration.primaryButtonConfiguration) + button.customConfigure(paymentProviderColors: viewModel.paymentProviderColors, + text: viewModel.ctaButtonText) + return button + }() + + private let bottomView = EmptyView() + + private let bottomStackView = EmptyStackView(orientation: .horizontal) + + private lazy var moreInformationView: MoreInformationView = { + let view = MoreInformationView() + let viewModel = MoreInformationViewModel() + viewModel.delegate = self + view.viewModel = viewModel + return view + }() + + private lazy var poweredByGiniView: PoweredByGiniView = { + let view = PoweredByGiniView() + view.viewModel = PoweredByGiniViewModel() + return view + }() + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + self.translatesAutoresizingMaskIntoConstraints = false + self.backgroundColor = viewModel.backgroundColor + + selectYourBankView.addSubview(selectYourBankLabel) + contentStackView.addArrangedSubview(selectYourBankView) + + buttonsStackView.addArrangedSubview(selectBankButton) + buttonsStackView.addArrangedSubview(payInvoiceButton) + buttonsView.addSubview(buttonsStackView) + contentStackView.addArrangedSubview(buttonsView) + + bottomStackView.addArrangedSubview(moreInformationView) + bottomStackView.addArrangedSubview(UIView()) + if viewModel.shouldShowBrandedView { + bottomStackView.addArrangedSubview(poweredByGiniView) + } + bottomView.addSubview(bottomStackView) + contentStackView.addArrangedSubview(bottomView) + + self.addSubview(contentStackView) + activateAllConstraints() + updateAvailableViews() + updateButtonsViews() + setupGestures() + } + + private func activateAllConstraints() { + activateContentStackViewConstraints() + activateSelectYourBankButtonConstraints() + activateButtonsConstraints() + activateBottomViewConstraints() + } + + private func setupGestures() { + payInvoiceButton.didTapButton = { [weak self] in + self?.tapOnPayInvoiceView() + } + selectBankButton.didTapButton = { [weak self] in + self?.tapOnBankPicker() + } + } + + private func updateAvailableViews() { + guard viewModel.hideInfoForReturningUser else { return } + let isPaymentComponentUsed = viewModel.isPaymentComponentUsed() + selectYourBankView.isHidden = isPaymentComponentUsed + moreInformationView.isHidden = isPaymentComponentUsed + } + + private func updateButtonsViews() { + selectBankButton.customConfigure(labelText: viewModel.selectBankButtonText, + leftImageIcon: viewModel.bankImageIcon, + rightImageIcon: viewModel.chevronDownIcon, + rightImageTintColor: viewModel.chevronDownIconColor, + shouldShowLabel: viewModel.showPaymentComponentInOneRow ? !viewModel.hasBankSelected : true) + payInvoiceButton.isHidden = !viewModel.hasBankSelected + selectBankButton.heightAnchor.constraint(equalToConstant: viewModel.showPaymentComponentInOneRow ? viewModel.minimumButtonsHeight : (viewModel.hasBankSelected ? viewModel.minimumButtonsHeight : Constants.defaultButtonHeihgt)).isActive = true + } + + private func activateContentStackViewConstraints() { + NSLayoutConstraint.activate([ + contentStackView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentStackView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentStackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.contentTopPadding), + contentStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.contentBottomPadding) + ]) + } + + private func activateSelectYourBankButtonConstraints() { + NSLayoutConstraint.activate([ + selectYourBankLabel.leadingAnchor.constraint(equalTo: selectYourBankView.leadingAnchor), + selectYourBankLabel.trailingAnchor.constraint(equalTo: selectYourBankView.trailingAnchor), + selectYourBankLabel.topAnchor.constraint(equalTo: selectYourBankView.topAnchor), + selectYourBankLabel.bottomAnchor.constraint(equalTo: selectYourBankView.bottomAnchor) + ]) + } + + private func activateButtonsConstraints() { + NSLayoutConstraint.activate([ + buttonsStackView.leadingAnchor.constraint(equalTo: buttonsView.leadingAnchor), + buttonsStackView.trailingAnchor.constraint(equalTo: buttonsView.trailingAnchor), + buttonsStackView.topAnchor.constraint(equalTo: buttonsView.topAnchor, constant: Constants.buttonsTopBottomSpacing), + buttonsStackView.bottomAnchor.constraint(equalTo: buttonsView.bottomAnchor, constant: -Constants.buttonsTopBottomSpacing), + payInvoiceButton.heightAnchor.constraint(equalToConstant: viewModel.minimumButtonsHeight) + ]) + } + + private func activateBottomViewConstraints() { + NSLayoutConstraint.activate([ + bottomStackView.leadingAnchor.constraint(equalTo: bottomView.leadingAnchor), + bottomStackView.trailingAnchor.constraint(equalTo: bottomView.trailingAnchor), + bottomStackView.topAnchor.constraint(equalTo: bottomView.topAnchor), + bottomStackView.bottomAnchor.constraint(equalTo: bottomView.bottomAnchor), + bottomStackView.heightAnchor.constraint(equalToConstant: Constants.bottomViewHeight) + ]) + } + + @objc + private func tapOnBankPicker() { + viewModel.tapOnBankPicker() + } + + @objc + private func tapOnPayInvoiceView() { + viewModel.tapOnPayInvoiceView() + } +} + +extension PaymentComponentView: MoreInformationViewProtocol { + func didTapOnMoreInformation() { + viewModel.tapOnMoreInformation() + } +} + +extension PaymentComponentView { + private enum Constants { + static let contentTopPadding = 8.0 + static let contentBottomPadding: CGFloat = 4 + static let buttonsSpacing = 8.0 + static let buttonsTopBottomSpacing = 4.0 + static let bottomViewHeight = 44.0 + static let defaultButtonHeihgt = 44.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentViewModel.swift new file mode 100644 index 0000000..fd27fe3 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentViewModel.swift @@ -0,0 +1,182 @@ +// +// PaymentComponentViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites +import GiniHealthAPILibrary + +/** + Delegate to inform about the actions happened of the custom payment component view. + You may find out when the user tapped on more information area, on the payment provider picker or on the pay invoice button + + */ +public protocol PaymentComponentViewProtocol: AnyObject { + /** + Called when the user tapped on the more information actionable label or the information icon + + - parameter documentId: Id of document + */ + func didTapOnMoreInformation(documentId: String?) + + /** + Called when the user tapped on payment provider picker to change the selected payment provider or install it + + - parameter documentId: Id of document + */ + func didTapOnBankPicker(documentId: String?) + + /** + Called when the user tapped on the pay the invoice button to pay the invoice/document + - parameter documentId: Id of document + */ + func didTapOnPayInvoice(documentId: String?) +} + +/** + Helping extension for using the PaymentComponentViewProtocol methods without the document ID. This should be kept by the document view model and passed hierarchically from there. + + */ +extension PaymentComponentViewProtocol { + public func didTapOnMoreInformation() { + didTapOnMoreInformation(documentId: nil) + } + public func didTapOnBankPicker() { + didTapOnBankPicker(documentId: nil) + } + public func didTapOnPayInvoice() { + didTapOnPayInvoice(documentId: nil) + } +} + +final class PaymentComponentViewModel { + let giniMerchantConfiguration: GiniMerchantConfiguration + + let backgroundColor: UIColor = UIColor.from(giniColor: GiniColor(lightModeColor: .clear, + darkModeColor: .clear)) + + // More information part + let moreInformationAccentColor: UIColor = GiniColor.standard2.uiColor() + let moreInformationLabelTextColor: UIColor = GiniColor.standard4.uiColor() + let moreInformationLabelText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.more.information.label", + comment: "Text for more information label") + let moreInformationActionablePartText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.more.information.underlined.part", + comment: "Text for more information actionable part from the label") + var moreInformationLabelFont: UIFont + var moreInformationLabelLinkFont: UIFont + + // Select bank label + let selectYourBankLabelText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.select.your.bank.label", + comment: "Text for the select your bank label that's above the payment provider picker") + let selectYourBankLabelFont: UIFont + let selectYourBankAccentColor: UIColor = GiniColor.standard1.uiColor() + + // Bank image icon + private var bankImageIconData: Data? + var bankImageIcon: UIImage? { + guard let bankImageIconData else { return nil } + return UIImage(data: bankImageIconData) + } + + // Primary button + let notInstalledBankTextColor: UIColor = GiniColor.standard4.uiColor() + private let placeholderBankNameText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.select.bank.label", + comment: "Placeholder text used when there isn't a payment provider app installed") + var selectBankButtonText: String { + showPaymentComponentInOneRow ? placeholderBankNameText : bankName ?? placeholderBankNameText + } + + let chevronDownIcon: UIImage = GiniMerchantImage.chevronDown.preferredUIImage() + let chevronDownIconColor: UIColor = GiniColor(lightModeColorName: .light7, darkModeColorName: .light1).uiColor() + + // Payment provider colors + var paymentProviderColors: ProviderColors? + + // CTA button + private let goToBankingAppLabelText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.to.banking.app.label", + comment: "Title label used for the cta button when review screen is not present") + private let continueToOverviewLabelText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.continue.to.overview.label", + comment: "Title label used for the cta button when review screen is present") + + var ctaButtonText: String { + isReviewScreenOn ? continueToOverviewLabelText : goToBankingAppLabelText + } + + private var paymentProviderScheme: String? + + weak var delegate: PaymentComponentViewProtocol? + + var documentId: String? + + var minimumButtonsHeight: CGFloat + + var hasBankSelected: Bool + + private var bankName: String? + private var isReviewScreenOn: Bool + + private var paymentComponentConfiguration: PaymentComponentConfiguration? + var shouldShowBrandedView: Bool { + paymentComponentConfiguration?.isPaymentComponentBranded ?? true + } + var showPaymentComponentInOneRow: Bool { + paymentComponentConfiguration?.showPaymentComponentInOneRow ?? false + } + var hideInfoForReturningUser: Bool { + paymentComponentConfiguration?.hideInfoForReturningUser ?? false + } + + init(paymentProvider: PaymentProvider?, + giniMerchantConfiguration: GiniMerchantConfiguration, + paymentComponentConfiguration: PaymentComponentConfiguration? = nil) { + self.giniMerchantConfiguration = giniMerchantConfiguration + + self.moreInformationLabelFont = giniMerchantConfiguration.font(for: .captions1) + self.moreInformationLabelLinkFont = giniMerchantConfiguration.font(for: .linkBold) + self.selectYourBankLabelFont = giniMerchantConfiguration.font(for: .subtitle2) + + self.hasBankSelected = paymentProvider != nil + self.bankImageIconData = paymentProvider?.iconData + self.paymentProviderColors = paymentProvider?.colors + self.paymentProviderScheme = paymentProvider?.appSchemeIOS + self.bankName = paymentProvider?.name + self.isReviewScreenOn = giniMerchantConfiguration.showPaymentReviewScreen + + self.minimumButtonsHeight = giniMerchantConfiguration.paymentComponentButtonsHeight + + self.paymentComponentConfiguration = paymentComponentConfiguration + } + + func tapOnMoreInformation() { + delegate?.didTapOnMoreInformation(documentId: documentId) + } + + func tapOnBankPicker() { + delegate?.didTapOnBankPicker(documentId: documentId) + } + + func tapOnPayInvoiceView() { + savePaymentComponentViewUsageStatus() + delegate?.didTapOnPayInvoice(documentId: documentId) + } + + // Function to check if Payment was used at least once + func isPaymentComponentUsed() -> Bool { + return UserDefaults.standard.bool(forKey: Constants.paymentComponentViewUsedKey) + } + + // Function to save the boolean value indicating whether Payment was used + private func savePaymentComponentViewUsageStatus() { + UserDefaults.standard.set(true, forKey: Constants.paymentComponentViewUsedKey) + } +} + +extension PaymentComponentViewModel { + private enum Constants { + static let paymentComponentViewUsedKey = "kPaymentComponentViewUsed" + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentsController.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentsController.swift new file mode 100644 index 0000000..6925be2 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentComponentsController.swift @@ -0,0 +1,489 @@ +// +// PaymentComponentController.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniHealthAPILibrary +import GiniUtilites +/** + Protocol used to provide updates on the current status of the Payment Components Controller. + Uses a callback mechanism to handle payment provider requests. + */ +public protocol PaymentComponentsControllerProtocol: AnyObject { + func isLoadingStateChanged(isLoading: Bool) // Because we can't use Combine + func didFetchedPaymentProviders() +} + +protocol PaymentComponentsProtocol { + var isLoading: Bool { get set } + var selectedPaymentProvider: PaymentProvider? { get set } + func loadPaymentProviders() + func checkIfDocumentIsPayable(docId: String, completion: @escaping (Result) -> Void) + func paymentView(documentId: String?) -> UIView + func bankSelectionBottomSheet() -> UIViewController + func loadPaymentReviewScreenFor(documentID: String?, paymentInfo: PaymentInfo?, trackingDelegate: GiniMerchantTrackingDelegate?, completion: @escaping (UIViewController?, GiniMerchantError?) -> Void) + func paymentInfoViewController() -> UIViewController + func paymentViewBottomSheet(documentID: String?) -> UIViewController +} + +private enum PaymentComponentScreenType { + case paymentComponent + case bankPicker +} + +/** + The `PaymentComponentsController` class allows control over the payment components. + */ +public final class PaymentComponentsController: PaymentComponentsProtocol { + /// handling the Payment Component Controller delegate + public weak var delegate: PaymentComponentsControllerProtocol? + /// handling the Payment Component view delegate + public weak var viewDelegate: PaymentComponentViewProtocol? + /// handling the Payment Bottom view delegate + public weak var bottomViewDelegate: PaymentProvidersBottomViewProtocol? + + private var giniMerchant: GiniMerchant + private let giniMerchantConfiguration = GiniMerchantConfiguration.shared + private var paymentProviders: PaymentProviders = [] + + /// storing the current selected payment provider + public var selectedPaymentProvider: PaymentProvider? + + /// Payment Component View Configuration + public var paymentComponentConfiguration: PaymentComponentConfiguration? + + /// Previous presented view + private var previousPresentedView: PaymentComponentScreenType? + + /// reponsible for storing the loading state of the controller and passing it to the delegate listeners + var isLoading: Bool = false { + didSet { + delegate?.isLoadingStateChanged(isLoading: isLoading) + } + } + + var paymentComponentView: PaymentComponentView! + + /** + Initializer of the Payment Component Controller class. + + - Parameters: + - giniMerchant: An instance of GiniMerchant initialized with GiniHealthAPI. + - Returns: + - instance of the payment component controller class + */ + public init(giniMerchant: GiniMerchant) { + self.giniMerchant = giniMerchant + giniMerchantConfiguration.useInvoiceWithoutDocument = true + setupObservers() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + /** + Retrieves the default installed payment provider, if available. + - Returns: a Payment Provider object. + */ + private func defaultInstalledPaymentProvider() -> PaymentProvider? { + savedPaymentProvider() + } + + /** + Loads the payment providers list and stores them. + - note: Also triggers a function that checks if the payment providers are installed. + */ + public func loadPaymentProviders() { + self.isLoading = true + self.giniMerchant.fetchBankingApps { [weak self] result in + self?.isLoading = false + switch result { + case let .success(paymentProviders): + self?.paymentProviders = paymentProviders + self?.selectedPaymentProvider = self?.defaultInstalledPaymentProvider() + self?.delegate?.didFetchedPaymentProviders() + case let .failure(error): + print("Couldn't load payment providers: \(error.localizedDescription)") + } + } + } + + private func storeDefaultPaymentProvider(paymentProvider: PaymentProvider) { + do { + let encoder = JSONEncoder() + let data = try encoder.encode(paymentProvider) + UserDefaults.standard.set(data, forKey: Constants.kDefaultPaymentProvider) + } catch { + print("Unable to encode payment provider: (\(error))") + } + } + + private func savedPaymentProvider() -> PaymentProvider? { + if let data = UserDefaults.standard.data(forKey: Constants.kDefaultPaymentProvider) { + do { + let decoder = JSONDecoder() + let paymentProvider = try decoder.decode(PaymentProvider.self, from: data) + if self.paymentProviders.contains(where: { $0.id == paymentProvider.id }) { + return paymentProvider + } + } catch { + print("Unable to decode payment provider: (\(error))") + } + } + return nil + } + + /** + Checks if the document is payable by extracting the IBAN. + - Parameters: + - docId: The ID of the uploaded document. + - completion: A closure for processing asynchronous data received from the service. It has a Result type parameter, representing either success or failure. The completion block is called on the main thread. + In the case of success, it includes a boolean value indicating whether the IBAN was extracted successfully. + In case of failure, it returns an error from the server side. + */ + public func checkIfDocumentIsPayable(docId: String, completion: @escaping (Result) -> Void) { + giniMerchant.checkIfDocumentIsPayable(docId: docId, completion: completion) + } + + /** + Provides a custom Gini view that contains more information, bank selection if available and a tappable button to pay the document/invoice + + - Parameters: + - Returns: a custom view + */ + public func paymentView(documentId: String?) -> UIView { + paymentComponentView = PaymentComponentView() + let paymentComponentViewModel = PaymentComponentViewModel(paymentProvider: selectedPaymentProvider, giniMerchantConfiguration: giniMerchantConfiguration, paymentComponentConfiguration: paymentComponentConfiguration) + paymentComponentViewModel.delegate = viewDelegate + paymentComponentViewModel.documentId = documentId + paymentComponentView.viewModel = paymentComponentViewModel + return paymentComponentView + } + + public func loadPaymentReviewScreenFor(documentID: String?, paymentInfo: PaymentInfo?, trackingDelegate: GiniMerchantTrackingDelegate?, completion: @escaping (UIViewController?, GiniMerchantError?) -> Void) { + previousPresentedView = nil + if !giniMerchantConfiguration.useInvoiceWithoutDocument { + guard let documentID else { + completion(nil, nil) + return + } + self.isLoading = true + self.giniMerchant.fetchDataForReview(documentId: documentID) { [weak self] result in + self?.isLoading = false + switch result { + case .success(let data): + guard let self else { + completion(nil, nil) + return + } + guard let selectedPaymentProvider else { + completion(nil, nil) + return + } + let vc = PaymentReviewViewController.instantiate(with: self.giniMerchant, + data: data, + paymentInfo: nil, + selectedPaymentProvider: selectedPaymentProvider, + trackingDelegate: trackingDelegate, + paymentComponentsController: self) + completion(vc, nil) + case .failure(let error): + completion(nil, error) + } + } + } else { + loadPaymentReviewScreenWithoutDocument(paymentInfo: paymentInfo, trackingDelegate: trackingDelegate, completion: completion) + } + } + + private func loadPaymentReviewScreenWithoutDocument(paymentInfo: PaymentInfo?, trackingDelegate: GiniMerchantTrackingDelegate?, completion: @escaping (UIViewController?, GiniMerchantError?) -> Void) { + previousPresentedView = nil + guard let selectedPaymentProvider else { + completion(nil, nil) + return + } + + let paymentReviewViewController = PaymentReviewViewController.instantiate(with: self.giniMerchant, + data: nil, + paymentInfo: paymentInfo, + selectedPaymentProvider: selectedPaymentProvider, + trackingDelegate: trackingDelegate, + paymentComponentsController: self) + completion(paymentReviewViewController, nil) + } + + // MARK: - Bottom Sheets + + public func paymentViewBottomSheet(documentID: String?) -> UIViewController { + previousPresentedView = .paymentComponent + let paymentComponentBottomView = PaymentComponentBottomView(paymentView: paymentView(documentId: documentID)) + return paymentComponentBottomView + } + + public func bankSelectionBottomSheet() -> UIViewController { + previousPresentedView = .bankPicker + let paymentProvidersBottomViewModel = BanksBottomViewModel(paymentProviders: paymentProviders, + selectedPaymentProvider: selectedPaymentProvider) + let paymentProvidersBottomView = BanksBottomView(viewModel: paymentProvidersBottomViewModel) + paymentProvidersBottomViewModel.viewDelegate = self + paymentProvidersBottomView.viewModel = paymentProvidersBottomViewModel + return paymentProvidersBottomView + } + + public func paymentInfoViewController() -> UIViewController { + let paymentInfoViewController = PaymentInfoViewController() + let paymentInfoViewModel = PaymentInfoViewModel(paymentProviders: paymentProviders) + paymentInfoViewController.viewModel = paymentInfoViewModel + return paymentInfoViewController + } + + public func installAppBottomSheet() -> UIViewController { + previousPresentedView = nil + let installAppBottomViewModel = InstallAppBottomViewModel(selectedPaymentProvider: selectedPaymentProvider) + installAppBottomViewModel.viewDelegate = self + let installAppBottomView = InstallAppBottomView(viewModel: installAppBottomViewModel) + return installAppBottomView + } + + public func shareInvoiceBottomSheet() -> UIViewController { + previousPresentedView = nil + let shareInvoiceBottomViewModel = ShareInvoiceBottomViewModel(selectedPaymentProvider: selectedPaymentProvider) + shareInvoiceBottomViewModel.viewDelegate = self + let shareInvoiceBottomView = ShareInvoiceBottomView(viewModel: shareInvoiceBottomViewModel) + incrementOnboardingCountFor(paymentProvider: selectedPaymentProvider) + return shareInvoiceBottomView + } + + // MARK: - Helping functions + public func canOpenPaymentProviderApp() -> Bool { + if supportsGPC() { + if selectedPaymentProvider?.appSchemeIOS.canOpenURLString() == true { + return true + } + } + return false + } + + public func supportsOpenWith() -> Bool { + if selectedPaymentProvider?.openWithSupportedPlatforms.contains(.ios) == true { + return true + } + return false + } + + public func supportsGPC() -> Bool { + if selectedPaymentProvider?.gpcSupportedPlatforms.contains(.ios) == true { + return true + } + return false + } + + public func obtainPDFURLFromPaymentRequest(paymentInfo: PaymentInfo, viewController: UIViewController) { + createPaymentRequest(paymentInfo: paymentInfo, completion: { [weak self] paymentRequestID, error in + if let paymentRequestID { + self?.loadPDFData(paymentRequestID: paymentRequestID, viewController: viewController) + } + }) + } + + public func createPaymentRequest(paymentInfo: PaymentInfo, completion: @escaping (_ paymentRequestID: String?, _ error: GiniMerchantError?) -> Void) { + giniMerchant.createPaymentRequest(paymentInfo: paymentInfo) { result in + switch result { + case let .success(requestId): + completion(requestId, nil) + case let .failure(error): + completion(nil, GiniMerchantError.apiError(error)) + } + } + } + + public func openPaymentProviderApp(requestId: String, universalLink: String) { + giniMerchant.openPaymentProviderApp(requestID: requestId, universalLink: universalLink) + } + + public func shouldShowOnboardingScreenFor() -> Bool { + let onboardingCounts = OnboardingShareInvoiceScreenCount.load() + let count = onboardingCounts.presentationCount(forProvider: selectedPaymentProvider?.name) + return count < Constants.numberOfTimesOnboardingShareScreenShouldAppear + } + + private func setupObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(paymentInfoDissapeared), name: .paymentInfoDissapeared, object: nil) + } + + @objc + private func paymentInfoDissapeared() { + if previousPresentedView == .bankPicker { + didTapOnBankPicker() + } else if previousPresentedView == .paymentComponent { + didTapOnPayButton() + } + previousPresentedView = nil + } +} + +extension PaymentComponentsController: PaymentComponentViewProtocol { + public func didTapOnMoreInformation(documentId: String?) { + viewDelegate?.didTapOnMoreInformation() + } + + public func didTapOnBankPicker(documentId: String?) { + viewDelegate?.didTapOnBankPicker() + } + + public func didTapOnPayInvoice(documentId: String?) { + viewDelegate?.didTapOnPayInvoice() + } +} + +extension PaymentComponentsController: PaymentProvidersBottomViewProtocol { + public func didTapForwardOnInstallBottomSheet() { + print("Tapped Forward on Install Bottom Sheet") + } + + public func didTapOnContinueOnShareBottomSheet() { + print("Tapped Continue on Share Bottom Sheet") + } + + public func didSelectPaymentProvider(paymentProvider: PaymentProvider) { + selectedPaymentProvider = paymentProvider + storeDefaultPaymentProvider(paymentProvider: paymentProvider) + bottomViewDelegate?.didSelectPaymentProvider(paymentProvider: paymentProvider) + } + + public func didTapOnClose() { + bottomViewDelegate?.didTapOnClose() + } + + public func didTapOnMoreInformation() { + viewDelegate?.didTapOnMoreInformation() + } + + public func didTapOnPayButton() { + bottomViewDelegate?.didTapOnPayButton() + } +} + +extension PaymentComponentsController { + + private func incrementOnboardingCountFor(paymentProvider: PaymentProvider?) { + var onboardingCounts = OnboardingShareInvoiceScreenCount.load() + onboardingCounts.incrementPresentationCount(forProvider: paymentProvider?.name) + } + + private func loadPDFData(paymentRequestID: String, viewController: UIViewController) { + self.loadPDF(paymentRequestID: paymentRequestID, completion: { [weak self] pdfData in + let pdfPath = self?.writePDFDataToFile(data: pdfData, fileName: paymentRequestID) + + guard let pdfPath else { + print("Couldn't retrieve pdf URL") + return + } + + self?.sharePDF(pdfURL: pdfPath, paymentRequestID: paymentRequestID, viewController: viewController) { [weak self] (activity, _, _, _) in + guard activity != nil else { + return + } + + // Publish the payment request id only after a user has picked an activity (app) + self?.giniMerchant.delegate?.didCreatePaymentRequest(paymentRequestID: paymentRequestID) + } + }) + } + + private func writePDFDataToFile(data: Data, fileName: String) -> URL? { + do { + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + guard let docDirectoryPath = paths.first else { return nil} + let pdfFileName = fileName + Constants.pdfExtension + let pdfPath = docDirectoryPath.appendingPathComponent(pdfFileName) + try data.write(to: pdfPath) + return pdfPath + } catch { + print("Error while write pdf file to location: \(error.localizedDescription)") + return nil + } + } + + private func sharePDF(pdfURL: URL, paymentRequestID: String, viewController: UIViewController, + completionWithItemsHandler: @escaping UIActivityViewController.CompletionWithItemsHandler) { + // Create UIActivityViewController with the PDF file + let activityViewController = UIActivityViewController(activityItems: [pdfURL], applicationActivities: nil) + activityViewController.completionWithItemsHandler = completionWithItemsHandler + + // Exclude some activities if needed + activityViewController.excludedActivityTypes = [ + .addToReadingList, + .assignToContact, + .airDrop, + .mail, + .message, + .postToFacebook, + .postToVimeo, + .postToWeibo, + .postToFlickr, + .postToTwitter, + .postToTencentWeibo, + .copyToPasteboard, + .markupAsPDF, + .openInIBooks, + .print, + .saveToCameraRoll + ] + + // Present the UIActivityViewController + DispatchQueue.main.async { + if let popoverController = activityViewController.popoverPresentationController { + popoverController.sourceView = viewController.view + popoverController.sourceRect = CGRect(x: viewController.view.bounds.midX, y: viewController.view.bounds.midY, width: 0, height: 0) + popoverController.permittedArrowDirections = [] + } + + if (viewController.presentedViewController != nil) { + viewController.presentedViewController?.dismiss(animated: true, completion: { + viewController.present(activityViewController, animated: true, completion: nil) + }) + } else { + viewController.present(activityViewController, animated: true, completion: nil) + } + } + } + + private func loadPDF(paymentRequestID: String, completion: @escaping (Data) -> ()) { + isLoading = true + giniMerchant.paymentService.pdfWithQRCode(paymentRequestId: paymentRequestID) { [weak self] result in + self?.isLoading = false + switch result { + case .success(let data): + completion(data) + case .failure: + break + } + } + } +} + +extension PaymentComponentsController: ShareInvoiceBottomViewProtocol { + func didTapOnContinueToShareInvoice() { + bottomViewDelegate?.didTapOnContinueOnShareBottomSheet() + } +} + +extension PaymentComponentsController: InstallAppBottomViewProtocol { + func didTapOnContinue() { + bottomViewDelegate?.didTapForwardOnInstallBottomSheet() + } +} + +extension PaymentComponentsController { + private enum Constants { + static let kDefaultPaymentProvider = "defaultPaymentProvider" + static let pdfExtension = ".pdf" + static let numberOfTimesOnboardingShareScreenShouldAppear = 3 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoAnswerTableViewCell.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoAnswerTableViewCell.swift new file mode 100644 index 0000000..17a0a60 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoAnswerTableViewCell.swift @@ -0,0 +1,76 @@ +// +// PaymentInfoAnswerTableViewCell.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class PaymentInfoAnswerTableViewCell: UITableViewCell, ReusableView { + private lazy var textView: UITextView = { + let textView = UITextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isScrollEnabled = false + textView.isEditable = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.isUserInteractionEnabled = true + textView.backgroundColor = .clear + return textView + }() + + var cellViewModel: PaymentInfoAnswerTableViewModel? { + didSet { + configure() + } + } + + override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { + super.init(style: style, reuseIdentifier: reuseIdentifier) + backgroundColor = .clear + contentView.addSubview(textView) + setupConstraints() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + textView.topAnchor.constraint(equalTo: contentView.topAnchor), + textView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + textView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Constants.bottomPadding) + ]) + } + + private func configure() { + guard let cellViewModel = cellViewModel else { return } + textView.attributedText = cellViewModel.answerAttributedText + textView.textColor = cellViewModel.answerTextColor + textView.linkTextAttributes = cellViewModel.answerLinkAttributes + textView.layoutIfNeeded() + } +} + +struct PaymentInfoAnswerTableViewModel { + let answerAttributedText: NSAttributedString + let answerTextColor: UIColor = GiniColor.standard1.uiColor() + let answerLinkColor: UIColor = GiniColor.accent1.uiColor() + let answerLinkAttributes: [NSAttributedString.Key: Any] + + init(answerAttributedText: NSAttributedString) { + self.answerAttributedText = answerAttributedText + self.answerLinkAttributes = [.foregroundColor: answerLinkColor] + } +} + +extension PaymentInfoAnswerTableViewCell { + private enum Constants { + static let bottomPadding = 16.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoBankCollectionViewCell.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoBankCollectionViewCell.swift new file mode 100644 index 0000000..f77e4ba --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoBankCollectionViewCell.swift @@ -0,0 +1,76 @@ +// +// PaymentInfoBankCollectionViewCell.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class PaymentInfoBankCollectionViewCell: UICollectionViewCell { + + static let identifier = "PaymentInfoBankCollectionViewCell" + + var cellViewModel: PaymentInfoBankCollectionViewCellModel? { + didSet { + guard let cellViewModel else { return } + bankIconImageView.image = cellViewModel.bankImageIcon + bankIconImageView.layer.borderColor = cellViewModel.borderColor.cgColor + } + } + + private lazy var bankIconImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.backgroundColor = .clear + imageView.roundCorners(corners: .allCorners, radius: Constants.bankIconCornerRadius) + imageView.layer.borderWidth = Constants.bankIconBorderWidth + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + contentView.addSubview(bankIconImageView) + setupConstraints() + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(frame:) has not been implemented") + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + bankIconImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bankIconImageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bankIconImageView.topAnchor.constraint(equalTo: contentView.topAnchor), + bankIconImageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + ]) + } +} + +final class PaymentInfoBankCollectionViewCellModel { + private var bankImageIconData: Data? + var bankImageIcon: UIImage { + if let bankImageIconData { + return UIImage(data: bankImageIconData) ?? UIImage() + } + return UIImage() + } + + var borderColor: UIColor = GiniColor.standard5.uiColor() + + init(bankImageIconData: Data?) { + self.bankImageIconData = bankImageIconData + } +} + +extension PaymentInfoBankCollectionViewCell { + private enum Constants { + static let bankIconCornerRadius = 6.0 + static let bankIconBorderWidth = 1.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoQuestionHeaderViewCell.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoQuestionHeaderViewCell.swift new file mode 100644 index 0000000..a643906 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoQuestionHeaderViewCell.swift @@ -0,0 +1,100 @@ +// +// PaymentInfoQuestionHeaderViewCell.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class PaymentInfoQuestionHeaderViewCell: UIView { + var didTapSelectButton: (() -> Void)? + + var headerViewModel: PaymentInfoQuestionHeaderViewModel? { + didSet { + guard let headerViewModel else { return } + configureView(viewModel: headerViewModel) + } + } + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + label.textAlignment = .left + return label + }() + + private lazy var extendedImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFit + imageView.clipsToBounds = true + imageView.backgroundColor = .clear + imageView.frame = CGRect(x: 0, y: 0, width: Constants.imageSize, height: Constants.imageSize) + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + addSubview(titleLabel) + addSubview(extendedImageView) + setupConstraints() + addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tappedOnView))) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + } + + private func configureView(viewModel: PaymentInfoQuestionHeaderViewModel) { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = Constants.titleLineHeight + titleLabel.attributedText = NSMutableAttributedString(string: viewModel.titleText, + attributes: [NSAttributedString.Key.paragraphStyle: paragraphStyle]) + titleLabel.textColor = viewModel.titleTextColor + titleLabel.font = viewModel.titleFont + extendedImageView.image = viewModel.extendedIcon + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + titleLabel.centerYAnchor.constraint(equalTo: centerYAnchor), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.titleRightPadding), + extendedImageView.widthAnchor.constraint(equalToConstant: extendedImageView.frame.width), + extendedImageView.heightAnchor.constraint(equalToConstant: extendedImageView.frame.height), + extendedImageView.trailingAnchor.constraint(equalTo: trailingAnchor), + extendedImageView.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + @objc private func tappedOnView() { + didTapSelectButton?() + } +} + +final class PaymentInfoQuestionHeaderViewModel { + var titleText: String + var titleFont: UIFont + let titleTextColor: UIColor = GiniColor.standard1.uiColor() + var extendedIcon: UIImage + + init(title: String, isExtended: Bool) { + self.titleText = title + let giniConfiguration = GiniMerchantConfiguration.shared + self.titleFont = giniConfiguration.font(for: .body1) + self.extendedIcon = (isExtended ? GiniMerchantImage.minus : GiniMerchantImage.plus).preferredUIImage() + } +} + +extension PaymentInfoQuestionHeaderViewCell { + private enum Constants { + static let titleLineHeight = 1.15 + static let titleRightPadding = 85.0 + static let imageSize = 24.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoViewController.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoViewController.swift new file mode 100644 index 0000000..a0a7a1e --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoViewController.swift @@ -0,0 +1,359 @@ +// +// PaymentInfoViewController.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit + +class PaymentInfoViewController: UIViewController { + + var viewModel: PaymentInfoViewModel! { + didSet { + setupView() + } + } + + private lazy var scrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.isScrollEnabled = true + scrollView.translatesAutoresizingMaskIntoConstraints = false + return scrollView + }() + + private lazy var contentView: UIView = { + let contentView = UIView() + contentView.translatesAutoresizingMaskIntoConstraints = false + return contentView + }() + + private lazy var bankIconsCollectionView: UICollectionView = { + let collectionLayout = UICollectionViewFlowLayout() + collectionLayout.scrollDirection = .horizontal + collectionLayout.minimumInteritemSpacing = Constants.bankIconsSpacing + + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: collectionLayout) + collectionView.translatesAutoresizingMaskIntoConstraints = false + collectionView.frame = CGRect(x: 0, y: 0, width: .greatestFiniteMagnitude, height: Constants.bankIconsWidth) + collectionView.dataSource = self + collectionView.delegate = self + collectionView.backgroundColor = .clear + collectionView.allowsSelection = false + collectionView.showsHorizontalScrollIndicator = false + collectionView.isScrollEnabled = true + collectionView.register(PaymentInfoBankCollectionViewCell.self, + forCellWithReuseIdentifier: PaymentInfoBankCollectionViewCell.identifier) + return collectionView + }() + + private lazy var poweredByGiniView: PoweredByGiniView = { + let view = PoweredByGiniView() + view.viewModel = PoweredByGiniViewModel() + return view + }() + + private lazy var payBillsTitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = viewModel.payBillsTitleFont + label.textColor = viewModel.payBillsTitleTextColor + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + label.textAlignment = .left + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = Constants.payBillsTitleLineHeight + label.attributedText = NSMutableAttributedString(string: viewModel.payBillsTitleText, + attributes: [NSAttributedString.Key.paragraphStyle: paragraphStyle]) + return label + }() + + private lazy var payBillsDescriptionTextView: UITextView = { + let textView = UITextView() + textView.translatesAutoresizingMaskIntoConstraints = false + textView.isScrollEnabled = false + textView.isEditable = false + textView.textContainerInset = .zero + textView.textContainer.lineFragmentPadding = 0 + textView.isUserInteractionEnabled = true + textView.backgroundColor = .clear + textView.attributedText = viewModel.payBillsDescriptionAttributedText + textView.linkTextAttributes = viewModel.payBillsDescriptionLinkAttributes + return textView + }() + + private lazy var questionsTitleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = viewModel.questionsTitleFont + label.textColor = viewModel.questionsTitleTextColor + label.lineBreakMode = .byWordWrapping + label.numberOfLines = 0 + label.textAlignment = .left + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = Constants.questionsTitleLineHeight + label.attributedText = NSMutableAttributedString(string: viewModel.questionsTitleText, + attributes: [NSAttributedString.Key.paragraphStyle: paragraphStyle]) + return label + }() + + private lazy var questionsTableView: UITableView = { + let tableView = UITableView() + tableView.delegate = self + tableView.dataSource = self + tableView.register(cellType: PaymentInfoAnswerTableViewCell.self) + tableView.separatorStyle = .singleLine + tableView.backgroundColor = .clear + tableView.showsVerticalScrollIndicator = false + tableView.translatesAutoresizingMaskIntoConstraints = false + tableView.isScrollEnabled = false + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = Constants.questionTitleHeight + tableView.estimatedSectionHeaderHeight = Constants.questionTitleHeight + tableView.estimatedSectionFooterHeight = 1.0 + if #available(iOS 15.0, *) { + tableView.sectionHeaderTopPadding = 0 + } + return tableView + }() + + private var heightsQuestionsTableView: [NSLayoutConstraint] = [] + + override func viewDidLoad() { + super.viewDidLoad() + self.title = viewModel.titleText + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + NotificationCenter.default.post(name: .paymentInfoDissapeared, object: nil) + } + + private func setupView() { + setupViewHierarchy() + setupViewAttributes() + setupViewConstraints() + } + + private func setupViewHierarchy() { + view.addSubview(scrollView) + scrollView.addSubview(contentView) + contentView.addSubview(bankIconsCollectionView) + contentView.addSubview(poweredByGiniView) + contentView.addSubview(payBillsTitleLabel) + contentView.addSubview(payBillsDescriptionTextView) + contentView.addSubview(questionsTitleLabel) + contentView.addSubview(questionsTableView) + } + + private func setupViewAttributes() { + view.backgroundColor = viewModel.backgroundColor + } + + private func setupViewConstraints() { + setupContentViewConstraints() + setupBankIconsCollectionViewConstraints() + setupPoweredByGiniConstraints() + setupPayBillsConstraints() + setupQuestionsConstraints() + } + + private func setupContentViewConstraints() { + 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.widthAnchor.constraint(equalTo: scrollView.widthAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor), + contentView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor), + ]) + } + + private func setupBankIconsCollectionViewConstraints() { + NSLayoutConstraint.activate([ + bankIconsCollectionView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + bankIconsCollectionView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + bankIconsCollectionView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: Constants.bankIconsTopSpacing), + bankIconsCollectionView.heightAnchor.constraint(equalToConstant: bankIconsCollectionView.frame.height) + ]) + } + + private func setupPoweredByGiniConstraints() { + NSLayoutConstraint.activate([ + poweredByGiniView.topAnchor.constraint(equalTo: bankIconsCollectionView.bottomAnchor, constant: Constants.poweredByGiniTopPadding), + poweredByGiniView.centerXAnchor.constraint(equalTo: view.centerXAnchor) + ]) + } + + private func setupPayBillsConstraints() { + NSLayoutConstraint.activate([ + payBillsTitleLabel.topAnchor.constraint(equalTo: poweredByGiniView.bottomAnchor, constant: Constants.payBillsTitleTopPadding), + payBillsTitleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.leftRightPadding), + payBillsTitleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.leftRightPadding), + payBillsTitleLabel.heightAnchor.constraint(lessThanOrEqualToConstant: Constants.maxPayBillsTitleHeight), + payBillsDescriptionTextView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.leftRightPadding), + payBillsDescriptionTextView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.payBillsDescriptionRightPadding), + payBillsDescriptionTextView.topAnchor.constraint(equalTo: payBillsTitleLabel.bottomAnchor, constant: Constants.payBillsDescriptionTopPadding), + payBillsDescriptionTextView.heightAnchor.constraint(greaterThanOrEqualToConstant: Constants.minPayBillsDescriptionHeight), + ]) + } + + private func setupQuestionsConstraints() { + NSLayoutConstraint.activate([ + questionsTitleLabel.topAnchor.constraint(equalTo: payBillsDescriptionTextView.bottomAnchor, constant: Constants.questionsTitleTopPadding), + questionsTitleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.leftRightPadding), + questionsTitleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.leftRightPadding), + questionsTableView.topAnchor.constraint(equalTo: questionsTitleLabel.bottomAnchor), + questionsTableView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.leftRightPadding), + questionsTableView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -Constants.leftRightPadding), + questionsTableView.heightAnchor.constraint(greaterThanOrEqualToConstant: Double(viewModel.questions.count) * Constants.questionTitleHeight), + questionsTableView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -Constants.leftRightPadding) + ]) + } + + private func extended(section: Int) { + let isExtended = viewModel.questions[section].isExtended + viewModel.questions[section].isExtended = !isExtended + questionsTableView.reloadData() + questionsTableView.layoutIfNeeded() + // Small hack needed to satisfy automatic dimension table view inside scrollView + NSLayoutConstraint.deactivate(heightsQuestionsTableView) + heightsQuestionsTableView = [questionsTableView.heightAnchor.constraint(greaterThanOrEqualToConstant: questionsTableView.contentSize.height)] + NSLayoutConstraint.activate(heightsQuestionsTableView) + } +} + +extension PaymentInfoViewController: UICollectionViewDataSource { + func collectionView(_ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: PaymentInfoBankCollectionViewCell.identifier, + for: indexPath) as? PaymentInfoBankCollectionViewCell else { + return UICollectionViewCell() + } + cell.cellViewModel = PaymentInfoBankCollectionViewCellModel(bankImageIconData: viewModel.paymentProviders[indexPath.row].iconData) + return cell + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + return viewModel.paymentProviders.count + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + return 1 + } +} + +extension PaymentInfoViewController: UICollectionViewDelegateFlowLayout { + func collectionView(_ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath) -> CGSize { + return CGSize(width: Constants.bankIconsWidth, height: Constants.bankIconsHeight) + } + + func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, insetForSectionAt section: Int) -> UIEdgeInsets { + let cellCount = Double(viewModel.paymentProviders.count) + if cellCount > 0 { + let cellWidth = Constants.bankIconsWidth + + let totalCellWidth = cellWidth * cellCount + Constants.bankIconsSpacing * (cellCount - 1) + let contentWidth = collectionView.frame.size.width - (2 * Constants.leftRightPadding) + + if totalCellWidth < contentWidth { + let padding = (contentWidth - totalCellWidth) / 2.0 + return UIEdgeInsets(top: 0, left: padding, bottom: 0, right: padding) + } else { + return UIEdgeInsets(top: 0, left: Constants.leftRightPadding, bottom: 0, right: Constants.leftRightPadding) + } + } + return UIEdgeInsets.zero + } +} + +extension PaymentInfoViewController: UITableViewDelegate, UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + if viewModel.questions[section].isExtended { + return 1 + } + return 0 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell: PaymentInfoAnswerTableViewCell = tableView.dequeueReusableCell(for: indexPath) + let answerTableViewCellModel = PaymentInfoAnswerTableViewModel(answerAttributedText: viewModel.questions[indexPath.section].description) + cell.cellViewModel = answerTableViewCellModel + return cell + } + + func numberOfSections(in tableView: UITableView) -> Int { + viewModel.questions.count + } + + func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { + let viewHeader = PaymentInfoQuestionHeaderViewCell(frame: CGRect(x: 0, y: 0, width: .greatestFiniteMagnitude, height: Constants.questionTitleHeight)) + viewHeader.headerViewModel = PaymentInfoQuestionHeaderViewModel(title: viewModel.questions[section].title, isExtended: viewModel.questions[section].isExtended) + viewHeader.didTapSelectButton = { [weak self] in + self?.extended(section: section) + } + return viewHeader + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + Constants.questionTitleHeight + } + + func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { + guard section < viewModel.questions.count - 1 else { return UIView() } + let separatorView = UIView(frame: CGRect(x: 0, y: 0, width: .greatestFiniteMagnitude, height: Constants.questionSectionSeparatorHeight)) + separatorView.backgroundColor = viewModel.separatorColor + return separatorView + } + + func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { + Constants.questionSectionSeparatorHeight + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + UITableView.automaticDimension + } + + func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { + Constants.estimatedAnswerHeight + } +} + +extension PaymentInfoViewController { + private enum Constants { + static let paragraphSpacing = 10.0 + + static let leftRightPadding = 16.0 + + static let bankIconsSpacing = 5.0 + static let bankIconsTopSpacing = 15.0 + static let bankIconsWidth = 36.0 + static let bankIconsHeight = 36.0 + + static let poweredByGiniTopPadding = 16.0 + + static let payBillsTitleTopPadding = 16.0 + static let payBillsTitleLineHeight = 1.26 + static let maxPayBillsTitleHeight = 100.0 + static let payBillsDescriptionTopPadding = 8.0 + static let payBillsDescriptionRightPadding = 31.0 + static let minPayBillsDescriptionHeight = 100.0 + + static let questionsTitleTopPadding = 24.0 + static let questionsTitleLineHeight = 1.28 + + static let questionTitleHeight = 72.0 + static let questionSectionSeparatorHeight = 1.0 + + static let estimatedAnswerHeight = 250.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoViewModel.swift new file mode 100644 index 0000000..8a7e107 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentInfoViewModel.swift @@ -0,0 +1,142 @@ +// +// PaymentInfoViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites +import GiniHealthAPILibrary + +struct FAQSection { + let title: String + var description: NSAttributedString + var isExtended: Bool +} + +final class PaymentInfoViewModel { + + var paymentProviders: PaymentProviders + + let backgroundColor: UIColor = GiniColor.standard7.uiColor() + + let titleText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.title.label", + comment: "Payment Info title label text") + + let payBillsTitleText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.pay.bills.title.label", + comment: "Payment Info pay bills title label text") + let payBillsTitleFont: UIFont + let payBillsTitleTextColor: UIColor = GiniColor.standard1.uiColor() + + private let payBillsDescriptionText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.pay.bills.description.label", + comment: "Payment Info pay bills description text") + var payBillsDescriptionAttributedText: NSMutableAttributedString = NSMutableAttributedString() + var payBillsDescriptionLinkAttributes: [NSAttributedString.Key: Any] + private let payBillsDescriptionFont: UIFont + private let payBillsDescriptionTextColor: UIColor = GiniColor.standard1.uiColor() + private let giniWebsiteText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.pay.bills.description.clickable.text", + comment: "Word range that's clickable in pay bills description") + private let giniFont: UIFont + private let giniURLText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.gini.link", + comment: "Gini website link url") + + let questionsTitleText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.paymentinfo.questions.title.label", + comment: "Payment Info questions title label text") + let questionsTitleFont: UIFont + let questionsTitleTextColor: UIColor = GiniColor.standard1.uiColor() + + private var answersFont: UIFont + private let answerPrivacyPolicyText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.questions.answer.clickable.text", + comment: "Payment info answers clickable privacy policy") + private let privacyPolicyURLText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.gini.privacypolicy.link", + comment: "Gini privacy policy link url") + private var linksFont: UIFont + private let linksTextColor: UIColor = GiniColor.accent1.uiColor() + + let separatorColor: UIColor = GiniColor.standard5.uiColor() + + var questions: [FAQSection] = [] + + init(paymentProviders: PaymentProviders) { + self.paymentProviders = paymentProviders + + let giniConfiguration = GiniMerchantConfiguration.shared + + payBillsTitleFont = giniConfiguration.font(for: .subtitle1) + payBillsDescriptionFont = giniConfiguration.font(for: .body2) + questionsTitleFont = giniConfiguration.font(for: .subtitle1) + giniFont = giniConfiguration.font(for: .button) + answersFont = giniConfiguration.font(for: .body2) + linksFont = giniConfiguration.font(for: .linkBold) + + payBillsDescriptionLinkAttributes = [.foregroundColor: linksTextColor] + + configurePayBillsGiniLink() + setupQuestions() + } + + private func setupQuestions() { + for index in 1 ... Constants.numberOfQuestions { + let answerAttributedString = answerWithAttributes(answer: NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.questions.answer.\(index)", + comment: "Answers description")) + let questionSection = FAQSection(title: NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.payment.info.questions.question.\(index)", + comment: "Questions titles"), + description: textWithLinks(linkFont: linksFont, + attributedString: answerAttributedString), + isExtended: false) + questions.append(questionSection) + } + } + + private func configurePayBillsGiniLink() { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = Constants.payBillsDescriptionLineHeight + paragraphStyle.paragraphSpacing = Constants.payBillsParagraphSpacing + payBillsDescriptionAttributedText = NSMutableAttributedString(string: payBillsDescriptionText, + attributes: [.paragraphStyle: paragraphStyle, + .font: payBillsDescriptionFont, + .foregroundColor: payBillsTitleTextColor]) + payBillsDescriptionAttributedText = textWithLinks(linkFont: giniFont, + attributedString: payBillsDescriptionAttributedText) + } + + private func answerWithAttributes(answer: String) -> NSMutableAttributedString { + let paragraphStyle = NSMutableParagraphStyle() + paragraphStyle.lineHeightMultiple = Constants.answersLineHeight + paragraphStyle.paragraphSpacing = Constants.answersParagraphSpacing + let answerAttributedText = NSMutableAttributedString(string: answer, + attributes: [.font: answersFont, .paragraphStyle: paragraphStyle]) + return answerAttributedText + } + + private func textWithLinks(linkFont: UIFont, attributedString: NSMutableAttributedString) -> NSMutableAttributedString { + let attributedString = attributedString + let giniRange = (attributedString.string as NSString).range(of: giniWebsiteText) + attributedString.addLinkToRange(link: giniURLText, + range: giniRange, + linkFont: linkFont, + textToRemove: Constants.linkTextToRemove) + let privacyPolicyRange = (attributedString.string as NSString).range(of: answerPrivacyPolicyText) + attributedString.addLinkToRange(link: privacyPolicyURLText, + range: privacyPolicyRange, + linkFont: linkFont, + textToRemove: Constants.linkTextToRemove) + return attributedString + } +} + +extension PaymentInfoViewModel { + private enum Constants { + static let numberOfQuestions = 6 + + static let payBillsDescriptionLineHeight = 1.32 + static let payBillsParagraphSpacing = 10.0 + + static let answersLineHeight = 1.32 + static let answersParagraphSpacing = 10.0 + + static let linkTextToRemove = "[LINK]" + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentPrimaryButton.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentPrimaryButton.swift new file mode 100644 index 0000000..1a3e19a --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentPrimaryButton.swift @@ -0,0 +1,113 @@ +// +// PaymentPrimaryButton.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites +import GiniHealthAPILibrary + +final class PaymentPrimaryButton: UIView { + + private let giniConfiguration = GiniMerchantConfiguration.shared + + var didTapButton: (() -> Void)? + + private lazy var contentView: UIView = { + let view = EmptyView() + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapOnPayInvoiceView))) + return view + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 1 + label.adjustsFontSizeToFitWidth = true + label.textAlignment = .center + return label + }() + + private lazy var leftImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.frame = CGRect(x: 0, y: 0, width: Constants.bankIconSize, height: Constants.bankIconSize) + return imageView + }() + + init() { + super.init(frame: .zero) + addSubview(contentView) + contentView.addSubview(titleLabel) + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + contentView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + contentView.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor) + ]) + } + + private func setupLeftImageConstraints() { + leftImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.contentLeadingPadding).isActive = true + leftImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true + leftImageView.widthAnchor.constraint(equalToConstant: leftImageView.frame.width).isActive = true + leftImageView.heightAnchor.constraint(equalToConstant: leftImageView.frame.height).isActive = true + } + + @objc private func tapOnPayInvoiceView() { + didTapButton?() + } +} + +extension PaymentPrimaryButton { + func configure(with configuration: ButtonConfiguration) { + self.contentView.backgroundColor = configuration.backgroundColor + self.contentView.layer.cornerRadius = configuration.cornerRadius + self.contentView.layer.borderColor = configuration.borderColor.cgColor + self.contentView.layer.shadowColor = configuration.shadowColor.cgColor + + self.titleLabel.textColor = configuration.titleColor + self.titleLabel.font = giniConfiguration.font(for: .button) + } + + func customConfigure(paymentProviderColors: ProviderColors?, text: String, leftImageData: Data? = nil) { + if let backgroundHexColor = paymentProviderColors?.background.toColor() { + contentView.backgroundColor = backgroundHexColor + } + contentView.isUserInteractionEnabled = true + + titleLabel.text = text + if let textHexColor = paymentProviderColors?.text.toColor() { + titleLabel.textColor = textHexColor + } + // Left image appears only on Payment Review Screen + if let leftImageData { + contentView.addSubview(leftImageView) + setupLeftImageConstraints() + leftImageView.roundCorners(corners: .allCorners, radius: Constants.bankIconCornerRadius) + leftImageView.image = UIImage(data: leftImageData) + } + } +} + +extension PaymentPrimaryButton { + private enum Constants { + static let bankIconSize: CGFloat = 36 + static let bankIconCornerRadius: CGFloat = 8 + static let contentLeadingPadding: CGFloat = 19 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentSecondaryButton.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentSecondaryButton.swift new file mode 100644 index 0000000..d13e214 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PaymentSecondaryButton.swift @@ -0,0 +1,146 @@ +// +// PaymentSecondaryButton.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class PaymentSecondaryButton: UIView { + + private let giniMerchantConfiguration = GiniMerchantConfiguration.shared + + var didTapButton: (() -> Void)? + + private lazy var contentView: UIView = { + let view = EmptyView() + view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tapOnBankPicker))) + return view + }() + + private lazy var leftImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.frame = CGRect(x: 0, y: 0, width: Constants.bankIconSize, height: Constants.bankIconSize) + return imageView + }() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.numberOfLines = 1 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var rightImageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.frame = CGRect(x: 0, y: 0, width: Constants.chevronIconSize, height: Constants.chevronIconSize) + return imageView + }() + + init() { + super.init(frame: .zero) + addSubview(contentView) + contentView.addSubview(leftImageView) + contentView.addSubview(titleLabel) + contentView.addSubview(rightImageView) + setupConstraints() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.topAnchor.constraint(equalTo: topAnchor), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor), + rightImageView.widthAnchor.constraint(equalToConstant: rightImageView.frame.width), + rightImageView.heightAnchor.constraint(equalToConstant: rightImageView.frame.height), + contentView.trailingAnchor.constraint(equalTo: rightImageView.trailingAnchor, constant: Constants.contentTrailingPadding), + rightImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), + ]) + } + + private func activateImagesViewConstraints() { + if !leftImageView.isHidden { + leftImageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.contentPadding).isActive = true + leftImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true + leftImageView.widthAnchor.constraint(equalToConstant: leftImageView.frame.width).isActive = true + leftImageView.heightAnchor.constraint(equalToConstant: leftImageView.frame.height).isActive = true + + titleLabel.leadingAnchor.constraint(equalTo: leftImageView.trailingAnchor, constant: Constants.contentPadding).isActive = true + leftImageView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor).isActive = true + } else { + titleLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: Constants.contentPadding).isActive = true + titleLabel.centerYAnchor.constraint(equalTo: contentView.centerYAnchor).isActive = true + } + if titleLabel.isHidden { + rightImageView.leadingAnchor.constraint(equalTo: leftImageView.trailingAnchor, constant: Constants.bankIconChevronIconPadding).isActive = true + } else { + rightImageView.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor).isActive = true + } + } + + @objc + private func tapOnBankPicker() { + didTapButton?() + } +} + +extension PaymentSecondaryButton { + func configure(with configuration: ButtonConfiguration) { + contentView.layer.cornerRadius = configuration.cornerRadius + contentView.layer.borderWidth = configuration.borderWidth + contentView.layer.borderColor = configuration.borderColor.cgColor + contentView.backgroundColor = configuration.backgroundColor + + leftImageView.layer.borderColor = configuration.borderColor.cgColor + leftImageView.layer.borderWidth = configuration.borderWidth + leftImageView.roundCorners(corners: .allCorners, radius: Constants.bankIconCornerRadius) + + titleLabel.textColor = configuration.titleColor + titleLabel.font = giniMerchantConfiguration.font(for: .input) + } + + func customConfigure(labelText: String, leftImageIcon: UIImage?, rightImageIcon: UIImage?, rightImageTintColor: UIColor, shouldShowLabel: Bool) { + if let leftImageIcon { + leftImageView.image = leftImageIcon + leftImageView.isHidden = false + } else { + leftImageView.isHidden = true + } + if let rightImageIcon { + rightImageView.image = rightImageIcon.withRenderingMode(.alwaysTemplate) + rightImageView.tintColor = rightImageTintColor + rightImageView.isHidden = false + } else { + rightImageView.isHidden = true + } + if shouldShowLabel { + titleLabel.text = labelText + titleLabel.isHidden = false + } else { + titleLabel.isHidden = true + } + activateImagesViewConstraints() + } +} + +extension PaymentSecondaryButton { + enum Constants { + static let bankIconSize: CGFloat = 32 + static let bankIconCornerRadius: CGFloat = 6 + static let chevronIconSize: CGFloat = 24 + static let contentTrailingPadding: CGFloat = 16 + static let bankIconChevronIconPadding: CGFloat = 12 + static let contentPadding: CGFloat = 12 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PoweredByGiniView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PoweredByGiniView.swift new file mode 100644 index 0000000..31211dd --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PoweredByGiniView.swift @@ -0,0 +1,80 @@ +// +// PoweredByGiniView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class PoweredByGiniView: UIView { + + var viewModel: PoweredByGiniViewModel! { + didSet { + setupView() + } + } + + private let mainContainer = EmptyView() + + private lazy var poweredByGiniLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.poweredByGiniLabelText + label.textColor = viewModel.poweredByGiniLabelAccentColor + label.font = viewModel.poweredByGiniLabelFont + label.numberOfLines = Constants.textNumberOfLines + label.adjustsFontSizeToFitWidth = true + label.textAlignment = .right + return label + }() + + private lazy var giniImageView: UIImageView = { + let imageView = UIImageView(image: viewModel.giniIcon) + imageView.frame = CGRect(x: 0, y: 0, width: Constants.widthGiniLogo, height: Constants.heightGiniLogo) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + self.translatesAutoresizingMaskIntoConstraints = false + + mainContainer.addSubview(poweredByGiniLabel) + mainContainer.addSubview(giniImageView) + self.addSubview(mainContainer) + + NSLayoutConstraint.activate([ + mainContainer.trailingAnchor.constraint(equalTo: trailingAnchor), + mainContainer.leadingAnchor.constraint(equalTo: leadingAnchor), + mainContainer.topAnchor.constraint(equalTo: topAnchor), + mainContainer.bottomAnchor.constraint(equalTo: bottomAnchor), + mainContainer.trailingAnchor.constraint(equalTo: giniImageView.trailingAnchor), + giniImageView.leadingAnchor.constraint(equalTo: poweredByGiniLabel.trailingAnchor, constant: Constants.spacingImageText), + poweredByGiniLabel.centerYAnchor.constraint(equalTo: giniImageView.centerYAnchor), + poweredByGiniLabel.leadingAnchor.constraint(equalTo: mainContainer.leadingAnchor), + giniImageView.heightAnchor.constraint(equalToConstant: giniImageView.frame.height), + giniImageView.widthAnchor.constraint(equalToConstant: giniImageView.frame.width), + giniImageView.centerYAnchor.constraint(equalTo: mainContainer.centerYAnchor) + ]) + } +} + +extension PoweredByGiniView { + private enum Constants { + static let imageTopBottomPadding = 3.0 + static let spacingImageText = 4.0 + static let widthGiniLogo = 28.0 + static let heightGiniLogo = 18.0 + static let textNumberOfLines = 1 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/PoweredByGiniViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/PoweredByGiniViewModel.swift new file mode 100644 index 0000000..5674f49 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/PoweredByGiniViewModel.swift @@ -0,0 +1,23 @@ +// +// PoweredByGiniViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +final class PoweredByGiniViewModel { + + // powered by Gini view + let poweredByGiniLabelText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.powered.by.gini.label", comment: "") + let poweredByGiniLabelFont: UIFont + let poweredByGiniLabelAccentColor: UIColor = GiniColor.standard4.uiColor() + let giniIcon: UIImage = GiniMerchantImage.logo.preferredUIImage() + + init() { + self.poweredByGiniLabelFont = GiniMerchantConfiguration.shared.font(for: .captions2) + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceBottomView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceBottomView.swift new file mode 100644 index 0000000..0107f63 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceBottomView.swift @@ -0,0 +1,309 @@ +// +// ShareInvoiceBottomView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +class ShareInvoiceBottomView: BottomSheetViewController { + + var viewModel: ShareInvoiceBottomViewModel + + private let contentStackView = EmptyStackView(orientation: .vertical) + + private let titleView = EmptyView() + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.titleText + label.textColor = viewModel.titleLabelAccentColor + label.font = viewModel.titleLabelFont + label.numberOfLines = 0 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private let descriptionView = EmptyView() + + private lazy var descriptionLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = viewModel.descriptionLabelText + label.textColor = viewModel.descriptionAccentColor + label.font = viewModel.descriptionLabelFont + label.numberOfLines = 0 + label.lineBreakMode = .byTruncatingTail + return label + }() + + private lazy var appsView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = viewModel.appsBackgroundColor + return view + }() + + private lazy var appsStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .horizontal) + stackView.distribution = .fillEqually + stackView.spacing = Constants.appsViewSpacing + return stackView + }() + + private lazy var bankIconImageView: UIImageView = { + let imageView = UIImageView(image: viewModel.bankImageIcon) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.frame = CGRect(x: 0, y: 0, width: Constants.bankIconSize, height: Constants.bankIconSize) + imageView.roundCorners(corners: .allCorners, radius: Constants.bankIconCornerRadius) + imageView.layer.borderWidth = Constants.bankIconBorderWidth + imageView.layer.borderColor = viewModel.bankIconBorderColor.cgColor + return imageView + }() + + private let tipView = EmptyView() + + private lazy var tipStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .horizontal) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.spacing = Constants.viewPaddingConstraint + stackView.distribution = .fillProportionally + return stackView + }() + + private lazy var tipLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.textColor = viewModel.tipAccentColor + label.font = viewModel.tipLabelFont + label.numberOfLines = 0 + label.text = viewModel.tipLabelText + + let tipActionableAttributtedString = NSMutableAttributedString(string: viewModel.tipLabelText) + let tipPartString = (viewModel.tipLabelText as NSString).range(of: viewModel.tipActionablePartText) + tipActionableAttributtedString.addAttribute(.foregroundColor, + value: viewModel.tipAccentColor, + range: tipPartString) + tipActionableAttributtedString.addAttribute(NSAttributedString.Key.underlineStyle, + value: NSUnderlineStyle.single.rawValue, + range: tipPartString) + tipActionableAttributtedString.addAttribute(NSAttributedString.Key.font, + value: viewModel.tipLabelLinkFont, + range: tipPartString) + let tapOnMoreInformation = UITapGestureRecognizer(target: self, + action: #selector(tapOnLabelAction(gesture:))) + label.isUserInteractionEnabled = true + label.addGestureRecognizer(tapOnMoreInformation) + label.attributedText = tipActionableAttributtedString + return label + }() + + private lazy var tipButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(viewModel.tipIcon, for: .normal) + button.tintColor = viewModel.tipAccentColor + button.isUserInteractionEnabled = false + button.imageView?.contentMode = .scaleAspectFit + return button + }() + + private let continueView = EmptyView() + + private lazy var continueButton: PaymentPrimaryButton = { + let button = PaymentPrimaryButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.configure(with: viewModel.giniMerchantConfiguration.primaryButtonConfiguration) + button.customConfigure(paymentProviderColors: viewModel.paymentProviderColors, + text: viewModel.continueLabelText) + return button + }() + + private let bottomView = EmptyView() + + private let bottomStackView = EmptyStackView(orientation: .horizontal) + + private lazy var poweredByGiniView: PoweredByGiniView = { + let view = PoweredByGiniView() + view.viewModel = PoweredByGiniViewModel() + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + setupView() + } + + init(viewModel: ShareInvoiceBottomViewModel) { + self.viewModel = viewModel + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + setupViewHierarchy() + setupLayout() + setButtonsState() + } + + private func setupViewHierarchy() { + titleView.addSubview(titleLabel) + contentStackView.addArrangedSubview(titleView) + descriptionView.addSubview(descriptionLabel) + contentStackView.addArrangedSubview(descriptionView) + generateAppViews().forEach { appView in + appsStackView.addArrangedSubview(appView) + } + appsView.addSubview(appsStackView) + contentStackView.addArrangedSubview(appsView) + tipStackView.addArrangedSubview(tipButton) + tipStackView.addArrangedSubview(tipLabel) + tipView.addSubview(tipStackView) + contentStackView.addArrangedSubview(tipView) + continueView.addSubview(continueButton) + contentStackView.addArrangedSubview(continueView) + bottomStackView.addArrangedSubview(UIView()) + bottomStackView.addArrangedSubview(poweredByGiniView) + bottomView.addSubview(bottomStackView) + contentStackView.addArrangedSubview(bottomView) + self.setContent(content: contentStackView) + } + + private func setupLayout() { + setupTitleViewConstraints() + setupDescriptionViewConstraints() + setupAppsView() + setupTipViewConstraints() + setupContinueButtonConstraints() + setupPoweredByGiniConstraints() + } + + private func setButtonsState() { + continueButton.didTapButton = { [weak self] in + self?.tapOnContinueButton() + } + } + + private func setupTitleViewConstraints() { + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: titleView.leadingAnchor, constant: Constants.viewPaddingConstraint), + titleLabel.trailingAnchor.constraint(equalTo: titleView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + titleLabel.topAnchor.constraint(equalTo: titleView.topAnchor, constant: Constants.topBottomPaddingConstraint), + titleLabel.bottomAnchor.constraint(equalTo: titleView.bottomAnchor, constant: -Constants.topBottomPaddingConstraint) + ]) + } + + private func setupDescriptionViewConstraints() { + NSLayoutConstraint.activate([ + descriptionLabel.leadingAnchor.constraint(equalTo: descriptionView.leadingAnchor, constant: Constants.viewPaddingConstraint), + descriptionLabel.trailingAnchor.constraint(equalTo: descriptionView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + descriptionLabel.topAnchor.constraint(equalTo: descriptionView.topAnchor, constant: Constants.topBottomPaddingConstraint), + descriptionLabel.bottomAnchor.constraint(equalTo: descriptionView.bottomAnchor, constant: -Constants.bottomDescriptionConstraint) + ]) + } + + private func setupAppsView() { + NSLayoutConstraint.activate([ + appsView.heightAnchor.constraint(equalToConstant: Constants.appsViewHeight), + appsStackView.leadingAnchor.constraint(equalTo: appsView.leadingAnchor), + appsStackView.topAnchor.constraint(equalTo: appsView.topAnchor, constant: Constants.topAnchorAppsViewConstraint), + appsStackView.bottomAnchor.constraint(equalTo: appsView.bottomAnchor, constant: -Constants.viewPaddingConstraint), + appsStackView.trailingAnchor.constraint(equalTo: appsView.trailingAnchor, constant: Constants.trailingAppsViewConstraint) + ]) + } + + private func setupTipViewConstraints() { + NSLayoutConstraint.activate([ + tipStackView.leadingAnchor.constraint(equalTo: tipView.leadingAnchor, constant: Constants.viewPaddingConstraint), + tipStackView.trailingAnchor.constraint(equalTo: tipView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + tipStackView.topAnchor.constraint(equalTo: tipView.topAnchor, constant: Constants.topAnchorTipViewConstraint), + tipStackView.bottomAnchor.constraint(equalTo: tipView.bottomAnchor, constant: -Constants.topBottomPaddingConstraint), + tipButton.widthAnchor.constraint(equalToConstant: Constants.tipIconSize) + ]) + } + + private func setupContinueButtonConstraints() { + NSLayoutConstraint.activate([ + continueButton.leadingAnchor.constraint(equalTo: continueView.leadingAnchor, constant: Constants.viewPaddingConstraint), + continueButton.trailingAnchor.constraint(equalTo: continueView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + continueButton.heightAnchor.constraint(equalToConstant: Constants.continueButtonViewHeight), + continueButton.topAnchor.constraint(equalTo: continueView.topAnchor, constant: Constants.topBottomPaddingConstraint), + continueButton.bottomAnchor.constraint(equalTo: continueView.bottomAnchor) + ]) + } + + private func setupPoweredByGiniConstraints() { + NSLayoutConstraint.activate([ + bottomStackView.leadingAnchor.constraint(equalTo: bottomView.leadingAnchor, constant: Constants.viewPaddingConstraint), + bottomStackView.trailingAnchor.constraint(equalTo: bottomView.trailingAnchor, constant: -Constants.viewPaddingConstraint), + bottomStackView.topAnchor.constraint(equalTo: bottomView.topAnchor, constant: Constants.topAnchorPoweredByGiniConstraint), + bottomStackView.bottomAnchor.constraint(equalTo: bottomView.bottomAnchor), + bottomStackView.heightAnchor.constraint(equalToConstant: Constants.bottomViewHeight) + ]) + } + + @objc + private func tapOnContinueButton() { + viewModel.didTapOnContinue() + } + + @objc + private func tapOnAppStoreButton() { + openPaymentProvidersAppStoreLink(urlString: viewModel.selectedPaymentProvider?.appStoreUrlIOS) + } + + @objc + private func tapOnLabelAction(gesture: UITapGestureRecognizer) { + if gesture.didTapAttributedTextInLabel(label: tipLabel, + targetText: viewModel.tipActionablePartText) { + tapOnAppStoreButton() + } + } + + private func openPaymentProvidersAppStoreLink(urlString: String?) { + guard let urlString = urlString else { + print("AppStore link unavailable for this payment provider") + return + } + if let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + } + + private func generateAppViews() -> [ShareInvoiceSingleAppView] { + var viewsToReturn: [ShareInvoiceSingleAppView] = [] + viewModel.appsMocked.forEach { singleApp in + let view = ShareInvoiceSingleAppView() + view.configure(image: singleApp.image, title: singleApp.title, isMoreButton: singleApp.isMoreButton) + viewsToReturn.append(view) + } + return viewsToReturn + } +} + +extension ShareInvoiceBottomView { + enum Constants { + static let viewPaddingConstraint = 16.0 + static let topBottomPaddingConstraint = 10.0 + static let bottomDescriptionConstraint = 20.0 + static let bankIconSize = 36 + static let bankIconCornerRadius = 6.0 + static let bankIconBorderWidth = 1.0 + static let continueButtonViewHeight = 56.0 + static let appsViewSpacing: CGFloat = -20 + static let appsViewHeight: CGFloat = 112.0 + static let topAnchorAppsViewConstraint = 20.0 + static let trailingAppsViewConstraint = 40.0 + static let topAnchorTipViewConstraint = 5.0 + static let topAnchorPoweredByGiniConstraint = 5.0 + static let tipIconSize = 24.0 + static let bottomViewHeight = 44.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceBottomViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceBottomViewModel.swift new file mode 100644 index 0000000..5b566aa --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceBottomViewModel.swift @@ -0,0 +1,113 @@ +// +// ShareInvoiceBottomViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites +import GiniHealthAPILibrary + +protocol ShareInvoiceBottomViewProtocol: AnyObject { + func didTapOnContinueToShareInvoice() +} + +struct SingleApp { + var title: String + var image: UIImage? + var isMoreButton: Bool +} + +final class ShareInvoiceBottomViewModel { + + var giniMerchantConfiguration = GiniMerchantConfiguration.shared + + var selectedPaymentProvider: PaymentProvider? + // Payment provider colors + var paymentProviderColors: ProviderColors? + + weak var viewDelegate: ShareInvoiceBottomViewProtocol? + + let backgroundColor: UIColor = GiniColor.standard7.uiColor() + let rectangleColor: UIColor = GiniColor.standard5.uiColor() + let dimmingBackgroundColor: UIColor = GiniColor(lightModeColor: UIColor.black, + darkModeColor: UIColor.white).uiColor().withAlphaComponent(0.4) + let appRectangleBackgroundColor: UIColor = GiniColor.standard6.uiColor() + + // Title label + var titleText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.title", + comment: "Share Invoice Bottom sheet title") + let titleLabelAccentColor: UIColor = GiniColor.standard2.uiColor() + var titleLabelFont: UIFont + + private var bankImageIconData: Data? + var bankImageIcon: UIImage { + if let bankImageIconData { + return UIImage(data: bankImageIconData) ?? UIImage() + } + return UIImage() + } + var bankIconBorderColor = GiniColor.standard5.uiColor() + + // Description label + let descriptionLabelTextColor: UIColor = GiniColor.standard3.uiColor() + let descriptionAccentColor: UIColor = GiniColor.standard3.uiColor() + var descriptionLabelText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.description", + comment: "Text description for share bottom sheet") + var descriptionLabelFont: UIFont + + // Apps View + let appsBackgroundColor: UIColor = GiniColor.standard6.uiColor() + let moreIcon: UIImage = GiniMerchantImage.more.preferredUIImage() + + // Tip label + let tipAccentColor: UIColor = GiniColor.standard2.uiColor() + let tipLabelTextColor: UIColor = GiniColor.standard4.uiColor() + var tipLabelText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.tip.description", + comment: "Text for tip label") + let tipActionablePartText = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.tip.underlined.part", + comment: "Text for tip actionable part from the label") + var tipLabelFont: UIFont + var tipLabelLinkFont: UIFont + let tipIcon: UIImage = GiniMerchantImage.info.preferredUIImage() + + // Continue label + let continueLabelText: String = NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.continue.button.text", + comment: "Title label used for the Continue button") + + let bankToReplaceString = "[BANK]" + + var appsMocked: [SingleApp] = [] + + init(selectedPaymentProvider: PaymentProvider?) { + self.selectedPaymentProvider = selectedPaymentProvider + self.bankImageIconData = selectedPaymentProvider?.iconData + self.paymentProviderColors = selectedPaymentProvider?.colors + + titleText = titleText.replacingOccurrences(of: bankToReplaceString, with: selectedPaymentProvider?.name ?? "") + descriptionLabelText = descriptionLabelText.replacingOccurrences(of: bankToReplaceString, with: selectedPaymentProvider?.name ?? "") + tipLabelText = tipLabelText.replacingOccurrences(of: bankToReplaceString, with: selectedPaymentProvider?.name ?? "") + self.titleLabelFont = giniMerchantConfiguration.font(for: .subtitle1) + self.descriptionLabelFont = giniMerchantConfiguration.font(for: .captions1) + self.tipLabelFont = giniMerchantConfiguration.font(for: .captions1) + self.tipLabelLinkFont = giniMerchantConfiguration.font(for: .linkBold) + + self.generateAppMockedElements() + } + + private func generateAppMockedElements() { + for _ in 0..<2 { + self.appsMocked.append(SingleApp(title: NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.app", comment: ""), isMoreButton: false)) + } + self.appsMocked.append(SingleApp(title: selectedPaymentProvider?.name ?? "", image: bankImageIcon, isMoreButton: false)) + self.appsMocked.append(SingleApp(title: NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.app", comment: ""), isMoreButton: false)) + self.appsMocked.append(SingleApp(title: NSLocalizedStringPreferredFormat("gini.merchant.paymentcomponent.share.invoice.bottom.sheet.more", comment: ""), image: moreIcon, isMoreButton: true)) + + } + + func didTapOnContinue() { + viewDelegate?.didTapOnContinueToShareInvoice() + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceSingleAppView.swift b/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceSingleAppView.swift new file mode 100644 index 0000000..29bf6bb --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentComponent/ShareInvoiceSingleAppView.swift @@ -0,0 +1,79 @@ +// +// ShareInvoiceSingleAppView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +class ShareInvoiceSingleAppView: UIView { + // Subviews + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.roundCorners(corners: .allCorners, radius: Constants.imageViewCornerRardius) + imageView.translatesAutoresizingMaskIntoConstraints = false + return imageView + }() + + private let titleLabel: UILabel = { + let label = UILabel() + label.textAlignment = .center + label.textColor = GiniColor.standard3.uiColor() + label.font = GiniMerchantConfiguration.shared.font(for: .captions2) + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + return label + }() + + // Initializer + override init(frame: CGRect) { + super.init(frame: frame) + setupViews() + } + + required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + setupViews() + } + + // Setup views and constraints + private func setupViews() { + addSubview(imageView) + addSubview(titleLabel) + + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.heightAnchor.constraint(equalToConstant: Constants.imageViewHeight), + imageView.widthAnchor.constraint(equalToConstant: Constants.imageViewHeight), + imageView.centerXAnchor.constraint(equalTo: centerXAnchor), + + titleLabel.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: Constants.topAnchorTitleLabelConstraint), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + titleLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + titleLabel.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + // Function to configure view + func configure(image: UIImage?, title: String?, isMoreButton: Bool) { + imageView.image = image + titleLabel.text = title + imageView.layer.borderColor = GiniColor.standard3.uiColor().cgColor + imageView.layer.borderWidth = isMoreButton ? 1 : 0 + let giniColor = GiniColor(lightModeColor: .white, + darkModeColor: GiniMerchantColorPalette.light3.preferredColor()) + imageView.backgroundColor = isMoreButton ? .clear : giniColor.uiColor() + imageView.contentMode = isMoreButton ? .center : .scaleAspectFit + } +} + +extension ShareInvoiceSingleAppView { + enum Constants { + static let imageViewHeight = 36.0 + static let topAnchorTitleLabelConstraint = 8.0 + static let imageViewCornerRardius = 6.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentInfo.swift b/Sources/GiniMerchantSDK/Core/PaymentInfo.swift new file mode 100644 index 0000000..a4ed2f7 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentInfo.swift @@ -0,0 +1,39 @@ +// +// PaymentInfo.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation +/** + Model object for payment information + */ + +public struct PaymentInfo { + + public var recipient: String + public var iban: String + public var bic: String + public var amount: String + public var purpose: String + public var paymentUniversalLink: String + public var paymentProviderId: String + + public init(recipient: String, iban: String, bic: String, amount: String, purpose: String, paymentUniversalLink: String, paymentProviderId: String) { + self.recipient = recipient + self.iban = iban + self.bic = bic + self.amount = amount + self.purpose = purpose + self.paymentUniversalLink = paymentUniversalLink + self.paymentProviderId = paymentProviderId + } + + public var isComplete: Bool { + !recipient.isEmpty && + !iban.isEmpty && + !amount.isEmpty && + !purpose.isEmpty + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentReviewContainerView.swift b/Sources/GiniMerchantSDK/Core/PaymentReviewContainerView.swift new file mode 100644 index 0000000..5a577e7 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentReviewContainerView.swift @@ -0,0 +1,766 @@ +// +// PaymentReviewContainerView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import UIKit +import GiniUtilites + +enum TextFieldType: Int { + case recipientFieldTag = 1 + case ibanFieldTag + case amountFieldTag + case usageFieldTag +} + +class PaymentReviewContainerView: UIView { + let ibanValidator = IBANValidator() + let giniMerchantConfiguration = GiniMerchantConfiguration.shared + + private lazy var paymentInfoStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .vertical) + stackView.distribution = .fill + stackView.spacing = Constants.stackViewSpacing + return stackView + }() + + private lazy var recipientStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .vertical) + stackView.distribution = .fill + stackView.spacing = Constants.errorTopMargin + return stackView + }() + + private lazy var recipientTextFieldView: TextFieldWithLabelView = { + let textFieldView = TextFieldWithLabelView() + textFieldView.tag = TextFieldType.recipientFieldTag.rawValue + textFieldView.isUserInteractionEnabled = false + return textFieldView + }() + + private lazy var recipientErrorLabel: UILabel = { + let label = UILabel() + label.font = giniMerchantConfiguration.font(for: .captions2) + return label + }() + + private lazy var ibanAmountContainerStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .vertical) + stackView.distribution = .fill + stackView.spacing = Constants.errorTopMargin + return stackView + }() + + private lazy var ibanAmountHorizontalStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .horizontal) + stackView.distribution = .fill + stackView.spacing = Constants.errorTopMargin + return stackView + }() + + private lazy var ibanTextFieldView: TextFieldWithLabelView = { + let textFieldView = TextFieldWithLabelView() + textFieldView.tag = TextFieldType.ibanFieldTag.rawValue + textFieldView.isUserInteractionEnabled = false + return textFieldView + }() + + private lazy var amountTextFieldView: TextFieldWithLabelView = { + let textFieldView = TextFieldWithLabelView() + textFieldView.tag = TextFieldType.amountFieldTag.rawValue + textFieldView.isUserInteractionEnabled = true + return textFieldView + }() + + private lazy var ibanAmountErrorsHorizontalStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .horizontal) + stackView.distribution = .fill + return stackView + }() + + private lazy var ibanErrorStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .vertical) + stackView.distribution = .fill + return stackView + }() + + private lazy var ibanErrorLabel: UILabel = { + let label = UILabel() + label.font = giniMerchantConfiguration.font(for: .captions2) + return label + }() + + private lazy var amountErrorStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .vertical) + stackView.distribution = .fill + return stackView + }() + + private lazy var amountErrorLabel: UILabel = { + let label = UILabel() + label.font = giniMerchantConfiguration.font(for: .captions2) + return label + }() + + private lazy var referenceNumberStackView: UIStackView = { + let stackView = EmptyStackView(orientation: .vertical) + stackView.distribution = .fill + stackView.spacing = Constants.errorTopMargin + return stackView + }() + + private lazy var usageTextFieldView: TextFieldWithLabelView = { + let textFieldView = TextFieldWithLabelView() + textFieldView.tag = TextFieldType.usageFieldTag.rawValue + textFieldView.isUserInteractionEnabled = false + return textFieldView + }() + + private lazy var usageErrorLabel: UILabel = { + let label = UILabel() + label.font = giniMerchantConfiguration.font(for: .captions2) + return label + }() + + private let buttonsView = EmptyView() + + private let buttonsStackView = EmptyStackView(orientation: .horizontal) + + private lazy var payInvoiceButton: PaymentPrimaryButton = { + let button = PaymentPrimaryButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.frame = CGRect(x: 0, y: 0, width: .greatestFiniteMagnitude, height: Constants.buttonViewHeight) + return button + }() + + private let bottomView = EmptyView() + + private let bottomStackView = EmptyStackView(orientation: .horizontal) + + private lazy var poweredByGiniView: PoweredByGiniView = { + let view = PoweredByGiniView() + view.viewModel = PoweredByGiniViewModel() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private var amountToPay = Price(value: 0, currencyCode: "€") + private var lastValidatedIBAN = "" + + private var paymentInputFields: [TextFieldWithLabelView] = [] + private var paymentInputFieldsErrorLabels: [UILabel] = [] + private var coupledErrorLabels: [UILabel] = [] + var model: PaymentReviewContainerViewModel! { + didSet { + configureUI() + } + } + var onPayButtonClicked: (() -> Void)? + + override init(frame: CGRect) { + super.init(frame: frame) + setupViewHierarchy() + setupLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupViewHierarchy() { + paymentInputFields = [recipientTextFieldView, amountTextFieldView, ibanTextFieldView, usageTextFieldView] + paymentInputFieldsErrorLabels = [recipientErrorLabel, amountErrorLabel, ibanErrorLabel, usageErrorLabel] + coupledErrorLabels = [amountErrorLabel, ibanErrorLabel] + + recipientStackView.addArrangedSubview(recipientTextFieldView) + recipientStackView.addArrangedSubview(recipientErrorLabel) + + ibanAmountHorizontalStackView.addArrangedSubview(ibanTextFieldView) + ibanAmountHorizontalStackView.addArrangedSubview(amountTextFieldView) + + ibanErrorStackView.addArrangedSubview(ibanErrorLabel) + amountErrorStackView.addArrangedSubview(amountErrorLabel) + ibanAmountErrorsHorizontalStackView.addArrangedSubview(ibanErrorStackView) + ibanAmountErrorsHorizontalStackView.addArrangedSubview(amountErrorStackView) + + ibanAmountContainerStackView.addArrangedSubview(ibanAmountHorizontalStackView) + ibanAmountContainerStackView.addArrangedSubview(ibanAmountErrorsHorizontalStackView) + + referenceNumberStackView.addArrangedSubview(usageTextFieldView) + referenceNumberStackView.addArrangedSubview(usageErrorLabel) + + buttonsStackView.addArrangedSubview(payInvoiceButton) + buttonsView.addSubview(buttonsStackView) + + bottomStackView.addArrangedSubview(poweredByGiniView) + bottomView.addSubview(bottomStackView) + + paymentInfoStackView.addArrangedSubview(recipientStackView) + + paymentInfoStackView.addArrangedSubview(ibanAmountContainerStackView) + + paymentInfoStackView.addArrangedSubview(referenceNumberStackView) + paymentInfoStackView.addArrangedSubview(buttonsView) + paymentInfoStackView.addArrangedSubview(bottomView) + + addSubview(paymentInfoStackView) + } + + // MARK: Layout & Constraints + + private func setupLayout() { + setupContainerContraints() + setupRecipientStackViewConstraints() + setupIbanAmountStackViewsConstraints() + setupUsageStackViewConstraints() + setupButtonConstraints() + setupPoweredByGiniConstraints() + } + + private func setupContainerContraints() { + NSLayoutConstraint.activate([ + paymentInfoStackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.leftRightPaymentInfoContainerPadding), + paymentInfoStackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.leftRightPaymentInfoContainerPadding), + paymentInfoStackView.topAnchor.constraint(equalTo: topAnchor, constant: Constants.topBottomPaymentInfoContainerPadding), + paymentInfoStackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.topBottomPaymentInfoContainerPadding) + ]) + } + + private func configureUI() { + setupViewModel() + configurePaymentInputFields() + configurePayButtonInitialState() + hideErrorLabels() + fillInInputFields() + addDoneButtonForNumPad(amountTextFieldView) + } + + private func setupRecipientStackViewConstraints() { + NSLayoutConstraint.activate([ + recipientTextFieldView.heightAnchor.constraint(equalToConstant: Constants.textFieldHeight), + recipientErrorLabel.heightAnchor.constraint(equalToConstant: Constants.errorLabelHeight), + ]) + } + + private func setupIbanAmountStackViewsConstraints() { + let amountTextFieldWidthConstraint = amountTextFieldView.widthAnchor.constraint(greaterThanOrEqualToConstant: Constants.amountWidth) + amountTextFieldWidthConstraint.priority = .required - 1 + let amountErrorLabelWidthConstraint = amountErrorLabel.widthAnchor.constraint(equalToConstant: Constants.amountWidth) + amountErrorLabelWidthConstraint.priority = .required - 1 + NSLayoutConstraint.activate([ + ibanTextFieldView.heightAnchor.constraint(equalToConstant: Constants.textFieldHeight), + amountTextFieldView.heightAnchor.constraint(equalToConstant: Constants.textFieldHeight), + amountTextFieldWidthConstraint, + ibanErrorLabel.heightAnchor.constraint(equalToConstant: Constants.errorLabelHeight), + amountErrorLabel.heightAnchor.constraint(equalToConstant: Constants.errorLabelHeight), + amountErrorLabelWidthConstraint + ]) + } + + private func setupUsageStackViewConstraints() { + NSLayoutConstraint.activate([ + usageTextFieldView.heightAnchor.constraint(equalToConstant: Constants.textFieldHeight), + usageErrorLabel.heightAnchor.constraint(equalToConstant: Constants.errorLabelHeight) + ]) + } + + private func setupButtonConstraints() { + NSLayoutConstraint.activate([ + buttonsStackView.leadingAnchor.constraint(equalTo: buttonsView.leadingAnchor), + buttonsStackView.trailingAnchor.constraint(equalTo: buttonsView.trailingAnchor), + buttonsStackView.topAnchor.constraint(equalTo: buttonsView.topAnchor), + buttonsStackView.bottomAnchor.constraint(equalTo: buttonsView.bottomAnchor), + buttonsStackView.heightAnchor.constraint(equalToConstant: Constants.textFieldHeight) + ]) + } + + private func setupPoweredByGiniConstraints() { + NSLayoutConstraint.activate([ + bottomStackView.leadingAnchor.constraint(equalTo: bottomView.leadingAnchor, constant: Constants.leftRightPaymentInfoContainerPadding), + bottomStackView.trailingAnchor.constraint(equalTo: bottomView.trailingAnchor, constant: -Constants.leftRightPaymentInfoContainerPadding), + bottomStackView.topAnchor.constraint(equalTo: bottomView.topAnchor, constant: Constants.topAnchorPoweredByGiniConstraint), + bottomStackView.bottomAnchor.constraint(equalTo: bottomView.bottomAnchor), + bottomStackView.heightAnchor.constraint(equalToConstant: Constants.bottomViewHeight) + ]) + } + + private func updateAmountIbanErrorState() { + ibanAmountErrorsHorizontalStackView.isHidden = coupledErrorLabels.allSatisfy { $0.isHidden } + } + + // MARK: - Input fields configuration + + fileprivate func setupViewModel() { + model?.onExtractionFetched = { [weak self] () in + DispatchQueue.main.async { + self?.fillInInputFields() + } + } + } + + fileprivate func configurePaymentInputFields() { + for field in paymentInputFields { + applyDefaultStyle(field) + } + } + + fileprivate func applyDefaultStyle(_ textFieldView: TextFieldWithLabelView) { + textFieldView.configure(configuration: giniMerchantConfiguration.defaultStyleInputFieldConfiguration) + textFieldView.customConfigure(labelTitle: inputFieldPlaceholderText(textFieldView)) + textFieldView.textField.delegate = self + textFieldView.textField.tag = textFieldView.tag + + if let fieldIdentifier = TextFieldType(rawValue: textFieldView.tag), fieldIdentifier != .amountFieldTag { + textFieldView.textField.textColor = giniMerchantConfiguration.defaultStyleInputFieldConfiguration.placeholderForegroundColor + } + + textFieldView.layer.masksToBounds = true + } + + fileprivate func applyErrorStyle(_ textFieldView: TextFieldWithLabelView) { + UIView.animate(withDuration: Constants.animationDuration) { + textFieldView.configure(configuration: self.giniMerchantConfiguration.errorStyleInputFieldConfiguration) + textFieldView.layer.masksToBounds = true + } + } + + fileprivate func applySelectionStyle(_ textFieldView: TextFieldWithLabelView) { + UIView.animate(withDuration: Constants.animationDuration) { [self] in + textFieldView.configure(configuration: self.giniMerchantConfiguration.selectionStyleInputFieldConfiguration) + textFieldView.layer.masksToBounds = true + } + } + + fileprivate func inputFieldPlaceholderText(_ textFieldView: TextFieldWithLabelView) -> NSAttributedString { + let fullString = NSMutableAttributedString() + if let fieldIdentifier = TextFieldType(rawValue: textFieldView.tag) { + var text = "" + switch fieldIdentifier { + case .recipientFieldTag: + text = NSLocalizedStringPreferredFormat("gini.merchant.reviewscreen.recipient.placeholder", + comment: "placeholder text for recipient input field") + case .ibanFieldTag: + text = NSLocalizedStringPreferredFormat("gini.merchant.reviewscreen.iban.placeholder", + comment: "placeholder text for iban input field") + case .amountFieldTag: + text = NSLocalizedStringPreferredFormat("gini.merchant.reviewscreen.amount.placeholder", + comment: "placeholder text for amount input field") + case .usageFieldTag: + text = NSLocalizedStringPreferredFormat("gini.merchant.reviewscreen.usage.placeholder", + comment: "placeholder text for usage input field") + } + fullString.append(NSAttributedString(string: text)) + + if fieldIdentifier != .amountFieldTag { + appendLockIcon(fullString) + } + } + + return fullString + } + + fileprivate func appendLockIcon(_ string: NSMutableAttributedString) { + let lockIconAttachment = NSTextAttachment() + let icon = GiniMerchantImage.lock.preferredUIImage() + lockIconAttachment.image = icon + + let height = Constants.lockIconHeight + let ratio = icon.size.width / icon.size.height + lockIconAttachment.bounds = CGRect(x: bounds.origin.x, y: bounds.origin.y, width: ratio * height, height: height) + + let lockString = NSAttributedString(attachment: lockIconAttachment) + + string.append(NSAttributedString(string: " ")) + string.append(lockString) + } + + fileprivate func validateTextField(_ textFieldViewTag: Int) { + let textFieldView = textFieldViewWithTag(tag: textFieldViewTag) + if let fieldIdentifier = TextFieldType(rawValue: textFieldViewTag) { + switch fieldIdentifier { + case .amountFieldTag: + if amountTextFieldView.textField.hasText && !amountTextFieldView.textField.isReallyEmpty { + let decimalPart = amountToPay.value + if decimalPart > 0 { + applyDefaultStyle(textFieldView) + hideErrorLabel(textFieldTag: fieldIdentifier) + } else { + amountTextFieldView.text = "" + applyErrorStyle(textFieldView) + showErrorLabel(textFieldTag: fieldIdentifier) + } + } else { + applyErrorStyle(textFieldView) + showErrorLabel(textFieldTag: fieldIdentifier) + } + case .ibanFieldTag, .recipientFieldTag, .usageFieldTag: + if textFieldView.textField.hasText && !textFieldView.textField.isReallyEmpty { + applyDefaultStyle(textFieldView) + hideErrorLabel(textFieldTag: fieldIdentifier) + } else { + applyErrorStyle(textFieldView) + showErrorLabel(textFieldTag: fieldIdentifier) + } + } + } + } + + fileprivate func textFieldViewWithTag(tag: Int) -> TextFieldWithLabelView { + paymentInputFields.first(where: { $0.tag == tag }) ?? TextFieldWithLabelView() + } + + fileprivate func validateIBANTextField(){ + if let ibanText = ibanTextFieldView.textField.text, ibanTextFieldView.textField.hasText { + if ibanValidator.isValid(iban: ibanText) { + applyDefaultStyle(ibanTextFieldView) + hideErrorLabel(textFieldTag: .ibanFieldTag) + } else { + applyErrorStyle(ibanTextFieldView) + showValidationErrorLabel(textFieldTag: .ibanFieldTag) + } + } else { + applyErrorStyle(ibanTextFieldView) + showErrorLabel(textFieldTag: .ibanFieldTag) + } + } + + fileprivate func showIBANValidationErrorIfNeeded(){ + if ibanValidator.isValid(iban: lastValidatedIBAN) { + applyDefaultStyle(ibanTextFieldView) + hideErrorLabel(textFieldTag: .ibanFieldTag) + } else { + applyErrorStyle(ibanTextFieldView) + showValidationErrorLabel(textFieldTag: .ibanFieldTag) + } + } + + fileprivate func validateAllInputFields() { + for textField in paymentInputFields { + validateTextField(textField.tag) + } + } + + fileprivate func hideErrorLabels() { + for errorLabel in paymentInputFieldsErrorLabels { + errorLabel.isHidden = true + } + updateAmountIbanErrorState() + } + + fileprivate func fillInInputFields() { + guard let model else { return } + if let extractions = model.extractions { + recipientTextFieldView.text = extractions.first(where: {$0.name == "payment_recipient"})?.value + ibanTextFieldView.text = extractions.first(where: {$0.name == "iban"})?.value + usageTextFieldView.text = extractions.first(where: {$0.name == "payment_purpose"})?.value + if let amountString = extractions.first(where: {$0.name == "amount_to_pay"})?.value, let amountToPay = Price(extractionString: amountString) { + self.amountToPay = amountToPay + let amountToPayText = amountToPay.string + amountTextFieldView.text = amountToPayText + } + } else if let paymentInfo = model.paymentInfo { + recipientTextFieldView.text = paymentInfo.recipient + ibanTextFieldView.text = paymentInfo.iban + usageTextFieldView.text = paymentInfo.purpose + if let amountToPay = Price(extractionString: paymentInfo.amount) { + self.amountToPay = amountToPay + let amountToPayText = amountToPay.string + amountTextFieldView.text = amountToPayText + } + } + validateAllInputFields() + disablePayButtonIfNeeded() + } + + fileprivate func showErrorLabel(textFieldTag: TextFieldType) { + var errorLabel = UILabel() + var errorMessage = "" + switch textFieldTag { + case .recipientFieldTag: + errorLabel = recipientErrorLabel + errorMessage = NSLocalizedStringPreferredFormat("gini.merchant.errors.failed.recipient.non.empty.check", + comment: " recipient failed non empty check") + case .ibanFieldTag: + errorLabel = ibanErrorLabel + errorMessage = NSLocalizedStringPreferredFormat("gini.merchant.errors.failed.iban.non.empty.check", + comment: "iban failed non empty check") + case .amountFieldTag: + errorLabel = amountErrorLabel + errorMessage = NSLocalizedStringPreferredFormat("gini.merchant.errors.failed.amount.non.empty.check", + comment: "amount failed non empty check") + case .usageFieldTag: + errorLabel = usageErrorLabel + errorMessage = NSLocalizedStringPreferredFormat("gini.merchant.errors.failed.purpose.non.empty.check", + comment: "purpose failed non empty check") + } + if errorLabel.isHidden { + errorLabel.isHidden = false + errorLabel.textColor = GiniColor.feedback1.uiColor() + errorLabel.text = errorMessage + } + updateAmountIbanErrorState() + } + + fileprivate func hideErrorLabel(textFieldTag: TextFieldType) { + var errorLabel = UILabel() + switch textFieldTag { + case .recipientFieldTag: + errorLabel = recipientErrorLabel + case .ibanFieldTag: + errorLabel = ibanErrorLabel + case .amountFieldTag: + errorLabel = amountErrorLabel + case .usageFieldTag: + errorLabel = usageErrorLabel + } + if !errorLabel.isHidden { + errorLabel.isHidden = true + } + disablePayButtonIfNeeded() + updateAmountIbanErrorState() + } + + // MARK: - Pay button + + fileprivate func disablePayButtonIfNeeded() { + payInvoiceButton.superview?.alpha = paymentInputFields.allSatisfy({ !$0.textField.isReallyEmpty }) && amountToPay.value > 0 ? 1 : Constants.payInvoiceInactiveAlpha + } + + fileprivate func showValidationErrorLabel(textFieldTag: TextFieldType) { + var errorLabel = UILabel() + var errorMessage = NSLocalizedStringPreferredFormat("gini.merchant.errors.failed.default.textfield.validation.check", + comment: "the field failed non empty check") + switch textFieldTag { + case .recipientFieldTag: + errorLabel = recipientErrorLabel + case .ibanFieldTag: + errorLabel = ibanErrorLabel + errorMessage = NSLocalizedStringPreferredFormat("gini.merchant.errors.failed.iban.validation.check", + comment: "iban failed validation check") + case .amountFieldTag: + errorLabel = amountErrorLabel + case .usageFieldTag: + errorLabel = usageErrorLabel + } + if errorLabel.isHidden { + errorLabel.isHidden = false + errorLabel.textColor = GiniColor.feedback1.uiColor() + errorLabel.text = errorMessage + } + updateAmountIbanErrorState() + } + + fileprivate func configurePayButtonInitialState() { + guard let model else { return } + payInvoiceButton.configure(with: giniMerchantConfiguration.primaryButtonConfiguration) + payInvoiceButton.customConfigure(paymentProviderColors: model.selectedPaymentProvider.colors, + text: model.payInvoiceLabelText, + leftImageData: model.selectedPaymentProvider.iconData) + disablePayButtonIfNeeded() + payInvoiceButton.didTapButton = { [weak self] in + self?.payButtonClicked() + } + } + + fileprivate func addDoneButtonForNumPad(_ textFieldView: TextFieldWithLabelView) { + let toolbarDone = UIToolbar(frame:CGRect(x: 0, y: 0, width: self.frame.width, height: Constants.heightToolbar)) + toolbarDone.sizeToFit() + let flexBarButton = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil) + let barBtnDone = UIBarButtonItem.init(barButtonSystemItem: UIBarButtonItem.SystemItem.done, + target: self, + action: #selector(doneWithAmountInputButtonTapped)) + + toolbarDone.items = [flexBarButton, barBtnDone] + textFieldView.textField.inputAccessoryView = toolbarDone + } + + @objc fileprivate func doneWithAmountInputButtonTapped() { + amountTextFieldView.textField.endEditing(true) + amountTextFieldView.textField.resignFirstResponder() + + if amountTextFieldView.textField.hasText && !amountTextFieldView.textField.isReallyEmpty { + updateAmoutToPayWithCurrencyFormat() + } + } + + // MARK: - Pay Button Action + fileprivate func payButtonClicked() { + self.endEditing(true) + validateAllInputFields() + validateIBANTextField() + if let iban = ibanTextFieldView.text { + lastValidatedIBAN = iban + } + + if noErrorsFound() { + onPayButtonClicked?() + } + } + + // MARK: - Helping functions + + func noErrorsFound() -> Bool { + // check if no errors labels are shown + if (paymentInputFieldsErrorLabels.allSatisfy { $0.isHidden }) { + return true + } else { + return false + } + } + + func isTextFieldEmpty(texFieldType: TextFieldType) -> Bool { + switch texFieldType { + case .recipientFieldTag: + return recipientTextFieldView.textField.isReallyEmpty + case .ibanFieldTag: + return ibanTextFieldView.textField.isReallyEmpty + case .amountFieldTag: + return amountTextFieldView.textField.isReallyEmpty + case .usageFieldTag: + return usageTextFieldView.textField.isReallyEmpty + } + } + + func obtainPaymentInfo() -> PaymentInfo { + let amountText = amountToPay.extractionString + let paymentInfo = PaymentInfo(recipient: recipientTextFieldView.text ?? "", + iban: ibanTextFieldView.text ?? "", + bic: "", amount: amountText, + purpose: usageTextFieldView.text ?? "", + paymentUniversalLink: model.selectedPaymentProvider.universalLinkIOS, + paymentProviderId: model.selectedPaymentProvider.id) + return paymentInfo + } + + func textFieldText(texFieldType: TextFieldType) -> String? { + switch texFieldType { + case .recipientFieldTag: + return recipientTextFieldView.textField.text + case .ibanFieldTag: + return ibanTextFieldView.textField.text + case .amountFieldTag: + return amountTextFieldView.textField.text + case .usageFieldTag: + return usageTextFieldView.textField.text + } + } + +} + +// MARK: - UITextFieldDelegate + +extension PaymentReviewContainerView: UITextFieldDelegate { + /** + Dissmiss the keyboard when return key pressed + */ + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + textField.resignFirstResponder() + return true + } + + /** + Updates amoutToPay, formated string with a currency and removes "0.00" value + */ + func updateAmoutToPayWithCurrencyFormat() { + if amountTextFieldView.textField.hasText, let amountFieldText = amountTextFieldView.text { + if let priceValue = amountFieldText.toDecimal() { + amountToPay.value = priceValue + if priceValue > 0 { + let amountToPayText = amountToPay.string + amountTextFieldView.text = amountToPayText + } else { + amountTextFieldView.text = "" + } + } + } + } + func textFieldDidBeginEditing(_ textField: UITextField) { + applySelectionStyle(textFieldViewWithTag(tag: textField.tag)) + + // remove currency symbol and whitespaces for edit mode + if let fieldIdentifier = TextFieldType(rawValue: textField.tag) { + hideErrorLabel(textFieldTag: fieldIdentifier) + + if fieldIdentifier == .amountFieldTag { + let amountToPayText = amountToPay.stringWithoutSymbol + amountTextFieldView.text = amountToPayText + } + } + } + + func textFieldDidEndEditing(_ textField: UITextField) { + // add currency format when edit is finished + if TextFieldType(rawValue: textField.tag) == .amountFieldTag { + updateAmoutToPayWithCurrencyFormat() + } + validateTextField(textField.tag) + if TextFieldType(rawValue: textField.tag) == .ibanFieldTag { + if textField.text == lastValidatedIBAN { + showIBANValidationErrorIfNeeded() + } + } + disablePayButtonIfNeeded() + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + if TextFieldType(rawValue: textField.tag) == .amountFieldTag, + let text = textField.text, + let textRange = Range(range, in: text) { + let updatedText = text.replacingCharacters(in: textRange, with: string) + + // Limit length to 7 digits + let onlyDigits = String(updatedText + .trimmingCharacters(in: .whitespaces) + .filter { c in c != "," && c != "."} + .prefix(7)) + + if let decimal = Decimal(string: onlyDigits) { + let decimalWithFraction = decimal / 100 + + if let newAmount = Price.stringWithoutSymbol(from: decimalWithFraction)?.trimmingCharacters(in: .whitespaces) { + // Save the selected text range to restore the cursor position after replacing the text + let selectedRange = textField.selectedTextRange + + textField.text = newAmount + amountToPay.value = decimalWithFraction + + // Move the cursor position after the inserted character + if let selectedRange = selectedRange { + let countDelta = newAmount.count - text.count + let offset = countDelta == 0 ? 1 : countDelta + textField.moveSelectedTextRange(from: selectedRange.start, to: offset) + } + } + } + disablePayButtonIfNeeded() + return false + } + return true + } + + func textFieldDidChangeSelection(_ textField: UITextField) { + disablePayButtonIfNeeded() + } +} + +extension PaymentReviewContainerView { + enum Constants { + static let buttonViewHeight = 56.0 + static let leftRightPaymentInfoContainerPadding = 8.0 + static let topBottomPaymentInfoContainerPadding = 0.0 + static let textFieldHeight = 56.0 + static let errorLabelHeight = 12.0 + static let amountWidth = 95.0 + static let animationDuration: CGFloat = 0.3 + static let topAnchorPoweredByGiniConstraint = 5.0 + static let heightToolbar = 40.0 + static let stackViewSpacing = 10.0 + static let payInvoiceInactiveAlpha = 0.4 + static let bottomViewHeight = 20.0 + static let errorTopMargin = 9.0 + static let lockIconHeight = 11.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentReviewContainerViewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentReviewContainerViewModel.swift new file mode 100644 index 0000000..9ae5748 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentReviewContainerViewModel.swift @@ -0,0 +1,32 @@ +// +// PaymentReviewContainerViewModel.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + + +import Foundation + +class PaymentReviewContainerViewModel { + var onExtractionFetched: (() -> Void)? + var selectedPaymentProvider: PaymentProvider + + // Pay invoice label + let payInvoiceLabelText: String = NSLocalizedStringPreferredFormat("gini.merchant.reviewscreen.banking.app.button.label", + comment: "Title label used for the pay invoice button") + + public var extractions: [Extraction]? { + didSet { + self.onExtractionFetched?() + } + } + + public var paymentInfo: PaymentInfo? + + init(extractions: [Extraction]?, paymentInfo: PaymentInfo?, selectedPaymentProvider: PaymentProvider) { + self.extractions = extractions + self.selectedPaymentProvider = selectedPaymentProvider + self.paymentInfo = paymentInfo + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentReviewModel.swift b/Sources/GiniMerchantSDK/Core/PaymentReviewModel.swift new file mode 100644 index 0000000..35d73fa --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentReviewModel.swift @@ -0,0 +1,186 @@ +// +// PaymentReviewModer.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import GiniHealthAPILibrary +import UIKit + +protocol PaymentReviewViewModelDelegate: AnyObject { + func presentInstallAppBottomSheet(bottomSheet: BottomSheetViewController) + func presentShareInvoiceBottomSheet(bottomSheet: BottomSheetViewController) + func createPaymentRequestAndOpenBankApp() + func obtainPDFFromPaymentRequest() +} + +/** + View model class for review screen + */ +public class PaymentReviewModel: NSObject { + + var onPreviewImagesFetched: (() -> Void)? + var reloadCollectionViewClosure: (() -> Void)? + var updateLoadingStatus: (() -> Void)? + var updateImagesLoadingStatus: (() -> Void)? + + var onErrorHandling: ((_ error: GiniMerchantError) -> Void)? + + var onCreatePaymentRequestErrorHandling: (() -> Void)? + + weak var viewModelDelegate: PaymentReviewViewModelDelegate? + + public var document: Document? + + public var extractions: [Extraction]? + public var paymentInfo: PaymentInfo? + + public var documentId: String? + private var merchantSDK: GiniMerchant + private var selectedPaymentProvider: PaymentProvider? + + private var cellViewModels: [PageCollectionCellViewModel] = [PageCollectionCellViewModel]() { + didSet { + self.reloadCollectionViewClosure?() + } + } + + var numberOfCells: Int { + return cellViewModels.count + } + + var isLoading: Bool = false { + didSet { + self.updateLoadingStatus?() + } + } + + var isImagesLoading: Bool = false { + didSet { + self.updateImagesLoadingStatus?() + } + } + + var paymentComponentsController: PaymentComponentsController + + public init(with giniMerchant: GiniMerchant, document: Document?, extractions: [Extraction]?, paymentInfo: PaymentInfo?, selectedPaymentProvider: PaymentProvider?, paymentComponentsController: PaymentComponentsController) { + self.merchantSDK = giniMerchant + self.documentId = document?.id + self.document = document + self.extractions = extractions + self.paymentInfo = paymentInfo + self.selectedPaymentProvider = selectedPaymentProvider + self.paymentComponentsController = paymentComponentsController + } + + func getCellViewModel(at indexPath: IndexPath) -> PageCollectionCellViewModel { + return cellViewModels[indexPath.section] + } + + private func createCellViewModel(previewImage: UIImage) -> PageCollectionCellViewModel { + return PageCollectionCellViewModel(preview: previewImage) + } + + func sendFeedback(updatedExtractions: [Extraction]) { + guard let document else { return } + merchantSDK.documentService.submitFeedback(for: document, with: [], and: ["payment": [updatedExtractions]], completion: { _ in }) + } + + func createPaymentRequest(paymentInfo: PaymentInfo, completion: ((_ paymentRequestID: String) -> ())? = nil) { + isLoading = true + self.paymentInfo = paymentInfo + merchantSDK.createPaymentRequest(paymentInfo: paymentInfo) {[weak self] result in + self?.isLoading = false + switch result { + case let .success(requestId): + completion?(requestId) + case let .failure(error): + if let delegate = self?.merchantSDK.delegate, delegate.shouldHandleErrorInternally(error: GiniMerchantError.apiError(error)) { + self?.onCreatePaymentRequestErrorHandling?() + } + } + } + } + + func openInstallAppBottomSheet() { + guard let installAppBottomSheet = paymentComponentsController.installAppBottomSheet() as? BottomSheetViewController else { return } + installAppBottomSheet.modalPresentationStyle = .overFullScreen + viewModelDelegate?.presentInstallAppBottomSheet(bottomSheet: installAppBottomSheet) + } + + func openOnboardingShareInvoiceBottomSheet() { + guard let shareInvoiceBottomSheet = paymentComponentsController.shareInvoiceBottomSheet() as? BottomSheetViewController else { return } + shareInvoiceBottomSheet.modalPresentationStyle = .overFullScreen + viewModelDelegate?.presentShareInvoiceBottomSheet(bottomSheet: shareInvoiceBottomSheet) + } + + func openPaymentProviderApp(requestId: String, universalLink: String) { + merchantSDK.openPaymentProviderApp(requestID: requestId, universalLink: universalLink) + } + + func fetchImages() { + guard let document else { return } + self.isImagesLoading = true + let dispatchGroup = DispatchGroup() + let dispatchQueue = DispatchQueue(label: "imagesQueue") + let dispatchSemaphore = DispatchSemaphore(value: 0) + var vms = [PageCollectionCellViewModel]() + dispatchQueue.async { + for page in 1 ... document.pageCount { + dispatchGroup.enter() + + self.merchantSDK.documentService.preview(for: document.id, pageNumber: page) { [weak self] result in + if let cellModel = self?.proccessPreview(result) { + vms.append(cellModel) + } + dispatchSemaphore.signal() + dispatchGroup.leave() + } + dispatchSemaphore.wait() + } + + dispatchGroup.notify(queue: dispatchQueue) { + DispatchQueue.main.async { + self.isImagesLoading = false + self.cellViewModels.append(contentsOf: vms) + self.onPreviewImagesFetched?() + } + } + } + } + + private func proccessPreview(_ result: Result) -> PageCollectionCellViewModel? { + switch result { + case let .success(dataImage): + if let image = UIImage(data: dataImage) { + return createCellViewModel(previewImage: image) + } + case let .failure(error): + if let delegate = merchantSDK.delegate, delegate.shouldHandleErrorInternally(error: GiniMerchantError.apiError(error)) { + onErrorHandling?(.apiError(error)) + } + } + return nil + } +} + +extension PaymentReviewModel: InstallAppBottomViewProtocol { + func didTapOnContinue() { + viewModelDelegate?.createPaymentRequestAndOpenBankApp() + } +} + +extension PaymentReviewModel: ShareInvoiceBottomViewProtocol { + func didTapOnContinueToShareInvoice() { + viewModelDelegate?.obtainPDFFromPaymentRequest() + } +} + +/** + View model class for collection view cell + + */ +public struct PageCollectionCellViewModel { + let preview: UIImage +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentReviewViewController+PaymentReviewViewModelDelegate.swift b/Sources/GiniMerchantSDK/Core/PaymentReviewViewController+PaymentReviewViewModelDelegate.swift new file mode 100644 index 0000000..c1fd65c --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentReviewViewController+PaymentReviewViewModelDelegate.swift @@ -0,0 +1,31 @@ +// +// PaymentReviewViewController+PaymentReviewViewModelDelegate.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit + +extension PaymentReviewViewController: PaymentReviewViewModelDelegate { + func presentInstallAppBottomSheet(bottomSheet: BottomSheetViewController) { + bottomSheet.minHeight = Constants.inputContainerHeight + presentBottomSheet(viewController: bottomSheet) + } + + func createPaymentRequestAndOpenBankApp() { + self.presentedViewController?.dismiss(animated: true) + if paymentInfoContainerView.noErrorsFound() { + createPaymentRequest() + } + } + + func presentShareInvoiceBottomSheet(bottomSheet: BottomSheetViewController) { + bottomSheet.minHeight = Constants.inputContainerHeight + presentBottomSheet(viewController: bottomSheet) + } + + func obtainPDFFromPaymentRequest() { + model?.paymentComponentsController.obtainPDFURLFromPaymentRequest(paymentInfo: paymentInfoContainerView.obtainPaymentInfo(), viewController: self) + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentReviewViewController+UICollection.swift b/Sources/GiniMerchantSDK/Core/PaymentReviewViewController+UICollection.swift new file mode 100644 index 0000000..aef6909 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentReviewViewController+UICollection.swift @@ -0,0 +1,43 @@ +// +// PaymentReviewViewController+UICollection.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniUtilites + +// MARK: - UICollectionViewDelegate, UICollectionViewDataSource + +extension PaymentReviewViewController: UICollectionViewDelegate, UICollectionViewDataSource, UICollectionViewDelegateFlowLayout { + + public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { 1 } + + public func numberOfSections(in collectionView: UICollectionView) -> Int { + model?.numberOfCells ?? 1 + } + + public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + let cell: PageCollectionViewCell = collectionView.dequeueReusableCell(for: indexPath) + cell.pageImageView.frame = CGRect(x: 0, y: 0, width: collectionView.frame.width, height: collectionView.frame.height) + cell.pageImageView.contentInset = UIEdgeInsets(top: 0.0, left: 0.0, bottom: Constants.bottomPaddingPageImageView, right: 0.0) + let cellModel = model?.getCellViewModel(at: indexPath) + cell.pageImageView.display(image: cellModel?.preview ?? UIImage()) + return cell + } + + // MARK: - UICollectionViewDelegateFlowLayout + + public func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { + let height = collectionView.frame.height + let width = collectionView.frame.width + return CGSize(width: width, height: height) + } + + // MARK: - For Display the page number in page controll of collection view Cell + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + pageControl.currentPage = Int(scrollView.contentOffset.x) / Int(scrollView.frame.width) + } +} diff --git a/Sources/GiniMerchantSDK/Core/PaymentReviewViewController.swift b/Sources/GiniMerchantSDK/Core/PaymentReviewViewController.swift new file mode 100644 index 0000000..9878484 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/PaymentReviewViewController.swift @@ -0,0 +1,519 @@ +// +// PaymentReviewViewController.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniUtilites +import GiniHealthAPILibrary + +private enum DisplayMode: Int { + case bottomSheet + case documentCollection +} + +public final class PaymentReviewViewController: BottomSheetController, UIGestureRecognizerDelegate { + private lazy var mainView = buildMainView() + private lazy var closeButton = buildCloseButton() + private lazy var infoBar = buildInfoBar() + private lazy var infoBarLabel = buildInfoBarLabel() + private lazy var containerCollectionView = buildContainerCollectionView() + private var isInfoBarHidden = true + lazy var paymentInfoContainerView = buildPaymentInfoContainerView() + lazy var collectionView = buildCollectionView() + lazy var pageControl = buildPageControl() + + private var infoBarBottomConstraint: NSLayoutConstraint? + private let screenBackgroundColor = GiniColor(lightModeColorName: .light7, darkModeColorName: .light7).uiColor() + private var showInfoBarOnce = true + private var keyboardWillShowCalled = false + private var displayMode = DisplayMode.bottomSheet + + public var model: PaymentReviewModel? + var selectedPaymentProvider: PaymentProvider! + + public weak var trackingDelegate: GiniMerchantTrackingDelegate? + + public static func instantiate(with giniMerchant: GiniMerchant, document: Document?, extractions: [Extraction]?, paymentInfo: PaymentInfo?, selectedPaymentProvider: PaymentProvider, trackingDelegate: GiniMerchantTrackingDelegate? = nil, paymentComponentsController: PaymentComponentsController, isInfoBarHidden: Bool = true) -> PaymentReviewViewController { + let viewController = PaymentReviewViewController() + let viewModel = PaymentReviewModel(with: giniMerchant, + document: document, + extractions: extractions, + paymentInfo: paymentInfo, + selectedPaymentProvider: selectedPaymentProvider, + paymentComponentsController: paymentComponentsController) + viewController.model = viewModel + viewController.trackingDelegate = trackingDelegate + viewController.selectedPaymentProvider = selectedPaymentProvider + viewController.isInfoBarHidden = isInfoBarHidden + viewController.displayMode = document != nil ? .documentCollection : .bottomSheet + return viewController + } + + public static func instantiate(with giniMerchant: GiniMerchant, data: DataForReview?, paymentInfo: PaymentInfo?, selectedPaymentProvider: PaymentProvider, trackingDelegate: GiniMerchantTrackingDelegate? = nil, paymentComponentsController: PaymentComponentsController) -> PaymentReviewViewController { + let viewController = PaymentReviewViewController() + let viewModel = PaymentReviewModel(with: giniMerchant, + document: data?.document, + extractions: data?.extractions, + paymentInfo: paymentInfo, + selectedPaymentProvider: selectedPaymentProvider, + paymentComponentsController: paymentComponentsController) + viewController.model = viewModel + viewController.trackingDelegate = trackingDelegate + viewController.selectedPaymentProvider = selectedPaymentProvider + return viewController + } + + let giniMerchantConfiguration = GiniMerchantConfiguration.shared + + override public func viewDidLoad() { + super.viewDidLoad() + subscribeOnNotifications() + dismissKeyboardOnTap() + setupViewModel() + layoutUI() + } + + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + if showInfoBarOnce && !isInfoBarHidden { + showInfoBar() + showInfoBarOnce = false + } + } + + fileprivate func setupViewModel() { + + model?.updateImagesLoadingStatus = { [weak self] () in + DispatchQueue.main.async { [weak self] in + let isLoading = self?.model?.isImagesLoading ?? false + if isLoading { + self?.collectionView.showLoading(style: Constants.loadingIndicatorStyle, + color: GiniMerchantColorPalette.accent1.preferredColor(), + scale: Constants.loadingIndicatorScale) + } else { + self?.collectionView.stopLoading() + } + } + } + + model?.updateLoadingStatus = { [weak self] () in + DispatchQueue.main.async { [weak self] in + let isLoading = self?.model?.isLoading ?? false + if isLoading { + self?.view.showLoading(style: Constants.loadingIndicatorStyle, + color: GiniMerchantColorPalette.accent1.preferredColor(), + scale: Constants.loadingIndicatorScale) + } else { + self?.view.stopLoading() + } + } + } + + model?.onErrorHandling = { [weak self] error in + self?.showError(message: NSLocalizedStringPreferredFormat("gini.merchant.errors.default", comment: "default error message")) + } + + model?.reloadCollectionViewClosure = { [weak self] () in + DispatchQueue.main.async { + self?.collectionView.reloadData() + } + } + + model?.onPreviewImagesFetched = { [weak self] () in + DispatchQueue.main.async { + self?.collectionView.reloadData() + } + } + + model?.onCreatePaymentRequestErrorHandling = { [weak self] () in + self?.showError(message: NSLocalizedStringPreferredFormat("gini.merchant.errors.failed.payment.request.creation", comment: "error for creating payment request")) + } + + if displayMode == .documentCollection { + model?.fetchImages() + } + model?.viewModelDelegate = self + + paymentInfoContainerView.model = PaymentReviewContainerViewModel(extractions: model?.extractions, paymentInfo: model?.paymentInfo, selectedPaymentProvider: selectedPaymentProvider) + } + + override public func viewDidDisappear(_ animated: Bool) { + unsubscribeFromNotifications() + } + + fileprivate func layoutUI() { + switch displayMode { + case .documentCollection: + layoutMainView() + layoutPaymentInfoContainerView() + layoutContainerCollectionView() + layoutInfoBar() + layoutCloseButton() + case .bottomSheet: + layoutPaymentInfoContainerView() + layoutInfoBar() + setContent(content: paymentInfoContainerView) + } + } + + // MARK: - Pay Button Action + func payButtonClicked() { + var event = TrackingEvent.init(type: PaymentReviewScreenEventType.onToTheBankButtonClicked) + event.info = ["paymentProvider": selectedPaymentProvider.name] + trackingDelegate?.onPaymentReviewScreenEvent(event: event) + view.endEditing(true) + + if model?.paymentComponentsController.supportsGPC() == true { + guard selectedPaymentProvider.appSchemeIOS.canOpenURLString() else { + model?.openInstallAppBottomSheet() + return + } + + if paymentInfoContainerView.noErrorsFound() { + createPaymentRequest() + } + } else if model?.paymentComponentsController.supportsOpenWith() == true { + if model?.paymentComponentsController.shouldShowOnboardingScreenFor() == true { + model?.openOnboardingShareInvoiceBottomSheet() + } else { + obtainPDFFromPaymentRequest() + } + } + } + + func createPaymentRequest() { + if !paymentInfoContainerView.isTextFieldEmpty(texFieldType: .amountFieldTag) { + let paymentInfo = paymentInfoContainerView.obtainPaymentInfo() + model?.createPaymentRequest(paymentInfo: paymentInfo, completion: { [weak self] requestId in + self?.model?.openPaymentProviderApp(requestId: requestId, universalLink: paymentInfo.paymentUniversalLink) + }) + sendFeedback(paymentInfo: paymentInfo) + } + } + + private func sendFeedback(paymentInfo: PaymentInfo) { + let paymentRecipientExtraction = Extraction(box: nil, + candidates: "", + entity: "text", + value: paymentInfo.recipient, + name: "payment_recipient") + let ibanExtraction = Extraction(box: nil, + candidates: "", + entity: "iban", + value: paymentInfo.iban, + name: "iban") + let referenceExtraction = Extraction(box: nil, + candidates: "", + entity: "text", + value: paymentInfo.purpose, + name: "payment_purpose") + let amoutToPayExtraction = Extraction(box: nil, + candidates: "", + entity: "amount", + value: paymentInfo.amount, + name: "amount_to_pay") + let updatedExtractions = [paymentRecipientExtraction, ibanExtraction, referenceExtraction, amoutToPayExtraction] + model?.sendFeedback(updatedExtractions: updatedExtractions) + } +} + +// MARK: - Keyboard handling +extension PaymentReviewViewController { + @objc func keyboardWillShow(notification: NSNotification) { + guard let keyboardSize = (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue else { + /** + If keyboard size is not available for some reason, dont do anything + */ + return + } + /** + Moves the root view up by the distance of keyboard height taking in account safeAreaInsets.bottom + */ + (displayMode == .bottomSheet ? view : mainView) + .bounds.origin.y = keyboardSize.height - view.safeAreaInsets.bottom + + keyboardWillShowCalled = true + } + + @objc func keyboardWillHide(notification: NSNotification) { + let animationDuration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double ?? Constants.animationDuration + let animationCurve = notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? UInt ?? UInt(UIView.AnimationCurve.easeOut.rawValue) + + keyboardWillShowCalled = false + + UIView.animate(withDuration: animationDuration, delay: 0.0, options: UIView.AnimationOptions(rawValue: animationCurve), animations: { [weak self] in + self?.view.bounds.origin.y = 0 + }, completion: nil) + } + + func subscribeOnNotifications() { + subscribeOnKeyboardNotifications() + } + + func subscribeOnKeyboardNotifications() { + /** + Calls the 'keyboardWillShow' function when the view controller receive the notification that a keyboard is going to be shown + */ + NotificationCenter.default.addObserver(self, selector: #selector(PaymentReviewViewController.keyboardWillShow), name: UIResponder.keyboardWillShowNotification, object: nil) + + /** + Calls the 'keyboardWillHide' function when the view controlelr receive notification that keyboard is going to be hidden + */ + NotificationCenter.default.addObserver(self, selector: #selector(PaymentReviewViewController.keyboardWillHide), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + fileprivate func unsubscribeFromKeyboardNotifications() { + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil) + } + + fileprivate func unsubscribeFromNotifications() { + unsubscribeFromKeyboardNotifications() + } + + fileprivate func dismissKeyboardOnTap() { + let tap = UITapGestureRecognizer(target: view, action: #selector(UIView.endEditing)) + tap.cancelsTouchesInView = false + (displayMode == .bottomSheet ? view : mainView).addGestureRecognizer(tap) + } +} + + +//MARK: - MainView +fileprivate extension PaymentReviewViewController { + func buildMainView() -> UIView { + let view = UIView() + view.backgroundColor = GiniColor.standard7.uiColor() + return view + } + + func layoutMainView() { + mainView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(mainView) + mainView.backgroundColor = screenBackgroundColor + NSLayoutConstraint.activate([ + mainView.topAnchor.constraint(equalTo: view.topAnchor), + mainView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + mainView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + mainView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } +} + +//MARK: - PaymentReviewContainerView +fileprivate extension PaymentReviewViewController { + func buildPaymentInfoContainerView() -> PaymentReviewContainerView { + let containerView = PaymentReviewContainerView() + containerView.backgroundColor = GiniColor.standard7.uiColor() + containerView.roundCorners(corners: [.topLeft, .topRight], radius: Constants.cornerRadius) + containerView.onPayButtonClicked = { [weak self] in + self?.payButtonClicked() + } + return containerView + } + + func layoutPaymentInfoContainerView() { + paymentInfoContainerView.translatesAutoresizingMaskIntoConstraints = false + + let container = displayMode == .bottomSheet ? (view ?? UIView()) : mainView + container.addSubview(paymentInfoContainerView) + + NSLayoutConstraint.activate([ + paymentInfoContainerView.leadingAnchor.constraint(equalTo: container.leadingAnchor), + paymentInfoContainerView.trailingAnchor.constraint(equalTo: container.trailingAnchor) + ]) + } +} + +//MARK: - Collection View Container +fileprivate extension PaymentReviewViewController { + func buildContainerCollectionView() -> UIStackView { + let container = UIStackView(arrangedSubviews: [collectionView, pageControl]) + container.spacing = 0 + container.axis = .vertical + container.distribution = .fill + return container + } + + func buildCollectionView() -> UICollectionView { + let flowLayout = UICollectionViewFlowLayout() + flowLayout.minimumInteritemSpacing = Constants.collectionViewPadding + flowLayout.minimumLineSpacing = Constants.collectionViewPadding + + let collection = UICollectionView(frame: CGRect.zero, collectionViewLayout: flowLayout) + collection.backgroundColor = screenBackgroundColor + collection.delegate = self + collection.dataSource = self + collection.register(cellType: PageCollectionViewCell.self) + return collection + } + + func buildPageControl() -> UIPageControl { + let control = UIPageControl() + control.pageIndicatorTintColor = GiniColor.standard4.uiColor() + control.currentPageIndicatorTintColor = GiniColor(lightModeColorName: .dark2, darkModeColorName: .light5).uiColor() + control.backgroundColor = screenBackgroundColor + control.hidesForSinglePage = true + control.numberOfPages = model?.document?.pageCount ?? 1 + return control + } + + func layoutContainerCollectionView() { + containerCollectionView.translatesAutoresizingMaskIntoConstraints = false + mainView.addSubview(containerCollectionView) + mainView.sendSubviewToBack(containerCollectionView) + + NSLayoutConstraint.activate([ + containerCollectionView.leadingAnchor.constraint(equalTo: mainView.leadingAnchor), + containerCollectionView.trailingAnchor.constraint(equalTo: mainView.trailingAnchor), + containerCollectionView.topAnchor.constraint(equalTo: mainView.topAnchor), + containerCollectionView.bottomAnchor.constraint(equalTo: paymentInfoContainerView.topAnchor), + + pageControl.heightAnchor.constraint(equalToConstant: Constants.pageControlHeight), + collectionView.widthAnchor.constraint(equalTo: containerCollectionView.widthAnchor), + collectionView.heightAnchor.constraint(equalTo: containerCollectionView.heightAnchor), + paymentInfoContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + paymentInfoContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor) + ]) + } +} + +//MARK: - Close Button used in Gini Health SDK +fileprivate extension PaymentReviewViewController { + func buildCloseButton() -> UIButton { + let button = UIButton() + button.isHidden = true + button.setImage(GiniMerchantImage.paymentReviewClose.preferredUIImage(), for: .normal) + button.addTarget(self, action: #selector(closeButtonClicked), for: .touchUpInside) + return button + } + + func layoutCloseButton() { + closeButton.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(closeButton) + + NSLayoutConstraint.activate([ + closeButton.heightAnchor.constraint(equalToConstant: Constants.closeButtonSide), + closeButton.widthAnchor.constraint(equalToConstant: Constants.closeButtonSide), + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: Constants.closeButtonPadding), + closeButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -Constants.closeButtonPadding) + ]) + + } + + @objc func closeButtonClicked(_ sender: UIButton) { + if (keyboardWillShowCalled) { + trackingDelegate?.onPaymentReviewScreenEvent(event: TrackingEvent.init(type: .onCloseKeyboardButtonClicked)) + view.endEditing(true) + } else { + trackingDelegate?.onPaymentReviewScreenEvent(event: TrackingEvent.init(type: .onCloseButtonClicked)) + dismiss(animated: true, completion: nil) + } + } +} + +// MARK: - Info Bar +fileprivate extension PaymentReviewViewController { + func buildInfoBar() -> UIView { + let view = UIView() + view.roundCorners(corners: [.topLeft, .topRight], radius: Constants.cornerRadius) + view.backgroundColor = GiniMerchantColorPalette.success1.preferredColor() + view.isHidden = isInfoBarHidden + return view + } + + func buildInfoBarLabel() -> UILabel { + let label = UILabel() + label.textColor = GiniMerchantColorPalette.dark7.preferredColor() + label.font = GiniMerchantConfiguration.shared.font(for: .captions1) + label.adjustsFontForContentSizeCategory = true + label.text = NSLocalizedStringPreferredFormat("gini.merchant.reviewscreen.infobar.message", comment: "info bar message") + label.textAlignment = .center + label.numberOfLines = 0 + return label + } + + func layoutInfoBar() { + infoBar.translatesAutoresizingMaskIntoConstraints = false + infoBarLabel.translatesAutoresizingMaskIntoConstraints = false + + view.insertSubview(infoBar, belowSubview: paymentInfoContainerView) + infoBar.addSubview(infoBarLabel) + + let bottomConstraint = infoBar.bottomAnchor.constraint(equalTo: paymentInfoContainerView.topAnchor, constant: Constants.infoBarHeight) + infoBarBottomConstraint = bottomConstraint + NSLayoutConstraint.activate([ + bottomConstraint, + infoBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + infoBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + infoBar.heightAnchor.constraint(equalToConstant: Constants.infoBarHeight), + + infoBarLabel.centerXAnchor.constraint(equalTo: infoBar.centerXAnchor), + infoBarLabel.topAnchor.constraint(equalTo: infoBar.topAnchor, constant: Constants.infoBarLabelPadding) + ]) + } + + func showInfoBar() { + guard !isInfoBarHidden else { return } + infoBar.isHidden = false + animateInfoBar(verticalConstant: Constants.moveHeightInfoBar) + + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.animateSlideDownInfoBar() + } + } + + func animateSlideDownInfoBar() { + guard !isInfoBarHidden else { return } + animateInfoBar(verticalConstant: Constants.infoBarHeight) { [weak self] _ in + self?.infoBar.isHidden = true + } + } + + func animateInfoBar(verticalConstant: CGFloat, completion: ((Bool) -> Void)? = nil) { + guard !isInfoBarHidden else { return } + UIView.animate(withDuration: Constants.animationDuration, + delay: 0, + usingSpringWithDamping: 1.0, + initialSpringVelocity: 1.0, + animations: { + self.infoBarBottomConstraint?.constant = verticalConstant + self.view.layoutIfNeeded() + }, completion: completion) + } +} + +extension PaymentReviewViewController { + func showError(_ title: String? = nil, message: String) { + let alertController = UIAlertController(title: title, + message: message, + preferredStyle: .alert) + let okAction = UIAlertAction(title: NSLocalizedStringPreferredFormat("gini.merchant.alert.ok.title", + comment: "ok title for action"), style: .default, handler: nil) + alertController.addAction(okAction) + DispatchQueue.main.async { [weak self] in + self?.present(alertController, animated: true, completion: nil) + } + } +} + +extension PaymentReviewViewController { + enum Constants { + static let animationDuration: CGFloat = 0.3 + static let bottomPaddingPageImageView = 20.0 + static let loadingIndicatorScale = 1.0 + static let loadingIndicatorStyle = UIActivityIndicatorView.Style.large + static let closeButtonSide = 48.0 + static let closeButtonPadding = 16.0 + static let infoBarHeight = 60.0 + static let infoBarLabelPadding = 8.0 + static let pageControlHeight = 20.0 + static let collectionViewPadding = 10.0 + static let inputContainerHeight = 300.0 + static let cornerRadius = 12.0 + static let moveHeightInfoBar = 32.0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/SSLPinning/GiniSessionDelegate.swift b/Sources/GiniMerchantSDK/Core/SSLPinning/GiniSessionDelegate.swift new file mode 100644 index 0000000..90d2f8f --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/SSLPinning/GiniSessionDelegate.swift @@ -0,0 +1,22 @@ +// +// GiniSessionDelegate.swift +// +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +class GiniSessionDelegate: NSObject, URLSessionDelegate { + private let pinningManager: SSLPinningManager + + internal init(pinningConfig: [String: [String]]) { + self.pinningManager = SSLPinningManager(pinningConfig: pinningConfig) + } + + func urlSession(_ session: URLSession, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + pinningManager.validate(challenge: challenge, completionHandler: completionHandler) + } +} diff --git a/Sources/GiniMerchantSDK/Core/SSLPinning/SSLPinningManager.swift b/Sources/GiniMerchantSDK/Core/SSLPinning/SSLPinningManager.swift new file mode 100644 index 0000000..17a224d --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/SSLPinning/SSLPinningManager.swift @@ -0,0 +1,140 @@ +// +// SSLPinningManager.swift +// +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import CryptoKit +import Foundation +import CommonCrypto + +struct SSLPinningManager { + // Custom error types for SSL pinning + private enum PinningError: Error { + case noCertificatesFromServer + case failedToGetPublicKey + case failedToGetDataFromPublicKey + case receivedWrongCertificate + } + + // ASN.1 header for RSA 2048-bit keys. The same for all keys + private static let rsa2048ASN1Header: [UInt8] = [ + 0x30, 0x82, 0x01, 0x22, 0x30, 0x0D, 0x06, 0x09, 0x2A, 0x86, 0x48, 0x86, + 0xF7, 0x0D, 0x01, 0x01, 0x01, 0x05, 0x00, 0x03, 0x82, 0x01, 0x0F, 0x00 + ] + + // Dictionary mapping domain names to their expected public key hashes + private let pinningConfig: [String: [String]] + + init(pinningConfig: [String: [String]]) { + self.pinningConfig = pinningConfig + } + + // Function to validate the server's certificate + func validate(challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { + do { + let trust = try validateAndGetTrust(with: challenge) + completionHandler(.useCredential, URLCredential(trust: trust)) + } catch { + completionHandler(.cancelAuthenticationChallenge, nil) + } + } +} + +//MARK: - private methods + +private extension SSLPinningManager { + // Validate the server's certificate and return the trust object if valid + func validateAndGetTrust(with challenge: URLAuthenticationChallenge) throws -> SecTrust { + // Step 1: Retrieve the server's trust object and its certificate chain + guard let trust = challenge.protectionSpace.serverTrust, + let trustCertificateChain = trustCopyCertificateChain(trust), + !trustCertificateChain.isEmpty else { + throw PinningError.noCertificatesFromServer + } + + // Step 2: Get the domain from the challenge and check if it has a pinning configuration + guard let domain = challenge.protectionSpace.host.lowercased() as String? else { + throw PinningError.receivedWrongCertificate + } + + // Step 3: Retrieve the pinned key hashes from pinning config for the domain + guard let pinnedKeyHashes = pinningConfig[domain] else { + throw PinningError.receivedWrongCertificate + } + + // Step 4: Iterate over the server's certificates to find a matching public key hash + for serverCertificate in trustCertificateChain { + let publicKey = try getPublicKey(for: serverCertificate) + let publicKeyHash = try getKeyHash(of: publicKey) + + // If a matching hash is found, the certificate is valid + if pinnedKeyHashes.contains(publicKeyHash) { + return trust + } + } + throw PinningError.receivedWrongCertificate + } + + // Extract the public key from the server's certificate + func getPublicKey(for certificate: SecCertificate) throws -> SecKey { + let policy = SecPolicyCreateBasicX509() + var trust: SecTrust? + let trustCreationStatus = SecTrustCreateWithCertificates(certificate, policy, &trust) + + guard let trust, trustCreationStatus == errSecSuccess, let publicKey = trustCopyPublicKey(trust) else { + throw PinningError.failedToGetPublicKey + } + + return publicKey + } + + // Generate a SHA-256 hash of the public key + func getKeyHash(of publicKey: SecKey) throws -> String { + guard let publicKeyCFData = SecKeyCopyExternalRepresentation(publicKey, nil) else { + throw PinningError.failedToGetDataFromPublicKey + } + + let publicKeyData = (publicKeyCFData as NSData) as Data + var publicKeyWithHeaderData = Data(Self.rsa2048ASN1Header) + publicKeyWithHeaderData.append(publicKeyData) + let publicKeyHashData = sha256(data: publicKeyWithHeaderData) + return publicKeyHashData.base64EncodedString() + } +} + +//MARK: - private methods for capability with old ios versions + +private extension SSLPinningManager { + func trustCopyPublicKey(_ trust: SecTrust) -> SecKey? { + if #available(iOS 14, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { + return SecTrustCopyKey(trust) + } else { + return SecTrustCopyPublicKey(trust) + } + } + + func trustCopyCertificateChain(_ trust: SecTrust) -> [SecCertificate]? { + if #available(iOS 15, macOS 11, tvOS 14, watchOS 7, visionOS 1, *) { + return (SecTrustCopyCertificateChain(trust) as? [SecCertificate]) + } else { + return (0.. Data { + if #available(iOS 13.0, macOS 10.15, watchOS 6.0, tvOS 13.0, *) { + return Data(SHA256.hash(data: data)) + } else { + var hash = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + data.withUnsafeBytes { buffer in + _ = CC_SHA256(buffer.baseAddress!, CC_LONG(buffer.count), &hash) + } + return Data(hash) + } + } +} diff --git a/Sources/GiniMerchantSDK/Core/TextFieldWithLabelView.swift b/Sources/GiniMerchantSDK/Core/TextFieldWithLabelView.swift new file mode 100644 index 0000000..ae79598 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/TextFieldWithLabelView.swift @@ -0,0 +1,92 @@ +// +// TextFieldWithLabelView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniUtilites + +final class TextFieldWithLabelView: UIView { + private lazy var configuration = GiniMerchantConfiguration.shared + + var text: String? { + get { + return textField.text + } + set { + textField.text = newValue + textField.accessibilityValue = newValue + } + } + + private lazy var titleLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = configuration.font(for: .caption2) + label.adjustsFontForContentSizeCategory = true + return label + }() + + lazy var textField: UITextField = { + let textField = UITextField() + textField.translatesAutoresizingMaskIntoConstraints = false + textField.adjustsFontForContentSizeCategory = true + return textField + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + setupConstraints() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + setupConstraints() + } + + private func setupView() { + addSubview(titleLabel) + addSubview(textField) + } + + func configure(configuration: TextFieldConfiguration) { + self.layer.cornerRadius = configuration.cornerRadius + self.layer.borderWidth = configuration.borderWidth + self.layer.borderColor = configuration.borderColor.cgColor + self.backgroundColor = configuration.backgroundColor + self.textField.textColor = configuration.textColor + self.textField.attributedPlaceholder = NSAttributedString(string: "", + attributes: [.foregroundColor: configuration.placeholderForegroundColor]) + self.titleLabel.textColor = configuration.placeholderForegroundColor + } + + func customConfigure(labelTitle: NSAttributedString) { + titleLabel.attributedText = labelTitle + titleLabel.accessibilityValue = labelTitle.string + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: Constants.topBottomPadding), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.leftRightPadding), + titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -Constants.leftRightPadding), + + textField.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: Constants.textFieldTopPadding), + textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Constants.leftRightPadding), + textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Constants.leftRightPadding), + textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Constants.topBottomPadding) + ]) + } +} + +private extension TextFieldWithLabelView { + enum Constants { + static let leftRightPadding: CGFloat = 12 + static let topBottomPadding: CGFloat = 8 + static let textFieldTopPadding: CGFloat = 0 + } +} diff --git a/Sources/GiniMerchantSDK/Core/Tracking/GiniMerchantTrackingDelegate.swift b/Sources/GiniMerchantSDK/Core/Tracking/GiniMerchantTrackingDelegate.swift new file mode 100644 index 0000000..0cbc22a --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Tracking/GiniMerchantTrackingDelegate.swift @@ -0,0 +1,39 @@ +// +// GiniMerchantTrackingDelegate.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/** +Delegate protocol that Gini Merchant SDK uses to report user events. + +The delegate is separated into smaller protocols relating to different screens of the Gini Merchant SDK. + +- note: The delegate isn't retained by Gini Merchant SDK. It should be retained by the client code. +*/ +public protocol GiniMerchantTrackingDelegate: + PaymentReviewScreenTrackingDelegate +{} + +/** +Event types relating to the payment review screen. +*/ +public enum PaymentReviewScreenEventType: String { + /// User tapped "To the banking app" button and ready to be redirected to the banking app + case onToTheBankButtonClicked + /// User tapped "close" button and closed the screen + case onCloseButtonClicked + /// User tapped "close" button and keyboard will be hidden + case onCloseKeyboardButtonClicked +} + +/** +Tracking delegate relating to the payment review screen. +*/ +public protocol PaymentReviewScreenTrackingDelegate: AnyObject { + + func onPaymentReviewScreenEvent(event: TrackingEvent) +} diff --git a/Sources/GiniMerchantSDK/Core/Tracking/TrackingEvent.swift b/Sources/GiniMerchantSDK/Core/Tracking/TrackingEvent.swift new file mode 100644 index 0000000..3e34095 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/Tracking/TrackingEvent.swift @@ -0,0 +1,26 @@ +// +// TrackingEvent.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +/** +Struct representing a tracking event. It contains the event type and an optional +dictionary for additional related data. +*/ +public struct TrackingEvent where T.RawValue == String { + + /// Type of the event. + public let type: T + + /// Additional information carried by the event. + public var info: [String : String]? + + init(type: T, info: [String : String]? = nil) { + self.type = type + self.info = info + } +} diff --git a/Sources/GiniMerchantSDK/Core/ZoomedImageView.swift b/Sources/GiniMerchantSDK/Core/ZoomedImageView.swift new file mode 100644 index 0000000..2764e26 --- /dev/null +++ b/Sources/GiniMerchantSDK/Core/ZoomedImageView.swift @@ -0,0 +1,352 @@ +// +// ZoomedImageView.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit + +@objc public protocol ImageScrollViewDelegate: UIScrollViewDelegate { + func imageScrollViewDidChangeOrientation(imageScrollView: ZoomedImageView) +} + +open class ZoomedImageView: UIScrollView { + + @objc public enum ScaleMode: Int { + case aspectFill + case aspectFit + case widthFill + case heightFill + } + + @objc public enum Offset: Int { + case begining + case center + } + + static let kZoomInFactorFromMinWhenDoubleTap: CGFloat = 2 + + @objc open var imageContentMode: ScaleMode = .aspectFit + @objc open var initialOffset: Offset = .begining + + @objc public private(set) var zoomView: UIImageView? = nil + + @objc open weak var imageScrollViewDelegate: ImageScrollViewDelegate? + + var imageSize: CGSize = CGSize.zero + private var pointToCenterAfterResize: CGPoint = CGPoint.zero + private var scaleToRestoreAfterResize: CGFloat = 1.0 + open var maxScaleFromMinScale: CGFloat = 3.0 + + override open var frame: CGRect { + willSet { + if !frame.equalTo(newValue) && !newValue.equalTo(CGRect.zero) && !imageSize.equalTo(CGSize.zero) { + prepareToResize() + } + } + + didSet { + if !frame.equalTo(oldValue) && !frame.equalTo(CGRect.zero) && !imageSize.equalTo(CGSize.zero) { + recoverFromResizing() + } + } + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + initialize() + } + + required public init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + + initialize() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func initialize() { + showsVerticalScrollIndicator = false + showsHorizontalScrollIndicator = false + bouncesZoom = true + decelerationRate = UIScrollView.DecelerationRate.fast + delegate = self + + NotificationCenter.default.addObserver(self, selector: #selector(ZoomedImageView.changeOrientationNotification), name: UIDevice.orientationDidChangeNotification, object: nil) + } + + @objc public func adjustFrameToCenter() { + + guard let unwrappedZoomView = zoomView else { + return + } + + var frameToCenter = unwrappedZoomView.frame + + // center horizontally + if frameToCenter.size.width < bounds.width { + frameToCenter.origin.x = (bounds.width - frameToCenter.size.width) / 2 + } + else { + frameToCenter.origin.x = 0 + } + + // center vertically + if frameToCenter.size.height < bounds.height { + frameToCenter.origin.y = (bounds.height - frameToCenter.size.height) / 2 + } + else { + frameToCenter.origin.y = 0 + } + + unwrappedZoomView.frame = frameToCenter + } + + private func prepareToResize() { + let boundsCenter = CGPoint(x: bounds.midX, y: bounds.midY) + pointToCenterAfterResize = convert(boundsCenter, to: zoomView) + + scaleToRestoreAfterResize = zoomScale + + // If we're at the minimum zoom scale, preserve that by returning 0, which will be converted to the minimum + // allowable scale when the scale is restored. + if scaleToRestoreAfterResize <= minimumZoomScale + CGFloat(Float.ulpOfOne) { + scaleToRestoreAfterResize = 0 + } + } + + private func recoverFromResizing() { + setMaxMinZoomScalesForCurrentBounds() + + // restore zoom scale, first making sure it is within the allowable range. + let maxZoomScale = max(minimumZoomScale, scaleToRestoreAfterResize) + zoomScale = min(maximumZoomScale, maxZoomScale) + + // restore center point, first making sure it is within the allowable range. + + // convert our desired center point back to our own coordinate space + let boundsCenter = convert(pointToCenterAfterResize, to: zoomView) + + // calculate the content offset that would yield that center point + var offset = CGPoint(x: boundsCenter.x - bounds.size.width/2.0, y: boundsCenter.y - bounds.size.height/2.0) + + // restore offset, adjusted to be within the allowable range + let maxOffset = maximumContentOffset() + let minOffset = minimumContentOffset() + + var realMaxOffset = min(maxOffset.x, offset.x) + offset.x = max(minOffset.x, realMaxOffset) + + realMaxOffset = min(maxOffset.y, offset.y) + offset.y = max(minOffset.y, realMaxOffset) + + contentOffset = offset + } + + private func maximumContentOffset() -> CGPoint { + return CGPoint(x: contentSize.width - bounds.width,y:contentSize.height - bounds.height) + } + + private func minimumContentOffset() -> CGPoint { + return CGPoint.zero + } + + // MARK: - Set up + + open func setup() { + var topSupperView = superview + + while topSupperView?.superview != nil { + topSupperView = topSupperView?.superview + } + + // Make sure views have already layout with precise frame + topSupperView?.layoutIfNeeded() + + DispatchQueue.main.async { + self.refresh() + } + } + + // MARK: - Display image + + @objc open func display(image: UIImage) { + + if let zoomView = zoomView { + zoomView.removeFromSuperview() + } + + zoomView = UIImageView(image: image) + zoomView!.isUserInteractionEnabled = true + addSubview(zoomView!) + + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(ZoomedImageView.doubleTapGestureRecognizer(_:))) + tapGesture.numberOfTapsRequired = 2 + zoomView!.addGestureRecognizer(tapGesture) + + configureImageForSize(image.size) + } + + private func configureImageForSize(_ size: CGSize) { + imageSize = size + contentSize = imageSize + setMaxMinZoomScalesForCurrentBounds() + zoomScale = minimumZoomScale + + switch initialOffset { + case .begining: + contentOffset = CGPoint.zero + case .center: + let xOffset = contentSize.width < bounds.width ? 0 : (contentSize.width - bounds.width)/2 + let yOffset = contentSize.height < bounds.height ? 0 : (contentSize.height - bounds.height)/2 + + switch imageContentMode { + case .aspectFit: + contentOffset = CGPoint.zero + case .aspectFill: + contentOffset = CGPoint(x: xOffset, y: yOffset) + case .heightFill: + contentOffset = CGPoint(x: xOffset, y: 0) + case .widthFill: + contentOffset = CGPoint(x: 0, y: yOffset) + } + } + } + + private func setMaxMinZoomScalesForCurrentBounds() { + // calculate min/max zoomscale + let xScale = bounds.width / imageSize.width // the scale needed to perfectly fit the image width-wise + let yScale = bounds.height / imageSize.height // the scale needed to perfectly fit the image height-wise + + var minScale: CGFloat = 1 + + switch imageContentMode { + case .aspectFill: + minScale = max(xScale, yScale) + case .aspectFit: + minScale = min(xScale, yScale) + case .widthFill: + minScale = xScale + case .heightFill: + minScale = yScale + } + + + let maxScale = maxScaleFromMinScale*minScale + + // don't let minScale exceed maxScale. (If the image is smaller than the screen, we don't want to force it to be zoomed.) + if minScale > maxScale { + minScale = maxScale + } + + maximumZoomScale = maxScale + minimumZoomScale = minScale * 0.999 // the multiply factor to prevent user cannot scroll page while they use this control in UIPageViewController + } + + // MARK: - Gesture + + @objc func doubleTapGestureRecognizer(_ gestureRecognizer: UIGestureRecognizer) { + // zoom out if it bigger than middle scale point. Else, zoom in + if zoomScale >= maximumZoomScale / 2.0 { + setZoomScale(minimumZoomScale, animated: true) + } + else { + let center = gestureRecognizer.location(in: gestureRecognizer.view) + let zoomRect = zoomRectForScale(ZoomedImageView.kZoomInFactorFromMinWhenDoubleTap * minimumZoomScale, center: center) + zoom(to: zoomRect, animated: true) + } + } + + private func zoomRectForScale(_ scale: CGFloat, center: CGPoint) -> CGRect { + var zoomRect = CGRect.zero + + // the zoom rect is in the content view's coordinates. + // at a zoom scale of 1.0, it would be the size of the imageScrollView's bounds. + // as the zoom scale decreases, so more content is visible, the size of the rect grows. + zoomRect.size.height = frame.size.height / scale + zoomRect.size.width = frame.size.width / scale + + // choose an origin so as to get the right center. + zoomRect.origin.x = center.x - (zoomRect.size.width / 2.0) + zoomRect.origin.y = center.y - (zoomRect.size.height / 2.0) + + return zoomRect + } + + open func refresh() { + if let image = zoomView?.image { + display(image: image) + } + } + + // MARK: - Actions + + @objc func changeOrientationNotification() { + // A weird bug that frames are not update right after orientation changed. Need delay a little bit with async. + DispatchQueue.main.async { + self.configureImageForSize(self.imageSize) + self.imageScrollViewDelegate?.imageScrollViewDidChangeOrientation(imageScrollView: self) + } + } +} + +extension ZoomedImageView: UIScrollViewDelegate { + + public func scrollViewDidScroll(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidScroll?(scrollView) + } + + public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewWillBeginDragging?(scrollView) + } + + public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { + imageScrollViewDelegate?.scrollViewWillEndDragging?(scrollView, withVelocity: velocity, targetContentOffset: targetContentOffset) + } + + public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + imageScrollViewDelegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) + } + + public func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewWillBeginDecelerating?(scrollView) + } + + public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidEndDecelerating?(scrollView) + } + + public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidEndScrollingAnimation?(scrollView) + } + + public func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) { + imageScrollViewDelegate?.scrollViewWillBeginZooming?(scrollView, with: view) + } + + public func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) { + imageScrollViewDelegate?.scrollViewDidEndZooming?(scrollView, with: view, atScale: scale) + } + + public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { + return false + } + + public func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) { + imageScrollViewDelegate?.scrollViewDidChangeAdjustedContentInset?(scrollView) + } + + public func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return zoomView + } + + public func scrollViewDidZoom(_ scrollView: UIScrollView) { + adjustFrameToCenter() + imageScrollViewDelegate?.scrollViewDidZoom?(scrollView) + } + +} diff --git a/Sources/GiniMerchantSDK/GiniMerchantSDKVersion.swift b/Sources/GiniMerchantSDK/GiniMerchantSDKVersion.swift new file mode 100644 index 0000000..8230cb9 --- /dev/null +++ b/Sources/GiniMerchantSDK/GiniMerchantSDKVersion.swift @@ -0,0 +1,8 @@ +// +// GiniMerchantSDKVersion.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +public let GiniMerchantSDKVersion = "1.0.0" diff --git a/Sources/GiniMerchantSDK/PrivacyInfo.xcprivacy b/Sources/GiniMerchantSDK/PrivacyInfo.xcprivacy new file mode 100644 index 0000000..d6c32fb --- /dev/null +++ b/Sources/GiniMerchantSDK/PrivacyInfo.xcprivacy @@ -0,0 +1,44 @@ + + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePhotosorVideos + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypePaymentInfo + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAppFunctionality + + + + + diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent01.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent01.colorset/Contents.json new file mode 100644 index 0000000..aada3cc --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent01.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDC", + "green" : "0x9E", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent02.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent02.colorset/Contents.json new file mode 100644 index 0000000..637e999 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent02.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDF", + "green" : "0xB4", + "red" : "0x45" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent03.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent03.colorset/Contents.json new file mode 100644 index 0000000..b848960 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent03.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE1", + "green" : "0xC8", + "red" : "0x89" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent04.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent04.colorset/Contents.json new file mode 100644 index 0000000..2263eb0 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent04.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF6", + "green" : "0xE7", + "red" : "0xBF" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent05.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent05.colorset/Contents.json new file mode 100644 index 0000000..011106b --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Accent05.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xF5", + "red" : "0xE5" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark01.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark01.colorset/Contents.json new file mode 100644 index 0000000..4f1f867 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark01.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x30", + "green" : "0x1D", + "red" : "0x19" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark02.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark02.colorset/Contents.json new file mode 100644 index 0000000..54f7f96 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark02.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x56", + "green" : "0x56", + "red" : "0x56" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark03.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark03.colorset/Contents.json new file mode 100644 index 0000000..e8140c0 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark03.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x80", + "green" : "0x80", + "red" : "0x80" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark04.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark04.colorset/Contents.json new file mode 100644 index 0000000..9a8b2f2 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark04.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8E", + "green" : "0x8A", + "red" : "0x8A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark05.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark05.colorset/Contents.json new file mode 100644 index 0000000..604e097 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark05.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEB", + "green" : "0xE9", + "red" : "0xE8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark06.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark06.colorset/Contents.json new file mode 100644 index 0000000..a7320b8 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark06.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF3", + "green" : "0xF3", + "red" : "0xF3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark07.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark07.colorset/Contents.json new file mode 100644 index 0000000..93ee5b8 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Dark07.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFA", + "green" : "0xFA", + "red" : "0xFA" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback01.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback01.colorset/Contents.json new file mode 100644 index 0000000..cc1c43c --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback01.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x1C", + "green" : "0x1C", + "red" : "0xFA" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback02.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback02.colorset/Contents.json new file mode 100644 index 0000000..96c3714 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback02.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x97", + "green" : "0x95", + "red" : "0xF1" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback03.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback03.colorset/Contents.json new file mode 100644 index 0000000..9dd6e8f --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback03.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xD0", + "green" : "0xD0", + "red" : "0xEC" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback04.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback04.colorset/Contents.json new file mode 100644 index 0000000..886b9e2 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Feedback04.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xDE", + "green" : "0xDE", + "red" : "0xEE" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light01.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light01.colorset/Contents.json new file mode 100644 index 0000000..03cea30 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light01.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF2", + "green" : "0xF2", + "red" : "0xF2" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light02.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light02.colorset/Contents.json new file mode 100644 index 0000000..604e097 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light02.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xEB", + "green" : "0xE9", + "red" : "0xE8" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light03.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light03.colorset/Contents.json new file mode 100644 index 0000000..a7320b8 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light03.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xF3", + "green" : "0xF3", + "red" : "0xF3" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light04.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light04.colorset/Contents.json new file mode 100644 index 0000000..9a8b2f2 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light04.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x8E", + "green" : "0x8A", + "red" : "0x8A" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light05.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light05.colorset/Contents.json new file mode 100644 index 0000000..54f7f96 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light05.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x56", + "green" : "0x56", + "red" : "0x56" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light06.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light06.colorset/Contents.json new file mode 100644 index 0000000..6649025 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light06.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x35", + "green" : "0x35", + "red" : "0x35" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light07.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light07.colorset/Contents.json new file mode 100644 index 0000000..5c9b7b9 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Light07.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x21", + "green" : "0x21", + "red" : "0x21" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success01.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success01.colorset/Contents.json new file mode 100644 index 0000000..6506bfa --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success01.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.280", + "green" : "0.701", + "red" : "0.690" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success02.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success02.colorset/Contents.json new file mode 100644 index 0000000..273ebda --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success02.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.627", + "green" : "0.841", + "red" : "0.836" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success03.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success03.colorset/Contents.json new file mode 100644 index 0000000..e390ae4 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success03.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.803", + "green" : "0.911", + "red" : "0.905" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success04.colorset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success04.colorset/Contents.json new file mode 100644 index 0000000..26df675 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniColors.xcassets/Success04.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.905", + "green" : "0.949", + "red" : "0.949" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/Contents.json new file mode 100644 index 0000000..ebff601 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/Contents.json @@ -0,0 +1,57 @@ +{ + "images" : [ + { + "filename" : "gm.appStoreIcon.black.en 1.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "gm.appStoreIcon.white.en 1.pdf", + "idiom" : "universal" + }, + { + "filename" : "gm.appStoreIcon.black.de.pdf", + "idiom" : "universal", + "locale" : "de" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "gm.appStoreIcon.white.de.pdf", + "idiom" : "universal", + "locale" : "de" + }, + { + "filename" : "gm.appStoreIcon.black.en.pdf", + "idiom" : "universal", + "locale" : "en" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "gm.appStoreIcon.white.en.pdf", + "idiom" : "universal", + "locale" : "en" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "localizable" : true + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.de.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.de.pdf new file mode 100644 index 0000000..ae8641d Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.de.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.en 1.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.en 1.pdf new file mode 100644 index 0000000..11d7fda Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.en 1.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.en.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.en.pdf new file mode 100644 index 0000000..11d7fda Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.black.en.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.de.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.de.pdf new file mode 100644 index 0000000..e3e5371 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.de.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.en 1.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.en 1.pdf new file mode 100644 index 0000000..9da6989 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.en 1.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.en.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.en.pdf new file mode 100644 index 0000000..9da6989 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.appStoreIcon.imageset/gm.appStoreIcon.white.en.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.close.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.close.imageset/Contents.json new file mode 100644 index 0000000..02fe891 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.close.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.close.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.close.imageset/gm.close.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.close.imageset/gm.close.pdf new file mode 100644 index 0000000..5aeb568 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.close.imageset/gm.close.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.giniLogo.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.giniLogo.imageset/Contents.json new file mode 100644 index 0000000..acd4204 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.giniLogo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.giniLogo.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.giniLogo.imageset/gm.giniLogo.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.giniLogo.imageset/gm.giniLogo.pdf new file mode 100644 index 0000000..7820169 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.giniLogo.imageset/gm.giniLogo.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconChevronDown.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconChevronDown.imageset/Contents.json new file mode 100644 index 0000000..1f70990 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconChevronDown.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.iconChevronDown.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconChevronDown.imageset/gm.iconChevronDown.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconChevronDown.imageset/gm.iconChevronDown.pdf new file mode 100644 index 0000000..0f4e4e3 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconChevronDown.imageset/gm.iconChevronDown.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconInputLock.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconInputLock.imageset/Contents.json new file mode 100644 index 0000000..9354a9c --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconInputLock.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.iconInputLock.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconInputLock.imageset/gm.iconInputLock.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconInputLock.imageset/gm.iconInputLock.pdf new file mode 100644 index 0000000..0142d44 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.iconInputLock.imageset/gm.iconInputLock.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.infoCircle.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.infoCircle.imageset/Contents.json new file mode 100644 index 0000000..db37840 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.infoCircle.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.infoCircle.imageset.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.infoCircle.imageset/gm.infoCircle.imageset.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.infoCircle.imageset/gm.infoCircle.imageset.pdf new file mode 100644 index 0000000..e07092d Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.infoCircle.imageset/gm.infoCircle.imageset.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.minus.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.minus.imageset/Contents.json new file mode 100644 index 0000000..05c01af --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.minus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.minus.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.minus.imageset/gm.minus.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.minus.imageset/gm.minus.pdf new file mode 100644 index 0000000..621faf3 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.minus.imageset/gm.minus.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/Contents.json new file mode 100644 index 0000000..2e18f56 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "gm.more.black.pdf", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "gm.more.white.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/gm.more.black.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/gm.more.black.pdf new file mode 100644 index 0000000..0ad555f Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/gm.more.black.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/gm.more.white.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/gm.more.white.pdf new file mode 100644 index 0000000..5d67ffe Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.more.imageset/gm.more.white.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.paymentReviewClose.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.paymentReviewClose.imageset/Contents.json new file mode 100644 index 0000000..5b661d4 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.paymentReviewClose.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.paymentReviewClose.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.paymentReviewClose.imageset/gm.paymentReviewClose.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.paymentReviewClose.imageset/gm.paymentReviewClose.pdf new file mode 100644 index 0000000..07800de Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.paymentReviewClose.imageset/gm.paymentReviewClose.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.plus.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.plus.imageset/Contents.json new file mode 100644 index 0000000..9c3201d --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.plus.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.plus.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.plus.imageset/gm.plus.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.plus.imageset/gm.plus.pdf new file mode 100644 index 0000000..92aa238 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.plus.imageset/gm.plus.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.selectionIndicator.imageset/Contents.json b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.selectionIndicator.imageset/Contents.json new file mode 100644 index 0000000..a846645 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.selectionIndicator.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "gm.selectionIndicator.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.selectionIndicator.imageset/gm.selectionIndicator.pdf b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.selectionIndicator.imageset/gm.selectionIndicator.pdf new file mode 100644 index 0000000..0f14611 Binary files /dev/null and b/Sources/GiniMerchantSDK/Resources/GiniImages.xcassets/gm.selectionIndicator.imageset/gm.selectionIndicator.pdf differ diff --git a/Sources/GiniMerchantSDK/Resources/de.lproj/Localizable.strings b/Sources/GiniMerchantSDK/Resources/de.lproj/Localizable.strings new file mode 100644 index 0000000..9dca26d --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/de.lproj/Localizable.strings @@ -0,0 +1,64 @@ +/* + File.strings + Pods + + // Copyright © 2024 Gini GmbH. All rights reserved. + +*/ +"gini.merchant.reviewscreen.recipient.placeholder" = "Empfänger"; +"gini.merchant.reviewscreen.iban.placeholder" = "IBAN"; +"gini.merchant.reviewscreen.amount.placeholder" = "Betrag"; +"gini.merchant.reviewscreen.usage.placeholder" = "Verwendungszweck"; +"gini.merchant.reviewscreen.infobar.message" = "Bitte prüfen Sie die vorausgefüllten Daten."; +"gini.merchant.reviewscreen.banking.app.button.label" = "Zur Banking App"; +"gini.merchant.errors.default" = "Oh da ist was schief gelaufen. Probiere es noch einmal."; +"gini.merchant.errors.failed.payment.request.creation" = "Oh da ist was schief gelaufen. Probiere es noch einmal."; +"gini.merchant.errors.failed.recipient.non.empty.check" = "Empfänger ist notwendig."; +"gini.merchant.errors.failed.iban.non.empty.check" = "IBAN ist notwendig."; +"gini.merchant.errors.failed.iban.validation.check" = "IBAN ist ungültig."; +"gini.merchant.errors.failed.amount.non.empty.check" = "Ungültig."; +"gini.merchant.errors.failed.purpose.non.empty.check" = "Verwendungszweck ist notwendig."; +"gini.merchant.errors.failed.default.textfield.validation.check" = "Das Feld ist ungültig."; + +"gini.merchant.alert.ok.title" = "OK"; + +"gini.merchant.paymentcomponent.more.information.label" = "Bezahldaten an Banking-App übergeben und dort direkt bezahlen. Mehr Informationen."; +"gini.merchant.paymentcomponent.more.information.underlined.part" = "Mehr Informationen."; +"gini.merchant.paymentcomponent.select.your.bank.label" = "Bank auswählen und bezahlen"; +"gini.merchant.paymentcomponent.pay.invoice.label" = "Rechnung bezahlen"; +"gini.merchant.paymentcomponent.to.banking.app.label" = "Zur Banking App"; +"gini.merchant.paymentcomponent.continue.to.overview.label" = "Weiter zur Übersicht"; +"gini.merchant.paymentcomponent.powered.by.gini.label" = "Powered by"; +"gini.merchant.paymentcomponent.select.bank.label" = "Bank auswählen"; +"gini.merchant.paymentcomponent.payment.providers.list.description" = "Sie können die Rechnung nur bezahlen, wenn Sie ein Konto bei einer der unten aufgeführten Banken haben."; +"gini.merchant.paymentcomponent.payment.info.title.label" = "Mehr Informationen"; +"gini.merchant.paymentcomponent.payment.info.pay.bills.title.label" = "Rechnungen ganz einfach mit der Banking-App bezahlen."; +"gini.merchant.paymentcomponent.payment.info.pay.bills.description.label" = "Arztrechnungen und andere eingereichte Belege können jetzt ganz einfach bezahlt werden.\nDie Bezahldaten wie IBAN, Betrag, Empfänger und Verwendungszweck werden nahtlos in die Banking-App übergeben und dort nur noch bestätigt.\nSie können die Rechnung auch parken und innerhalb von 3 Monaten nach Upload begleichen.\nDie für die Zahlung notwendigen Daten werden verschlüsselt und sicher an Ihre Banking-App übertragen. Es gelten die Datenschutzbestimmungen Ihrer Bank.\nUnterstützt von den größten Bankinstituten. Integration durch Gini[LINK]."; +"gini.merchant.paymentcomponent.payment.info.pay.bills.description.clickable.text" = "Gini[LINK]"; +"gini.merchant.paymentcomponent.payment.info.questions.title.label" = "Häufig gestellte Fragen"; +"gini.merchant.paymentcomponent.payment.info.questions.question.1" = "Kann ich Rechnungen einreichen und später bezahlen?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.2" = "Ist der Service kostenlos?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.3" = "Sind meine Daten sicher?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.4" = "Wer oder Was ist Gini?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.5" = "Welches Format muss die eingereichte Rechnung haben?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.6" = "Wie erkenne ich, welche Banken unterstützt werden?"; +"gini.merchant.paymentcomponent.payment.info.questions.answer.1" = "Ja, das ist möglich. So können insbesondere größere Beträge erst nach der erfolgten Erstattung bezahlt werden."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.2" = "Ja, die Übertragung der Bezahldaten in die gewählte Banking-App ist kostenlos. Für die eigentliche Überweisung können je nach Kontenmodell Gebühren anfallen – wenden Sie sich bitte für weitere Details an Ihre Bank."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.3" = "Ja! Die Übertragung der Daten an die Banking-App erfolgt verschlüsselt über einen Server von Gini. Gini nimmt die Zahlungsdaten entgegen und leitet sie in die Banking-App weiter. Dort besteht stets die Möglichkeit, die Zahlungsdaten zu überprüfen, bevor die Überweisung ausgeführt wird. Gini hat hierfür sowohl mit der Versicherung als auch mit den Banken Verträge geschlossen, und wir lassen uns regelmäßig von beiden auditieren."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.4" = "Gini macht das Bezahlen automagisch einfach. Das Münchner Unternehmen, das hinter der Fotoüberweisung steht, arbeitet mit den führenden deutschen Banken und Versicherungen zusammen, um die direkte Zahlung mit der Hausbank zu ermöglichen.\nGini ist für maximale Datensicherheit ISO 27001 zertifiziert und betreibt eigene Rechner in einem ISO 27001 zertifizierten Rechenzentrum in Deutschland. Weitere Informationen finden Sie in der Datenschutzerklärung[LINK] sowie auf der Website von Gini[LINK]."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.5" = "Ob Foto einer Rechnung, Screenshot, oder digitales PDF – jedes Format ist geeignet. Bitte achten Sie lediglich darauf, dass alle Bezahlinformationen, wie IBAN, Empfänger, Verwendungszweck und Betrag sichtbar und nicht abgeschnitten sind."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.6" = "Im Bank-Auswahlmenü werden die Banken angezeigt, die die Gini-Bezahlfunktion unterstützen. Voraussetzung für die Nutzung ist, dass Sie die mobile Banking-App ihrer Bank auf demselben Smartphone oder Tablet installiert haben, auf dem Sie die Versicherungs-App nutzen. Sollten Sie keine der Banking-Apps installiert haben, können Sie diese aus dem Apple App- oder Google Playstore herunterladen. Eine anschließende Aktivierung der Banking-App kann nötig sein."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.clickable.text" = "Datenschutzerklärung[LINK]"; +"gini.merchant.paymentcomponent.payment.info.gini.link" = "https://gini.net/"; +"gini.merchant.paymentcomponent.payment.info.gini.privacypolicy.link" = ""; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.title" = "Rechnungsdaten bereit zum Teilen mit der [BANK]"; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.notes.description" = "Hinweis: Bitte aktualisieren oder installieren Sie zunächst die [BANK]-App aus dem App-Store."; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.tip.description" = "Tipp: Tippen Sie auf 'Weiter', um die Zahlung in der [BANK]-App abzuschließen."; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.continue.button.text" = "Weiter"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.title" = "Rechnungsdaten bereit zum Teilen mit der [BANK]"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.description" = "Im nächsten Schritt wählen Sie die [BANK]-App auf der Seite aus, um Ihre Zahlung in der [BANK]-App abzuschließen."; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.app" = "App"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.more" = "Mehr"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.tip.description" = "Tipp: Wenn Sie die App nicht sehen, scrollen Sie oder tippen Sie auf \"Mehr\", bis Sie die [BANK]-App finden. Wenn Sie die [BANK]-App noch nicht installiert haben, laden Sie sie im Store herunter."; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.tip.underlined.part" = "laden Sie sie im Store herunter"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.continue.button.text" = "Weiter"; diff --git a/Sources/GiniMerchantSDK/Resources/en.lproj/Localizable.strings b/Sources/GiniMerchantSDK/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..4e2c0f4 --- /dev/null +++ b/Sources/GiniMerchantSDK/Resources/en.lproj/Localizable.strings @@ -0,0 +1,64 @@ +/* + File.strings + Pods + + // Copyright © 2024 Gini GmbH. All rights reserved. + +*/ +"gini.merchant.reviewscreen.recipient.placeholder" = "Recipient"; +"gini.merchant.reviewscreen.iban.placeholder" = "IBAN"; +"gini.merchant.reviewscreen.amount.placeholder" = "Amount"; +"gini.merchant.reviewscreen.usage.placeholder" = "Reference number"; +"gini.merchant.reviewscreen.banking.app.button.label" = "To the banking app"; +"gini.merchant.reviewscreen.infobar.message" = "Please check the pre-filled data."; +"gini.merchant.errors.default" = "Oops something went wrong. Please try again."; +"gini.merchant.errors.failed.payment.request.creation" = "Oops something went wrong. Please try again."; +"gini.merchant.errors.failed.recipient.non.empty.check" = "Recipient is required."; +"gini.merchant.errors.failed.iban.non.empty.check" = "IBAN is required."; +"gini.merchant.errors.failed.iban.validation.check" = "IBAN is not valid."; +"gini.merchant.errors.failed.amount.non.empty.check" = "Invalid."; +"gini.merchant.errors.failed.purpose.non.empty.check" = "Purpose is required."; +"gini.merchant.errors.failed.default.textfield.validation.check" = "The field is not valid."; + +"gini.merchant.alert.ok.title" = "OK"; + +"gini.merchant.paymentcomponent.more.information.label" = "Transfer payment data to the banking app and pay there directly. More information."; +"gini.merchant.paymentcomponent.more.information.underlined.part" = "More information."; +"gini.merchant.paymentcomponent.select.your.bank.label" = "Select your bank to pay"; +"gini.merchant.paymentcomponent.pay.invoice.label" = "Pay the invoice"; +"gini.merchant.paymentcomponent.to.banking.app.label" = "To the banking app"; +"gini.merchant.paymentcomponent.continue.to.overview.label" = "Continue to overview"; +"gini.merchant.paymentcomponent.powered.by.gini.label" = "Powered by"; +"gini.merchant.paymentcomponent.select.bank.label" = "Select bank"; +"gini.merchant.paymentcomponent.payment.providers.list.description" = "You can only pay the bill if you have an account with one of the banks listed below."; +"gini.merchant.paymentcomponent.payment.info.title.label" = "More information"; +"gini.merchant.paymentcomponent.payment.info.pay.bills.title.label" = "Pay bills easily with the banking app."; +"gini.merchant.paymentcomponent.payment.info.pay.bills.description.label" = "Medical bills and other submitted receipts can now be paid very easily.\nThe payment data such as IBAN, amount, recipient and purpose are seamlessly transferred to the banking app, and the payment only needs to be confirmed there.\nYou can also park the invoice and pay it within 3 months after uploading.\nThe data is encrypted and transferred securely to your banking app. The data protection regulations of your bank apply.\nSupported by the largest banks. Integration by Gini[LINK]."; +"gini.merchant.paymentcomponent.payment.info.pay.bills.description.clickable.text" = "Gini[LINK]"; +"gini.merchant.paymentcomponent.paymentinfo.questions.title.label" = "Frequently asked questions"; +"gini.merchant.paymentcomponent.payment.info.questions.question.1" = "Can I submit invoices and pay them later?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.2" = "Is the service free of charge?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.3" = "Is my data secure?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.4" = "Who or what is Gini?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.5" = "What format must the submitted invoice have?"; +"gini.merchant.paymentcomponent.payment.info.questions.question.6" = "How do I know which banks are supported?"; +"gini.merchant.paymentcomponent.payment.info.questions.answer.1" = "Yes, this is possible. Larger amounts in particular can be paid after the reimbursement has been made."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.2" = "Yes, transferring the payment data to the selected banking app is free of charge. Charges may apply for the actual transfer, depending on the account model - please contact your bank for further details."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.3" = "Yes, the data is transferred to the banking app in encrypted form via a Gini server. Gini receives the payment data and forwards it to the banking app. There you always have the option of checking the payment data before the transfer is executed. Gini has concluded contracts with the insurance company and the banks for this purpose, and both regularly audit us."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.4" = "Gini makes simplifies payments. The Munich-based company behind the photo payment works with the leading German banks and insurance companies to enable direct payment with your bank.\nGini is ISO 27001 certified for maximum data security and operates its own server machines in an ISO 27001 certified data center in Germany. Further information can be found in the privacy policy[LINK] and on the Gini[LINK] website."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.5" = "Whether a photo of an invoice, screenshot or digital PDF - any format is suitable. Please just make sure that all payment information such as IBAN, recipient, purpose and amount are visible and not cut off."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.6" = "The banks that support the Gini payment function are displayed in the bank selection menu. To use it, you must have installed your bank's mobile banking app on the same smartphone or tablet on which you use the insurance app. If you have not installed any of the banking apps, you can download them from the Apple App Store or Google Playstore. Subsequent activation of the banking app may be necessary."; +"gini.merchant.paymentcomponent.payment.info.questions.answer.clickable.text" = "privacy policy[LINK]"; +"gini.merchant.paymentcomponent.payment.info.gini.link" = "https://gini.net/en/"; +"gini.merchant.paymentcomponent.payment.info.gini.privacypolicy.link" = ""; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.title" = "Invoice data ready to share with the [BANK] bank"; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.notes.description" = "Note: You must first update or install the [BANK] app from the App store"; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.tip.description" = "Tip: Tap 'Forward' to complete the payment in the [BANK] app."; +"gini.merchant.paymentcomponent.install.app.bottom.sheet.continue.button.text" = "Forward"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.title" = "Invoice data ready to share"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.description" = "In the next step, select the [BANK] app to open and complete your payment in the banking app."; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.app" = "App"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.more" = "More"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.tip.description" = "Tip: If you don't see the app, scroll or tap \"More\" until you find the [BANK] app. If you haven't installed the [BANK] app yet, download it from the store."; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.tip.underlined.part" = "download it from the store"; +"gini.merchant.paymentcomponent.share.invoice.bottom.sheet.continue.button.text" = "Forward"; diff --git a/Tests/GiniMerchantSDKTests/GiniMerchant+Test.swift b/Tests/GiniMerchantSDKTests/GiniMerchant+Test.swift new file mode 100644 index 0000000..1aaf81d --- /dev/null +++ b/Tests/GiniMerchantSDKTests/GiniMerchant+Test.swift @@ -0,0 +1,18 @@ +// +// GiniMerchant+Test.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +@testable import GiniHealthAPILibrary +@testable import GiniMerchantSDK + +extension GiniMerchant { + convenience init(documentService: GiniHealthAPILibrary.DocumentService, + paymentService: GiniHealthAPILibrary.PaymentService) { + let giniHealthAPI = GiniHealthAPI(documentService: documentService, + paymentService: paymentService) + self.init(giniApiLib: giniHealthAPI) + } +} diff --git a/Tests/GiniMerchantSDKTests/GiniMerchantSDKTests.swift b/Tests/GiniMerchantSDKTests/GiniMerchantSDKTests.swift new file mode 100644 index 0000000..0ee0c8f --- /dev/null +++ b/Tests/GiniMerchantSDKTests/GiniMerchantSDKTests.swift @@ -0,0 +1,372 @@ +// +// GiniMerchantTests.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import XCTest +@testable import GiniUtilites +@testable import GiniMerchantSDK +@testable import GiniHealthAPILibrary + +final class GiniMerchantTests: XCTestCase { + + var giniHealthAPI: GiniHealthAPI! + var giniMerchant: GiniMerchant! + private let versionAPI = 1 + + override func setUp() { + let sessionManagerMock = MockSessionManager() + let documentService = DefaultDocumentService(sessionManager: sessionManagerMock, apiDomain: .merchant, apiVersion: versionAPI) + let paymentService = PaymentService(sessionManager: sessionManagerMock, apiDomain: .merchant, apiVersion: versionAPI) + giniHealthAPI = GiniHealthAPI(documentService: documentService, paymentService: paymentService) + giniMerchant = GiniMerchant(giniApiLib: giniHealthAPI) + } + + override func tearDown() { + giniMerchant = nil + super.tearDown() + } + + func testSetConfiguration() throws { + // Given + let configuration = GiniMerchantConfiguration() + + // When + giniMerchant.setConfiguration(configuration) + + // Then + XCTAssertEqual(GiniMerchantConfiguration.shared, configuration) + } + + func testFetchBankingApps_Success() { + // Given + let expectedProviders: [GiniMerchantSDK.PaymentProvider]? = loadProviders(fileName: "providers") + + // When + let expectation = self.expectation(description: "Fetching banking apps") + var receivedProviders: [GiniMerchantSDK.PaymentProvider]? + giniMerchant.fetchBankingApps { result in + switch result { + case .success(let providers): + receivedProviders = providers + case .failure(_): + receivedProviders = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNotNil(receivedProviders) + XCTAssertEqual(receivedProviders?.count, expectedProviders?.count) + XCTAssertEqual(receivedProviders, expectedProviders) + } + + func testCheckIfDocumentIsPayable_Success() { + // Given + let fileName = "extractionResultWithIBAN" + let expectedExtractions: GiniMerchantSDK.ExtractionsContainer? = GiniMerchantSDKTests.load(fromFile: fileName) + guard let expectedExtractions else { + XCTFail("Error loading file: `\(fileName).json`") + return + } + let expectedExtractionsResult = ExtractionResult(extractionsContainer: expectedExtractions) + let expectedIsPayable = expectedExtractionsResult.extractions.first(where: { $0.name == "iban" })?.value.isNotEmpty + + // When + let expectation = self.expectation(description: "Checking if document is payable") + var isDocumentPayable: Bool? + giniMerchant.checkIfDocumentIsPayable(docId: MockSessionManager.payableDocumentID) { result in + switch result { + case .success(let isPayable): + isDocumentPayable = isPayable + case .failure(_): + isDocumentPayable = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertEqual(expectedIsPayable, isDocumentPayable) + } + + func testCheckIfDocumentIsNotPayable_Success() { + // Given + let fileName = "extractionResultWithIBAN" + let expectedExtractions: GiniMerchantSDK.ExtractionsContainer? = GiniMerchantSDKTests.load(fromFile: fileName) + guard let expectedExtractions else { + XCTFail("Error loading file: `\(fileName).json`") + return + } + let expectedExtractionsResult = ExtractionResult(extractionsContainer: expectedExtractions) + let expectedIsPayable = expectedExtractionsResult.extractions.first(where: { $0.name == "iban" })?.value.isEmpty + + // When + let expectation = self.expectation(description: "Checking if document is not payable") + var isDocumentPayable: Bool? + giniMerchant.checkIfDocumentIsPayable(docId: MockSessionManager.notPayableDocumentID) { result in + switch result { + case .success(let isPayable): + isDocumentPayable = isPayable + case .failure(_): + isDocumentPayable = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertEqual(expectedIsPayable, isDocumentPayable) + } + + func testCheckIfDocumentIsPayable_Failure() { + // When + let expectation = self.expectation(description: "Checking if request fails") + var isDocumentPayable: Bool? + giniMerchant.checkIfDocumentIsPayable(docId: MockSessionManager.failurePayableDocumentID) { result in + switch result { + case .success(let isPayable): + isDocumentPayable = isPayable + case .failure(_): + isDocumentPayable = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNil(isDocumentPayable) + } + + func testPollDocument_Success() { + // Given + let healthDocument: GiniHealthAPILibrary.Document = GiniMerchantSDKTests.load(fromFile: "document1")! + let expectedDocument: GiniMerchantSDK.Document? = GiniMerchantSDK.Document(healthDocument: healthDocument) + + // When + let expectation = self.expectation(description: "Polling document") + var receivedDocument: GiniMerchantSDK.Document? + giniMerchant.pollDocument(docId: MockSessionManager.payableDocumentID) { result in + switch result { + case .success(let document): + receivedDocument = document + case .failure(_): + receivedDocument = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNotNil(receivedDocument) + XCTAssertEqual(receivedDocument, expectedDocument) + } + + func testPollDocument_Failure() { + // When + let expectation = self.expectation(description: "Polling failure document") + var receivedDocument: GiniMerchantSDK.Document? + giniMerchant.pollDocument(docId: MockSessionManager.missingDocumentID) { result in + switch result { + case .success(let document): + receivedDocument = document + case .failure(_): + receivedDocument = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNil(receivedDocument) + } + + func testGetExtractions_Success() { + // Given + let fileName = "extractionsWithPayment" + let expectedExtractionContainer: GiniMerchantSDK.ExtractionsContainer? = GiniMerchantSDKTests.load(fromFile: fileName) + guard let expectedExtractionContainer else { + XCTFail("Error loading file: `\(fileName).json`") + return + } + let expectedExtractions: [GiniMerchantSDK.Extraction] = ExtractionResult(extractionsContainer: expectedExtractionContainer).payment?.first ?? [] + + // When + let expectation = self.expectation(description: "Getting extractions") + var receivedExtractions: [GiniMerchantSDK.Extraction]? + giniMerchant.getExtractions(docId: MockSessionManager.extractionsWithPaymentDocumentID) { result in + switch result { + case .success(let extractions): + receivedExtractions = extractions + case .failure(_): + receivedExtractions = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNotNil(receivedExtractions) + XCTAssertEqual(receivedExtractions?.count, expectedExtractions.count) + } + + func testGetExtractions_Failure() { + // When + let expectation = self.expectation(description: "Extraction failure") + var receivedExtractions: [GiniMerchantSDK.Extraction]? + giniMerchant.getExtractions(docId: MockSessionManager.failurePayableDocumentID) { result in + switch result { + case .success(let extractions): + receivedExtractions = extractions + case .failure(_): + receivedExtractions = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNil(receivedExtractions) + } + + func testCreatePaymentRequest_Success() { + // Given + let expectedPaymentRequestID = MockSessionManager.paymentRequestId + + // When + let expectation = self.expectation(description: "Creating payment request") + var receivedRequestId: String? + let paymentInfo = PaymentInfo(recipient: "Uno Flüchtlingshilfe", iban: "DE78370501980020008850", bic: "COLSDE33", amount: "1.00:EUR", purpose: "ReNr 12345", paymentUniversalLink: "ginipay-test://paymentRequester", paymentProviderId: "b09ef70a-490f-11eb-952e-9bc6f4646c57") + giniMerchant.createPaymentRequest(paymentInfo: paymentInfo, completion: { result in + switch result { + case .success(let requestId): + receivedRequestId = requestId + case .failure(_): + receivedRequestId = nil + } + expectation.fulfill() + }) + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNotNil(receivedRequestId) + XCTAssertEqual(receivedRequestId, expectedPaymentRequestID) + } + + func testOpenLink_Success() { + let mockUIApplication = MockUIApplication(canOpen: true) + let urlOpener = URLOpener(mockUIApplication) + let waitForWebsiteOpen = expectation(description: "Link was opened") + + giniMerchant.openPaymentProviderApp(requestID: "123", universalLink: "ginipay-bank://", urlOpener: urlOpener, completion: { open in + waitForWebsiteOpen.fulfill() + XCTAssert(open, "testOpenLink - FAILED to open link") + }) + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testOpenLink_Failure() { + let mockUIApplication = MockUIApplication(canOpen: false) + let urlOpener = URLOpener(mockUIApplication) + let waitForWebsiteOpen = expectation(description: "Link was not opened") + + giniMerchant.openPaymentProviderApp(requestID: "123", universalLink: "ginipay-bank://", urlOpener: urlOpener, completion: { open in + waitForWebsiteOpen.fulfill() + XCTAssert(open == false, "testOpenLink - MANAGED to open link") + }) + + waitForExpectations(timeout: 0.1, handler: nil) + } + + func testSetDocumentForReview_Success() { + // Given + let fileName = "extractionsWithPayment" + let expectedExtractionContainer: GiniMerchantSDK.ExtractionsContainer? = GiniMerchantSDKTests.load(fromFile: fileName) + guard let expectedExtractionContainer else { + XCTFail("Error loading file: `\(fileName).json`") + return + } + let expectedExtractions: [GiniMerchantSDK.Extraction] = ExtractionResult(extractionsContainer: expectedExtractionContainer).payment?.first ?? [] + + // When + let expectation = self.expectation(description: "Setting document for review") + var receivedExtractions: [GiniMerchantSDK.Extraction]? + giniMerchant.setDocumentForReview(documentId: MockSessionManager.extractionsWithPaymentDocumentID) { result in + switch result { + case .success(let extractions): + receivedExtractions = extractions + case .failure(_): + receivedExtractions = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNotNil(receivedExtractions) + XCTAssertEqual(receivedExtractions?.count, expectedExtractions.count) + } + + func testFetchDataForReview_Success() { + // Given + let fileName = "extractionsWithPayment" + let expectedExtractionContainer: GiniMerchantSDK.ExtractionsContainer? = GiniMerchantSDKTests.load(fromFile: fileName) + guard let expectedExtractionContainer else { + XCTFail("Error loading file: `\(fileName).json`") + return + } + let expectedExtractions: [GiniMerchantSDK.Extraction] = ExtractionResult(extractionsContainer: expectedExtractionContainer).payment?.first ?? [] + let documentFileName = "document4" + + let healthDocument: GiniHealthAPILibrary.Document = GiniMerchantSDKTests.load(fromFile: documentFileName)! + let expectedDocument: GiniMerchantSDK.Document? = GiniMerchantSDK.Document(healthDocument: healthDocument) + + guard let expectedDocument else { + XCTFail("Error loading file: `\(documentFileName).json`") + return + } + let expectedDatForReview = DataForReview(document: expectedDocument, extractions: expectedExtractions) + + // When + let expectation = self.expectation(description: "Fetching data for review") + var receivedDataForReview: DataForReview? + giniMerchant.fetchDataForReview(documentId: MockSessionManager.extractionsWithPaymentDocumentID) { result in + switch result { + case .success(let dataForReview): + receivedDataForReview = dataForReview + case .failure(_): + receivedDataForReview = nil + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNotNil(receivedDataForReview) + XCTAssertEqual(receivedDataForReview?.document, expectedDatForReview.document) + XCTAssertEqual(receivedDataForReview?.extractions.count, expectedDatForReview.extractions.count) + } + + func testFetchDataForReview_Failure() { + // When + let expectation = self.expectation(description: "Failure fetching data for review") + var receivedError: GiniMerchantError? + giniMerchant.fetchDataForReview(documentId: MockSessionManager.missingDocumentID) { result in + switch result { + case .success(_): + receivedError = nil + case .failure(let error): + receivedError = error + } + expectation.fulfill() + } + waitForExpectations(timeout: 1, handler: nil) + + // Then + XCTAssertNotNil(receivedError) + } +} diff --git a/Tests/GiniMerchantSDKTests/MockHelpers/FileLoader.swift b/Tests/GiniMerchantSDKTests/MockHelpers/FileLoader.swift new file mode 100644 index 0000000..19421bc --- /dev/null +++ b/Tests/GiniMerchantSDKTests/MockHelpers/FileLoader.swift @@ -0,0 +1,26 @@ +// +// FileLoader.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import Foundation + +struct FileLoader { + static func loadFile(withName mockFileName: String, ofType fileType: String) -> Data? { + guard let filePath = Bundle.module.path(forResource: mockFileName, ofType: fileType) else { + print("File not found.") + return nil + } + + let fileURL = URL(fileURLWithPath: filePath) + do { + let data = try Data(contentsOf: fileURL) + return data + } catch { + print("Error loading file:", error) + return nil + } + } +} diff --git a/Tests/GiniMerchantSDKTests/MockHelpers/MockPaymentComponents.swift b/Tests/GiniMerchantSDKTests/MockHelpers/MockPaymentComponents.swift new file mode 100644 index 0000000..06c1244 --- /dev/null +++ b/Tests/GiniMerchantSDKTests/MockHelpers/MockPaymentComponents.swift @@ -0,0 +1,103 @@ +// +// MockPaymentComponents.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +@testable import GiniMerchantSDK +@testable import GiniHealthAPILibrary + +class MockPaymentComponents: PaymentComponentsProtocol { + + var isLoading: Bool = false + var selectedPaymentProvider: GiniMerchantSDK.PaymentProvider? + + private var giniMerchant: GiniMerchant + private var paymentProviders: GiniMerchantSDK.PaymentProviders = [] + private var installedPaymentProviders: GiniMerchantSDK.PaymentProviders = [] + private let giniMerchantConfiguration = GiniMerchantConfiguration.shared + + init(giniMerchant: GiniMerchant) { + self.giniMerchant = giniMerchant + } + + func loadPaymentProviders() { + isLoading = false + guard let paymentProviderResponse: PaymentProviderResponse = load(fromFile: "provider") else { + return + } + if let iconData = Data(url: URL(string: paymentProviderResponse.iconLocation)) { + let openWithPlatforms = paymentProviderResponse.openWithSupportedPlatforms.compactMap { GiniMerchantSDK.PlatformSupported(rawValue: $0.rawValue) } + let gpcSupportedPlatforms = paymentProviderResponse.gpcSupportedPlatforms.compactMap { GiniMerchantSDK.PlatformSupported(rawValue: $0.rawValue) } + let colors = GiniMerchantSDK.ProviderColors(background: paymentProviderResponse.colors.background, + text: paymentProviderResponse.colors.text) + + let provider = GiniMerchantSDK.PaymentProvider(id: paymentProviderResponse.id, + name: paymentProviderResponse.name, + appSchemeIOS: paymentProviderResponse.appSchemeIOS, + minAppVersion: nil, + colors: colors, + iconData: iconData, + appStoreUrlIOS: paymentProviderResponse.appStoreUrlIOS, + universalLinkIOS: paymentProviderResponse.universalLinkIOS, + index: paymentProviderResponse.index, + gpcSupportedPlatforms: gpcSupportedPlatforms, + openWithSupportedPlatforms: openWithPlatforms) + + selectedPaymentProvider = provider + } + } + + func checkIfDocumentIsPayable(docId: String, completion: @escaping (Result) -> Void) { + switch docId { + case MockSessionManager.payableDocumentID: + completion(.success(true)) + case MockSessionManager.notPayableDocumentID: + completion(.success(false)) + case MockSessionManager.missingDocumentID: + completion(.failure(.apiError(GiniError.decorator(.noResponse)))) + default: + fatalError("Document id not handled in tests") + } + } + + func paymentView(documentId: String?) -> UIView { + let viewModel = PaymentComponentViewModel(paymentProvider: selectedPaymentProvider, giniMerchantConfiguration: giniMerchantConfiguration) + viewModel.documentId = documentId + let view = PaymentComponentView() + view.viewModel = viewModel + return view + } + + func bankSelectionBottomSheet() -> UIViewController { + let paymentProvidersBottomViewModel = BanksBottomViewModel(paymentProviders: paymentProviders, + selectedPaymentProvider: selectedPaymentProvider) + let paymentProvidersBottomView = BanksBottomView(viewModel: paymentProvidersBottomViewModel) + return paymentProvidersBottomView + } + + func loadPaymentReviewScreenFor(documentID: String?, paymentInfo: GiniMerchantSDK.PaymentInfo?, trackingDelegate: (any GiniMerchantSDK.GiniMerchantTrackingDelegate)?, completion: @escaping (UIViewController?, GiniMerchantSDK.GiniMerchantError?) -> Void) { + switch documentID { + case MockSessionManager.payableDocumentID: + completion(PaymentReviewViewController(), nil) + case MockSessionManager.missingDocumentID: + completion(nil, .apiError(GiniError.decorator(.noResponse))) + default: + fatalError("Document id not handled in tests") + } + } + + func paymentInfoViewController() -> UIViewController { + let paymentInfoViewController = PaymentInfoViewController() + let paymentInfoViewModel = PaymentInfoViewModel(paymentProviders: paymentProviders) + paymentInfoViewController.viewModel = paymentInfoViewModel + return paymentInfoViewController + } + + func paymentViewBottomSheet(documentID: String?) -> UIViewController { + let paymentComponentBottomView = PaymentComponentBottomView(paymentView: paymentView(documentId: documentID)) + return paymentComponentBottomView + } +} diff --git a/Tests/GiniMerchantSDKTests/MockHelpers/MockSessionManager.swift b/Tests/GiniMerchantSDKTests/MockHelpers/MockSessionManager.swift new file mode 100644 index 0000000..2e1ba3b --- /dev/null +++ b/Tests/GiniMerchantSDKTests/MockHelpers/MockSessionManager.swift @@ -0,0 +1,118 @@ +// +// MockSessionManager.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +@testable import GiniHealthAPILibrary + +final class MockSessionManager: SessionManagerProtocol { + static let payableDocumentID = "626626a0-749f-11e2-bfd6-000000000001" + static let notPayableDocumentID = "626626a0-749f-11e2-bfd6-000000000002" + static let failurePayableDocumentID = "626626a0-749f-11e2-bfd6-000000000003" + static let missingDocumentID = "626626a0-749f-11e2-bfd6-000000000000" + static let extractionsWithPaymentDocumentID = "626626a0-749f-11e2-bfd6-000000000004" + static let paymentRequestId = "b09ef70a-490f-11eb-952e-9bc6f4646c57" + + func upload(resource: T, data: Data, cancellationToken: GiniHealthAPILibrary.CancellationToken?, completion: @escaping GiniHealthAPILibrary.CompletionResult) where T : GiniHealthAPILibrary.Resource { + // + } + + func download(resource: T, cancellationToken: GiniHealthAPILibrary.CancellationToken?, completion: @escaping GiniHealthAPILibrary.CompletionResult) where T : GiniHealthAPILibrary.Resource { + if let apiMethod = resource.method as? APIMethod { + switch apiMethod { + case .file(_): + let imageData = UIImage(named: "Gini-Test-Payment-Provider", in: Bundle.module, compatibleWith: nil)?.pngData() + if let imageData = imageData as? T.ResponseType { + completion(.success(imageData)) + } + default: + break + } + } + } + + func logIn(completion: @escaping (Result) -> Void) { + // + } + + func logOut() { + // + } + + func data(resource: T, cancellationToken: GiniHealthAPILibrary.CancellationToken?, completion: @escaping GiniHealthAPILibrary.CompletionResult) where T : GiniHealthAPILibrary.Resource { + if let apiMethod = resource.method as? APIMethod { + switch apiMethod { + case .document(let id): + switch (id, resource.params.method) { + case (MockSessionManager.payableDocumentID, .get): + let document: Document? = load(fromFile: "document1", type: "json") + if let document = document as? T.ResponseType { + completion(.success(document)) + } + case (MockSessionManager.notPayableDocumentID, .get): + let document: Document? = load(fromFile: "document2", type: "json") + if let document = document as? T.ResponseType { + completion(.success(document)) + } + case (MockSessionManager.failurePayableDocumentID, .get): + let document: Document? = load(fromFile: "document3", type: "json") + if let document = document as? T.ResponseType { + completion(.success(document)) + } + case (MockSessionManager.missingDocumentID, .get): + completion(.failure(.notFound(response: nil, data: nil))) + case (MockSessionManager.extractionsWithPaymentDocumentID, .get): + let document: Document? = load(fromFile: "document4", type: "json") + if let document = document as? T.ResponseType { + completion(.success(document)) + } + default: + fatalError("Document id not found in tests") + } + case .createPaymentRequest: + if let paymentRequestId = MockSessionManager.paymentRequestId as? T.ResponseType { + completion(.success(paymentRequestId)) + } + case .paymentProvider(_): + let providerResponse: PaymentProviderResponse? = load(fromFile: "provider") + if let providerResponse = providerResponse as? T.ResponseType { + completion(.success(providerResponse)) + } + case .paymentProviders: + let paymentProvidersResponse: [PaymentProviderResponse]? = load(fromFile: "providers") + if let paymentProvidersResponse = paymentProvidersResponse as? T.ResponseType { + completion(.success(paymentProvidersResponse)) + } + case .extractions(let documentId): + switch (documentId, resource.params.method) { + case (MockSessionManager.payableDocumentID, .get): + let extractionResults: ExtractionsContainer? = load(fromFile: "extractionResultWithIBAN") + if let extractionResults = extractionResults as? T.ResponseType { + completion(.success(extractionResults)) + } + case (MockSessionManager.notPayableDocumentID, .get): + let extractionResults: ExtractionsContainer? = load(fromFile: "extractionResultWithoutIBAN") + if let extractionResults = extractionResults as? T.ResponseType { + completion(.success(extractionResults)) + } + case (MockSessionManager.failurePayableDocumentID, .get): + completion(.failure(.noResponse)) + case (MockSessionManager.extractionsWithPaymentDocumentID, .get): + let extractionResults: ExtractionsContainer? = load(fromFile: "extractionsWithPayment") + if let extractionResults = extractionResults as? T.ResponseType { + completion(.success(extractionResults)) + } + default: + fatalError("Document id not found in tests") + } + default: + let error = GiniError.unknown(response: nil, data: nil) + completion(.failure(error)) + } + } + } +} + diff --git a/Tests/GiniMerchantSDKTests/MockHelpers/MockUIApplication.swift b/Tests/GiniMerchantSDKTests/MockHelpers/MockUIApplication.swift new file mode 100644 index 0000000..1f29a1f --- /dev/null +++ b/Tests/GiniMerchantSDKTests/MockHelpers/MockUIApplication.swift @@ -0,0 +1,29 @@ +// +// MockUIApplication.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +@testable import GiniUtilites + +struct MockUIApplication: URLOpenerProtocol { + var canOpen: Bool + + func canOpenURL(_ url: URL) -> Bool { + switch url.absoluteString { + case "ginipay-bank://", "ginipay-ingdiba://": + // In tests we "open" Gini-Test-Payment-Provider and ING-DiBa + return true + default: + return canOpen + } + } + + func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey : Any], completionHandler completion: ((Bool) -> Void)?) { + if canOpen { + completion?(true) + } + } +} diff --git a/Tests/GiniMerchantSDKTests/MockHelpers/Utils.swift b/Tests/GiniMerchantSDKTests/MockHelpers/Utils.swift new file mode 100644 index 0000000..a5b8a18 --- /dev/null +++ b/Tests/GiniMerchantSDKTests/MockHelpers/Utils.swift @@ -0,0 +1,45 @@ +// +// Utils.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import UIKit +import GiniHealthAPILibrary +import GiniMerchantSDK + +func loadProviders(fileName: String) -> GiniMerchantSDK.PaymentProviders? { + var providers: GiniMerchantSDK.PaymentProviders = [] + let providersResponse: [PaymentProviderResponse]? = load(fromFile: fileName) + guard let providersResponse else { return nil } + for providerResponse in providersResponse { + let imageData = UIImage(named: "Gini-Test-Payment-Provider", in: Bundle.module, compatibleWith: nil)?.pngData() + let openWithPlatforms = providerResponse.openWithSupportedPlatforms.compactMap { GiniMerchantSDK.PlatformSupported(rawValue: $0.rawValue) } + let gpcSupportedPlatforms = providerResponse.gpcSupportedPlatforms.compactMap { GiniMerchantSDK.PlatformSupported(rawValue: $0.rawValue) } + let colors = GiniMerchantSDK.ProviderColors(background: providerResponse.colors.background, + text: providerResponse.colors.text) + + let provider = GiniMerchantSDK.PaymentProvider(id: providerResponse.id, + name: providerResponse.name, + appSchemeIOS: providerResponse.appSchemeIOS, + minAppVersion: nil, + colors: colors, + iconData: imageData ?? Data(), + appStoreUrlIOS: providerResponse.appStoreUrlIOS, + universalLinkIOS: providerResponse.universalLinkIOS, + index: providerResponse.index, + gpcSupportedPlatforms: gpcSupportedPlatforms, + openWithSupportedPlatforms: openWithPlatforms) + providers.append(provider) + } + return providers +} + +func load(fromFile named: String, type: String = "json") -> T? { + guard let jsonData = FileLoader.loadFile(withName: named, ofType: type) else { + return nil + } + + return try? JSONDecoder().decode(T.self, from: jsonData) +} diff --git a/Tests/GiniMerchantSDKTests/PaymentComponentsControllerTests.swift b/Tests/GiniMerchantSDKTests/PaymentComponentsControllerTests.swift new file mode 100644 index 0000000..65da76c --- /dev/null +++ b/Tests/GiniMerchantSDKTests/PaymentComponentsControllerTests.swift @@ -0,0 +1,180 @@ +// +// PaymentComponentsControllerTests.swift +// GiniMerchantSDK +// +// Copyright © 2024 Gini GmbH. All rights reserved. +// + +import XCTest +@testable import GiniUtilites +@testable import GiniMerchantSDK +@testable import GiniHealthAPILibrary + +final class PaymentComponentsControllerTests: XCTestCase { + private var giniHealthAPI: GiniHealthAPI! + private var mockPaymentComponentsController: PaymentComponentsProtocol! + private let giniMerchantConfiguration = GiniMerchantConfiguration.shared + private let versionAPI = 1 + + override func setUp() { + super.setUp() + let sessionManagerMock = MockSessionManager() + let documentService = DefaultDocumentService(sessionManager: sessionManagerMock, apiDomain: .merchant, apiVersion: versionAPI) + let paymentService = PaymentService(sessionManager: sessionManagerMock, apiDomain: .merchant, apiVersion: versionAPI) + giniHealthAPI = GiniHealthAPI(documentService: documentService, paymentService: paymentService) + let giniMerchant = GiniMerchant(giniApiLib: giniHealthAPI) + mockPaymentComponentsController = MockPaymentComponents(giniMerchant: giniMerchant) + } + + override func tearDown() { + giniHealthAPI = nil + mockPaymentComponentsController = nil + super.tearDown() + } + + func testLoadPaymentProviders_Success() { + // When + mockPaymentComponentsController.loadPaymentProviders() + + // Then + XCTAssertFalse(mockPaymentComponentsController.isLoading) + XCTAssertNil(mockPaymentComponentsController.selectedPaymentProvider) + } + + func testCheckIfDocumentIsPayable_Success() { + let expectedResult: Result = .success(true) + // When + var receivedResult: Result? + mockPaymentComponentsController.checkIfDocumentIsPayable(docId: MockSessionManager.payableDocumentID) { result in + receivedResult = result + } + + // Then + XCTAssertEqual(receivedResult, expectedResult) + } + + func testCheckIfDocumentIsPayable_NotPayable() { + let expectedResult: Result = .success(false) + // When + var receivedResult: Result? + mockPaymentComponentsController.checkIfDocumentIsPayable(docId: MockSessionManager.notPayableDocumentID) { result in + receivedResult = result + } + + // Then + XCTAssertEqual(receivedResult, expectedResult) + } + + func testCheckIfDocumentIsPayable_Failure() { + let expectedResult: Result = .failure(.apiError(GiniError.decorator(.noResponse))) + // When + var receivedResult: Result? + mockPaymentComponentsController.checkIfDocumentIsPayable(docId: MockSessionManager.missingDocumentID) { result in + receivedResult = result + } + + // Then + XCTAssertEqual(receivedResult, expectedResult) + } + + func testPaymentView_ReturnsView() { + // Given + let documentId = "123456" + let expectedViewModel = PaymentComponentViewModel(paymentProvider: nil, giniMerchantConfiguration: giniMerchantConfiguration) + let expectedView = PaymentComponentView() + expectedView.viewModel = expectedViewModel + + // When + let view = mockPaymentComponentsController.paymentView(documentId: documentId) + + // Then + XCTAssertTrue(view is PaymentComponentView) + guard let view = view as? PaymentComponentView else { + XCTFail("Error finding correct view.") + return + } + XCTAssertEqual(view.viewModel?.documentId, documentId) + } + + func testBankSelectionBottomSheet_ReturnsViewController() { + // When + let viewController = mockPaymentComponentsController.bankSelectionBottomSheet() + + // Then + XCTAssertTrue(viewController is BanksBottomView) + guard let bottomSheet = viewController as? BanksBottomView else { + XCTFail("Error finding correct viewController.") + return + } + XCTAssertNotNil(bottomSheet.viewModel) + } + + func testLoadPaymentReviewScreenFor_Success() { + // Given + let documentID = MockSessionManager.payableDocumentID + + // When + var receivedViewController: UIViewController? + var receivedError: GiniMerchantError? + mockPaymentComponentsController.loadPaymentReviewScreenFor(documentID: documentID, paymentInfo: nil, trackingDelegate: nil) { viewController, error in + receivedViewController = viewController + receivedError = error + } + + // Then + XCTAssertNil(receivedError) + XCTAssertNotNil(receivedViewController) + XCTAssertTrue(receivedViewController is PaymentReviewViewController) + } + + func testLoadPaymentReviewScreenFor_Failure() { + // Given + let documentID = MockSessionManager.missingDocumentID + + // When + var receivedViewController: UIViewController? + var receivedError: GiniMerchantError? + mockPaymentComponentsController.loadPaymentReviewScreenFor(documentID: documentID, paymentInfo: nil, trackingDelegate: nil) { viewController, error in + receivedViewController = viewController + receivedError = error + } + + // Then + XCTAssertNotNil(receivedError) + XCTAssertNil(receivedViewController) + XCTAssertEqual(receivedError, .apiError(GiniError.decorator(.noResponse))) + } + + func testPaymentInfoViewController_ReturnsCorrectViewController() { + // When + let viewController = mockPaymentComponentsController.paymentInfoViewController() + + // Then + XCTAssertTrue(viewController is PaymentInfoViewController) + guard let paymentInfoVC = viewController as? PaymentInfoViewController else { + XCTFail("Error finding correct viewController.") + return + } + XCTAssertNotNil(paymentInfoVC.viewModel) + guard let paymentInfoViewModel = paymentInfoVC.viewModel else { + XCTFail("Error finding payment info viewModel.") + return + } + XCTAssertEqual(paymentInfoViewModel.paymentProviders, []) + } + + func testPaymentProvidersSorting() { + let fileName = "notSortedBanks" + guard let givenPaymentProviders = loadProviders(fileName: fileName) else { + XCTFail("Error loading file: `\(fileName).json`") + return + } + + let expectedPaymentProviders = loadProviders(fileName: "sortedBanks") + + let bottomViewModel = BanksBottomViewModel(paymentProviders: givenPaymentProviders, selectedPaymentProvider: nil, urlOpener: URLOpener(MockUIApplication(canOpen: false))) + + XCTAssertEqual(bottomViewModel.paymentProviders.count, 11) + XCTAssertEqual(bottomViewModel.paymentProviders.map { $0.paymentProvider }, expectedPaymentProviders) + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/Gini-Test-Payment-Provider.png b/Tests/GiniMerchantSDKTests/Resources/Gini-Test-Payment-Provider.png new file mode 100644 index 0000000..12766c5 Binary files /dev/null and b/Tests/GiniMerchantSDKTests/Resources/Gini-Test-Payment-Provider.png differ diff --git a/Tests/GiniMerchantSDKTests/Resources/document1.json b/Tests/GiniMerchantSDKTests/Resources/document1.json new file mode 100644 index 0000000..37ec975 --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/document1.json @@ -0,0 +1,26 @@ +{ + "id": "626626a0-749f-11e2-bfd6-000000000001", + "creationDate": 1515932941.2839971, + "expirationDate": 1515932941.2839971, + "name": "scanned.jpg", + "progress": "COMPLETED", + "origin": "UPLOAD", + "sourceClassification": "SCANNED", + "pageCount": 1, + "pages" : [ + { + "images" : { + "750x900" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000001/pages/1/750x900", + "1280x1810" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000001/pages/1/1280x1810" + }, + "pageNumber" : 1 + } + ], + "_links": { + "extractions": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000001/extractions", + "layout": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000001/layout", + "document": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000001", + "processed": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000001/processed", + "pages": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000001/pages" + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/document2.json b/Tests/GiniMerchantSDKTests/Resources/document2.json new file mode 100644 index 0000000..2db59a7 --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/document2.json @@ -0,0 +1,26 @@ +{ + "id": "626626a0-749f-11e2-bfd6-000000000002", + "creationDate": 1515932941.2839971, + "expirationDate": 1515932941.2839971, + "name": "scanned.jpg", + "progress": "COMPLETED", + "origin": "UPLOAD", + "sourceClassification": "SCANNED", + "pageCount": 1, + "pages" : [ + { + "images" : { + "750x900" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000002/pages/1/750x900", + "1280x1810" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000002/pages/1/1280x1810" + }, + "pageNumber" : 1 + } + ], + "_links": { + "extractions": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000002/extractions", + "layout": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000002/layout", + "document": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000002", + "processed": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000002/processed", + "pages": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000002/pages" + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/document3.json b/Tests/GiniMerchantSDKTests/Resources/document3.json new file mode 100644 index 0000000..c25ceef --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/document3.json @@ -0,0 +1,26 @@ +{ + "id": "626626a0-749f-11e2-bfd6-000000000003", + "creationDate": 1515932941.2839971, + "expirationDate": 1515932941.2839971, + "name": "scanned.jpg", + "progress": "COMPLETED", + "origin": "UPLOAD", + "sourceClassification": "SCANNED", + "pageCount": 1, + "pages" : [ + { + "images" : { + "750x900" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000003/pages/1/750x900", + "1280x1810" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000003/pages/1/1280x1810" + }, + "pageNumber" : 1 + } + ], + "_links": { + "extractions": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000003/extractions", + "layout": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000003/layout", + "document": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000003", + "processed": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000003/processed", + "pages": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000003/pages" + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/document4.json b/Tests/GiniMerchantSDKTests/Resources/document4.json new file mode 100644 index 0000000..0bc2048 --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/document4.json @@ -0,0 +1,26 @@ +{ + "id": "626626a0-749f-11e2-bfd6-000000000004", + "creationDate": 1515932941.2839971, + "expirationDate": 1515932941.2839971, + "name": "scanned.jpg", + "progress": "COMPLETED", + "origin": "UPLOAD", + "sourceClassification": "SCANNED", + "pageCount": 1, + "pages" : [ + { + "images" : { + "750x900" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000004/pages/1/750x900", + "1280x1810" : "http://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000004/pages/1/1280x1810" + }, + "pageNumber" : 1 + } + ], + "_links": { + "extractions": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000004/extractions", + "layout": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000004/layout", + "document": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000004", + "processed": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000004/processed", + "pages": "https://api.gini.net/documents/626626a0-749f-11e2-bfd6-000000000004/pages" + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/extractionResultWithIBAN.json b/Tests/GiniMerchantSDKTests/Resources/extractionResultWithIBAN.json new file mode 100644 index 0000000..4fe4f8a --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/extractionResultWithIBAN.json @@ -0,0 +1,158 @@ +{ + "extractions": { + "paymentRecipient": { + "entity": "companyname", + "value": "Fahrrad Rückenwind", + "box": { + "top": 617.53, + "left": 66.25, + "width": 192.95999999999998, + "height": 11.040000000000077, + "page": 1 + }, + "candidates": "paymentRecipients" + }, + "paymentPurpose": { + "entity": "reference", + "value": "RE-20210512-02" + }, + "bic": { + "entity": "bic", + "value": "BYLADEM1001", + "box": { + "top": 642.49, + "left": 66.25, + "width": 86.03, + "height": 11.039999999999964, + "page": 1 + }, + "candidates": "bics" + }, + "amountToPay": { + "entity": "amount", + "value": "995.00:EUR", + "box": { + "top": 464.89, + "left": 496.45, + "width": 29.439999999999998, + "height": 10.080000000000041, + "page": 1 + }, + "candidates": "amounts" + }, + "iban": { + "entity": "iban", + "value": "DE02120300000000202051", + "box": { + "top": 666.49, + "left": 93.65, + "width": 121.82999999999998, + "height": 11.039999999999964, + "page": 1 + }, + "candidates": "ibans" + }, + "docType": { + "entity": "doctype", + "value": "Invoice" + } + }, + "candidates": { + "paymentRecipients": [ + { + "entity": "companyname", + "value": "Fahrrad Rückenwind", + "box": { + "top": 103.69, + "left": 66.25, + "width": 107.0, + "height": 12.0, + "page": 1 + } + }, + { + "entity": "companyname", + "value": "Fahrrad Rückenwind", + "box": { + "top": 617.53, + "left": 66.25, + "width": 192.95999999999998, + "height": 11.040000000000077, + "page": 1 + } + } + ], + "bics": [ + { + "entity": "bic", + "value": "BYLADEM1001", + "box": { + "top": 794.41, + "left": 282.25, + "width": 62.420000000000016, + "height": 7.920000000000073, + "page": 1 + } + }, + { + "entity": "bic", + "value": "BYLADEM1001", + "box": { + "top": 642.49, + "left": 66.25, + "width": 86.03, + "height": 11.039999999999964, + "page": 1 + } + } + ], + "amounts": [ + { + "entity": "amount", + "value": "995.00:EUR", + "box": { + "top": 464.89, + "left": 496.45, + "width": 29.439999999999998, + "height": 10.080000000000041, + "page": 1 + } + }, + { + "entity": "amount", + "value": "995.00:EUR", + "box": { + "top": 464.89, + "left": 496.45, + "width": 29.439999999999998, + "height": 10.080000000000041, + "page": 1 + } + } + ], + "ibans": [ + { + "entity": "iban", + "value": "DE02120300000000202051", + "box": { + "top": 804.49, + "left": 302.17, + "width": 88.56, + "height": 7.919999999999959, + "page": 1 + } + }, + { + "entity": "iban", + "value": "DE02120300000000202051", + "box": { + "top": 666.49, + "left": 93.65, + "width": 121.82999999999998, + "height": 11.039999999999964, + "page": 1 + } + } + ] + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/extractionResultWithoutIBAN.json b/Tests/GiniMerchantSDKTests/Resources/extractionResultWithoutIBAN.json new file mode 100644 index 0000000..896c10d --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/extractionResultWithoutIBAN.json @@ -0,0 +1,158 @@ +{ + "extractions": { + "paymentRecipient": { + "entity": "companyname", + "value": "Fahrrad Rückenwind", + "box": { + "top": 617.53, + "left": 66.25, + "width": 192.95999999999998, + "height": 11.040000000000077, + "page": 1 + }, + "candidates": "paymentRecipients" + }, + "paymentPurpose": { + "entity": "reference", + "value": "RE-20210512-02" + }, + "bic": { + "entity": "bic", + "value": "BYLADEM1001", + "box": { + "top": 642.49, + "left": 66.25, + "width": 86.03, + "height": 11.039999999999964, + "page": 1 + }, + "candidates": "bics" + }, + "amountToPay": { + "entity": "amount", + "value": "995.00:EUR", + "box": { + "top": 464.89, + "left": 496.45, + "width": 29.439999999999998, + "height": 10.080000000000041, + "page": 1 + }, + "candidates": "amounts" + }, + "iban": { + "entity": "iban", + "value": "", + "box": { + "top": 666.49, + "left": 93.65, + "width": 121.82999999999998, + "height": 11.039999999999964, + "page": 1 + }, + "candidates": "ibans" + }, + "docType": { + "entity": "doctype", + "value": "Invoice" + } + }, + "candidates": { + "paymentRecipients": [ + { + "entity": "companyname", + "value": "Fahrrad Rückenwind", + "box": { + "top": 103.69, + "left": 66.25, + "width": 107.0, + "height": 12.0, + "page": 1 + } + }, + { + "entity": "companyname", + "value": "Fahrrad Rückenwind", + "box": { + "top": 617.53, + "left": 66.25, + "width": 192.95999999999998, + "height": 11.040000000000077, + "page": 1 + } + } + ], + "bics": [ + { + "entity": "bic", + "value": "BYLADEM1001", + "box": { + "top": 794.41, + "left": 282.25, + "width": 62.420000000000016, + "height": 7.920000000000073, + "page": 1 + } + }, + { + "entity": "bic", + "value": "BYLADEM1001", + "box": { + "top": 642.49, + "left": 66.25, + "width": 86.03, + "height": 11.039999999999964, + "page": 1 + } + } + ], + "amounts": [ + { + "entity": "amount", + "value": "995.00:EUR", + "box": { + "top": 464.89, + "left": 496.45, + "width": 29.439999999999998, + "height": 10.080000000000041, + "page": 1 + } + }, + { + "entity": "amount", + "value": "995.00:EUR", + "box": { + "top": 464.89, + "left": 496.45, + "width": 29.439999999999998, + "height": 10.080000000000041, + "page": 1 + } + } + ], + "ibans": [ + { + "entity": "iban", + "value": "DE02120300000000202051", + "box": { + "top": 804.49, + "left": 302.17, + "width": 88.56, + "height": 7.919999999999959, + "page": 1 + } + }, + { + "entity": "iban", + "value": "DE02120300000000202051", + "box": { + "top": 666.49, + "left": 93.65, + "width": 121.82999999999998, + "height": 11.039999999999964, + "page": 1 + } + } + ] + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/extractionsWithPayment.json b/Tests/GiniMerchantSDKTests/Resources/extractionsWithPayment.json new file mode 100644 index 0000000..e970498 --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/extractionsWithPayment.json @@ -0,0 +1,546 @@ +{ + "extractions": { + "amount_to_pay": { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + }, + "candidates": "amounts" + }, + "document_date": { + "entity": "date", + "value": "2020-09-23", + "box": { + "top": 135.85, + "left": 418.85, + "width": 82.30000000000001, + "height": 13.0, + "page": 1 + }, + "candidates": "dates" + }, + "gross_amount": { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + }, + "candidates": "gross_amounts" + }, + "doc_type": { + "entity": "doctype", + "value": "Invoice" + }, + "language": { + "entity": "language", + "value": "de" + }, + "payment_state": { + "entity": "paymentstate", + "value": "ToBePaid" + }, + "invoice_id": { + "entity": "invoiceid", + "value": "3963757910434", + "box": { + "top": 116.7, + "left": 368.7, + "width": 132.0, + "height": 11.999999999999986, + "page": 1 + }, + "candidates": "invoice_ids" + } + }, + "compoundExtractions": { + "line_items": [ + { + "quantity": { + "entity": "numeric", + "value": "1", + "box": { + "top": 595.15, + "left": 721.15, + "width": 11.700000000000045, + "height": 14.0, + "page": 1 + } + }, + "description": { + "entity": "text", + "value": "Damen Yoga Leggings Fitness Hose Gym Fit- ness Sport bequeme Hose mit Tasche", + "box": { + "top": 593.45, + "left": 283.15, + "width": 333.30000000000007, + "height": 31.699999999999932, + "page": 1 + } + }, + "amount": { + "entity": "amount", + "value": "4.73:EUR", + "box": { + "top": 597.3, + "left": 1120.3, + "width": 36.40000000000009, + "height": 15.0, + "page": 1 + } + }, + "fee_schedule_number": { + "entity": "text", + "value": "4341", + "box": { + "top": 609.3, + "left": 82.3, + "width": 38.39999999999999, + "height": 15.0, + "page": 1 + } + } + }, + { + "quantity": { + "entity": "numeric", + "value": "1", + "box": { + "top": 635.15, + "left": 721.15, + "width": 11.700000000000045, + "height": 14.0, + "page": 1 + } + }, + "description": { + "entity": "text", + "value": "Lässige Leggings mit Totenkopf- und Blu-", + "box": { + "top": 634.0, + "left": 283.0, + "width": 303.0, + "height": 14.0, + "page": 1 + } + }, + "amount": { + "entity": "amount", + "value": "5.00:EUR", + "box": { + "top": 635.75, + "left": 1119.75, + "width": 35.5, + "height": 17.0, + "page": 1 + } + } + }, + { + "quantity": { + "entity": "numeric", + "value": "1", + "box": { + "top": 674.7, + "left": 721.7, + "width": 9.600000000000023, + "height": 12.0, + "page": 1 + } + }, + "description": { + "entity": "text", + "value": "Eng anliegende Anti-Cellulite Kompression Slim Yogahose für Damen Sport schneil trocknende Hose mit hohem Bund", + "box": { + "top": 672.3, + "left": 282.15, + "width": 347.15, + "height": 47.85000000000002, + "page": 1 + } + }, + "amount": { + "entity": "amount", + "value": "5.54:EUR", + "box": { + "top": 673.6, + "left": 808.6, + "width": 33.799999999999955, + "height": 16.0, + "page": 1 + } + } + }, + { + "amount": { + "entity": "amount", + "value": "5.00:EUR", + "box": { + "top": 775.45, + "left": 1120.45, + "width": 34.09999999999991, + "height": 16.0, + "page": 1 + } + } + } + ], + "invoice_sender": [ + { + "website": { + "entity": "url", + "value": "www.klana.de", + "box": { + "top": 880.5, + "left": 818.5, + "width": 79.0, + "height": 21.0, + "page": 1 + } + }, + "name": { + "entity": "text", + "value": "Kundenservice Klana", + "box": { + "top": 1027.0, + "left": 69.0, + "width": 407.0, + "height": 24.0, + "page": 1 + } + }, + "bic": { + "entity": "bic", + "value": "DEUTDEMM760", + "box": { + "top": 363.6, + "left": 656.6, + "width": 494.94999999999993, + "height": 16.849999999999966, + "page": 1 + } + }, + "vat_reg_number": { + "entity": "vat", + "value": "SE556737043101", + "box": { + "top": 1067.85, + "left": 713.85, + "width": 99.29999999999995, + "height": 13.0, + "page": 1 + } + }, + "iban": { + "entity": "iban", + "value": "DE13760700120500154000", + "box": { + "top": 344.0, + "left": 946.0, + "width": 206.0, + "height": 14.0, + "page": 1 + } + } + } + ], + "payment": [ + { + "amount_to_pay": { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + } + }, + "bic": { + "entity": "bic", + "value": "DEUTDEMM760", + "box": { + "top": 1331.5, + "left": 403.5, + "width": 306.0, + "height": 21.0, + "page": 1 + } + }, + "payment_reference": { + "entity": "text", + "value": "3963757910434", + "box": { + "top": 1473.2, + "left": 404.2, + "width": 364.00000000000006, + "height": 19.0, + "page": 1 + } + }, + "payment_recipient": { + "entity": "text", + "value": "KLARNA" + }, + "payment_purpose": { + "entity": "text", + "value": "39 63 75 79 10 43 4", + "box": { + "top": 1473.2, + "left": 404.2, + "width": 364.00000000000006, + "height": 19.0, + "page": 1 + } + }, + "iban": { + "entity": "iban", + "value": "DE13760700120500154000", + "box": { + "top": 1282.2, + "left": 403.2, + "width": 499.00000000000006, + "height": 19.0, + "page": 1 + } + } + } + ] + }, + "candidates": { + "ibans": [ + { + "entity": "iban", + "value": "DE13760700120500154000", + "box": { + "top": 344.0, + "left": 946.0, + "width": 206.0, + "height": 14.0, + "page": 1 + } + }, + { + "entity": "iban", + "value": "DE13760700120500154000", + "box": { + "top": 1056.85, + "left": 954.85, + "width": 192.9999999999999, + "height": 13.0, + "page": 1 + } + } + ], + "gross_amounts": [ + { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 388.15, + "left": 1070.15, + "width": 84.0, + "height": 14.0, + "page": 1 + } + }, + { + "entity": "amount", + "value": "5.00:EUR", + "box": { + "top": 798.6, + "left": 1120.6, + "width": 33.799999999999955, + "height": 16.0, + "page": 1 + } + }, + { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + } + }, + { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + } + }, + { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + } + } + ], + "bics": [ + { + "entity": "bic", + "value": "DEUTDEMM760", + "box": { + "top": 1070.4, + "left": 955.4, + "width": 121.00000000000011, + "height": 11.0, + "page": 1 + } + }, + { + "entity": "bic", + "value": "DEUTDEMM760", + "box": { + "top": 363.6, + "left": 656.6, + "width": 494.94999999999993, + "height": 16.849999999999966, + "page": 1 + } + } + ], + "websites": [ + { + "entity": "url", + "value": "www.klana.de", + "box": { + "top": 880.5, + "left": 818.5, + "width": 79.0, + "height": 21.0, + "page": 1 + } + }, + { + "entity": "url", + "value": "www.klarna.de", + "box": { + "top": 1040.0, + "left": 438.0, + "width": 163.0, + "height": 14.0, + "page": 1 + } + } + ], + "dates": [ + { + "entity": "date", + "value": "2020-09-23", + "box": { + "top": 135.85, + "left": 418.85, + "width": 82.30000000000001, + "height": 13.0, + "page": 1 + } + }, + { + "entity": "date", + "value": "2020-07-24", + "box": { + "top": 173.85, + "left": 417.85, + "width": 83.30000000000001, + "height": 13.0, + "page": 1 + } + }, + { + "entity": "date", + "value": "2020-10-07", + "box": { + "top": 298.3, + "left": 1069.3, + "width": 83.40000000000009, + "height": 15.0, + "page": 1 + } + } + ], + "vat_reg_numbers": [ + { + "entity": "vat", + "value": "SE556737043101", + "box": { + "top": 1067.85, + "left": 713.85, + "width": 99.29999999999995, + "height": 13.0, + "page": 1 + } + } + ], + "invoice_ids": [ + { + "entity": "invoiceid", + "value": "3963757910434", + "box": { + "top": 116.7, + "left": 368.7, + "width": 132.0, + "height": 11.999999999999986, + "page": 1 + } + } + ], + "amounts": [ + { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + } + }, + { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 860.6, + "left": 1106.6, + "width": 48.799999999999955, + "height": 16.0, + "page": 1 + } + }, + { + "entity": "amount", + "value": "30.27:EUR", + "box": { + "top": 388.15, + "left": 1070.15, + "width": 84.0, + "height": 14.0, + "page": 1 + } + } + ] + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/notSortedBanks.json b/Tests/GiniMerchantSDKTests/Resources/notSortedBanks.json new file mode 100644 index 0000000..70c55aa --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/notSortedBanks.json @@ -0,0 +1,266 @@ +[ + { + "id": "b09ef70a-490f-11eb-952e-9bc6f4646c57", + "name": "Gini-Test-Payment-Provider", + "appSchemeIOS": "ginipay-bank://", + "packageNameAndroid": "net.gini.android.bank.sdk.exampleapp", + "minAppVersion": { + "ios": "??.?", + "android": "??.?" + }, + "colors": { + "background": "009EDC", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/b09ef70a-490f-11eb-952e-9bc6f4646c57/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.bank.insurance.mock", + "universalLinkIOS": "ginipay-bank://", + "index": 0, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f7d06ee0-51fd-11ec-8216-97f0937beb16", + "name": "GiniBank", + "appSchemeIOS": "ginipay-ginibank://", + "packageNameAndroid": "net.gini.android.bank.sdk.ginibank", + "minAppVersion": { + "ios": "1.2.3", + "android": "4.5.6" + }, + "colors": { + "background": "FFFFFF", + "text": "009EDC" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f7d06ee0-51fd-11ec-8216-97f0937beb16/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-ginibank://", + "index": 1, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "0bd58eae-7ea2-11ec-8a88-8fbe75353d94", + "name": "ConsorsbankMock", + "appSchemeIOS": "ginipay-consorsbank-mock://", + "packageNameAndroid": "de.consorsbank.bankingapp", + "minAppVersion": { + "ios": "1.2.3", + "android": "4.5.6" + }, + "colors": { + "background": "4DBED3", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/0bd58eae-7ea2-11ec-8a88-8fbe75353d94/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-consorsbank-mock://", + "index": 2, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f2d2f9a6-8e4a-11ec-97e4-e372f4cd98db", + "name": "Consorsbank Test", + "appSchemeIOS": "ginipay-consorsbanktest://", + "packageNameAndroid": "de.consorsbank.test", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f2d2f9a6-8e4a-11ec-97e4-e372f4cd98db/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-consorsbanktest://", + "index": 3, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "d5a56d26-8e4c-11ec-9c27-5b350ade3856", + "name": "Consorsbank Dev", + "appSchemeIOS": "ginipay-consorsbankdebug://", + "packageNameAndroid": "de.consorsbank.debug", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/d5a56d26-8e4c-11ec-9c27-5b350ade3856/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-consorsbankdebug://", + "index": 4, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f2e66ede-57be-43d9-b483-5c746220c594", + "name": "Bank", + "appSchemeIOS": "ginipay-insurance-bank-mock://", + "packageNameAndroid": "net.gini.android.bank.insurance.mock", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "003FE2", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f2e66ede-57be-43d9-b483-5c746220c594/icon", + "appStoreUrlIOS": "https://testflight.apple.com/join/BTe3AH8w", + "playStoreUrlAndroid": "https://install.appcenter.ms/orgs/gini-team-organization/apps/bank-1/distribution_groups/internal", + "universalLinkIOS": "ginipay-insurance-bank-mock://", + "index": 5, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "dbe3a2ca-c9df-11eb-a1d8-a7efff6e88b7", + "name": "ING-DiBa", + "appSchemeIOS": "ginipay-ingdiba://", + "packageNameAndroid": "de.ingdiba.bankingapp", + "minAppVersion": { + "ios": "??.?", + "android": "??.?" + }, + "colors": { + "background": "daa520", + "text": "54f1db" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/dbe3a2ca-c9df-11eb-a1d8-a7efff6e88b7/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-ingdiba://", + "index": 6, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "a65b0646-51fe-11ec-8736-c338396b2f09", + "name": "Consorsbank", + "appSchemeIOS": "ginipay-consorsbank://", + "packageNameAndroid": "de.consorsbank", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/a65b0646-51fe-11ec-8736-c338396b2f09/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/consorsbank/id930883278", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=de.consorsbank", + "universalLinkIOS": "ginipay-consorsbank://", + "index": 7, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "03e8f20c-8e4d-11ec-a97f-5f81b1fbfee4", + "name": "BNP Paribas myPrivateBank", + "appSchemeIOS": "ginipay-cbwm://", + "packageNameAndroid": "de.bnp.wm", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/03e8f20c-8e4d-11ec-a97f-5f81b1fbfee4/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/myprivatebank/id1115169520", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=de.bnp.wm", + "universalLinkIOS": "ginipay-cbwm://", + "index": 8, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "43119f92-8e4d-11ec-861f-c75b8eeceadc", + "name": "BNP Paribas myPrivateBank Dev", + "appSchemeIOS": "ginipay-cbwmdebug://", + "packageNameAndroid": "de.bnp.wm.debug", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/43119f92-8e4d-11ec-861f-c75b8eeceadc/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-cbwmdebug://", + "index": 9, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "72e3b804-8e4d-11ec-be15-9bfa6d461196", + "name": "BNP Paribas myPrivateBank Test", + "appSchemeIOS": "ginipay-cbwmtest://", + "packageNameAndroid": "de.bnp.wm.test", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/72e3b804-8e4d-11ec-be15-9bfa6d461196/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-cbwmtest://", + "index": 10, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + } +] diff --git a/Tests/GiniMerchantSDKTests/Resources/paymentRequest.json b/Tests/GiniMerchantSDKTests/Resources/paymentRequest.json new file mode 100644 index 0000000..1fd4c6c --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/paymentRequest.json @@ -0,0 +1,15 @@ +{ + "paymentProvider": "b09ef70a-490f-11eb-952e-9bc6f4646c57", + "requesterUri": "ginipay-test://paymentRequester", + "iban": "DE78370501980020008850", + "bic": "COLSDE33", + "amount": "1.00:EUR", + "purpose": "ReNr 12345", + "recipient": "Uno Flüchtlingshilfe", + "createdAt": "2021-03-23T08:40:01.830403", + "status": "open", + "_links": { + "self": "https://pay-api.gini.net/paymentRequests/118edf41-102a-4b40-8753-df2f0634cb86", + "paymentProvider": "https://pay-api.gini.net/paymentProviders/b09ef70a-490f-11eb-952e-9bc6f4646c57" + } +} diff --git a/Tests/GiniMerchantSDKTests/Resources/provider.json b/Tests/GiniMerchantSDKTests/Resources/provider.json new file mode 100644 index 0000000..59ef1ea --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/provider.json @@ -0,0 +1,17 @@ +{ + "id": "b09ef70a-490f-11eb-952e-9bc6f4646c57", + "name": "Gini-Test-Payment-Provider", + "appSchemeIOS": "ginipay-bank://", + "packageNameAndroid": "net.gini.android.bank.sdk.screenapiexample", + "minAppVersion": { + "ios": "??.?", + "android": "??.?" + }, + "colors": { + "background": "009EDC", + "text": "FFFFFF" + }, + "iconLocation": "https://pay-api.gini.net/paymentProviders/b09ef70a-490f-11eb-952e-9bc6f4646c57/icon", + "universalLinkIOS": "ginipay-bank://", + "index": 0 +} diff --git a/Tests/GiniMerchantSDKTests/Resources/providers.json b/Tests/GiniMerchantSDKTests/Resources/providers.json new file mode 100644 index 0000000..70c55aa --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/providers.json @@ -0,0 +1,266 @@ +[ + { + "id": "b09ef70a-490f-11eb-952e-9bc6f4646c57", + "name": "Gini-Test-Payment-Provider", + "appSchemeIOS": "ginipay-bank://", + "packageNameAndroid": "net.gini.android.bank.sdk.exampleapp", + "minAppVersion": { + "ios": "??.?", + "android": "??.?" + }, + "colors": { + "background": "009EDC", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/b09ef70a-490f-11eb-952e-9bc6f4646c57/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.bank.insurance.mock", + "universalLinkIOS": "ginipay-bank://", + "index": 0, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f7d06ee0-51fd-11ec-8216-97f0937beb16", + "name": "GiniBank", + "appSchemeIOS": "ginipay-ginibank://", + "packageNameAndroid": "net.gini.android.bank.sdk.ginibank", + "minAppVersion": { + "ios": "1.2.3", + "android": "4.5.6" + }, + "colors": { + "background": "FFFFFF", + "text": "009EDC" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f7d06ee0-51fd-11ec-8216-97f0937beb16/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-ginibank://", + "index": 1, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "0bd58eae-7ea2-11ec-8a88-8fbe75353d94", + "name": "ConsorsbankMock", + "appSchemeIOS": "ginipay-consorsbank-mock://", + "packageNameAndroid": "de.consorsbank.bankingapp", + "minAppVersion": { + "ios": "1.2.3", + "android": "4.5.6" + }, + "colors": { + "background": "4DBED3", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/0bd58eae-7ea2-11ec-8a88-8fbe75353d94/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-consorsbank-mock://", + "index": 2, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f2d2f9a6-8e4a-11ec-97e4-e372f4cd98db", + "name": "Consorsbank Test", + "appSchemeIOS": "ginipay-consorsbanktest://", + "packageNameAndroid": "de.consorsbank.test", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f2d2f9a6-8e4a-11ec-97e4-e372f4cd98db/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-consorsbanktest://", + "index": 3, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "d5a56d26-8e4c-11ec-9c27-5b350ade3856", + "name": "Consorsbank Dev", + "appSchemeIOS": "ginipay-consorsbankdebug://", + "packageNameAndroid": "de.consorsbank.debug", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/d5a56d26-8e4c-11ec-9c27-5b350ade3856/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-consorsbankdebug://", + "index": 4, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f2e66ede-57be-43d9-b483-5c746220c594", + "name": "Bank", + "appSchemeIOS": "ginipay-insurance-bank-mock://", + "packageNameAndroid": "net.gini.android.bank.insurance.mock", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "003FE2", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f2e66ede-57be-43d9-b483-5c746220c594/icon", + "appStoreUrlIOS": "https://testflight.apple.com/join/BTe3AH8w", + "playStoreUrlAndroid": "https://install.appcenter.ms/orgs/gini-team-organization/apps/bank-1/distribution_groups/internal", + "universalLinkIOS": "ginipay-insurance-bank-mock://", + "index": 5, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "dbe3a2ca-c9df-11eb-a1d8-a7efff6e88b7", + "name": "ING-DiBa", + "appSchemeIOS": "ginipay-ingdiba://", + "packageNameAndroid": "de.ingdiba.bankingapp", + "minAppVersion": { + "ios": "??.?", + "android": "??.?" + }, + "colors": { + "background": "daa520", + "text": "54f1db" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/dbe3a2ca-c9df-11eb-a1d8-a7efff6e88b7/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-ingdiba://", + "index": 6, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "a65b0646-51fe-11ec-8736-c338396b2f09", + "name": "Consorsbank", + "appSchemeIOS": "ginipay-consorsbank://", + "packageNameAndroid": "de.consorsbank", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/a65b0646-51fe-11ec-8736-c338396b2f09/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/consorsbank/id930883278", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=de.consorsbank", + "universalLinkIOS": "ginipay-consorsbank://", + "index": 7, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "03e8f20c-8e4d-11ec-a97f-5f81b1fbfee4", + "name": "BNP Paribas myPrivateBank", + "appSchemeIOS": "ginipay-cbwm://", + "packageNameAndroid": "de.bnp.wm", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/03e8f20c-8e4d-11ec-a97f-5f81b1fbfee4/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/myprivatebank/id1115169520", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=de.bnp.wm", + "universalLinkIOS": "ginipay-cbwm://", + "index": 8, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "43119f92-8e4d-11ec-861f-c75b8eeceadc", + "name": "BNP Paribas myPrivateBank Dev", + "appSchemeIOS": "ginipay-cbwmdebug://", + "packageNameAndroid": "de.bnp.wm.debug", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/43119f92-8e4d-11ec-861f-c75b8eeceadc/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-cbwmdebug://", + "index": 9, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "72e3b804-8e4d-11ec-be15-9bfa6d461196", + "name": "BNP Paribas myPrivateBank Test", + "appSchemeIOS": "ginipay-cbwmtest://", + "packageNameAndroid": "de.bnp.wm.test", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/72e3b804-8e4d-11ec-be15-9bfa6d461196/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.fake", + "universalLinkIOS": "ginipay-cbwmtest://", + "index": 10, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + } +] diff --git a/Tests/GiniMerchantSDKTests/Resources/sortedBanks.json b/Tests/GiniMerchantSDKTests/Resources/sortedBanks.json new file mode 100644 index 0000000..51b7c7c --- /dev/null +++ b/Tests/GiniMerchantSDKTests/Resources/sortedBanks.json @@ -0,0 +1,252 @@ +[ + { + "id": "b09ef70a-490f-11eb-952e-9bc6f4646c57", + "name": "Gini-Test-Payment-Provider", + "appSchemeIOS": "ginipay-bank://", + "packageNameAndroid": "net.gini.android.bank.sdk.exampleapp", + "minAppVersion": { + "ios": "??.?", + "android": "??.?" + }, + "colors": { + "background": "009EDC", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/b09ef70a-490f-11eb-952e-9bc6f4646c57/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/bank/id1234567890", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=net.gini.android.bank.insurance.mock", + "universalLinkIOS": "ginipay-bank://", + "index": 0, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "dbe3a2ca-c9df-11eb-a1d8-a7efff6e88b7", + "name": "ING-DiBa", + "appSchemeIOS": "ginipay-ingdiba://", + "packageNameAndroid": "de.ingdiba.bankingapp", + "minAppVersion": { + "ios": "??.?", + "android": "??.?" + }, + "colors": { + "background": "daa520", + "text": "54f1db" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/dbe3a2ca-c9df-11eb-a1d8-a7efff6e88b7/icon", + "universalLinkIOS": "ginipay-ingdiba://", + "index": 6, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f7d06ee0-51fd-11ec-8216-97f0937beb16", + "name": "GiniBank", + "appSchemeIOS": "ginipay-ginibank://", + "packageNameAndroid": "net.gini.android.bank.sdk.ginibank", + "minAppVersion": { + "ios": "1.2.3", + "android": "4.5.6" + }, + "colors": { + "background": "FFFFFF", + "text": "009EDC" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f7d06ee0-51fd-11ec-8216-97f0937beb16/icon", + "universalLinkIOS": "ginipay-ginibank://", + "index": 1, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "0bd58eae-7ea2-11ec-8a88-8fbe75353d94", + "name": "ConsorsbankMock", + "appSchemeIOS": "ginipay-consorsbank-mock://", + "packageNameAndroid": "de.consorsbank.bankingapp", + "minAppVersion": { + "ios": "1.2.3", + "android": "4.5.6" + }, + "colors": { + "background": "4DBED3", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/0bd58eae-7ea2-11ec-8a88-8fbe75353d94/icon", + "universalLinkIOS": "ginipay-consorsbank-mock://", + "index": 2, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f2d2f9a6-8e4a-11ec-97e4-e372f4cd98db", + "name": "Consorsbank Test", + "appSchemeIOS": "ginipay-consorsbanktest://", + "packageNameAndroid": "de.consorsbank.test", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f2d2f9a6-8e4a-11ec-97e4-e372f4cd98db/icon", + "universalLinkIOS": "ginipay-consorsbanktest://", + "index": 3, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "d5a56d26-8e4c-11ec-9c27-5b350ade3856", + "name": "Consorsbank Dev", + "appSchemeIOS": "ginipay-consorsbankdebug://", + "packageNameAndroid": "de.consorsbank.debug", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/d5a56d26-8e4c-11ec-9c27-5b350ade3856/icon", + "universalLinkIOS": "ginipay-consorsbankdebug://", + "index": 4, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "f2e66ede-57be-43d9-b483-5c746220c594", + "name": "Bank", + "appSchemeIOS": "ginipay-insurance-bank-mock://", + "packageNameAndroid": "net.gini.android.bank.insurance.mock", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "003FE2", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/f2e66ede-57be-43d9-b483-5c746220c594/icon", + "appStoreUrlIOS": "https://testflight.apple.com/join/BTe3AH8w", + "playStoreUrlAndroid": "https://install.appcenter.ms/orgs/gini-team-organization/apps/bank-1/distribution_groups/internal", + "universalLinkIOS": "ginipay-insurance-bank-mock://", + "index": 5, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "a65b0646-51fe-11ec-8736-c338396b2f09", + "name": "Consorsbank", + "appSchemeIOS": "ginipay-consorsbank://", + "packageNameAndroid": "de.consorsbank", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "0080A6", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/a65b0646-51fe-11ec-8736-c338396b2f09/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/consorsbank/id930883278", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=de.consorsbank", + "universalLinkIOS": "ginipay-consorsbank://", + "index": 7, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "03e8f20c-8e4d-11ec-a97f-5f81b1fbfee4", + "name": "BNP Paribas myPrivateBank", + "appSchemeIOS": "ginipay-cbwm://", + "packageNameAndroid": "de.bnp.wm", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/03e8f20c-8e4d-11ec-a97f-5f81b1fbfee4/icon", + "appStoreUrlIOS": "https://apps.apple.com/de/app/myprivatebank/id1115169520", + "playStoreUrlAndroid": "https://play.google.com/store/apps/details?id=de.bnp.wm", + "universalLinkIOS": "ginipay-cbwm://", + "index": 8, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "43119f92-8e4d-11ec-861f-c75b8eeceadc", + "name": "BNP Paribas myPrivateBank Dev", + "appSchemeIOS": "ginipay-cbwmdebug://", + "packageNameAndroid": "de.bnp.wm.debug", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/43119f92-8e4d-11ec-861f-c75b8eeceadc/icon", + "universalLinkIOS": "ginipay-cbwmdebug://", + "index": 9, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + }, + { + "id": "72e3b804-8e4d-11ec-be15-9bfa6d461196", + "name": "BNP Paribas myPrivateBank Test", + "appSchemeIOS": "ginipay-cbwmtest://", + "packageNameAndroid": "de.bnp.wm.test", + "minAppVersion": { + "ios": "1.0.0", + "android": "1.0.0" + }, + "colors": { + "background": "01975E", + "text": "FFFFFF" + }, + "iconLocation": "https://merchant-api.gini.net/paymentProviders/72e3b804-8e4d-11ec-be15-9bfa6d461196/icon", + "universalLinkIOS": "ginipay-cbwmtest://", + "index": 10, + "gpcSupportedPlatforms": [ + "ios", + "android" + ], + "openWithSupportedPlatforms": [] + } +] diff --git a/credentials_plist_format.png b/credentials_plist_format.png new file mode 100644 index 0000000..6d8f4ad Binary files /dev/null and b/credentials_plist_format.png differ