diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2c5125e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,616 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 100 +tab_width = 4 +ij_continuation_indent_size = 4 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = false +ij_smart_tabs = false +ij_visual_guides = none +ij_wrap_on_typing = false + +[*.java] +max_line_length = 120 +ij_java_align_consecutive_assignments = false +ij_java_align_consecutive_variable_declarations = false +ij_java_align_group_field_declarations = false +ij_java_align_multiline_annotation_parameters = false +ij_java_align_multiline_array_initializer_expression = false +ij_java_align_multiline_assignment = false +ij_java_align_multiline_binary_operation = false +ij_java_align_multiline_chained_methods = false +ij_java_align_multiline_extends_list = false +ij_java_align_multiline_for = true +ij_java_align_multiline_method_parentheses = false +ij_java_align_multiline_parameters = true +ij_java_align_multiline_parameters_in_calls = false +ij_java_align_multiline_parenthesized_expression = false +ij_java_align_multiline_records = true +ij_java_align_multiline_resources = true +ij_java_align_multiline_ternary_operation = false +ij_java_align_multiline_text_blocks = false +ij_java_align_multiline_throws_list = false +ij_java_align_subsequent_simple_methods = false +ij_java_align_throws_keyword = false +ij_java_annotation_parameter_wrap = off +ij_java_array_initializer_new_line_after_left_brace = false +ij_java_array_initializer_right_brace_on_new_line = false +ij_java_array_initializer_wrap = off +ij_java_assert_statement_colon_on_next_line = false +ij_java_assert_statement_wrap = off +ij_java_assignment_wrap = off +ij_java_binary_operation_sign_on_next_line = false +ij_java_binary_operation_wrap = off +ij_java_blank_lines_after_anonymous_class_header = 0 +ij_java_blank_lines_after_class_header = 0 +ij_java_blank_lines_after_imports = 1 +ij_java_blank_lines_after_package = 1 +ij_java_blank_lines_around_class = 1 +ij_java_blank_lines_around_field = 0 +ij_java_blank_lines_around_field_in_interface = 0 +ij_java_blank_lines_around_initializer = 1 +ij_java_blank_lines_around_method = 1 +ij_java_blank_lines_around_method_in_interface = 1 +ij_java_blank_lines_before_class_end = 0 +ij_java_blank_lines_before_imports = 1 +ij_java_blank_lines_before_method_body = 0 +ij_java_blank_lines_before_package = 0 +ij_java_block_brace_style = end_of_line +ij_java_block_comment_at_first_column = true +ij_java_builder_methods = none +ij_java_call_parameters_new_line_after_left_paren = false +ij_java_call_parameters_right_paren_on_new_line = false +ij_java_call_parameters_wrap = off +ij_java_case_statement_on_separate_line = true +ij_java_catch_on_new_line = false +ij_java_class_annotation_wrap = split_into_lines +ij_java_class_brace_style = end_of_line +ij_java_class_count_to_use_import_on_demand = 99 +ij_java_class_names_in_javadoc = 1 +ij_java_do_not_indent_top_level_class_members = false +ij_java_do_not_wrap_after_single_annotation = false +ij_java_do_while_brace_force = never +ij_java_doc_add_blank_line_after_description = true +ij_java_doc_add_blank_line_after_param_comments = false +ij_java_doc_add_blank_line_after_return = false +ij_java_doc_add_p_tag_on_empty_lines = true +ij_java_doc_align_exception_comments = true +ij_java_doc_align_param_comments = true +ij_java_doc_do_not_wrap_if_one_line = false +ij_java_doc_enable_formatting = true +ij_java_doc_enable_leading_asterisks = true +ij_java_doc_indent_on_continuation = false +ij_java_doc_keep_empty_lines = true +ij_java_doc_keep_empty_parameter_tag = true +ij_java_doc_keep_empty_return_tag = true +ij_java_doc_keep_empty_throws_tag = true +ij_java_doc_keep_invalid_tags = true +ij_java_doc_param_description_on_new_line = false +ij_java_doc_preserve_line_breaks = false +ij_java_doc_use_throws_not_exception_tag = true +ij_java_else_on_new_line = false +ij_java_enum_constants_wrap = off +ij_java_extends_keyword_wrap = off +ij_java_extends_list_wrap = off +ij_java_field_annotation_wrap = split_into_lines +ij_java_finally_on_new_line = false +ij_java_for_brace_force = never +ij_java_for_statement_new_line_after_left_paren = false +ij_java_for_statement_right_paren_on_new_line = false +ij_java_for_statement_wrap = off +ij_java_generate_final_locals = false +ij_java_generate_final_parameters = false +ij_java_if_brace_force = never +ij_java_imports_layout = $android.**,$androidx.**,$com.**,$junit.**,$net.**,$org.**,$java.**,$javax.**,$*,|,android.**,|,androidx.**,|,com.**,|,junit.**,|,net.**,|,org.**,|,java.**,|,javax.**,|,*,| +ij_java_indent_case_from_switch = true +ij_java_insert_inner_class_imports = false +ij_java_insert_override_annotation = true +ij_java_keep_blank_lines_before_right_brace = 2 +ij_java_keep_blank_lines_between_package_declaration_and_header = 2 +ij_java_keep_blank_lines_in_code = 2 +ij_java_keep_blank_lines_in_declarations = 2 +ij_java_keep_builder_methods_indents = false +ij_java_keep_control_statement_in_one_line = true +ij_java_keep_first_column_comment = true +ij_java_keep_indents_on_empty_lines = false +ij_java_keep_line_breaks = true +ij_java_keep_multiple_expressions_in_one_line = false +ij_java_keep_simple_blocks_in_one_line = false +ij_java_keep_simple_classes_in_one_line = false +ij_java_keep_simple_lambdas_in_one_line = false +ij_java_keep_simple_methods_in_one_line = false +ij_java_label_indent_absolute = false +ij_java_label_indent_size = 0 +ij_java_lambda_brace_style = end_of_line +ij_java_layout_static_imports_separately = true +ij_java_line_comment_add_space = false +ij_java_line_comment_at_first_column = true +ij_java_method_annotation_wrap = split_into_lines +ij_java_method_brace_style = end_of_line +ij_java_method_call_chain_wrap = off +ij_java_method_parameters_new_line_after_left_paren = false +ij_java_method_parameters_right_paren_on_new_line = false +ij_java_method_parameters_wrap = off +ij_java_modifier_list_wrap = false +ij_java_names_count_to_use_import_on_demand = 99 +ij_java_new_line_after_lparen_in_record_header = false +ij_java_parameter_annotation_wrap = off +ij_java_parentheses_expression_new_line_after_left_paren = false +ij_java_parentheses_expression_right_paren_on_new_line = false +ij_java_place_assignment_sign_on_next_line = false +ij_java_prefer_longer_names = true +ij_java_prefer_parameters_wrap = false +ij_java_record_components_wrap = normal +ij_java_repeat_synchronized = true +ij_java_replace_instanceof_and_cast = false +ij_java_replace_null_check = true +ij_java_replace_sum_lambda_with_method_ref = true +ij_java_resource_list_new_line_after_left_paren = false +ij_java_resource_list_right_paren_on_new_line = false +ij_java_resource_list_wrap = off +ij_java_rparen_on_new_line_in_record_header = false +ij_java_space_after_closing_angle_bracket_in_type_argument = false +ij_java_space_after_colon = true +ij_java_space_after_comma = true +ij_java_space_after_comma_in_type_arguments = true +ij_java_space_after_for_semicolon = true +ij_java_space_after_quest = true +ij_java_space_after_type_cast = true +ij_java_space_before_annotation_array_initializer_left_brace = false +ij_java_space_before_annotation_parameter_list = false +ij_java_space_before_array_initializer_left_brace = false +ij_java_space_before_catch_keyword = true +ij_java_space_before_catch_left_brace = true +ij_java_space_before_catch_parentheses = true +ij_java_space_before_class_left_brace = true +ij_java_space_before_colon = true +ij_java_space_before_colon_in_foreach = true +ij_java_space_before_comma = false +ij_java_space_before_do_left_brace = true +ij_java_space_before_else_keyword = true +ij_java_space_before_else_left_brace = true +ij_java_space_before_finally_keyword = true +ij_java_space_before_finally_left_brace = true +ij_java_space_before_for_left_brace = true +ij_java_space_before_for_parentheses = true +ij_java_space_before_for_semicolon = false +ij_java_space_before_if_left_brace = true +ij_java_space_before_if_parentheses = true +ij_java_space_before_method_call_parentheses = false +ij_java_space_before_method_left_brace = true +ij_java_space_before_method_parentheses = false +ij_java_space_before_opening_angle_bracket_in_type_parameter = false +ij_java_space_before_quest = true +ij_java_space_before_switch_left_brace = true +ij_java_space_before_switch_parentheses = true +ij_java_space_before_synchronized_left_brace = true +ij_java_space_before_synchronized_parentheses = true +ij_java_space_before_try_left_brace = true +ij_java_space_before_try_parentheses = true +ij_java_space_before_type_parameter_list = false +ij_java_space_before_while_keyword = true +ij_java_space_before_while_left_brace = true +ij_java_space_before_while_parentheses = true +ij_java_space_inside_one_line_enum_braces = false +ij_java_space_within_empty_array_initializer_braces = false +ij_java_space_within_empty_method_call_parentheses = false +ij_java_space_within_empty_method_parentheses = false +ij_java_spaces_around_additive_operators = true +ij_java_spaces_around_assignment_operators = true +ij_java_spaces_around_bitwise_operators = true +ij_java_spaces_around_equality_operators = true +ij_java_spaces_around_lambda_arrow = true +ij_java_spaces_around_logical_operators = true +ij_java_spaces_around_method_ref_dbl_colon = false +ij_java_spaces_around_multiplicative_operators = true +ij_java_spaces_around_relational_operators = true +ij_java_spaces_around_shift_operators = true +ij_java_spaces_around_type_bounds_in_type_parameters = true +ij_java_spaces_around_unary_operator = false +ij_java_spaces_within_angle_brackets = false +ij_java_spaces_within_annotation_parentheses = false +ij_java_spaces_within_array_initializer_braces = false +ij_java_spaces_within_braces = false +ij_java_spaces_within_brackets = false +ij_java_spaces_within_cast_parentheses = false +ij_java_spaces_within_catch_parentheses = false +ij_java_spaces_within_for_parentheses = false +ij_java_spaces_within_if_parentheses = false +ij_java_spaces_within_method_call_parentheses = false +ij_java_spaces_within_method_parentheses = false +ij_java_spaces_within_parentheses = false +ij_java_spaces_within_record_header = false +ij_java_spaces_within_switch_parentheses = false +ij_java_spaces_within_synchronized_parentheses = false +ij_java_spaces_within_try_parentheses = false +ij_java_spaces_within_while_parentheses = false +ij_java_special_else_if_treatment = true +ij_java_subclass_name_suffix = Impl +ij_java_ternary_operation_signs_on_next_line = false +ij_java_ternary_operation_wrap = off +ij_java_test_name_suffix = Test +ij_java_throws_keyword_wrap = off +ij_java_throws_list_wrap = off +ij_java_use_external_annotations = false +ij_java_use_fq_class_names = false +ij_java_use_relative_indents = false +ij_java_use_single_class_imports = true +ij_java_variable_annotation_wrap = off +ij_java_visibility = public +ij_java_while_brace_force = never +ij_java_while_on_new_line = false +ij_java_wrap_comments = false +ij_java_wrap_first_method_in_call_chain = false +ij_java_wrap_long_lines = false + +[*.properties] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = false +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false + +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true + +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] +ij_xml_align_attributes = false +ij_xml_align_text = false +ij_xml_attribute_wrap = normal +ij_xml_block_comment_at_first_column = true +ij_xml_keep_blank_lines = 2 +ij_xml_keep_indents_on_empty_lines = false +ij_xml_keep_line_breaks = false +ij_xml_keep_line_breaks_in_text = true +ij_xml_keep_whitespaces = false +ij_xml_keep_whitespaces_around_cdata = preserve +ij_xml_keep_whitespaces_inside_cdata = false +ij_xml_line_comment_at_first_column = true +ij_xml_space_after_tag_name = false +ij_xml_space_around_equals_in_attribute = false +ij_xml_space_inside_empty_tag = true +ij_xml_text_wrap = normal +ij_xml_use_custom_settings = true + +[{*.bash,*.sh,*.zsh}] +indent_size = 2 +tab_width = 2 +ij_shell_binary_ops_start_line = false +ij_shell_keep_column_alignment_padding = false +ij_shell_minify_program = false +ij_shell_redirect_followed_by_space = false +ij_shell_switch_cases_indented = false +ij_shell_use_unix_line_separator = true + +[{*.gant,*.gradle,*.groovy,*.gy}] +ij_groovy_align_group_field_declarations = false +ij_groovy_align_multiline_array_initializer_expression = false +ij_groovy_align_multiline_assignment = false +ij_groovy_align_multiline_binary_operation = false +ij_groovy_align_multiline_chained_methods = false +ij_groovy_align_multiline_extends_list = false +ij_groovy_align_multiline_for = true +ij_groovy_align_multiline_list_or_map = true +ij_groovy_align_multiline_method_parentheses = false +ij_groovy_align_multiline_parameters = true +ij_groovy_align_multiline_parameters_in_calls = false +ij_groovy_align_multiline_resources = true +ij_groovy_align_multiline_ternary_operation = false +ij_groovy_align_multiline_throws_list = false +ij_groovy_align_named_args_in_map = true +ij_groovy_align_throws_keyword = false +ij_groovy_array_initializer_new_line_after_left_brace = false +ij_groovy_array_initializer_right_brace_on_new_line = false +ij_groovy_array_initializer_wrap = off +ij_groovy_assert_statement_wrap = off +ij_groovy_assignment_wrap = off +ij_groovy_binary_operation_wrap = off +ij_groovy_blank_lines_after_class_header = 0 +ij_groovy_blank_lines_after_imports = 1 +ij_groovy_blank_lines_after_package = 1 +ij_groovy_blank_lines_around_class = 1 +ij_groovy_blank_lines_around_field = 0 +ij_groovy_blank_lines_around_field_in_interface = 0 +ij_groovy_blank_lines_around_method = 1 +ij_groovy_blank_lines_around_method_in_interface = 1 +ij_groovy_blank_lines_before_imports = 1 +ij_groovy_blank_lines_before_method_body = 0 +ij_groovy_blank_lines_before_package = 0 +ij_groovy_block_brace_style = end_of_line +ij_groovy_block_comment_at_first_column = true +ij_groovy_call_parameters_new_line_after_left_paren = false +ij_groovy_call_parameters_right_paren_on_new_line = false +ij_groovy_call_parameters_wrap = off +ij_groovy_catch_on_new_line = false +ij_groovy_class_annotation_wrap = split_into_lines +ij_groovy_class_brace_style = end_of_line +ij_groovy_class_count_to_use_import_on_demand = 5 +ij_groovy_do_while_brace_force = never +ij_groovy_else_on_new_line = false +ij_groovy_enum_constants_wrap = off +ij_groovy_extends_keyword_wrap = off +ij_groovy_extends_list_wrap = off +ij_groovy_field_annotation_wrap = split_into_lines +ij_groovy_finally_on_new_line = false +ij_groovy_for_brace_force = never +ij_groovy_for_statement_new_line_after_left_paren = false +ij_groovy_for_statement_right_paren_on_new_line = false +ij_groovy_for_statement_wrap = off +ij_groovy_if_brace_force = never +ij_groovy_import_annotation_wrap = 2 +ij_groovy_imports_layout = *,|,javax.**,java.**,|,$* +ij_groovy_indent_case_from_switch = true +ij_groovy_indent_label_blocks = true +ij_groovy_insert_inner_class_imports = false +ij_groovy_keep_blank_lines_before_right_brace = 2 +ij_groovy_keep_blank_lines_in_code = 2 +ij_groovy_keep_blank_lines_in_declarations = 2 +ij_groovy_keep_control_statement_in_one_line = true +ij_groovy_keep_first_column_comment = true +ij_groovy_keep_indents_on_empty_lines = false +ij_groovy_keep_line_breaks = true +ij_groovy_keep_multiple_expressions_in_one_line = false +ij_groovy_keep_simple_blocks_in_one_line = false +ij_groovy_keep_simple_classes_in_one_line = true +ij_groovy_keep_simple_lambdas_in_one_line = true +ij_groovy_keep_simple_methods_in_one_line = true +ij_groovy_label_indent_absolute = false +ij_groovy_label_indent_size = 0 +ij_groovy_lambda_brace_style = end_of_line +ij_groovy_layout_static_imports_separately = true +ij_groovy_line_comment_add_space = false +ij_groovy_line_comment_at_first_column = true +ij_groovy_method_annotation_wrap = split_into_lines +ij_groovy_method_brace_style = end_of_line +ij_groovy_method_call_chain_wrap = off +ij_groovy_method_parameters_new_line_after_left_paren = false +ij_groovy_method_parameters_right_paren_on_new_line = false +ij_groovy_method_parameters_wrap = off +ij_groovy_modifier_list_wrap = false +ij_groovy_names_count_to_use_import_on_demand = 3 +ij_groovy_parameter_annotation_wrap = off +ij_groovy_parentheses_expression_new_line_after_left_paren = false +ij_groovy_parentheses_expression_right_paren_on_new_line = false +ij_groovy_prefer_parameters_wrap = false +ij_groovy_resource_list_new_line_after_left_paren = false +ij_groovy_resource_list_right_paren_on_new_line = false +ij_groovy_resource_list_wrap = off +ij_groovy_space_after_assert_separator = true +ij_groovy_space_after_colon = true +ij_groovy_space_after_comma = true +ij_groovy_space_after_comma_in_type_arguments = true +ij_groovy_space_after_for_semicolon = true +ij_groovy_space_after_quest = true +ij_groovy_space_after_type_cast = true +ij_groovy_space_before_annotation_parameter_list = false +ij_groovy_space_before_array_initializer_left_brace = false +ij_groovy_space_before_assert_separator = false +ij_groovy_space_before_catch_keyword = true +ij_groovy_space_before_catch_left_brace = true +ij_groovy_space_before_catch_parentheses = true +ij_groovy_space_before_class_left_brace = true +ij_groovy_space_before_closure_left_brace = true +ij_groovy_space_before_colon = true +ij_groovy_space_before_comma = false +ij_groovy_space_before_do_left_brace = true +ij_groovy_space_before_else_keyword = true +ij_groovy_space_before_else_left_brace = true +ij_groovy_space_before_finally_keyword = true +ij_groovy_space_before_finally_left_brace = true +ij_groovy_space_before_for_left_brace = true +ij_groovy_space_before_for_parentheses = true +ij_groovy_space_before_for_semicolon = false +ij_groovy_space_before_if_left_brace = true +ij_groovy_space_before_if_parentheses = true +ij_groovy_space_before_method_call_parentheses = false +ij_groovy_space_before_method_left_brace = true +ij_groovy_space_before_method_parentheses = false +ij_groovy_space_before_quest = true +ij_groovy_space_before_switch_left_brace = true +ij_groovy_space_before_switch_parentheses = true +ij_groovy_space_before_synchronized_left_brace = true +ij_groovy_space_before_synchronized_parentheses = true +ij_groovy_space_before_try_left_brace = true +ij_groovy_space_before_try_parentheses = true +ij_groovy_space_before_while_keyword = true +ij_groovy_space_before_while_left_brace = true +ij_groovy_space_before_while_parentheses = true +ij_groovy_space_in_named_argument = true +ij_groovy_space_in_named_argument_before_colon = false +ij_groovy_space_within_empty_array_initializer_braces = false +ij_groovy_space_within_empty_method_call_parentheses = false +ij_groovy_spaces_around_additive_operators = true +ij_groovy_spaces_around_assignment_operators = true +ij_groovy_spaces_around_bitwise_operators = true +ij_groovy_spaces_around_equality_operators = true +ij_groovy_spaces_around_lambda_arrow = true +ij_groovy_spaces_around_logical_operators = true +ij_groovy_spaces_around_multiplicative_operators = true +ij_groovy_spaces_around_regex_operators = true +ij_groovy_spaces_around_relational_operators = true +ij_groovy_spaces_around_shift_operators = true +ij_groovy_spaces_within_annotation_parentheses = false +ij_groovy_spaces_within_array_initializer_braces = false +ij_groovy_spaces_within_braces = true +ij_groovy_spaces_within_brackets = false +ij_groovy_spaces_within_cast_parentheses = false +ij_groovy_spaces_within_catch_parentheses = false +ij_groovy_spaces_within_for_parentheses = false +ij_groovy_spaces_within_gstring_injection_braces = false +ij_groovy_spaces_within_if_parentheses = false +ij_groovy_spaces_within_list_or_map = false +ij_groovy_spaces_within_method_call_parentheses = false +ij_groovy_spaces_within_method_parentheses = false +ij_groovy_spaces_within_parentheses = false +ij_groovy_spaces_within_switch_parentheses = false +ij_groovy_spaces_within_synchronized_parentheses = false +ij_groovy_spaces_within_try_parentheses = false +ij_groovy_spaces_within_tuple_expression = false +ij_groovy_spaces_within_while_parentheses = false +ij_groovy_special_else_if_treatment = true +ij_groovy_ternary_operation_wrap = off +ij_groovy_throws_keyword_wrap = off +ij_groovy_throws_list_wrap = off +ij_groovy_use_flying_geese_braces = false +ij_groovy_use_fq_class_names = false +ij_groovy_use_fq_class_names_in_javadoc = true +ij_groovy_use_relative_indents = false +ij_groovy_use_single_class_imports = true +ij_groovy_variable_annotation_wrap = off +ij_groovy_while_brace_force = never +ij_groovy_while_on_new_line = false +ij_groovy_wrap_long_lines = false + +# noinspection EditorConfigKeyCorrectness +[{*.kt,*.kts}] +ktlint_code_style = android_studio +ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_trailing-comma-on-declaration-site = disabled +ktlint_standard_function-signature = disabled +ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_standard_function-expression-body = disabled +ktlint_standard_class-signature = disabled +max_line_length = 120 +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_assignment_wrap = normal +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = true +ij_kotlin_call_parameters_right_paren_on_new_line = true +ij_kotlin_call_parameters_wrap = on_every_item +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_for_chained_calls = false +ij_kotlin_continuation_indent_for_expression_bodies = false +ij_kotlin_continuation_indent_in_argument_lists = false +ij_kotlin_continuation_indent_in_elvis = false +ij_kotlin_continuation_indent_in_if_conditions = false +ij_kotlin_continuation_indent_in_parameter_lists = false +ij_kotlin_continuation_indent_in_supertype_lists = false +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = normal +ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = true +ij_kotlin_import_nested_classes = false +ij_kotlin_imports_layout = *,java.**,javax.**,kotlin.**,^ +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = normal +ij_kotlin_method_parameters_new_line_after_left_paren = true +ij_kotlin_method_parameters_right_paren_on_new_line = true +ij_kotlin_method_parameters_wrap = on_every_item +ij_kotlin_name_count_to_use_star_import = 2147483647 +ij_kotlin_name_count_to_use_star_import_for_members = 2147483647 +ij_kotlin_packages_to_use_import_on_demand = kotlinx.android.synthetic.**,io.ktor.** +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_use_custom_formatting_for_modifiers = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false + +[{*.har,*.json}] +indent_size = 2 +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = true +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.htm,*.html,*.sht,*.shtm,*.shtml}] +ij_html_add_new_line_before_tags = body,div,p,form,h1,h2,h3 +ij_html_align_attributes = true +ij_html_align_text = false +ij_html_attribute_wrap = normal +ij_html_block_comment_at_first_column = true +ij_html_do_not_align_children_of_min_lines = 0 +ij_html_do_not_break_if_inline_tags = title,h1,h2,h3,h4,h5,h6,p +ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot +ij_html_enforce_quotes = false +ij_html_inline_tags = a,abbr,acronym,b,basefont,bdo,big,br,cite,cite,code,dfn,em,font,i,img,input,kbd,label,q,s,samp,select,small,span,strike,strong,sub,sup,textarea,tt,u,var +ij_html_keep_blank_lines = 2 +ij_html_keep_indents_on_empty_lines = false +ij_html_keep_line_breaks = true +ij_html_keep_line_breaks_in_text = true +ij_html_keep_whitespaces = false +ij_html_keep_whitespaces_inside = span,pre,textarea +ij_html_line_comment_at_first_column = true +ij_html_new_line_after_last_attribute = never +ij_html_new_line_before_first_attribute = never +ij_html_quote_style = double +ij_html_remove_new_line_before_tags = br +ij_html_space_after_tag_name = false +ij_html_space_around_equality_in_attribute = false +ij_html_space_inside_empty_tag = false +ij_html_text_wrap = normal + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..02cf6d1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +.idea/* +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..d21149d --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,136 @@ +import org.gradle.configurationcache.extensions.capitalized +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.jetbrains.kotlin.android) + id("com.google.dagger.hilt.android") + id("com.google.devtools.ksp") + id("kotlin-parcelize") + id("com.google.protobuf") version ("0.9.4") +} + +android { + namespace = "com.adyen.testcards" + compileSdk = 34 + + defaultConfig { + applicationId = "com.adyen.testcards" + minSdk = 26 + targetSdk = 34 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary = true + } + } + + signingConfigs { + getByName("debug") {} + } + + + buildTypes { + debug { + isMinifyEnabled = false + } + release { + isMinifyEnabled = true + isDebuggable = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + + signingConfig = signingConfigs.getByName("debug") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = "1.8" + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = "1.5.15" + } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } +} + +dependencies { + + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.autofill) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.datastore) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.material3) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.hilt) + implementation(libs.material) + implementation(libs.protobuf.lite) + implementation(libs.retrofit) + implementation(libs.retrofit.moshi) + + ksp(libs.hiltCompiler) + ksp(libs.moshi.code.gen) + + testImplementation(libs.junit) + + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.ui.test.junit4) + + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) +} + +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}" + } + + // Generates the java Protobuf-lite code for the Protobufs in this project. See + // https://github.com/google/protobuf-gradle-plugin#customizing-protobuf-compilation + // for more information. + generateProtoTasks { + all().forEach { task -> + task.plugins { + create("java") { + option("lite") + } + } + + } + } +} + +androidComponents { + onVariants(selector().all()) { variant -> + afterEvaluate { + val capName = variant.name.capitalized() + tasks.getByName("ksp${capName}Kotlin") { + setSource(tasks.getByName("generate${capName}Proto").outputs) + } + } + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..0e32771 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,22 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile +-keep class com.adyen.testcards.data.StoredFavorites { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..aecd0e9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/upis.json b/app/src/main/assets/upis.json new file mode 100644 index 0000000..c4756d1 --- /dev/null +++ b/app/src/main/assets/upis.json @@ -0,0 +1,8 @@ +[ + { + "virtualPaymentAddress": "testvpa@icici" + }, + { + "virtualPaymentAddress": "billdesk@upi" + } +] \ No newline at end of file diff --git a/app/src/main/assets/usernamepasswords.json b/app/src/main/assets/usernamepasswords.json new file mode 100644 index 0000000..b31bd6c --- /dev/null +++ b/app/src/main/assets/usernamepasswords.json @@ -0,0 +1,32 @@ +[ + { + "username": "forex_1698906295803@alitest.com", + "password": "111111", + "type": "Alipay" + }, + { + "username": "hkbuyer_9709@alitest.com", + "password": "a111111", + "type": "Alipay HK" + }, + { + "username": "u83646180", + "password": "rlf446", + "type": "Pay by bank" + }, + { + "username": "u83188312", + "password": "zhx571", + "type": "Pay by bank" + }, + { + "username": "u92721594", + "password": "nbs589", + "type": "Pay by bank" + }, + { + "username": "u91902655", + "password": "jtx720", + "type": "Pay by bank" + } +] diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..165b718 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/com/adyen/testcards/App.kt b/app/src/main/java/com/adyen/testcards/App.kt new file mode 100644 index 0000000..6a6503d --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/App.kt @@ -0,0 +1,7 @@ +package com.adyen.testcards + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class App : Application() diff --git a/app/src/main/java/com/adyen/testcards/autofill/AdyenTestCardsAutofillService.kt b/app/src/main/java/com/adyen/testcards/autofill/AdyenTestCardsAutofillService.kt new file mode 100644 index 0000000..8336068 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/autofill/AdyenTestCardsAutofillService.kt @@ -0,0 +1,75 @@ +package com.adyen.testcards.autofill + +import android.app.PendingIntent +import android.os.Build +import android.os.CancellationSignal +import android.service.autofill.AutofillService +import android.service.autofill.Dataset +import android.service.autofill.Field +import android.service.autofill.FillCallback +import android.service.autofill.FillRequest +import android.service.autofill.FillResponse +import android.service.autofill.Presentations +import android.service.autofill.SaveCallback +import android.service.autofill.SaveRequest +import android.util.Log +import android.widget.RemoteViews +import com.adyen.testcards.R +import java.util.concurrent.atomic.AtomicInteger + +class AdyenTestCardsAutofillService : AutofillService() { + + private val requestCode = AtomicInteger() + + override fun onFillRequest(request: FillRequest, cancellationSignal: CancellationSignal, callback: FillCallback) { + Log.d(TAG, "onFillRequest") + var pendingIntent: PendingIntent? = null + + cancellationSignal.setOnCancelListener { + try { + pendingIntent?.cancel() + } catch (e: Exception) { + Log.e(TAG, "Error while cancelling pending intent", e) + } + } + + val structure = request.fillContexts.last().structure + val parsedStructure = StructureParser().parse(structure) ?: return + + val responseBuilder = FillResponse.Builder() + + pendingIntent = AutofillActivity.createPendingIntent(this, parsedStructure, requestCode.getAndIncrement()) + val remoteViews = RemoteViews(packageName, R.layout.item_autofill_entry) + + val datasetBuilder = Dataset.Builder() + parsedStructure.allIds().forEach { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val presentations = Presentations.Builder() + .setMenuPresentation(remoteViews) + .setDialogPresentation(remoteViews) + .build() + datasetBuilder.setField( + it, + Field.Builder().setPresentations(presentations).build(), + ) + } else { + @Suppress("DEPRECATION") + datasetBuilder.setValue(it, null, remoteViews) + } + } + + datasetBuilder.setAuthentication(pendingIntent.intentSender) + + responseBuilder.addDataset(datasetBuilder.build()) + + callback.onSuccess(responseBuilder.build()) + } + + override fun onSaveRequest(request: SaveRequest, callback: SaveCallback) { + callback.onSuccess() + } + + companion object { + private const val TAG = "TestCardAutofillService" + } +} diff --git a/app/src/main/java/com/adyen/testcards/autofill/AutofillActivity.kt b/app/src/main/java/com/adyen/testcards/autofill/AutofillActivity.kt new file mode 100644 index 0000000..ac66c3e --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/autofill/AutofillActivity.kt @@ -0,0 +1,75 @@ +package com.adyen.testcards.autofill + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.service.autofill.Dataset +import android.view.autofill.AutofillManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import com.adyen.testcards.ui.theme.AdyenTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +@AndroidEntryPoint +internal class AutofillActivity : ComponentActivity() { + + private val viewModel: AutofillViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + viewModel.dataset + .flowWithLifecycle(lifecycle) + .onEach(::onDataset) + .launchIn(lifecycleScope) + + enableEdgeToEdge() + setContent { + AdyenTheme { + AutofillScreen( + viewModel = viewModel, + onDismiss = ::finish, + ) + } + } + } + + private fun onDataset(dataset: Dataset) { + val resultIntent = Intent().apply { + putExtra(AutofillManager.EXTRA_AUTHENTICATION_RESULT, dataset) + } + setResult(RESULT_OK, resultIntent) + finish() + } + + companion object { + + private const val EXTRA_PARSED_RESULT = "EXTRA_PARSED_RESULT" + + internal fun createPendingIntent( + context: Context, + parsedStructure: ParsedStructure, + requestCode: Int, + ): PendingIntent { + val intent = Intent(context, AutofillActivity::class.java).apply { + putExtra(EXTRA_PARSED_RESULT, parsedStructure) + } + + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT + } else { + PendingIntent.FLAG_CANCEL_CURRENT + } + + return PendingIntent.getActivity(context, requestCode, intent, flags) + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/autofill/AutofillScreen.kt b/app/src/main/java/com/adyen/testcards/autofill/AutofillScreen.kt new file mode 100644 index 0000000..f590942 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/autofill/AutofillScreen.kt @@ -0,0 +1,173 @@ +package com.adyen.testcards.autofill + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.adyen.testcards.R +import com.adyen.testcards.ui.AdyenSearchBar +import com.adyen.testcards.ui.creditCardSection +import com.adyen.testcards.ui.favoritesSection +import com.adyen.testcards.ui.giftCardSection +import com.adyen.testcards.ui.ibanSection +import com.adyen.testcards.ui.upiSection +import com.adyen.testcards.ui.usernamePasswordSection + +@Composable +internal fun AutofillScreen( + viewModel: AutofillViewModel, + onDismiss: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + AutofillScreen( + uiState, + onDismiss, + viewModel::onQueryChange, + viewModel::onFavoriteClick, + viewModel::onPaymentMethodClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AutofillScreen( + uiState: AutofillUIState, + onDismiss: () -> Unit, + onQueryChange: (String) -> Unit, + onFavoriteClick: (Any, Boolean) -> Unit, + onPaymentMethodClick: (Any) -> Unit, +) { + val modalBottomSheetState = rememberModalBottomSheetState() + + ModalBottomSheet( + onDismissRequest = { onDismiss() }, + sheetState = modalBottomSheetState, + dragHandle = { BottomSheetDefaults.DragHandle() }, + ) { + Box { + AdyenSearchBar( + query = uiState.query, + onQueryChange = onQueryChange, + modifier = Modifier.align(Alignment.TopCenter), + ) + + when (uiState) { + is AutofillUIState.Content -> AutofillContent(uiState, onFavoriteClick, onPaymentMethodClick) + is AutofillUIState.Empty -> if (!uiState.isLoading) AutofillEmpty() + is AutofillUIState.Error -> AutofillError() + } + } + } +} + +@Composable +private fun AutofillContent( + uiState: AutofillUIState.Content, + onFavoriteClick: (Any, Boolean) -> Unit, + onPaymentMethodClick: (Any) -> Unit, +) { + LazyColumn( + contentPadding = PaddingValues(0.dp, 72.dp, 0.dp, 0.dp), + modifier = Modifier.fillMaxSize(), + ) { + val paymentMethods = uiState.paymentMethods + + if (paymentMethods.hasFavorites()) { + favoritesSection( + paymentMethods.favoriteCreditCards, + paymentMethods.favoriteGiftCards, + paymentMethods.favoriteIBANs, + paymentMethods.favoriteUPIs, + paymentMethods.favoriteUsernamePasswords, + onFavoriteClick, + onPaymentMethodClick, + ) + } + + if (paymentMethods.creditCards.isNotEmpty()) { + creditCardSection(paymentMethods.creditCards, onFavoriteClick, onPaymentMethodClick) + } + + if (paymentMethods.giftCards.isNotEmpty()) { + giftCardSection(paymentMethods.giftCards, onFavoriteClick, onPaymentMethodClick) + } + + if (paymentMethods.ibans.isNotEmpty()) { + ibanSection(paymentMethods.ibans, onFavoriteClick, onPaymentMethodClick) + } + + if (paymentMethods.upis.isNotEmpty()) { + upiSection(paymentMethods.upis, onFavoriteClick, onPaymentMethodClick) + } + + if (paymentMethods.usernamePasswords.isNotEmpty()) { + usernamePasswordSection(paymentMethods.usernamePasswords, onFavoriteClick, onPaymentMethodClick) + } + } +} + +@Composable +private fun AutofillEmpty() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.card_phone_payment), + contentDescription = null, + modifier = Modifier.fillMaxWidth(.8f), + ) + Spacer(Modifier.padding(32.dp)) + Text("No content!", style = MaterialTheme.typography.titleLarge) + Text( + text = "Try improving your search query.", + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp, 8.dp), + ) + } +} + +@Composable +private fun AutofillError() { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.man_shrugging), + contentDescription = null, + modifier = Modifier.fillMaxWidth(.8f), + ) + Spacer(Modifier.padding(32.dp)) + Text("Error!", style = MaterialTheme.typography.titleLarge) + Text( + text = "Whoops, something went wrong...", + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp, 8.dp), + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/autofill/AutofillUIState.kt b/app/src/main/java/com/adyen/testcards/autofill/AutofillUIState.kt new file mode 100644 index 0000000..d7bb88a --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/autofill/AutofillUIState.kt @@ -0,0 +1,25 @@ +package com.adyen.testcards.autofill + +import com.adyen.testcards.domain.CombinedPaymentMethods + +sealed interface AutofillUIState { + + val isLoading: Boolean + val query: String + + data class Content( + override val isLoading: Boolean, + override val query: String, + val paymentMethods: CombinedPaymentMethods, + ) : AutofillUIState + + data class Empty( + override val isLoading: Boolean, + override val query: String, + ) : AutofillUIState + + data class Error( + override val isLoading: Boolean, + override val query: String, + ) : AutofillUIState +} diff --git a/app/src/main/java/com/adyen/testcards/autofill/AutofillViewModel.kt b/app/src/main/java/com/adyen/testcards/autofill/AutofillViewModel.kt new file mode 100644 index 0000000..646c577 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/autofill/AutofillViewModel.kt @@ -0,0 +1,202 @@ +package com.adyen.testcards.autofill + +import android.service.autofill.Dataset +import android.util.Log +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adyen.testcards.domain.CombinedPaymentMethods +import com.adyen.testcards.domain.CreateDatasetUseCase +import com.adyen.testcards.domain.FavoriteItemUseCase +import com.adyen.testcards.domain.GetPaymentMethodsUseCase +import com.adyen.testcards.domain.PaymentMethodType +import com.adyen.testcards.domain.SearchUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class AutofillViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val searchUseCase: SearchUseCase, + private val getPaymentMethodsUseCase: GetPaymentMethodsUseCase, + private val favoriteItemUseCase: FavoriteItemUseCase, + private val createDatasetUseCase: CreateDatasetUseCase, +) : ViewModel() { + + private val parsedStructure: ParsedStructure = savedStateHandle["EXTRA_PARSED_RESULT"]!! + private val detectedPaymentMethod = detectPaymentMethod() + + private val _dataset = MutableSharedFlow() + val dataset = _dataset.asSharedFlow() + + private val viewModelState = MutableStateFlow( + AutofillViewModelState( + isLoading = true, + ), + ) + + private val searchResults: MutableStateFlow = MutableStateFlow(null) + + private val paymentMethodFlow = getPaymentMethodsUseCase.getPaymentMethods() + // Filter payment methods to only show the detected payment method + .map { result -> + result.map { combinedPaymentMethods -> + when (detectedPaymentMethod) { + PaymentMethodType.CREDIT_CARD -> CombinedPaymentMethods( + favoriteCreditCards = combinedPaymentMethods.favoriteCreditCards, + creditCards = combinedPaymentMethods.creditCards, + ) + + PaymentMethodType.GIFT_CARD -> CombinedPaymentMethods( + favoriteGiftCards = combinedPaymentMethods.favoriteGiftCards, + giftCards = combinedPaymentMethods.giftCards, + ) + + PaymentMethodType.UPI -> CombinedPaymentMethods( + favoriteUPIs = combinedPaymentMethods.favoriteUPIs, + upis = combinedPaymentMethods.upis, + ) + + PaymentMethodType.USERNAME_PASSWORD -> CombinedPaymentMethods( + favoriteUsernamePasswords = combinedPaymentMethods.favoriteUsernamePasswords, + usernamePasswords = combinedPaymentMethods.usernamePasswords, + ) + + PaymentMethodType.UNKNOWN -> combinedPaymentMethods + } + } + } + .onEach { viewModelState.update { it.copy(isLoading = false) } } + + val uiState: StateFlow = combine( + viewModelState, + paymentMethodFlow, + searchResults, + ) { viewModelState, paymentMethods, searchResults -> + if (searchResults != null) { + viewModelState.copy(paymentMethods = searchResults) + } else { + paymentMethods.fold( + onSuccess = { + backupPaymentMethods = it + viewModelState.copy(paymentMethods = it) + }, + onFailure = { + Log.e("MainViewModel", "Failed to fetch payment methods.", it) + viewModelState.copy(hasError = true) + }, + ) + } + } + .map { it.toUIState() } + .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUIState()) + + private var backupPaymentMethods = viewModelState.value.paymentMethods + + init { + viewModelScope.launch { + getPaymentMethodsUseCase.refresh() + } + } + + fun onQueryChange(query: String) { + viewModelState.update { it.copy(query = query) } + viewModelScope.launch { + searchResults.update { + if (query.isNotBlank()) { + CombinedPaymentMethods( + favoriteCreditCards = emptyList(), + favoriteGiftCards = emptyList(), + favoriteIBANs = emptyList(), + favoriteUPIs = emptyList(), + favoriteUsernamePasswords = emptyList(), + creditCards = searchUseCase.searchCreditCards(query, backupPaymentMethods.creditCards), + giftCards = searchUseCase.searchGiftCards(query, backupPaymentMethods.giftCards), + ibans = searchUseCase.searchIBANs(query, backupPaymentMethods.ibans), + upis = searchUseCase.searchUPIs(query, backupPaymentMethods.upis), + usernamePasswords = searchUseCase.searchUsernamePasswords( + query, + backupPaymentMethods.usernamePasswords, + ), + ) + } else { + null + } + } + } + } + + fun onFavoriteClick(item: Any, isFavorite: Boolean) { + viewModelScope.launch { + if (isFavorite) { + favoriteItemUseCase.favorite(item) + } else { + favoriteItemUseCase.unfavorite(item) + } + } + } + + fun onPaymentMethodClick(item: Any) { + val dataset = createDatasetUseCase.createDataset(item, parsedStructure) + viewModelScope.launch { + _dataset.emit(dataset) + } + } + + private fun detectPaymentMethod(): PaymentMethodType { + return with(parsedStructure) { + when { + creditCardNumberId != null + || creditCardExpiryDateId != null + || creditCardSecurityCodeId != null -> PaymentMethodType.CREDIT_CARD + + giftCardNumberId != null + || giftCardPinId != null -> PaymentMethodType.GIFT_CARD + + upiVpaId != null -> PaymentMethodType.UPI + + usernameId != null + || passwordId != null -> PaymentMethodType.USERNAME_PASSWORD + + else -> PaymentMethodType.UNKNOWN + } + } + } +} + +private data class AutofillViewModelState( + val isLoading: Boolean = false, + val query: String = "", + val paymentMethods: CombinedPaymentMethods = CombinedPaymentMethods(), + val hasError: Boolean = false, +) { + + fun toUIState() = when { + hasError -> AutofillUIState.Error( + isLoading = isLoading, + query = query, + ) + + paymentMethods.isEmpty() -> AutofillUIState.Empty( + isLoading = isLoading, + query = query, + ) + + else -> AutofillUIState.Content( + isLoading = isLoading, + query = query, + paymentMethods = paymentMethods, + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/autofill/ParsedStructure.kt b/app/src/main/java/com/adyen/testcards/autofill/ParsedStructure.kt new file mode 100644 index 0000000..c58ad9b --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/autofill/ParsedStructure.kt @@ -0,0 +1,34 @@ +package com.adyen.testcards.autofill + +import android.os.Parcelable +import android.view.autofill.AutofillId +import kotlinx.parcelize.Parcelize + +@Parcelize +internal data class ParsedStructure( + var applicationId: String? = null, + var creditCardNumberId: AutofillId? = null, + var creditCardExpiryDateId: AutofillId? = null, + var creditCardSecurityCodeId: AutofillId? = null, + var giftCardNumberId: AutofillId? = null, + var giftCardPinId: AutofillId? = null, + var ibanId: AutofillId? = null, + var upiVpaId: AutofillId? = null, + var usernameId: AutofillId? = null, + var usernameCandidate: AutofillId? = null, + var passwordId: AutofillId? = null, +) : Parcelable { + + fun isValid() = allIds().isNotEmpty() + + fun allIds() = listOfNotNull( + creditCardNumberId, + creditCardExpiryDateId, + creditCardSecurityCodeId, + giftCardNumberId, + giftCardPinId, + upiVpaId, + usernameId, + passwordId, + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/adyen/testcards/autofill/StructureParser.kt b/app/src/main/java/com/adyen/testcards/autofill/StructureParser.kt new file mode 100644 index 0000000..0999a0a --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/autofill/StructureParser.kt @@ -0,0 +1,215 @@ +package com.adyen.testcards.autofill + +import android.app.assist.AssistStructure +import android.app.assist.AssistStructure.ViewNode +import android.text.InputType +import android.util.Log +import android.view.View +import androidx.autofill.HintConstants + +internal class StructureParser { + + fun parse(structure: AssistStructure): ParsedStructure? { + val result = ParsedStructure() + + for (i in 0 until structure.windowNodeCount) { + val node = structure.getWindowNodeAt(i) + val applicationId = node.title.split("/").firstOrNull().orEmpty() + when { + blockedAppIds.any { applicationId.startsWith(it) } -> { + Log.d(TAG, "Application ID ignored: $applicationId") + } + + else -> { + result.applicationId = applicationId + parseNode(node.rootViewNode, result) + } + } + } + + if (result.usernameId == null && result.passwordId != null) { + result.usernameId = result.usernameCandidate + } + + return if (result.isValid()) { + result + } else { + null + } + } + + private fun parseNode(node: ViewNode, result: ParsedStructure) { + if (node.visibility == View.VISIBLE) { + if (node.autofillId != null) { + if (!node.autofillHints.isNullOrEmpty()) { + parseNodeByAutofillHint(node, result) + } else if (node.htmlInfo != null) { + parseNodeByHtmlInfo(node, result) + } else { + parseNodeByInputType(node, result) + } + } + + for (i in 0 until node.childCount) { + parseNode(node.getChildAt(i), result) + } + } + } + + private fun parseNodeByAutofillHint(node: ViewNode, result: ParsedStructure) { + Log.d(TAG, "Parsing by autofill hint") + node.autofillHints?.forEach { hint -> + when { + hint.contains(View.AUTOFILL_HINT_USERNAME, true) + || hint.contains(View.AUTOFILL_HINT_EMAIL_ADDRESS, true) + || hint.contains(View.AUTOFILL_HINT_PHONE, true) + || hint.contains(AUTOFILL_HINT_EMAIL, true) + || hint.contains(AUTOFILL_HINT_LOGIN, true) -> { + if (result.passwordId == null) { + result.usernameId = node.autofillId + } else { + result.usernameCandidate = node.autofillId + } + } + + hint.contains(View.AUTOFILL_HINT_PASSWORD, true) -> { + result.passwordId = node.autofillId + } + + hint.contains(View.AUTOFILL_HINT_CREDIT_CARD_NUMBER, true) -> { + result.creditCardNumberId = node.autofillId + } + + hint.contains(View.AUTOFILL_HINT_CREDIT_CARD_EXPIRATION_DATE, true) -> { + result.creditCardExpiryDateId = node.autofillId + } + + hint.contains(View.AUTOFILL_HINT_CREDIT_CARD_SECURITY_CODE, true) -> { + result.creditCardSecurityCodeId = node.autofillId + } + + hint.contains(HintConstants.AUTOFILL_HINT_GIFT_CARD_NUMBER, true) -> { + result.giftCardNumberId = node.autofillId + } + + hint.contains(HintConstants.AUTOFILL_HINT_GIFT_CARD_PIN, true) -> { + result.giftCardPinId = node.autofillId + } + + hint.contains(HintConstants.AUTOFILL_HINT_UPI_VPA, true) -> { + result.upiVpaId = node.autofillId + } + } + } + } + + private fun parseNodeByHtmlInfo(node: ViewNode, result: ParsedStructure) { + Log.d(TAG, "Parsing by html info") + val htmlInfo = node.htmlInfo + if (htmlInfo?.tag?.lowercase() == "input") { + htmlInfo.attributes?.forEach { attrPair -> + if (attrPair.first.lowercase() == "type") { + when (attrPair.second.lowercase()) { + ATTR_TEL, ATTR_EMAIL -> { + if (result.passwordId == null) { + result.usernameId = node.autofillId + } + } + + ATTR_TEXT -> { + if (result.passwordId == null) { + result.usernameCandidate = node.autofillId + } + } + + ATTR_PASSWORD -> { + result.passwordId = node.autofillId + } + } + } + } + } + } + + private fun parseNodeByInputType(node: ViewNode, result: ParsedStructure) { + Log.d(TAG, "Parsing by input type") + when (node.inputType and InputType.TYPE_MASK_CLASS) { + InputType.TYPE_CLASS_TEXT -> parseByInputTypeText(node, result) + InputType.TYPE_CLASS_NUMBER -> parseByInputTypeNumber(node, result) + InputType.TYPE_NULL -> parseByInputTypeNull(node, result) + } + } + + private fun parseByInputTypeText(node: ViewNode, result: ParsedStructure) { + Log.d(TAG, "Parsing by input type - text") + when (node.inputType and InputType.TYPE_MASK_VARIATION) { + InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS, + InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS -> { + if (result.passwordId == null) { + result.usernameId = node.autofillId + } + } + + InputType.TYPE_TEXT_VARIATION_NORMAL, + InputType.TYPE_TEXT_VARIATION_PERSON_NAME, + InputType.TYPE_TEXT_VARIATION_WEB_EDIT_TEXT -> { + if (result.passwordId == null) { + result.usernameCandidate = node.autofillId + } + } + + InputType.TYPE_TEXT_VARIATION_PASSWORD, + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD -> { + result.passwordId = node.autofillId + } + } + } + + private fun parseByInputTypeNumber(node: ViewNode, result: ParsedStructure) { + Log.d(TAG, "Parsing by input type - number") + when (node.inputType and InputType.TYPE_MASK_VARIATION) { + InputType.TYPE_NUMBER_VARIATION_NORMAL -> { + if (result.usernameCandidate == null) { + result.usernameCandidate = node.autofillId + } + } + + InputType.TYPE_NUMBER_VARIATION_PASSWORD -> { + result.passwordId = node.autofillId + } + } + } + + private fun parseByInputTypeNull(node: ViewNode, result: ParsedStructure) { + Log.d(TAG, "Parsing by input type - null") + when (node.className) { + EDIT_TEXT_CLASSNAME, + APP_COMPAT_EDIT_TEXT_CLASSNAME -> { + if (result.passwordId == null) { + result.usernameCandidate = node.autofillId + } + } + } + } + + companion object { + private const val TAG = "StructureParser" + + private const val AUTOFILL_HINT_EMAIL = "email" + private const val AUTOFILL_HINT_LOGIN = "login" + + private const val ATTR_EMAIL = "email" + private const val ATTR_PASSWORD = "password" + private const val ATTR_TEL = "tel" + private const val ATTR_TEXT = "text" + + private const val EDIT_TEXT_CLASSNAME = "android.widget.EditText" + private const val APP_COMPAT_EDIT_TEXT_CLASSNAME = "androidx.appcompat.widget.AppCompatEditText" + + private val blockedAppIds = listOf( + "PopupWindow:", + "com.android.settings", + "com.google.android.settings", + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/data/CreditCardGroupData.kt b/app/src/main/java/com/adyen/testcards/data/CreditCardGroupData.kt new file mode 100644 index 0000000..cbf4cea --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/CreditCardGroupData.kt @@ -0,0 +1,39 @@ +package com.adyen.testcards.data + +import com.adyen.testcards.domain.CreditCard +import com.adyen.testcards.domain.CreditCardGroup +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class CreditCardGroupData( + val group: String, + val logo: String, + val items: List, +) { + + fun toDomain() = CreditCardGroup( + group = group, + icon = LogoMapping.getResourceId(logo), + items = items.map { it.toDomain() }, + ) +} + +@JsonClass(generateAdapter = true) +data class CreditCardData( + @Json(name = "cardnumber") val number: String, + @Json(name = "expiry") val expiryDate: String, + @Json(name = "CVC") val securityCode: String, + @Json(name = "country") val issuingCountry: String, + @Json(name = "secure3DS") val is3DS: Boolean = false, +) { + + fun toDomain() = CreditCard( + number = number, + expiryDate = expiryDate, + securityCode = securityCode, + issuingCountry = issuingCountry, + is3DS = is3DS, + isFavorite = false, + ) +} diff --git a/app/src/main/java/com/adyen/testcards/data/FavoriteData.kt b/app/src/main/java/com/adyen/testcards/data/FavoriteData.kt new file mode 100644 index 0000000..f4ec918 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/FavoriteData.kt @@ -0,0 +1,9 @@ +package com.adyen.testcards.data + +internal data class FavoriteData( + val creditCards: Set, + val giftCards: Set, + val ibans: Set, + val upis: Set, + val usernamePasswords: Set, +) diff --git a/app/src/main/java/com/adyen/testcards/data/FavoriteRepository.kt b/app/src/main/java/com/adyen/testcards/data/FavoriteRepository.kt new file mode 100644 index 0000000..156b3c5 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/FavoriteRepository.kt @@ -0,0 +1,139 @@ +package com.adyen.testcards.data + +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +internal interface FavoriteRepository { + + fun getFavorites(): Flow + + suspend fun storeCreditCard(id: String) + + suspend fun removeCreditCard(id: String) + + suspend fun storeGiftCard(id: String) + + suspend fun removeGiftCard(id: String) + + suspend fun storeIBAN(id: String) + + suspend fun removeIBAN(id: String) + + suspend fun storeUPI(id: String) + + suspend fun removeUPI(id: String) + + suspend fun storeUsernamePassword(id: String) + + suspend fun removeUsernamePassword(id: String) +} + +@Singleton +internal class DefaultFavoriteRepository @Inject constructor( + private val favoritesDataStore: DataStore, +) : FavoriteRepository { + + override fun getFavorites(): Flow = favoritesDataStore.data + .map { + FavoriteData( + creditCards = it.creditCardsList.toSet(), + giftCards = it.giftCardsList.toSet(), + ibans = it.ibansList.toSet(), + upis = it.upisList.toSet(), + usernamePasswords = it.usernamePasswordsList.toSet(), + ) + } + + override suspend fun storeCreditCard(id: String) { + favoritesDataStore.updateData { preferences -> + preferences.toBuilder() + .addCreditCards(id) + .build() + } + } + + override suspend fun removeCreditCard(id: String) { + favoritesDataStore.updateData { preferences -> + val filtered = preferences.creditCardsList.filterNot { it == id } + preferences.toBuilder() + .clearCreditCards() + .addAllCreditCards(filtered) + .build() + } + } + + override suspend fun storeGiftCard(id: String) { + favoritesDataStore.updateData { preferences -> + preferences.toBuilder() + .addGiftCards(id) + .build() + } + } + + override suspend fun removeGiftCard(id: String) { + favoritesDataStore.updateData { preferences -> + val filtered = preferences.giftCardsList.filterNot { it == id } + preferences.toBuilder() + .clearGiftCards() + .addAllGiftCards(filtered) + .build() + } + } + + override suspend fun storeIBAN(id: String) { + favoritesDataStore.updateData { preferences -> + preferences.toBuilder() + .addIbans(id) + .build() + } + } + + override suspend fun removeIBAN(id: String) { + favoritesDataStore.updateData { preferences -> + val filtered = preferences.ibansList.filterNot { it == id } + preferences.toBuilder() + .clearIbans() + .addAllIbans(filtered) + .build() + } + } + + override suspend fun storeUPI(id: String) { + favoritesDataStore.updateData { preferences -> + preferences.toBuilder() + .addUpis(id) + .build() + } + } + + override suspend fun removeUPI(id: String) { + favoritesDataStore.updateData { preferences -> + val filtered = preferences.upisList.filterNot { it == id } + preferences.toBuilder() + .clearUpis() + .addAllUpis(filtered) + .build() + } + } + + override suspend fun storeUsernamePassword(id: String) { + favoritesDataStore.updateData { preferences -> + preferences.toBuilder() + .addUsernamePasswords(id) + .build() + } + } + + override suspend fun removeUsernamePassword(id: String) { + favoritesDataStore.updateData { preferences -> + val filtered = preferences.usernamePasswordsList.filterNot { it == id } + preferences.toBuilder() + .clearUsernamePasswords() + .addAllUsernamePasswords(filtered) + .build() + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/data/GiftCardData.kt b/app/src/main/java/com/adyen/testcards/data/GiftCardData.kt new file mode 100644 index 0000000..8c0a1b7 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/GiftCardData.kt @@ -0,0 +1,22 @@ +package com.adyen.testcards.data + +import com.adyen.testcards.domain.GiftCard +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class GiftCardData( + @Json(name = "cardnumber") val number: String, + @Json(name = "code") val securityCode: String, + @Json(name = "type") val type: String, + @Json(name = "logo") val logo: String, +) { + + fun toDomain() = GiftCard( + number = number, + securityCode = securityCode, + type = type, + logo = logo, + isFavorite = false, + ) +} diff --git a/app/src/main/java/com/adyen/testcards/data/IBANData.kt b/app/src/main/java/com/adyen/testcards/data/IBANData.kt new file mode 100644 index 0000000..9da82b7 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/IBANData.kt @@ -0,0 +1,20 @@ +package com.adyen.testcards.data + +import com.adyen.testcards.domain.IBAN +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class IBANData( + val iban: String, + @Json(name = "name") val holderName: String, + @Json(name = "country") val issuingCountry: String, +) { + + fun toDomain() = IBAN( + iban = iban, + holderName = holderName, + issuingCountry = issuingCountry, + isFavorite = false, + ) +} diff --git a/app/src/main/java/com/adyen/testcards/data/LogoMapping.kt b/app/src/main/java/com/adyen/testcards/data/LogoMapping.kt new file mode 100644 index 0000000..477cecc --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/LogoMapping.kt @@ -0,0 +1,20 @@ +package com.adyen.testcards.data + +import com.adyen.testcards.R + +internal object LogoMapping { + + fun getResourceId(type: String): Int = when (type) { + "amex" -> R.drawable.ic_pm_amex + "bank" -> R.drawable.ic_pm_bank + "cartebancaire" -> R.drawable.ic_pm_carte_bancaire + "cup" -> R.drawable.ic_pm_unionpay + "diners" -> R.drawable.ic_pm_diners + "discover" -> R.drawable.ic_pm_discover + "maestro" -> R.drawable.ic_pm_maestro + "mc" -> R.drawable.ic_pm_mastercard + "visa" -> R.drawable.ic_pm_visa + "vpay" -> R.drawable.ic_pm_vpay + else -> 0 + } +} diff --git a/app/src/main/java/com/adyen/testcards/data/PaymentMethodDataSource.kt b/app/src/main/java/com/adyen/testcards/data/PaymentMethodDataSource.kt new file mode 100644 index 0000000..0bcb1b6 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/PaymentMethodDataSource.kt @@ -0,0 +1,80 @@ +package com.adyen.testcards.data + +import android.content.Context +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.BufferedReader +import java.io.InputStreamReader +import javax.inject.Inject + +internal interface PaymentMethodDataSource { + + suspend fun getCreditCards(): List + + suspend fun getGiftCards(): List + + suspend fun getIBANs(): List + + suspend fun getUPIs(): List + + suspend fun getUsernamePasswords(): List +} + +internal class RemotePaymentMethodDataSource @Inject constructor( + private val paymentMethodService: PaymentMethodService, +) : PaymentMethodDataSource { + + override suspend fun getCreditCards(): List = paymentMethodService.getCards() + + override suspend fun getGiftCards(): List = paymentMethodService.getGiftCards() + + override suspend fun getIBANs(): List = paymentMethodService.getIBANs() + + override suspend fun getUPIs(): List { + error("UPIs cannot be retrieved from service yet.") + } + + override suspend fun getUsernamePasswords(): List { + error("Username passwords cannot be retrieved from service yet.") + } +} + +internal class LocalPaymentMethodDataSource @Inject constructor( + @ApplicationContext private val applicationContext: Context, + private val moshi: Moshi, +) : PaymentMethodDataSource { + + override suspend fun getCreditCards(): List { + error("Credit cards should be fetched from a remote data source.") + } + + override suspend fun getGiftCards(): List { + error("Gift cards should be fetched from a remote data source.") + } + + override suspend fun getIBANs(): List { + error("IBANs should be fetched from a remote data source.") + } + + override suspend fun getUPIs(): List = withContext(Dispatchers.IO) { + readJsonFile("upis.json", UPIData::class.java) + } + + override suspend fun getUsernamePasswords(): List = withContext(Dispatchers.IO) { + readJsonFile("usernamepasswords.json", UsernamePasswordData::class.java) + } + + private fun readJsonFile(fileName: String, type: Class): List { + val stringBuilder = StringBuilder() + applicationContext.assets.open(fileName).use { stream -> + val reader = BufferedReader(InputStreamReader(stream)) + reader.forEachLine { stringBuilder.append(it) } + } + + val typedList = Types.newParameterizedType(List::class.java, type) + return moshi.adapter>(typedList).fromJson(stringBuilder.toString())!! + } +} diff --git a/app/src/main/java/com/adyen/testcards/data/PaymentMethodRepository.kt b/app/src/main/java/com/adyen/testcards/data/PaymentMethodRepository.kt new file mode 100644 index 0000000..bc538b6 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/PaymentMethodRepository.kt @@ -0,0 +1,35 @@ +package com.adyen.testcards.data + +import javax.inject.Inject +import javax.inject.Singleton + +internal interface PaymentMethodRepository { + + suspend fun getCreditCards(): List + + suspend fun getGiftCards(): List + + suspend fun getIBANs(): List + + suspend fun getUPIs(): List + + suspend fun getUsernamePasswords(): List +} + +@Singleton +internal class DefaultPaymentMethodRepository @Inject constructor( + private val remotePaymentMethodDataSource: RemotePaymentMethodDataSource, + private val localPaymentMethodDataSource: LocalPaymentMethodDataSource, +) : PaymentMethodRepository { + + override suspend fun getCreditCards(): List = remotePaymentMethodDataSource.getCreditCards() + + override suspend fun getGiftCards(): List = remotePaymentMethodDataSource.getGiftCards() + + override suspend fun getIBANs(): List = remotePaymentMethodDataSource.getIBANs() + + override suspend fun getUPIs(): List = localPaymentMethodDataSource.getUPIs() + + override suspend fun getUsernamePasswords(): List = + localPaymentMethodDataSource.getUsernamePasswords() +} diff --git a/app/src/main/java/com/adyen/testcards/data/PaymentMethodService.kt b/app/src/main/java/com/adyen/testcards/data/PaymentMethodService.kt new file mode 100644 index 0000000..b919444 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/PaymentMethodService.kt @@ -0,0 +1,15 @@ +package com.adyen.testcards.data + +import retrofit2.http.GET + +internal interface PaymentMethodService { + + @GET("main/data/cards.json") + suspend fun getCards(): List + + @GET("main/data/giftcards.json") + suspend fun getGiftCards(): List + + @GET("main/data/ibans.json") + suspend fun getIBANs(): List +} diff --git a/app/src/main/java/com/adyen/testcards/data/StoredFavoritesSerializer.kt b/app/src/main/java/com/adyen/testcards/data/StoredFavoritesSerializer.kt new file mode 100644 index 0000000..5f02ea1 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/StoredFavoritesSerializer.kt @@ -0,0 +1,18 @@ +package com.adyen.testcards.data + +import androidx.datastore.core.Serializer +import java.io.InputStream +import java.io.OutputStream + +internal object StoredFavoritesSerializer : Serializer { + + override val defaultValue: StoredFavorites = StoredFavorites.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): StoredFavorites { + return StoredFavorites.parseFrom(input) + } + + override suspend fun writeTo(t: StoredFavorites, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/app/src/main/java/com/adyen/testcards/data/UPIData.kt b/app/src/main/java/com/adyen/testcards/data/UPIData.kt new file mode 100644 index 0000000..0ade3b7 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/UPIData.kt @@ -0,0 +1,15 @@ +package com.adyen.testcards.data + +import com.adyen.testcards.domain.UPI +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UPIData( + val virtualPaymentAddress: String, +) { + + fun toDomain() = UPI( + virtualPaymentAddress = virtualPaymentAddress, + isFavorite = false, + ) +} diff --git a/app/src/main/java/com/adyen/testcards/data/UsernamePasswordData.kt b/app/src/main/java/com/adyen/testcards/data/UsernamePasswordData.kt new file mode 100644 index 0000000..c0776d6 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/data/UsernamePasswordData.kt @@ -0,0 +1,19 @@ +package com.adyen.testcards.data + +import com.adyen.testcards.domain.UsernamePassword +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class UsernamePasswordData( + val username: String, + val password: String, + val type: String, +) { + + fun toDomain() = UsernamePassword( + username = username, + password = password, + type = type, + isFavorite = false, + ) +} diff --git a/app/src/main/java/com/adyen/testcards/di/DataModule.kt b/app/src/main/java/com/adyen/testcards/di/DataModule.kt new file mode 100644 index 0000000..31702a5 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/di/DataModule.kt @@ -0,0 +1,67 @@ +package com.adyen.testcards.di + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStoreFile +import com.adyen.testcards.data.DefaultFavoriteRepository +import com.adyen.testcards.data.DefaultPaymentMethodRepository +import com.adyen.testcards.data.FavoriteRepository +import com.adyen.testcards.data.PaymentMethodRepository +import com.adyen.testcards.data.PaymentMethodService +import com.adyen.testcards.data.StoredFavorites +import com.adyen.testcards.data.StoredFavoritesSerializer +import com.squareup.moshi.Moshi +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.create +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +abstract class DataModule { + + @Binds + internal abstract fun bindPaymentMethodRepository(default: DefaultPaymentMethodRepository): PaymentMethodRepository + + @Binds + internal abstract fun bindFavoriteRepository(default: DefaultFavoriteRepository): FavoriteRepository + + companion object { + + @Singleton + @Provides + internal fun provideMoshi(): Moshi = Moshi.Builder().build() + + @Singleton + @Provides + internal fun provideRetrofit(moshi: Moshi): Retrofit = Retrofit.Builder() + .baseUrl("https://raw.githubusercontent.com/adyen-examples/adyen-testcards-extension/") + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + + @Provides + internal fun providePaymentMethodService(retrofit: Retrofit): PaymentMethodService = retrofit.create() + + @Singleton + @Provides + internal fun provideFavoritesDataStore(@ApplicationContext context: Context): DataStore = + DataStoreFactory.create( + serializer = StoredFavoritesSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { e -> + Log.e("DataModule", "", e) + StoredFavorites.getDefaultInstance() + }, + ) { + context.dataStoreFile("stored_favorites.pb") + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/domain/CreateDatasetUseCase.kt b/app/src/main/java/com/adyen/testcards/domain/CreateDatasetUseCase.kt new file mode 100644 index 0000000..d998544 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/CreateDatasetUseCase.kt @@ -0,0 +1,105 @@ +package com.adyen.testcards.domain + +import android.service.autofill.Dataset +import android.view.autofill.AutofillValue +import android.widget.RemoteViews +import com.adyen.testcards.autofill.ParsedStructure +import javax.inject.Inject + +@Suppress("DEPRECATION") +internal class CreateDatasetUseCase @Inject constructor() { + + fun createDataset(input: Any, parsedStructure: ParsedStructure): Dataset = when (input) { + is CreditCard -> createCreditCardDataset(input, parsedStructure) + is GiftCard -> createGiftCardDataset(input, parsedStructure) + is IBAN -> createIBANDataset(input, parsedStructure) + is UPI -> createUPIDataset(input, parsedStructure) + is UsernamePassword -> createUsernamePasswordDataset(input, parsedStructure) + else -> error("Unknown input: $input") + } + + private fun createCreditCardDataset(card: CreditCard, parsedStructure: ParsedStructure): Dataset { + val datasetBuilder = Dataset.Builder() + + with(parsedStructure) { + val emptyPresentation = RemoteViews(applicationId, android.R.layout.simple_list_item_1) + creditCardNumberId?.let { + datasetBuilder.setValue(it, AutofillValue.forText(card.number), emptyPresentation) + } + creditCardExpiryDateId?.let { + datasetBuilder.setValue( + it, + AutofillValue.forText(card.expiryDate), + emptyPresentation, + ) + } + creditCardSecurityCodeId?.let { + datasetBuilder.setValue( + it, + AutofillValue.forText(card.securityCode), + emptyPresentation, + ) + } + } + + return datasetBuilder.build() + } + + private fun createGiftCardDataset(card: GiftCard, parsedStructure: ParsedStructure): Dataset { + val datasetBuilder = Dataset.Builder() + + with(parsedStructure) { + val emptyPresentation = RemoteViews(applicationId, android.R.layout.simple_list_item_1) + giftCardNumberId?.let { + datasetBuilder.setValue(it, AutofillValue.forText(card.number), emptyPresentation) + } + giftCardPinId?.let { + datasetBuilder.setValue(it, AutofillValue.forText(card.securityCode), emptyPresentation) + } + } + + return datasetBuilder.build() + } + + private fun createIBANDataset(iban: IBAN, parsedStructure: ParsedStructure): Dataset { + val datasetBuilder = Dataset.Builder() + + with(parsedStructure) { + val emptyPresentation = RemoteViews(applicationId, android.R.layout.simple_list_item_1) + ibanId?.let { + datasetBuilder.setValue(it, AutofillValue.forText(iban.iban), emptyPresentation) + } + } + + return datasetBuilder.build() + } + + private fun createUPIDataset(upi: UPI, parsedStructure: ParsedStructure): Dataset { + val datasetBuilder = Dataset.Builder() + + with(parsedStructure) { + val emptyPresentation = RemoteViews(applicationId, android.R.layout.simple_list_item_1) + upiVpaId?.let { + datasetBuilder.setValue(it, AutofillValue.forText(upi.virtualPaymentAddress), emptyPresentation) + } + } + + return datasetBuilder.build() + } + + private fun createUsernamePasswordDataset(data: UsernamePassword, parsedStructure: ParsedStructure): Dataset { + val datasetBuilder = Dataset.Builder() + + with(parsedStructure) { + val emptyPresentation = RemoteViews(applicationId, android.R.layout.simple_list_item_1) + usernameId?.let { + datasetBuilder.setValue(it, AutofillValue.forText(data.username), emptyPresentation) + } + passwordId?.let { + datasetBuilder.setValue(it, AutofillValue.forText(data.password), emptyPresentation) + } + } + + return datasetBuilder.build() + } +} diff --git a/app/src/main/java/com/adyen/testcards/domain/CreditCardGroup.kt b/app/src/main/java/com/adyen/testcards/domain/CreditCardGroup.kt new file mode 100644 index 0000000..ecad2b0 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/CreditCardGroup.kt @@ -0,0 +1,21 @@ +package com.adyen.testcards.domain + +import androidx.annotation.DrawableRes + +data class CreditCardGroup( + val group: String, + @DrawableRes val icon: Int, + val items: List, +) + +data class CreditCard( + val number: String, + val expiryDate: String, + val securityCode: String, + val issuingCountry: String, + val is3DS: Boolean, + val isFavorite: Boolean, +) { + + fun toSearchString(): String = "$number $expiryDate $securityCode $issuingCountry" +} diff --git a/app/src/main/java/com/adyen/testcards/domain/FavoriteItemUseCase.kt b/app/src/main/java/com/adyen/testcards/domain/FavoriteItemUseCase.kt new file mode 100644 index 0000000..b64ea41 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/FavoriteItemUseCase.kt @@ -0,0 +1,29 @@ +package com.adyen.testcards.domain + +import com.adyen.testcards.data.FavoriteRepository +import javax.inject.Inject + +internal class FavoriteItemUseCase @Inject constructor( + private val favoriteRepository: FavoriteRepository, +) { + + suspend fun favorite(item: Any) { + when (item) { + is CreditCard -> favoriteRepository.storeCreditCard(item.number) + is GiftCard -> favoriteRepository.storeGiftCard(item.number) + is IBAN -> favoriteRepository.storeIBAN(item.iban) + is UPI -> favoriteRepository.storeUPI(item.virtualPaymentAddress) + is UsernamePassword -> favoriteRepository.storeUsernamePassword(item.username) + } + } + + suspend fun unfavorite(item: Any) { + when (item) { + is CreditCard -> favoriteRepository.removeCreditCard(item.number) + is GiftCard -> favoriteRepository.removeGiftCard(item.number) + is IBAN -> favoriteRepository.removeIBAN(item.iban) + is UPI -> favoriteRepository.removeUPI(item.virtualPaymentAddress) + is UsernamePassword -> favoriteRepository.removeUsernamePassword(item.username) + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/domain/GetPaymentMethodsUseCase.kt b/app/src/main/java/com/adyen/testcards/domain/GetPaymentMethodsUseCase.kt new file mode 100644 index 0000000..7f19e3c --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/GetPaymentMethodsUseCase.kt @@ -0,0 +1,115 @@ +package com.adyen.testcards.domain + +import com.adyen.testcards.data.FavoriteData +import com.adyen.testcards.data.FavoriteRepository +import com.adyen.testcards.data.PaymentMethodRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.supervisorScope +import javax.inject.Inject + +internal class GetPaymentMethodsUseCase @Inject constructor( + private val paymentMethodRepository: PaymentMethodRepository, + favoriteRepository: FavoriteRepository, +) { + + private val paymentMethods = MutableSharedFlow>() + + private val combined: Flow> = combine( + paymentMethods, + favoriteRepository.getFavorites(), + ) { paymentMethods, favoriteData -> + paymentMethods.map { combinePaymentMethods(it, favoriteData) } + } + + fun getPaymentMethods(): Flow> = combined + + suspend fun refresh() { + paymentMethods.emit(fetchPaymentMethods()) + } + + private suspend fun fetchPaymentMethods(): Result = supervisorScope { + val defCreditCards = async { paymentMethodRepository.getCreditCards() } + val defGiftCards = async { paymentMethodRepository.getGiftCards() } + val defIBANs = async { paymentMethodRepository.getIBANs() } + val defUPIs = async { paymentMethodRepository.getUPIs() } + val defUsernamePasswords = async { paymentMethodRepository.getUsernamePasswords() } + + try { + Result.success( + PaymentMethods( + defCreditCards.await().map { it.toDomain() }, + defGiftCards.await().map { it.toDomain() }, + defIBANs.await().map { it.toDomain() }, + defUPIs.await().map { it.toDomain() }, + defUsernamePasswords.await().map { it.toDomain() }, + ), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + Result.failure(e) + } + } + + private fun combinePaymentMethods(paymentMethods: PaymentMethods, favorites: FavoriteData): CombinedPaymentMethods { + val favoriteCreditCards = mutableListOf() + val creditCards = paymentMethods.creditCards.map { group -> + val items = group.items.map { + val isFavorite = favorites.creditCards.contains(it.number) + it.copy(isFavorite = isFavorite).also { copy -> + if (isFavorite) favoriteCreditCards.add(copy) + } + } + group.copy(items = items) + } + + val favoriteGiftCards = mutableListOf() + val giftCards = paymentMethods.giftCards.map { + val isFavorite = favorites.giftCards.contains(it.number) + it.copy(isFavorite = isFavorite).also { copy -> + if (isFavorite) favoriteGiftCards.add(copy) + } + } + + val favoriteIBANs = mutableListOf() + val ibans = paymentMethods.ibans.map { + val isFavorite = favorites.ibans.contains(it.iban) + it.copy(isFavorite = isFavorite).also { copy -> + if (isFavorite) favoriteIBANs.add(copy) + } + } + + val favoriteUPIs = mutableListOf() + val upis = paymentMethods.upis.map { + val isFavorite = favorites.upis.contains(it.virtualPaymentAddress) + it.copy(isFavorite = isFavorite).also { copy -> + if (isFavorite) favoriteUPIs.add(copy) + } + } + + val favoriteUsernamePasswords = mutableListOf() + val usernamePasswords = paymentMethods.usernamePasswords.map { + val isFavorite = favorites.usernamePasswords.contains(it.username) + it.copy(isFavorite = isFavorite).also { copy -> + if (isFavorite) favoriteUsernamePasswords.add(copy) + } + } + + return CombinedPaymentMethods( + favoriteCreditCards = favoriteCreditCards, + creditCards = creditCards, + favoriteGiftCards = favoriteGiftCards, + giftCards = giftCards, + favoriteIBANs = favoriteIBANs, + ibans = ibans, + favoriteUPIs = favoriteUPIs, + upis = upis, + favoriteUsernamePasswords = favoriteUsernamePasswords, + usernamePasswords = usernamePasswords, + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/domain/GiftCard.kt b/app/src/main/java/com/adyen/testcards/domain/GiftCard.kt new file mode 100644 index 0000000..1bb40c4 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/GiftCard.kt @@ -0,0 +1,12 @@ +package com.adyen.testcards.domain + +data class GiftCard( + val number: String, + val securityCode: String, + val type: String, + val logo: String, + val isFavorite: Boolean, +) { + + fun toSearchString(): String = "$number $securityCode $type" +} \ No newline at end of file diff --git a/app/src/main/java/com/adyen/testcards/domain/IBAN.kt b/app/src/main/java/com/adyen/testcards/domain/IBAN.kt new file mode 100644 index 0000000..1db8621 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/IBAN.kt @@ -0,0 +1,11 @@ +package com.adyen.testcards.domain + +data class IBAN( + val iban: String, + val holderName: String, + val issuingCountry: String, + val isFavorite: Boolean, +) { + + fun toSearchString(): String = "$iban $holderName $issuingCountry" +} diff --git a/app/src/main/java/com/adyen/testcards/domain/PaymentMethodType.kt b/app/src/main/java/com/adyen/testcards/domain/PaymentMethodType.kt new file mode 100644 index 0000000..825ad29 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/PaymentMethodType.kt @@ -0,0 +1,9 @@ +package com.adyen.testcards.domain + +internal enum class PaymentMethodType { + CREDIT_CARD, + GIFT_CARD, + UPI, + USERNAME_PASSWORD, + UNKNOWN, +} diff --git a/app/src/main/java/com/adyen/testcards/domain/PaymentMethods.kt b/app/src/main/java/com/adyen/testcards/domain/PaymentMethods.kt new file mode 100644 index 0000000..e377a3a --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/PaymentMethods.kt @@ -0,0 +1,47 @@ +package com.adyen.testcards.domain + +data class PaymentMethods( + val creditCards: List = emptyList(), + val giftCards: List = emptyList(), + val ibans: List = emptyList(), + val upis: List = emptyList(), + val usernamePasswords: List = emptyList(), +) { + + fun isEmpty(): Boolean = creditCards.isEmpty() + && giftCards.isEmpty() + && ibans.isEmpty() + && upis.isEmpty() + && usernamePasswords.isEmpty() +} + +data class CombinedPaymentMethods( + val favoriteCreditCards: List = emptyList(), + val favoriteGiftCards: List = emptyList(), + val favoriteIBANs: List = emptyList(), + val favoriteUPIs: List = emptyList(), + val favoriteUsernamePasswords: List = emptyList(), + val creditCards: List = emptyList(), + val giftCards: List = emptyList(), + val ibans: List = emptyList(), + val upis: List = emptyList(), + val usernamePasswords: List = emptyList(), +) { + + fun isEmpty(): Boolean = favoriteCreditCards.isEmpty() && + favoriteGiftCards.isEmpty() && + favoriteIBANs.isEmpty() && + favoriteUPIs.isEmpty() && + favoriteUsernamePasswords.isEmpty() && + creditCards.isEmpty() && + giftCards.isEmpty() && + ibans.isEmpty() && + upis.isEmpty() && + usernamePasswords.isEmpty() + + fun hasFavorites() = favoriteCreditCards.isNotEmpty() || + favoriteGiftCards.isNotEmpty() || + favoriteIBANs.isNotEmpty() || + favoriteUPIs.isNotEmpty() || + favoriteUsernamePasswords.isNotEmpty() +} diff --git a/app/src/main/java/com/adyen/testcards/domain/SearchUseCase.kt b/app/src/main/java/com/adyen/testcards/domain/SearchUseCase.kt new file mode 100644 index 0000000..0df47f3 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/SearchUseCase.kt @@ -0,0 +1,73 @@ +package com.adyen.testcards.domain + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +internal class SearchUseCase @Inject constructor() { + + suspend fun searchCreditCards( + query: String, + creditCardGroups: List + ): List = withContext(Dispatchers.Default) { + // Search based on group name first + val groupHits = creditCardGroups.filter { it.group.contains(query, ignoreCase = true) } + + if (groupHits.isNotEmpty()) { + return@withContext groupHits + } + + val itemHits = mutableListOf() + creditCardGroups.forEach { group -> + val filteredItems = group.items.filter { it.toSearchString().contains(query) } + if (filteredItems.isNotEmpty()) { + itemHits.add(group.copy(items = filteredItems)) + } + } + return@withContext itemHits + } + + suspend fun searchGiftCards( + query: String, + giftCards: List, + ): List = withContext(Dispatchers.Default) { + if ("gift cards".contains(query, ignoreCase = true)) { + return@withContext giftCards + } + + return@withContext giftCards.filter { it.toSearchString().contains(query, ignoreCase = true) } + } + + suspend fun searchIBANs( + query: String, + ibans: List, + ): List = withContext(Dispatchers.Default) { + if ("IBAN".contains(query, ignoreCase = true)) { + return@withContext ibans + } + + return@withContext ibans.filter { it.toSearchString().contains(query, ignoreCase = true) } + } + + suspend fun searchUPIs( + query: String, + upis: List, + ): List = withContext(Dispatchers.Default) { + if ("UPI".contains(query, ignoreCase = true)) { + return@withContext upis + } + + return@withContext upis.filter { it.toSearchString().contains(query, ignoreCase = true) } + } + + suspend fun searchUsernamePasswords( + query: String, + usernamePasswords: List, + ): List = withContext(Dispatchers.Default) { + if ("username password".contains(query, ignoreCase = true)) { + return@withContext usernamePasswords + } + + return@withContext usernamePasswords.filter { it.toSearchString().contains(query, ignoreCase = true) } + } +} diff --git a/app/src/main/java/com/adyen/testcards/domain/UPI.kt b/app/src/main/java/com/adyen/testcards/domain/UPI.kt new file mode 100644 index 0000000..698133f --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/UPI.kt @@ -0,0 +1,9 @@ +package com.adyen.testcards.domain + +data class UPI( + val virtualPaymentAddress: String, + val isFavorite: Boolean, +) { + + fun toSearchString(): String = virtualPaymentAddress +} diff --git a/app/src/main/java/com/adyen/testcards/domain/UsernamePassword.kt b/app/src/main/java/com/adyen/testcards/domain/UsernamePassword.kt new file mode 100644 index 0000000..2c87253 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/domain/UsernamePassword.kt @@ -0,0 +1,11 @@ +package com.adyen.testcards.domain + +data class UsernamePassword( + val username: String, + val password: String, + val type: String, + val isFavorite: Boolean, +) { + + fun toSearchString(): String = "$username $password $type" +} diff --git a/app/src/main/java/com/adyen/testcards/main/MainActivity.kt b/app/src/main/java/com/adyen/testcards/main/MainActivity.kt new file mode 100644 index 0000000..58dfa3b --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/main/MainActivity.kt @@ -0,0 +1,22 @@ +package com.adyen.testcards.main + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import com.adyen.testcards.ui.theme.AdyenTheme +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +internal class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + setContent { + AdyenTheme { + MainScreen() + } + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/main/MainScreen.kt b/app/src/main/java/com/adyen/testcards/main/MainScreen.kt new file mode 100644 index 0000000..f96798c --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/main/MainScreen.kt @@ -0,0 +1,251 @@ +package com.adyen.testcards.main + +import android.content.Intent +import android.net.Uri +import android.provider.Settings.ACTION_REQUEST_SET_AUTOFILL_SERVICE +import android.view.autofill.AutofillManager +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.adyen.testcards.R +import com.adyen.testcards.ui.AdyenSearchBar +import com.adyen.testcards.ui.creditCardSection +import com.adyen.testcards.ui.favoritesSection +import com.adyen.testcards.ui.giftCardSection +import com.adyen.testcards.ui.ibanSection +import com.adyen.testcards.ui.upiSection +import com.adyen.testcards.ui.usernamePasswordSection + +@Composable +internal fun MainScreen( + snackBarHostState: SnackbarHostState = remember { SnackbarHostState() }, + mainViewModel: MainViewModel = viewModel(), +) { + val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() + + MainScreen( + uiState = uiState, + snackBarHostState = snackBarHostState, + onRefresh = mainViewModel::onRefresh, + onErrorDismiss = mainViewModel::onErrorDismiss, + onQueryChange = mainViewModel::onQueryChange, + onFavoriteClick = mainViewModel::onFavoriteClick, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MainScreen( + uiState: MainUIState, + snackBarHostState: SnackbarHostState, + onRefresh: () -> Unit, + onErrorDismiss: (String) -> Unit, + onQueryChange: (String) -> Unit, + onFavoriteClick: (Any, Boolean) -> Unit, +) { + Scaffold( + modifier = Modifier.fillMaxSize(), + snackbarHost = { SnackbarHost(snackBarHostState) }, + containerColor = MaterialTheme.colorScheme.surface, + ) { innerPadding -> + PullToRefreshBox( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + isRefreshing = remember(uiState) { uiState.isLoading }, + onRefresh = onRefresh, + ) { + AdyenSearchBar( + query = uiState.query, + onQueryChange = onQueryChange, + modifier = Modifier.align(Alignment.TopCenter), + ) + when (uiState) { + is MainUIState.Content -> MainContent(uiState, onFavoriteClick) + is MainUIState.Empty -> MainEmpty() + is MainUIState.Error -> MainError() + } + } + } + + if (uiState.errorMessages.isNotEmpty()) { + val errorMessage = remember(uiState) { uiState.errorMessages.first() } + val onRefreshState by rememberUpdatedState(onRefresh) + val onErrorDismissState by rememberUpdatedState(onErrorDismiss) + + LaunchedEffect(errorMessage, snackBarHostState) { + val snackBarResult = snackBarHostState.showSnackbar( + message = errorMessage, + actionLabel = "Retry", + duration = SnackbarDuration.Short, + ) + if (snackBarResult == SnackbarResult.ActionPerformed) { + onRefreshState() + } + onErrorDismissState(errorMessage) + } + } + + val currentContext = LocalContext.current + var shouldShowDialog by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + val autofillManager = currentContext.getSystemService(AutofillManager::class.java) + shouldShowDialog = autofillManager.isAutofillSupported && !autofillManager.hasEnabledAutofillServices() + } + + if (shouldShowDialog) { + AlertDialog( + onDismissRequest = { shouldShowDialog = false }, + icon = { Icon(Icons.Default.Settings, null) }, + title = { Text("Almost there!") }, + text = { Text("Enable the Adyen Test Cards service to easily fill in test data.") }, + confirmButton = { + TextButton( + onClick = { + currentContext.startActivity( + Intent(ACTION_REQUEST_SET_AUTOFILL_SERVICE, Uri.parse("package:com.adyen.testcards")), + ) + shouldShowDialog = false + }, + ) { Text("Go to settings") } + }, + dismissButton = { + TextButton( + onClick = { + shouldShowDialog = false + }, + ) { Text("Cancel") } + }, + ) + } +} + +@Composable +private fun MainContent( + uiState: MainUIState.Content, + onFavoriteClick: (Any, Boolean) -> Unit, +) { + LazyColumn( + contentPadding = PaddingValues(0.dp, 72.dp, 0.dp, 0.dp), + modifier = Modifier.fillMaxSize(), + ) { + val paymentMethods = uiState.paymentMethods + + if (paymentMethods.hasFavorites()) { + favoritesSection( + paymentMethods.favoriteCreditCards, + paymentMethods.favoriteGiftCards, + paymentMethods.favoriteIBANs, + paymentMethods.favoriteUPIs, + paymentMethods.favoriteUsernamePasswords, + onFavoriteClick, + ) + } + + if (paymentMethods.creditCards.isNotEmpty()) { + creditCardSection(paymentMethods.creditCards, onFavoriteClick) + } + + if (paymentMethods.giftCards.isNotEmpty()) { + giftCardSection(paymentMethods.giftCards, onFavoriteClick) + } + + if (paymentMethods.ibans.isNotEmpty()) { + ibanSection(paymentMethods.ibans, onFavoriteClick) + } + + if (paymentMethods.upis.isNotEmpty()) { + upiSection(paymentMethods.upis, onFavoriteClick) + } + + if (paymentMethods.usernamePasswords.isNotEmpty()) { + usernamePasswordSection(paymentMethods.usernamePasswords, onFavoriteClick) + } + } +} + +@Composable +private fun MainEmpty() { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.card_phone_payment), + contentDescription = null, + modifier = Modifier.fillMaxWidth(.8f), + ) + Spacer(Modifier.padding(32.dp)) + Text("No content!", style = MaterialTheme.typography.titleLarge) + Text( + text = "Improve your search query or try pulling to refresh the content.", + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp, 8.dp), + ) + } +} + +@Composable +private fun MainError() { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Image( + imageVector = ImageVector.vectorResource(R.drawable.man_shrugging), + contentDescription = null, + modifier = Modifier.fillMaxWidth(.8f), + ) + Spacer(Modifier.padding(32.dp)) + Text("Error!", style = MaterialTheme.typography.titleLarge) + Text( + text = "Try pulling to refresh the content.", + textAlign = TextAlign.Center, + modifier = Modifier.padding(16.dp, 8.dp), + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/main/MainUIState.kt b/app/src/main/java/com/adyen/testcards/main/MainUIState.kt new file mode 100644 index 0000000..5d05216 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/main/MainUIState.kt @@ -0,0 +1,29 @@ +package com.adyen.testcards.main + +import com.adyen.testcards.domain.CombinedPaymentMethods + +sealed interface MainUIState { + + val isLoading: Boolean + val query: String + val errorMessages: List + + data class Content( + override val isLoading: Boolean, + override val query: String, + override val errorMessages: List, + val paymentMethods: CombinedPaymentMethods, + ) : MainUIState + + data class Empty( + override val isLoading: Boolean, + override val query: String, + override val errorMessages: List, + ) : MainUIState + + data class Error( + override val isLoading: Boolean, + override val query: String, + override val errorMessages: List, + ) : MainUIState +} diff --git a/app/src/main/java/com/adyen/testcards/main/MainViewModel.kt b/app/src/main/java/com/adyen/testcards/main/MainViewModel.kt new file mode 100644 index 0000000..f806a40 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/main/MainViewModel.kt @@ -0,0 +1,157 @@ +package com.adyen.testcards.main + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.adyen.testcards.domain.CombinedPaymentMethods +import com.adyen.testcards.domain.FavoriteItemUseCase +import com.adyen.testcards.domain.GetPaymentMethodsUseCase +import com.adyen.testcards.domain.SearchUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class MainViewModel @Inject constructor( + private val searchUseCase: SearchUseCase, + private val getPaymentMethodsUseCase: GetPaymentMethodsUseCase, + private val favoriteItemUseCase: FavoriteItemUseCase, +) : ViewModel() { + + private val viewModelState = MutableStateFlow( + MainViewModelState( + isLoading = true, + ), + ) + + private val searchResults: MutableStateFlow = MutableStateFlow(null) + + private val paymentMethodFlow = getPaymentMethodsUseCase.getPaymentMethods() + .onEach { setLoadingState(false) } + + val uiState: StateFlow = combine( + viewModelState, + paymentMethodFlow, + searchResults, + ) { viewModelState, paymentMethods, searchResults -> + if (searchResults != null) { + viewModelState.copy(paymentMethods = searchResults) + } else { + paymentMethods.fold( + onSuccess = { + backupPaymentMethods = it + viewModelState.copy(paymentMethods = it) + }, + onFailure = { + val errorMessage = "Failed to fetch payment methods." + Log.e("MainViewModel", errorMessage, it) + viewModelState.copy(errorMessages = listOf(errorMessage)) + }, + ) + } + } + .map { it.toUIState() } + .stateIn(viewModelScope, SharingStarted.Eagerly, viewModelState.value.toUIState()) + + private var backupPaymentMethods = viewModelState.value.paymentMethods + + init { + viewModelScope.launch { + getPaymentMethodsUseCase.refresh() + } + } + + private fun setLoadingState(isLoading: Boolean) { + viewModelState.update { + it.copy(isLoading = isLoading) + } + } + + fun onRefresh() { + setLoadingState(true) + viewModelScope.launch { + getPaymentMethodsUseCase.refresh() + } + } + + fun onErrorDismiss(errorMessage: String) { + viewModelState.update { currentState -> + val newErrorMessages = currentState.errorMessages.filterNot { it == errorMessage } + currentState.copy(errorMessages = newErrorMessages) + } + } + + fun onQueryChange(query: String) { + viewModelState.update { it.copy(query = query) } + viewModelScope.launch { + searchResults.update { + if (query.isNotBlank()) { + CombinedPaymentMethods( + favoriteCreditCards = emptyList(), + favoriteGiftCards = emptyList(), + favoriteIBANs = emptyList(), + favoriteUPIs = emptyList(), + favoriteUsernamePasswords = emptyList(), + creditCards = searchUseCase.searchCreditCards(query, backupPaymentMethods.creditCards), + giftCards = searchUseCase.searchGiftCards(query, backupPaymentMethods.giftCards), + ibans = searchUseCase.searchIBANs(query, backupPaymentMethods.ibans), + upis = searchUseCase.searchUPIs(query, backupPaymentMethods.upis), + usernamePasswords = searchUseCase.searchUsernamePasswords( + query, + backupPaymentMethods.usernamePasswords, + ), + ) + } else { + null + } + } + } + } + + fun onFavoriteClick(item: Any, isFavorite: Boolean) { + viewModelScope.launch { + if (isFavorite) { + favoriteItemUseCase.favorite(item) + } else { + favoriteItemUseCase.unfavorite(item) + } + } + } +} + +private data class MainViewModelState( + val isLoading: Boolean = false, + val query: String = "", + val errorMessages: List = emptyList(), + val paymentMethods: CombinedPaymentMethods = CombinedPaymentMethods(), +) { + + fun toUIState() = when { + errorMessages.isNotEmpty() && paymentMethods.isEmpty() -> MainUIState.Error( + isLoading = isLoading, + query = query, + errorMessages = errorMessages, + ) + + paymentMethods.isEmpty() -> MainUIState.Empty( + isLoading = isLoading, + query = query, + errorMessages = errorMessages, + ) + + else -> MainUIState.Content( + isLoading = isLoading, + query = query, + errorMessages = errorMessages, + paymentMethods = paymentMethods, + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/AdyenSearchBar.kt b/app/src/main/java/com/adyen/testcards/ui/AdyenSearchBar.kt new file mode 100644 index 0000000..da12bb7 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/AdyenSearchBar.kt @@ -0,0 +1,52 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.size +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SearchBar +import androidx.compose.material3.SearchBarDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import com.adyen.testcards.R + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdyenSearchBar( + query: String, + onQueryChange: (String) -> Unit, + modifier: Modifier = Modifier +) { + val focusManager = LocalFocusManager.current + SearchBar( + inputField = { + SearchBarDefaults.InputField( + query = query, + onQueryChange = onQueryChange, + onSearch = { focusManager.clearFocus() }, + expanded = false, + onExpandedChange = { }, + placeholder = { Text("Search...") }, + leadingIcon = { + Icon( + ImageVector.vectorResource(R.drawable.ic_adyen), + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.primary, + ) + }, + ) + }, + content = {}, + expanded = false, + onExpandedChange = {}, + windowInsets = WindowInsets(0.dp), + modifier = modifier, + ) +} diff --git a/app/src/main/java/com/adyen/testcards/ui/CreditCard.kt b/app/src/main/java/com/adyen/testcards/ui/CreditCard.kt new file mode 100644 index 0000000..4d71df2 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/CreditCard.kt @@ -0,0 +1,111 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.adyen.testcards.R +import com.adyen.testcards.domain.CreditCard +import com.adyen.testcards.domain.CreditCardGroup + +internal fun LazyListScope.creditCardSection( + groups: List, + onFavoriteClick: (CreditCard, Boolean) -> Unit, + onItemClick: ((CreditCard) -> Unit)? = null, +) { + item(key = "Credit cards") { + PaymentMethodTitle("Credit cards", R.drawable.ic_pm_card, Modifier.animateItem()) + } + + groups.forEach { group -> + item(key = group.group) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .animateItem() + .padding(16.dp), + ) { + Text(group.group, style = MaterialTheme.typography.titleLarge) + Spacer(Modifier.padding(4.dp)) + PaymentMethodIcon(resId = group.icon, modifier = Modifier.size(30.dp, Dp.Unspecified)) + } + } + + items(group.items, key = { it.number }) { card -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + CreditCard( + card = card, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } + } +} + +@Composable +internal fun CreditCard( + card: CreditCard, + onFavoriteClick: (CreditCard, Boolean) -> Unit, + modifier: Modifier = Modifier, + onClick: ((CreditCard) -> Unit)? = null, +) { + var isFavorite by remember(card) { mutableStateOf(card.isFavorite) } + FavoritableRow( + isFavorite = isFavorite, + onFavoriteClicked = { + isFavorite = it + onFavoriteClick(card, it) + }, + modifier = modifier + .clickable(enabled = onClick != null) { onClick?.invoke(card) } + .fillMaxWidth() + .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + ) { + Column { + Text(text = card.number) + Spacer(modifier = Modifier.padding(4.dp)) + Row { + Text(text = card.expiryDate, style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.padding(8.dp)) + Text(text = card.securityCode, style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.padding(8.dp)) + Text(text = card.issuingCountry, style = MaterialTheme.typography.labelSmall) + + if (card.is3DS) { + Spacer(modifier = Modifier.padding(8.dp)) + Text(text = "3DS", style = MaterialTheme.typography.labelSmall) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun CreditCardPreview() { + val card = CreditCard("1234 1234 1234 1234", "03/30", "123", "NL", true, true) + CreditCard(card = card, onFavoriteClick = { _, _ -> }) +} diff --git a/app/src/main/java/com/adyen/testcards/ui/FavoritableRow.kt b/app/src/main/java/com/adyen/testcards/ui/FavoritableRow.kt new file mode 100644 index 0000000..1ca2bea --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/FavoritableRow.kt @@ -0,0 +1,26 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +internal fun FavoritableRow( + isFavorite: Boolean, + modifier: Modifier = Modifier, + onFavoriteClicked: (Boolean) -> Unit, + content: @Composable RowScope.() -> Unit, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = modifier, + ) { + content() + + FavoriteButton(isFavorite, onFavoriteClicked) + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/FavoriteButton.kt b/app/src/main/java/com/adyen/testcards/ui/FavoriteButton.kt new file mode 100644 index 0000000..c10c2da --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/FavoriteButton.kt @@ -0,0 +1,39 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material3.Icon +import androidx.compose.material3.IconToggleButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.tooling.preview.Preview +import com.adyen.testcards.ui.theme.AdyenTheme + +@Composable +internal fun FavoriteButton( + isFavorite: Boolean, + onClick: (Boolean) -> Unit, +) { + val checkedState = rememberUpdatedState(newValue = isFavorite) + IconToggleButton(checked = checkedState.value, onCheckedChange = onClick) { + val image = if (checkedState.value) { + Icons.Filled.Favorite + } else { + Icons.Default.FavoriteBorder + } + Icon(imageVector = image, contentDescription = null) + } +} + +@Preview(showBackground = true) +@Composable +private fun FavoriteButtonPreview() { + AdyenTheme { + Column { + FavoriteButton(isFavorite = true) {} + FavoriteButton(isFavorite = false) {} + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/Favorites.kt b/app/src/main/java/com/adyen/testcards/ui/Favorites.kt new file mode 100644 index 0000000..b0d8fc1 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/Favorites.kt @@ -0,0 +1,121 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.adyen.testcards.domain.CreditCard +import com.adyen.testcards.domain.GiftCard +import com.adyen.testcards.domain.IBAN +import com.adyen.testcards.domain.UPI +import com.adyen.testcards.domain.UsernamePassword + +fun LazyListScope.favoritesSection( + creditCards: List, + giftCards: List, + ibans: List, + upis: List, + usernamePasswords: List, + onFavoriteClick: (Any, Boolean) -> Unit, + onItemClick: ((Any) -> Unit)? = null, +) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(16.dp), + ) { + Icon( + imageVector = Icons.Default.Favorite, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(40.dp, 28.dp), + ) + Spacer(Modifier.padding(8.dp)) + Text( + text = "Favorites", + style = MaterialTheme.typography.headlineLarge, + ) + } + } + + items(creditCards, key = { "fav" + it.number }) { card -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + CreditCard( + card = card, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } + + items(giftCards, key = { "fav" + it.number }) { + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + GiftCard( + giftCard = it, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } + + items(ibans, key = { "fav" + it.iban }) { iban -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + IBAN( + iban = iban, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } + + items(upis, key = { "fav" + it.virtualPaymentAddress }) { item -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + UPI( + upi = item, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } + + items(usernamePasswords, key = { "fav" + it.username + it.type }) { item -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + UsernamePassword( + data = item, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/GiftCard.kt b/app/src/main/java/com/adyen/testcards/ui/GiftCard.kt new file mode 100644 index 0000000..c04baf9 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/GiftCard.kt @@ -0,0 +1,85 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.adyen.testcards.R +import com.adyen.testcards.domain.GiftCard + +internal fun LazyListScope.giftCardSection( + giftCards: List, + onFavoriteClick: (GiftCard, Boolean) -> Unit, + onItemClick: ((GiftCard) -> Unit)? = null, +) { + item(key = "Gift cards") { + PaymentMethodTitle("Gift cards", R.drawable.ic_pm_gift_card, Modifier.animateItem()) + } + + items(giftCards, key = { it.number }) { + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + GiftCard( + giftCard = it, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } +} + +@Composable +internal fun GiftCard( + giftCard: GiftCard, + onFavoriteClick: (GiftCard, Boolean) -> Unit, + modifier: Modifier = Modifier, + onClick: ((GiftCard) -> Unit)? = null, +) { + var isFavorite by remember(giftCard) { mutableStateOf(giftCard.isFavorite) } + FavoritableRow( + isFavorite = isFavorite, + onFavoriteClicked = { + isFavorite = it + onFavoriteClick(giftCard, it) + }, + modifier = modifier + .clickable(enabled = onClick != null) { onClick?.invoke(giftCard) } + .fillMaxWidth() + .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + ) { + Column { + Text(text = giftCard.number) + Spacer(modifier = Modifier.padding(4.dp)) + Row { + Text(text = giftCard.type, style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.padding(8.dp)) + Text(text = giftCard.securityCode, style = MaterialTheme.typography.labelSmall) + } + } + } +} + +@Preview(showBackground = true) +@Composable +private fun GiftCardPreview() { + val card = GiftCard("1234 1234 1234 1234", "123", "test", "logo", true) + GiftCard(card, { _, _ -> }) +} diff --git a/app/src/main/java/com/adyen/testcards/ui/IBAN.kt b/app/src/main/java/com/adyen/testcards/ui/IBAN.kt new file mode 100644 index 0000000..7c3fbb2 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/IBAN.kt @@ -0,0 +1,77 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.adyen.testcards.R +import com.adyen.testcards.domain.IBAN + +internal fun LazyListScope.ibanSection( + ibans: List, + onFavoriteClick: (IBAN, Boolean) -> Unit, + onItemClick: ((IBAN) -> Unit)? = null, +) { + item(key = "IBANs") { + PaymentMethodTitle("IBANs", R.drawable.ic_pm_bank, Modifier.animateItem()) + } + + items(ibans, key = { it.iban }) { iban -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + IBAN( + iban = iban, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } +} + +@Composable +internal fun IBAN( + iban: IBAN, + onFavoriteClick: (IBAN, Boolean) -> Unit, + modifier: Modifier = Modifier, + onClick: ((IBAN) -> Unit)? = null, +) { + var isFavorite by remember(iban) { mutableStateOf(iban.isFavorite) } + FavoritableRow( + isFavorite = isFavorite, + onFavoriteClicked = { + isFavorite = it + onFavoriteClick(iban, it) + }, + modifier = modifier + .clickable(enabled = onClick != null) { onClick?.invoke(iban) } + .fillMaxWidth() + .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + ) { + Column { + Text(text = iban.iban) + Spacer(modifier = Modifier.padding(4.dp)) + Row { + Text(text = iban.holderName, style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.padding(8.dp)) + Text(text = iban.issuingCountry, style = MaterialTheme.typography.labelSmall) + } + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/PaymentMethodIcon.kt b/app/src/main/java/com/adyen/testcards/ui/PaymentMethodIcon.kt new file mode 100644 index 0000000..8358ce5 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/PaymentMethodIcon.kt @@ -0,0 +1,45 @@ +package com.adyen.testcards.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.adyen.testcards.R + +@Composable +fun PaymentMethodIcon( + @DrawableRes resId: Int, + modifier: Modifier = Modifier, + contentDescription: String? = null, +) { + Image( + imageVector = ImageVector.vectorResource(resId), + contentDescription = contentDescription, + contentScale = ContentScale.Fit, + modifier = modifier + .clip(RoundedCornerShape(5.dp)) + .border(0.5.dp, Color.LightGray, RoundedCornerShape(5.dp)), + ) +} + +@Preview(showBackground = true) +@Composable +private fun PaymentMethodIconPreview() { + Column { + PaymentMethodIcon(R.drawable.ic_pm_bank) + Spacer(Modifier.padding(4.dp)) + PaymentMethodIcon(R.drawable.ic_pm_visa) + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/PaymentMethodTitle.kt b/app/src/main/java/com/adyen/testcards/ui/PaymentMethodTitle.kt new file mode 100644 index 0000000..8f22d8a --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/PaymentMethodTitle.kt @@ -0,0 +1,31 @@ +package com.adyen.testcards.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun PaymentMethodTitle( + title: String, + @DrawableRes icon: Int, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier.padding(16.dp), + ) { + PaymentMethodIcon(icon) + Spacer(Modifier.padding(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + ) + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/UPI.kt b/app/src/main/java/com/adyen/testcards/ui/UPI.kt new file mode 100644 index 0000000..da941d8 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/UPI.kt @@ -0,0 +1,65 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.adyen.testcards.R +import com.adyen.testcards.domain.UPI + +internal fun LazyListScope.upiSection( + upis: List, + onFavoriteClick: (UPI, Boolean) -> Unit, + onItemClick: ((UPI) -> Unit)? = null, +) { + item(key = "UPI") { + PaymentMethodTitle("UPI", R.drawable.ic_pm_upi, Modifier.animateItem()) + } + + items(upis, key = { it.virtualPaymentAddress }) { item -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + UPI( + upi = item, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } +} + +@Composable +internal fun UPI( + upi: UPI, + onFavoriteClick: (UPI, Boolean) -> Unit, + modifier: Modifier = Modifier, + onClick: ((UPI) -> Unit)? = null, +) { + var isFavorite by remember(upi) { mutableStateOf(upi.isFavorite) } + FavoritableRow( + isFavorite = isFavorite, + onFavoriteClicked = { + isFavorite = it + onFavoriteClick(upi, it) + }, + modifier = modifier + .clickable(enabled = onClick != null) { onClick?.invoke(upi) } + .fillMaxWidth() + .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + ) { + Text(text = upi.virtualPaymentAddress) + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/UsernamePassword.kt b/app/src/main/java/com/adyen/testcards/ui/UsernamePassword.kt new file mode 100644 index 0000000..76ea623 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/UsernamePassword.kt @@ -0,0 +1,77 @@ +package com.adyen.testcards.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.adyen.testcards.R +import com.adyen.testcards.domain.UsernamePassword + +internal fun LazyListScope.usernamePasswordSection( + usernamePasswords: List, + onFavoriteClick: (UsernamePassword, Boolean) -> Unit, + onItemClick: ((UsernamePassword) -> Unit)? = null, +) { + item(key = "Username password") { + PaymentMethodTitle("Username password", R.drawable.ic_pm_wallet, Modifier.animateItem()) + } + + items(usernamePasswords, key = { it.username + it.type }) { item -> + HorizontalDivider( + modifier = Modifier + .animateItem() + .padding(16.dp, 0.dp), + ) + UsernamePassword( + data = item, + onFavoriteClick = onFavoriteClick, + onClick = onItemClick, + modifier = Modifier.animateItem(), + ) + } +} + +@Composable +internal fun UsernamePassword( + data: UsernamePassword, + onFavoriteClick: (UsernamePassword, Boolean) -> Unit, + modifier: Modifier = Modifier, + onClick: ((UsernamePassword) -> Unit)? = null, +) { + var isFavorite by remember(data) { mutableStateOf(data.isFavorite) } + FavoritableRow( + isFavorite = isFavorite, + onFavoriteClicked = { + isFavorite = it + onFavoriteClick(data, it) + }, + modifier = modifier + .clickable(enabled = onClick != null) { onClick?.invoke(data) } + .fillMaxWidth() + .padding(start = 16.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + ) { + Column { + Text(text = data.username) + Spacer(modifier = Modifier.padding(4.dp)) + Row { + Text(text = data.type, style = MaterialTheme.typography.labelSmall) + Spacer(modifier = Modifier.padding(8.dp)) + Text(text = data.password, style = MaterialTheme.typography.labelSmall) + } + } + } +} diff --git a/app/src/main/java/com/adyen/testcards/ui/theme/Color.kt b/app/src/main/java/com/adyen/testcards/ui/theme/Color.kt new file mode 100644 index 0000000..df87f08 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/theme/Color.kt @@ -0,0 +1,153 @@ +package com.adyen.testcards.ui.theme + +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color + +private val primaryLight = Color(0xFF00BE5A) +private val onPrimaryLight = Color(0xFFFFFFFF) +private val primaryContainerLight = Color(0xFF25CA65) +private val onPrimaryContainerLight = Color(0xFF002D10) +private val secondaryLight = Color(0xFF003EBE) +private val onSecondaryLight = Color(0xFFFFFFFF) +private val secondaryContainerLight = Color(0xFF2C62FA) +private val onSecondaryContainerLight = Color(0xFFFFFFFF) +private val tertiaryLight = Color(0xFF000000) +private val onTertiaryLight = Color(0xFFFFFFFF) +private val tertiaryContainerLight = Color(0xFF182541) +private val onTertiaryContainerLight = Color(0xFFA4B1D3) +private val errorLight = Color(0xFFA50011) +private val onErrorLight = Color(0xFFFFFFFF) +private val errorContainerLight = Color(0xFFDF2B2B) +private val onErrorContainerLight = Color(0xFFFFFFFF) +private val backgroundLight = Color(0xFFF3FCEF) +private val onBackgroundLight = Color(0xFF161D16) +private val surfaceLight = Color(0xFFFCF8F8) +private val onSurfaceLight = Color(0xFF1C1B1B) +private val surfaceVariantLight = Color(0xFFE0E3E3) +private val onSurfaceVariantLight = Color(0xFF444748) +private val outlineLight = Color(0xFF747878) +private val outlineVariantLight = Color(0xFFC4C7C8) +private val scrimLight = Color(0xFF000000) +private val inverseSurfaceLight = Color(0xFF313030) +private val inverseOnSurfaceLight = Color(0xFFF4F0EF) +private val inversePrimaryLight = Color(0xFF46E178) +private val surfaceDimLight = Color(0xFFDDD9D9) +private val surfaceBrightLight = Color(0xFFFCF8F8) +private val surfaceContainerLowestLight = Color(0xFFFFFFFF) +private val surfaceContainerLowLight = Color(0xFFF6F3F2) +private val surfaceContainerLight = Color(0xFFF1EDEC) +private val surfaceContainerHighLight = Color(0xFFEBE7E7) +private val surfaceContainerHighestLight = Color(0xFFE5E2E1) + +private val primaryDark = Color(0xFF00BE5A) +private val onPrimaryDark = Color(0xFF003916) +private val primaryContainerDark = Color(0xFF00B555) +private val onPrimaryContainerDark = Color(0xFF001204) +private val secondaryDark = Color(0xFFB6C4FF) +private val onSecondaryDark = Color(0xFF00277F) +private val secondaryContainerDark = Color(0xFF1B57F0) +private val onSecondaryContainerDark = Color(0xFFFFFFFF) +private val tertiaryDark = Color(0xFFB9C6EA) +private val onTertiaryDark = Color(0xFF23304C) +private val tertiaryContainerDark = Color(0xFF000A22) +private val onTertiaryContainerDark = Color(0xFF8E9BBC) +private val errorDark = Color(0xFFFFB4AC) +private val onErrorDark = Color(0xFF690007) +private val errorContainerDark = Color(0xFFD62326) +private val onErrorContainerDark = Color(0xFFFFFFFF) +private val backgroundDark = Color(0xFF0E150E) +private val onBackgroundDark = Color(0xFFDCE5D9) +private val surfaceDark = Color(0xFF141313) +private val onSurfaceDark = Color(0xFFE5E2E1) +private val surfaceVariantDark = Color(0xFF444748) +private val onSurfaceVariantDark = Color(0xFFC4C7C8) +private val outlineDark = Color(0xFF8E9192) +private val outlineVariantDark = Color(0xFF444748) +private val scrimDark = Color(0xFF000000) +private val inverseSurfaceDark = Color(0xFFE5E2E1) +private val inverseOnSurfaceDark = Color(0xFF313030) +private val inversePrimaryDark = Color(0xFF006D31) +private val surfaceDimDark = Color(0xFF141313) +private val surfaceBrightDark = Color(0xFF3A3939) +private val surfaceContainerLowestDark = Color(0xFF0E0E0E) +private val surfaceContainerLowDark = Color(0xFF1C1B1B) +private val surfaceContainerDark = Color(0xFF201F1F) +private val surfaceContainerHighDark = Color(0xFF2A2A2A) +private val surfaceContainerHighestDark = Color(0xFF353434) + +internal val lightScheme = lightColorScheme( + primary = primaryLight, + onPrimary = onPrimaryLight, + primaryContainer = primaryContainerLight, + onPrimaryContainer = onPrimaryContainerLight, + secondary = secondaryLight, + onSecondary = onSecondaryLight, + secondaryContainer = secondaryContainerLight, + onSecondaryContainer = onSecondaryContainerLight, + tertiary = tertiaryLight, + onTertiary = onTertiaryLight, + tertiaryContainer = tertiaryContainerLight, + onTertiaryContainer = onTertiaryContainerLight, + error = errorLight, + onError = onErrorLight, + errorContainer = errorContainerLight, + onErrorContainer = onErrorContainerLight, + background = backgroundLight, + onBackground = onBackgroundLight, + surface = surfaceLight, + onSurface = onSurfaceLight, + surfaceVariant = surfaceVariantLight, + onSurfaceVariant = onSurfaceVariantLight, + outline = outlineLight, + outlineVariant = outlineVariantLight, + scrim = scrimLight, + inverseSurface = inverseSurfaceLight, + inverseOnSurface = inverseOnSurfaceLight, + inversePrimary = inversePrimaryLight, + surfaceDim = surfaceDimLight, + surfaceBright = surfaceBrightLight, + surfaceContainerLowest = surfaceContainerLowestLight, + surfaceContainerLow = surfaceContainerLowLight, + surfaceContainer = surfaceContainerLight, + surfaceContainerHigh = surfaceContainerHighLight, + surfaceContainerHighest = surfaceContainerHighestLight, +) + +internal val darkScheme = darkColorScheme( + primary = primaryDark, + onPrimary = onPrimaryDark, + primaryContainer = primaryContainerDark, + onPrimaryContainer = onPrimaryContainerDark, + secondary = secondaryDark, + onSecondary = onSecondaryDark, + secondaryContainer = secondaryContainerDark, + onSecondaryContainer = onSecondaryContainerDark, + tertiary = tertiaryDark, + onTertiary = onTertiaryDark, + tertiaryContainer = tertiaryContainerDark, + onTertiaryContainer = onTertiaryContainerDark, + error = errorDark, + onError = onErrorDark, + errorContainer = errorContainerDark, + onErrorContainer = onErrorContainerDark, + background = backgroundDark, + onBackground = onBackgroundDark, + surface = surfaceDark, + onSurface = onSurfaceDark, + surfaceVariant = surfaceVariantDark, + onSurfaceVariant = onSurfaceVariantDark, + outline = outlineDark, + outlineVariant = outlineVariantDark, + scrim = scrimDark, + inverseSurface = inverseSurfaceDark, + inverseOnSurface = inverseOnSurfaceDark, + inversePrimary = inversePrimaryDark, + surfaceDim = surfaceDimDark, + surfaceBright = surfaceBrightDark, + surfaceContainerLowest = surfaceContainerLowestDark, + surfaceContainerLow = surfaceContainerLowDark, + surfaceContainer = surfaceContainerDark, + surfaceContainerHigh = surfaceContainerHighDark, + surfaceContainerHighest = surfaceContainerHighestDark, +) diff --git a/app/src/main/java/com/adyen/testcards/ui/theme/Theme.kt b/app/src/main/java/com/adyen/testcards/ui/theme/Theme.kt new file mode 100644 index 0000000..8f50cd4 --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/theme/Theme.kt @@ -0,0 +1,33 @@ +package com.adyen.testcards.ui.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +@Composable +fun AdyenTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> darkScheme + else -> lightScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content, + ) +} diff --git a/app/src/main/java/com/adyen/testcards/ui/theme/Type.kt b/app/src/main/java/com/adyen/testcards/ui/theme/Type.kt new file mode 100644 index 0000000..330c28c --- /dev/null +++ b/app/src/main/java/com/adyen/testcards/ui/theme/Type.kt @@ -0,0 +1,5 @@ +package com.adyen.testcards.ui.theme + +import androidx.compose.material3.Typography + +val Typography = Typography() diff --git a/app/src/main/proto/StoredFavorites.proto b/app/src/main/proto/StoredFavorites.proto new file mode 100644 index 0000000..ce1df45 --- /dev/null +++ b/app/src/main/proto/StoredFavorites.proto @@ -0,0 +1,13 @@ +syntax = "proto3"; + +option java_package = "com.adyen.testcards.data"; +option java_multiple_files = true; + +message StoredFavorites { + string type = 1; + repeated string creditCards = 2; + repeated string giftCards = 3; + repeated string ibans = 4; + repeated string upis = 5; + repeated string usernamePasswords = 6; +} diff --git a/app/src/main/res/drawable/card_phone_payment.xml b/app/src/main/res/drawable/card_phone_payment.xml new file mode 100644 index 0000000..c7b8f15 --- /dev/null +++ b/app/src/main/res/drawable/card_phone_payment.xml @@ -0,0 +1,131 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_adyen.xml b/app/src/main/res/drawable/ic_adyen.xml new file mode 100644 index 0000000..160dd5f --- /dev/null +++ b/app/src/main/res/drawable/ic_adyen.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..d4d29f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_pm_amex.xml b/app/src/main/res/drawable/ic_pm_amex.xml new file mode 100644 index 0000000..3325c47 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_amex.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_bank.xml b/app/src/main/res/drawable/ic_pm_bank.xml new file mode 100644 index 0000000..022b849 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_bank.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_card.xml b/app/src/main/res/drawable/ic_pm_card.xml new file mode 100644 index 0000000..c3cdc1f --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_card.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_carte_bancaire.xml b/app/src/main/res/drawable/ic_pm_carte_bancaire.xml new file mode 100644 index 0000000..a6a03ed --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_carte_bancaire.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_diners.xml b/app/src/main/res/drawable/ic_pm_diners.xml new file mode 100644 index 0000000..11d7c0a --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_diners.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_discover.xml b/app/src/main/res/drawable/ic_pm_discover.xml new file mode 100644 index 0000000..0014ca5 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_discover.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_gift_card.xml b/app/src/main/res/drawable/ic_pm_gift_card.xml new file mode 100644 index 0000000..ea1f6a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_gift_card.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_maestro.xml b/app/src/main/res/drawable/ic_pm_maestro.xml new file mode 100644 index 0000000..0bb7361 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_maestro.xml @@ -0,0 +1,27 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_mastercard.xml b/app/src/main/res/drawable/ic_pm_mastercard.xml new file mode 100644 index 0000000..da27ca3 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_mastercard.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_unionpay.xml b/app/src/main/res/drawable/ic_pm_unionpay.xml new file mode 100644 index 0000000..299acb1 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_unionpay.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_upi.xml b/app/src/main/res/drawable/ic_pm_upi.xml new file mode 100644 index 0000000..bf14f9e --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_upi.xml @@ -0,0 +1,25 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_visa.xml b/app/src/main/res/drawable/ic_pm_visa.xml new file mode 100644 index 0000000..5381f71 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_visa.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_pm_vpay.xml b/app/src/main/res/drawable/ic_pm_vpay.xml new file mode 100644 index 0000000..e5a4fd0 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_vpay.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_pm_wallet.xml b/app/src/main/res/drawable/ic_pm_wallet.xml new file mode 100644 index 0000000..c591a34 --- /dev/null +++ b/app/src/main/res/drawable/ic_pm_wallet.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/app/src/main/res/drawable/man_shrugging.xml b/app/src/main/res/drawable/man_shrugging.xml new file mode 100644 index 0000000..17fff12 --- /dev/null +++ b/app/src/main/res/drawable/man_shrugging.xml @@ -0,0 +1,147 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_autofill_entry.xml b/app/src/main/res/layout/item_autofill_entry.xml new file mode 100644 index 0000000..92c49fb --- /dev/null +++ b/app/src/main/res/layout/item_autofill_entry.xml @@ -0,0 +1,22 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..29ed157 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..29639fc Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..3aaf802 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a7c530a Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..4bf9f65 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..a5aba36 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..c01f40e Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1acd7c3 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..0b2690f Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..e48174e Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..7ec065c --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Adyen Test Cards + MainActivity + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..82c6792 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..148c18b --- /dev/null +++ b/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000..7b7d3cb --- /dev/null +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,19 @@ + + + + + + + diff --git a/app/src/main/res/xml/test_card_autofill_service_configuration.xml b/app/src/main/res/xml/test_card_autofill_service_configuration.xml new file mode 100644 index 0000000..eca54e0 --- /dev/null +++ b/app/src/main/res/xml/test_card_autofill_service_configuration.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dc419a3 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,7 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.jetbrains.kotlin.android) apply false + id("com.google.dagger.hilt.android") version "2.52" apply false + id("com.google.devtools.ksp") version "1.9.25-1.0.20" apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..20e2a01 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,23 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..c435b91 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,49 @@ +[versions] +activityCompose = "1.9.2" +agp = "8.4.2" +appcompat = "1.7.0" +autofill = "1.3.0-beta01" +composeBom = "2024.09.02" +coreKtx = "1.13.1" +datastore = "1.1.1" +espressoCore = "3.6.1" +hilt = "2.52" +junit = "4.13.2" +junitVersion = "1.2.1" +kotlin = "1.9.25" +material = "1.12.0" +moshi = "1.15.1" +protobuf = "4.28.1" +retrofit = "2.11.0" + +[libraries] +androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } +androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } +androidx-autofill = { group = "androidx.autofill", name = "autofill", version.ref = "autofill" } +androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } +androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } +androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } +androidx-datastore = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } +androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose" } +androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx" } +androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" } +androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-ui = { group = "androidx.compose.ui", name = "ui" } +androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } +androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } +androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hiltCompiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } +junit = { group = "junit", name = "junit", version.ref = "junit" } +material = { group = "com.google.android.material", name = "material", version.ref = "material" } +moshi-code-gen = { group = "com.squareup.moshi", name = "moshi-kotlin-codegen", version.ref = "moshi" } +protobuf-lite = { group = "com.google.protobuf", name = "protobuf-javalite", version.ref = "protobuf" } +retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } +retrofit-moshi = { group = "com.squareup.retrofit2", name = "converter-moshi", version.ref = "retrofit" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..151dd68 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Fri May 24 15:29:47 CEST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..6530c2c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,23 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "Adyen Test Cards" +include(":app")