diff --git a/.editorconfig b/.editorconfig index 25dde57..14e481f 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,31 +1,637 @@ -# EditorConfig helps developers define and maintain consistent -# coding styles between different editors and IDEs -# editorconfig.org +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = false +max_line_length = 130 +tab_width = 4 +ij_continuation_indent_size = 8 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = false +ij_smart_tabs = false +ij_visual_guides = 120 +ij_wrap_on_typing = false -root = true +[*.java] +ij_continuation_indent_size = 4 +ij_visual_guides = none +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_deconstruction_list_components = true +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_align_types_in_multi_catch = true +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_add_space = false +ij_java_block_comment_at_first_column = true +ij_java_builder_methods = none +ij_java_call_parameters_new_line_after_left_paren = true +ij_java_call_parameters_right_paren_on_new_line = true +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_deconstruction_list_wrap = normal +ij_java_do_not_indent_top_level_class_members = false +ij_java_do_not_wrap_after_single_annotation = false +ij_java_do_not_wrap_after_single_annotation_in_parameter = 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.**,|,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_add_space_on_reformat = 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 = true +ij_java_method_parameters_right_paren_on_new_line = true +ij_java_method_parameters_wrap = off +ij_java_modifier_list_wrap = false +ij_java_multi_catch_types_wrap = normal +ij_java_names_count_to_use_import_on_demand = 99 +ij_java_new_line_after_lparen_in_annotation = false +ij_java_new_line_after_lparen_in_deconstruction_pattern = true +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_annotation = false +ij_java_rparen_on_new_line_in_deconstruction_pattern = true +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_deconstruction_list = 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_annotation_eq = 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_deconstruction_list = 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_visual_guides = none +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_visual_guides = none +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 -indent_style = space -indent_size = 4 -tab_width = 4 +[{*.ant,*.fxml,*.jhm,*.jnlp,*.jrxml,*.rng,*.tld,*.wsdl,*.xml,*.xsd,*.xsl,*.xslt,*.xul}] +ij_continuation_indent_size = 4 +ij_visual_guides = none +ij_xml_align_attributes = false +ij_xml_align_text = false +ij_xml_attribute_wrap = normal +ij_xml_block_comment_add_space = false +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 -disabled_rules=import-ordering +[{*.apinotes,*.yaml,*.yml}] +indent_size = 2 +ij_visual_guides = none +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 -no-unused-imports = true +[{*.gant,*.groovy,*.gy}] +ij_visual_guides = none +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_add_space = false +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_enable_groovydoc_formatting = true +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_ginq_general_clause_wrap_policy = 2 +ij_groovy_ginq_having_wrap_policy = 1 +ij_groovy_ginq_indent_having_clause = true +ij_groovy_ginq_indent_on_clause = true +ij_groovy_ginq_on_wrap_policy = 1 +ij_groovy_ginq_space_after_keyword = true +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 = 4 +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_add_space_on_reformat = 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_packages_to_use_import_on_demand = java.awt.*,javax.swing.* +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_record_parentheses = false +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_chain_calls_after_dot = false +ij_groovy_wrap_long_lines = false -# https://stackoverflow.com/questions/147454/why-is-using-a-wild-card-with-a-java-import-statement-bad -no-wildcard-import = true +[{*.gradle.kts,*.kt,*.kts,*.main.kts}] +ij_continuation_indent_size = 4 +ij_visual_guides = none +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 = false +ij_kotlin_allow_trailing_comma_on_call_site = false +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_add_space = false +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 = androidx.**,android.**,*,java.**,javax.**,kotlin.**,kotlinx.**,org.p2p.** +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 0 +ij_kotlin_keep_blank_lines_in_code = 1 +ij_kotlin_keep_blank_lines_in_declarations = 1 +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_break_after_multiline_when_entry = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_add_space_on_reformat = 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_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 = 0 +ij_kotlin_wrap_expression_body_functions = 1 +ij_kotlin_wrap_first_method_in_call_chain = false -# https://softwareengineering.stackexchange.com/questions/604/is-the-80-character-limit-still-relevant-in-times-of-widescreen-monitors -max_line_length = 85 +[{*.har,*.json}] +indent_size = 2 +ij_visual_guides = none +ij_json_array_wrapping = split_into_lines +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_keep_trailing_comma = false +ij_json_object_wrapping = split_into_lines +ij_json_property_alignment = do_not_align +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = false +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false -# We recommend you to keep these unchanged -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true +[{*.markdown,*.md}] +max_line_length = 200 +ij_visual_guides = none +ij_markdown_force_one_space_after_blockquote_symbol = true +ij_markdown_force_one_space_after_header_symbol = true +ij_markdown_force_one_space_after_list_bullet = true +ij_markdown_force_one_space_between_words = true +ij_markdown_format_tables = true +ij_markdown_insert_quote_arrows_on_wrap = true +ij_markdown_keep_indents_on_empty_lines = false +ij_markdown_keep_line_breaks_inside_text_blocks = true +ij_markdown_max_lines_around_block_elements = 1 +ij_markdown_max_lines_around_header = 1 +ij_markdown_max_lines_between_paragraphs = 1 +ij_markdown_min_lines_around_block_elements = 1 +ij_markdown_min_lines_around_header = 1 +ij_markdown_min_lines_between_paragraphs = 1 +ij_markdown_wrap_text_if_long = true +ij_markdown_wrap_text_inside_blockquotes = true -[*.md] -trim_trailing_whitespace = false +[{*.toml,Cargo.lock,Cargo.toml.orig,Gopkg.lock,Pipfile,poetry.lock}] +ij_visual_guides = none +ij_toml_keep_indents_on_empty_lines = false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f4e142e..9678b0d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -18,17 +18,17 @@ jobs: environment: Development runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' cache: gradle - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1.0.5 + uses: gradle/actions/wrapper-validation@v3 - name: Build debug APK run: ./gradlew assembleDebug @@ -38,17 +38,17 @@ jobs: environment: Development runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' cache: gradle - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1.0.5 + uses: gradle/actions/wrapper-validation@v3 - name: Decrypt the keystore for signing run: | @@ -59,7 +59,7 @@ jobs: run: ./gradlew assembleRelease - name: Upload release APK - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: ark-retouch path: ./app/build/outputs/apk/release/*.apk @@ -69,10 +69,10 @@ jobs: environment: Development runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' @@ -80,7 +80,7 @@ jobs: - name: Run linter run: ./gradlew lint - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: lint-results path: ./app/build/reports/*.html @@ -90,14 +90,14 @@ jobs: environment: Development runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' - name: Kotlin linter - run: ./gradlew ktlintCheck + run: ./gradlew ktlint -PautoCorrect=0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index af25b0a..6fe190f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,16 +15,16 @@ jobs: ACRA_URI: ${{ secrets.ACRARIUM_URI }} BRANCH_NAME: ${{ github.ref_name }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up JDK 17 - uses: actions/setup-java@v3 + uses: actions/setup-java@v4 with: java-version: '17' distribution: 'adopt' - name: Validate Gradle wrapper - uses: gradle/wrapper-validation-action@v1.0.5 + uses: gradle/actions/wrapper-validation@v3 - name: Build Release APK run: ./gradlew assembleRelease diff --git a/.gitignore b/.gitignore index a7d754e..ed65772 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,5 @@ *.tab *.tab.values.at misc.xml + +*.DS_Store \ No newline at end of file diff --git a/.scripts/ktlint.gradle b/.scripts/ktlint.gradle new file mode 100644 index 0000000..59a98f4 --- /dev/null +++ b/.scripts/ktlint.gradle @@ -0,0 +1,31 @@ +configurations { + ktlint +} + +dependencies { + ktlint "com.pinterest:ktlint:0.45.2" +} + +tasks.register('ktlint', JavaExec) { + mainClass = "com.pinterest.ktlint.Main" + classpath = configurations.ktlint + args("$rootDir/**/*.kt", "!$rootDir/**/build/**") + jvmArgs "--add-opens=java.base/java.lang=ALL-UNNAMED" + + if (project.hasProperty("autoCorrect") && project.property("autoCorrect") == "0") { + logger.quiet("(KTLINT): auto correction is disabled") + } else { + logger.quiet("(KTLINT): auto correction is enabled") + args "-F" + } +} + +tasks.register('ktlintCheck', JavaExec) { + classpath = configurations.ktlint + mainClass = "com.pinterest.ktlint.Main" + args "src/**/*.kt", "**.kts", "!**/build/**" +} + +tasks.preBuild.dependsOn ktlintCheck + +tasks.ktlintCheck.dependsOn ktlint \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index a258dfa..5d300ee 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,15 +1,16 @@ -import org.apache.tools.ant.taskdefs.condition.Os - plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-kapt' - id 'org.jlleitschuh.gradle.ktlint' id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' } +apply from: "$project.rootDir/.scripts/ktlint.gradle" + android { - compileSdk 33 + compileSdk 34 + + namespace 'dev.arkbuilders.arkretouch' defaultConfig { def login = System.getenv("ACRA_LOGIN") ?: "" @@ -18,7 +19,7 @@ android { applicationId "dev.arkbuilders.arkretouch2" minSdk 26 - targetSdk 32 + targetSdk 34 versionCode 1 versionName "1.0" setProperty("archivesBaseName", "ark-retouch") @@ -69,6 +70,7 @@ android { buildFeatures { compose true viewBinding true + buildConfig = true } composeOptions { kotlinCompilerExtensionVersion "1.3.1" @@ -82,20 +84,24 @@ android { dependencies { implementation 'androidx.core:core-ktx:1.7.0' - implementation "androidx.compose.ui:ui:$compose_version" - implementation "androidx.compose.material:material:$compose_version" - implementation "androidx.compose.ui:ui-tooling-preview:$compose_version" implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' implementation 'androidx.activity:activity-compose:1.3.1' implementation "androidx.appcompat:appcompat:1.5.1" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0" + def composeVersion = "1.6.8" + implementation "androidx.compose.ui:ui-graphics-android:$composeVersion" + implementation "androidx.compose.ui:ui:$composeVersion" + implementation "androidx.compose.material:material:$composeVersion" + implementation "androidx.compose.ui:ui-tooling-preview:$composeVersion" + implementation 'com.jakewharton.timber:timber:5.0.1' implementation "com.github.bumptech.glide:glide:4.16.0" kapt "com.github.bumptech.glide:compiler:4.16.0" - implementation 'com.google.dagger:dagger:2.50' - kapt 'com.google.dagger:dagger-compiler:2.50' + def koinVersion = "3.4.3" + implementation("io.insert-koin:koin-android:$koinVersion") + implementation "io.insert-koin:koin-androidx-compose:$koinVersion" implementation 'com.godaddy.android.colorpicker:compose-color-picker:0.7.0' @@ -110,21 +116,7 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' - androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" - debugImplementation "androidx.compose.ui:ui-tooling:$compose_version" - debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" -} - -task installGitHook(type: Copy) { - def suffix = "linux" - if (Os.isFamily(Os.FAMILY_WINDOWS)) { - suffix = "windows" - } - from new File(rootProject.rootDir, "scripts/pre-commit-$suffix") - into { new File(rootProject.rootDir, '.git/hooks') } - rename("pre-commit-$suffix", 'pre-commit') - fileMode 0775 -} - -tasks.preBuild.dependsOn installGitHook -tasks.preBuild.dependsOn ktlintCheck + androidTestImplementation "androidx.compose.ui:ui-test-junit4:$composeVersion" + debugImplementation "androidx.compose.ui:ui-tooling:$composeVersion" + debugImplementation "androidx.compose.ui:ui-test-manifest:$composeVersion" +} \ No newline at end of file diff --git a/app/src/androidTest/java/dev/arkbuilders/arkretouch/ExampleInstrumentedTest.kt b/app/src/androidTest/java/dev/arkbuilders/arkretouch/ExampleInstrumentedTest.kt deleted file mode 100644 index 3544aec..0000000 --- a/app/src/androidTest/java/dev/arkbuilders/arkretouch/ExampleInstrumentedTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -package dev.arkbuilders.arkretouch - -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertEquals - -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class ExampleInstrumentedTest { - @Test - fun useAppContext() { - // Context of the app under test. - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("dev.arkbuilders.arkretouch", appContext.packageName) - } -} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 710084c..dba70df 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/App.kt b/app/src/main/java/dev/arkbuilders/arkretouch/App.kt similarity index 59% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/App.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/App.kt index b5d888e..25a4bca 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/App.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/App.kt @@ -1,24 +1,33 @@ -package dev.arkbuilders.arkretouch.presentation +package dev.arkbuilders.arkretouch import android.app.Application +import dev.arkbuilders.arkfilepicker.folders.FoldersRepo +import dev.arkbuilders.arkretouch.di.EditModule +import dev.arkbuilders.arkretouch.di.common.CommonModule import org.acra.config.dialog import org.acra.config.httpSender import org.acra.data.StringFormat import org.acra.ktx.initAcra import org.acra.sender.HttpSender -import dev.arkbuilders.arkretouch.BuildConfig -import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkfilepicker.folders.FoldersRepo -import dev.arkbuilders.arkretouch.di.DIManager +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger +import org.koin.core.context.startKoin +import org.koin.core.logger.Level import timber.log.Timber class App : Application() { override fun onCreate() { super.onCreate() - DIManager.init(this) Timber.plant(Timber.DebugTree()) FoldersRepo.init(this) + setupKoin() + + setupAcra() + } + + // FIXME: Let's use Firebase crashlytics instead + private fun setupAcra() { initAcra { buildConfigClass = BuildConfig::class.java reportFormat = StringFormat.JSON @@ -35,4 +44,15 @@ class App : Application() { } } } -} + + private fun setupKoin() { + startKoin { + androidContext(applicationContext) + androidLogger(Level.INFO) + modules( + EditModule.create(), + CommonModule.create(), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/data/model/DrawPath.kt b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/DrawPath.kt new file mode 100644 index 0000000..572799c --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/DrawPath.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.arkretouch.data.model + +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path + +class DrawPath( + val path: Path, + val paint: Paint +) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/data/model/DrawingState.kt b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/DrawingState.kt new file mode 100644 index 0000000..4ccadfa --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/DrawingState.kt @@ -0,0 +1,21 @@ +package dev.arkbuilders.arkretouch.data.model + +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import dev.arkbuilders.arkretouch.utils.defaultPaint + +data class DrawingState( + val currentPaint: Paint = defaultPaint(), + val drawPaint: Paint = defaultPaint(), + val backgroundPaint: Paint = Paint().also { it.color = Color.Transparent }, + val erasePaint: Paint = Paint().apply { + shader = null + color = backgroundPaint.color + style = PaintingStyle.Stroke + blendMode = BlendMode.SrcOut + }, + val blurSize: Float = 250f, + val blurIntensity: Float = 12f +) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/data/model/EditingState.kt b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/EditingState.kt new file mode 100644 index 0000000..9381bcb --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/EditingState.kt @@ -0,0 +1,31 @@ +package dev.arkbuilders.arkretouch.data.model + +import androidx.compose.ui.graphics.Color +import dev.arkbuilders.arkretouch.editing.manager.EditingMode + +data class EditingState( + val mode: EditingMode = EditingMode.DRAW, + val strokeSliderExpanded: Boolean = false, + val showColorDialog: Boolean = false, + val strokeWidth: Float = 5f, + val menusVisible: Boolean = true, + val showSavePathDialog: Boolean = false, + val showExitDialog: Boolean = false, + val showMoreOptionsPopup: Boolean = false, + val imageSaved: Boolean = false, + val isSavingImage: Boolean = false, + val showEyeDropperHint: Boolean = false, + val showConfirmClearDialog: Boolean = false, + val isLoaded: Boolean = false, + val exitConfirmed: Boolean = false, + val bottomButtonsScrollIsAtStart: Boolean = false, + val bottomButtonsScrollIsAtEnd: Boolean = false, + val usedColors: List = listOf(), + val canUndo: Boolean = false, + val canRedo: Boolean = false +) { + + companion object { + val DEFAULT = EditingState() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/data/ImageDefaults.kt b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/ImageDefaults.kt similarity index 91% rename from app/src/main/java/dev/arkbuilders/arkretouch/data/ImageDefaults.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/data/model/ImageDefaults.kt index 1718e9b..e63f9a4 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/data/ImageDefaults.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/ImageDefaults.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.arkretouch.data +package dev.arkbuilders.arkretouch.data.model import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.IntSize @@ -20,4 +20,4 @@ data class Resolution( companion object { fun fromIntSize(intSize: IntSize) = Resolution(intSize.width, intSize.height) } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/data/model/ImageViewParams.kt b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/ImageViewParams.kt new file mode 100644 index 0000000..d463bf9 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/data/model/ImageViewParams.kt @@ -0,0 +1,9 @@ +package dev.arkbuilders.arkretouch.data.model + +import androidx.compose.ui.unit.IntSize +import dev.arkbuilders.arkretouch.editing.resize.ResizeOperation + +class ImageViewParams( + val drawArea: IntSize, + val scale: ResizeOperation.Scale +) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/data/repo/FilesRepository.kt b/app/src/main/java/dev/arkbuilders/arkretouch/data/repo/FilesRepository.kt new file mode 100644 index 0000000..04650c4 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/data/repo/FilesRepository.kt @@ -0,0 +1,37 @@ +package dev.arkbuilders.arkretouch.data.repo + +import androidx.core.content.FileProvider +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import timber.log.Timber +import java.io.File + +class FilesRepository { + + fun getCachedImageUri( + context: Context, + bitmap: Bitmap, + name: String = "" + ): Uri { + var uri: Uri? = null + val imageCacheFolder = File(context.cacheDir, "images") + try { + imageCacheFolder.mkdirs() + val file = File(imageCacheFolder, "image$name.png") + file.outputStream().use { out -> + bitmap + .compress(Bitmap.CompressFormat.PNG, 100, out) + } + Timber.tag("Cached image path").d(file.path.toString()) + uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + } catch (e: Exception) { + e.printStackTrace() + } + return uri!! + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/data/Preferences.kt b/app/src/main/java/dev/arkbuilders/arkretouch/data/repo/OldStorageRepository.kt similarity index 82% rename from app/src/main/java/dev/arkbuilders/arkretouch/data/Preferences.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/data/repo/OldStorageRepository.kt index 771a608..afb0798 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/data/Preferences.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/data/repo/OldStorageRepository.kt @@ -1,29 +1,30 @@ -package dev.arkbuilders.arkretouch.data +package dev.arkbuilders.arkretouch.data.repo -import android.content.Context import androidx.compose.ui.graphics.Color -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.decodeFromString -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json +import android.content.Context +import dev.arkbuilders.arkretouch.data.model.ImageDefaults +import dev.arkbuilders.arkretouch.data.model.Resolution import java.io.IOException import java.nio.file.Files -import javax.inject.Inject -import javax.inject.Singleton import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText import kotlin.text.Charsets.UTF_8 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json -@Singleton -class Preferences @Inject constructor(private val appCtx: Context) { +class OldStorageRepository constructor( + private val context: Context +) { suspend fun persistUsedColors( colors: List ) = withContext(Dispatchers.IO) { try { - val colorsStorage = appCtx.filesDir.resolve(COLORS_STORAGE) + val colorsStorage = context.filesDir.resolve(COLORS_STORAGE) .toPath() val lines = colors.map { it.value.toString() } Files.write(colorsStorage, lines, UTF_8) @@ -37,7 +38,7 @@ class Preferences @Inject constructor(private val appCtx: Context) { withContext(Dispatchers.IO) { try { - val colorsStorage = appCtx + val colorsStorage = context .filesDir .resolve(COLORS_STORAGE) .toPath() @@ -57,7 +58,7 @@ class Preferences @Inject constructor(private val appCtx: Context) { suspend fun persistDefaults(color: Color, resolution: Resolution) { withContext(Dispatchers.IO) { - val defaultsStorage = appCtx.filesDir.resolve(DEFAULTS_STORAGE) + val defaultsStorage = context.filesDir.resolve(DEFAULTS_STORAGE) .toPath() val defaults = ImageDefaults( color.value, @@ -72,7 +73,7 @@ class Preferences @Inject constructor(private val appCtx: Context) { var defaults = ImageDefaults() try { withContext(Dispatchers.IO) { - val defaultsStorage = appCtx.filesDir.resolve(DEFAULTS_STORAGE) + val defaultsStorage = context.filesDir.resolve(DEFAULTS_STORAGE) .toPath() if (defaultsStorage.exists()) { val jsonString = defaultsStorage.readText(UTF_8) @@ -89,4 +90,4 @@ class Preferences @Inject constructor(private val appCtx: Context) { private const val COLORS_STORAGE = "colors" private const val DEFAULTS_STORAGE = "defaults" } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/di/AppComponent.kt b/app/src/main/java/dev/arkbuilders/arkretouch/di/AppComponent.kt deleted file mode 100644 index a8419a9..0000000 --- a/app/src/main/java/dev/arkbuilders/arkretouch/di/AppComponent.kt +++ /dev/null @@ -1,22 +0,0 @@ -package dev.arkbuilders.arkretouch.di - -import android.app.Application -import android.content.Context -import dagger.BindsInstance -import dagger.Component -import dev.arkbuilders.arkretouch.presentation.edit.EditViewModelFactory -import javax.inject.Singleton - -@Singleton -@Component -interface AppComponent { - fun editVMFactory(): EditViewModelFactory.Factory - fun app(): Application - @Component.Factory - interface Factory { - fun create( - @BindsInstance application: Application, - @BindsInstance context: Context - ): AppComponent - } -} diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/di/DIManager.kt b/app/src/main/java/dev/arkbuilders/arkretouch/di/DIManager.kt deleted file mode 100644 index 618bd33..0000000 --- a/app/src/main/java/dev/arkbuilders/arkretouch/di/DIManager.kt +++ /dev/null @@ -1,12 +0,0 @@ -package dev.arkbuilders.arkretouch.di - -import android.app.Application - -object DIManager { - lateinit var component: AppComponent - private set - - fun init(app: Application) { - component = DaggerAppComponent.factory().create(app, app.applicationContext) - } -} diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/di/EditModule.kt b/app/src/main/java/dev/arkbuilders/arkretouch/di/EditModule.kt new file mode 100644 index 0000000..fcc54f4 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/di/EditModule.kt @@ -0,0 +1,23 @@ +package dev.arkbuilders.arkretouch.di + +import dev.arkbuilders.arkretouch.data.model.Resolution +import dev.arkbuilders.arkretouch.di.common.InjectionModule +import dev.arkbuilders.arkretouch.presentation.viewmodels.EditViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module +import java.nio.file.Path + +object EditModule : InjectionModule { + + override fun create() = module { + viewModel { + ( + primaryColor: Long, + imagePath: Path?, + imageUri: String?, + maxResolution: Resolution + ) -> + EditViewModel(primaryColor, imagePath, imageUri, maxResolution, get()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/di/common/CommonModule.kt b/app/src/main/java/dev/arkbuilders/arkretouch/di/common/CommonModule.kt new file mode 100644 index 0000000..1396a5d --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/di/common/CommonModule.kt @@ -0,0 +1,14 @@ +package dev.arkbuilders.arkretouch.di.common + +import dev.arkbuilders.arkretouch.data.repo.FilesRepository +import dev.arkbuilders.arkretouch.data.repo.OldStorageRepository +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +object CommonModule : InjectionModule { + + override fun create() = module { + singleOf(::OldStorageRepository) + singleOf(::FilesRepository) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/di/common/InjectionModule.kt b/app/src/main/java/dev/arkbuilders/arkretouch/di/common/InjectionModule.kt new file mode 100644 index 0000000..ec485f6 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/di/common/InjectionModule.kt @@ -0,0 +1,7 @@ +package dev.arkbuilders.arkretouch.di.common + +import org.koin.core.module.Module + +fun interface InjectionModule { + fun create(): Module +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/di/common/dispatchers/CoroutinesDispatchers.kt b/app/src/main/java/dev/arkbuilders/arkretouch/di/common/dispatchers/CoroutinesDispatchers.kt new file mode 100644 index 0000000..960326a --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/di/common/dispatchers/CoroutinesDispatchers.kt @@ -0,0 +1,19 @@ +package dev.arkbuilders.arkretouch.di.common.dispatchers + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +interface CoroutineDispatchers { + val io: CoroutineDispatcher + val computation: CoroutineDispatcher + val ui: CoroutineDispatcher +} + +class DefaultDispatchers : CoroutineDispatchers { + override val io: CoroutineDispatcher + get() = Dispatchers.IO + override val computation: CoroutineDispatcher + get() = Dispatchers.Default + override val ui: CoroutineDispatcher + get() = Dispatchers.Main.immediate +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/Operation.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/Operation.kt similarity index 56% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/Operation.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/editing/Operation.kt index bffb639..a36f038 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/Operation.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/Operation.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.arkretouch.presentation.edit +package dev.arkbuilders.arkretouch.editing interface Operation { fun apply() @@ -6,4 +6,4 @@ interface Operation { fun undo() fun redo() -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/blur/BlurOperation.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/blur/BlurOperation.kt similarity index 80% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/blur/BlurOperation.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/editing/blur/BlurOperation.kt index 6b6a6d9..36774b6 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/blur/BlurOperation.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/blur/BlurOperation.kt @@ -1,8 +1,5 @@ -package dev.arkbuilders.arkretouch.presentation.edit.blur +package dev.arkbuilders.arkretouch.editing.blur -import android.content.Context -import android.graphics.Bitmap -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Canvas import androidx.compose.ui.graphics.ImageBitmap @@ -11,12 +8,19 @@ import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.unit.IntOffset +import android.content.Context +import android.graphics.Bitmap import com.hoko.blur.processor.HokoBlurBuild -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.Operation +import dev.arkbuilders.arkretouch.editing.Operation +import dev.arkbuilders.arkretouch.editing.manager.EditManager +import timber.log.Timber import java.util.Stack -class BlurOperation(private val editManager: EditManager) : Operation { +class BlurOperation( + private val editManager: EditManager, + private val onApply: () -> Unit +) : Operation { + private lateinit var blurredBitmap: Bitmap private lateinit var brushBitmap: Bitmap private lateinit var context: Context @@ -25,21 +29,22 @@ class BlurOperation(private val editManager: EditManager) : Operation { private var offset = Offset.Zero private var bitmapPosition = IntOffset.Zero - val blurSize = mutableStateOf(BRUSH_SIZE.toFloat()) + private var blurSize = BRUSH_SIZE.toFloat() + private var intensity = 12 fun init() { editManager.apply { backgroundImage.value?.let { bitmapPosition = IntOffset( - (it.width / 2) - (blurSize.value.toInt() / 2), - (it.height / 2) - (blurSize.value.toInt() / 2) + (it.width / 2) - (blurSize.toInt() / 2), + (it.height / 2) - (blurSize.toInt() / 2) ) brushBitmap = Bitmap.createBitmap( it.asAndroidBitmap(), bitmapPosition.x, bitmapPosition.y, - blurSize.value.toInt(), - blurSize.value.toInt() + blurSize.toInt(), + blurSize.toInt() ) } scaleToFitOnEdit() @@ -53,15 +58,15 @@ class BlurOperation(private val editManager: EditManager) : Operation { it.asAndroidBitmap(), bitmapPosition.x, bitmapPosition.y, - blurSize.value.toInt(), - blurSize.value.toInt() + blurSize.toInt(), + blurSize.toInt() ) } } } fun draw(context: Context, canvas: Canvas) { - if (blurSize.value in MIN_SIZE..MAX_SIZE) { + if (blurSize in MIN_SIZE..MAX_SIZE) { editManager.backgroundImage.value?.let { this.context = context if (isWithinBounds(it)) { @@ -97,8 +102,8 @@ class BlurOperation(private val editManager: EditManager) : Operation { it.asAndroidBitmap(), bitmapPosition.x, bitmapPosition.y, - blurSize.value.toInt(), - blurSize.value.toInt() + blurSize.toInt(), + blurSize.toInt() ) } } @@ -109,20 +114,27 @@ class BlurOperation(private val editManager: EditManager) : Operation { fun clear() { blurs.clear() redoBlurs.clear() - editManager.updateRevised() } fun cancel() { editManager.redrawBackgroundImage2() } + fun setSize(size: Float) { + blurSize = size + } + + fun setIntensity(intensity: Float) { + this.intensity = intensity.toInt() + } + private fun isWithinBounds(image: ImageBitmap) = bitmapPosition.x >= 0 && - (bitmapPosition.x + blurSize.value) <= image.width && - bitmapPosition.y >= 0 && (bitmapPosition.y + blurSize.value) <= image.height + (bitmapPosition.x + blurSize) <= image.width && + bitmapPosition.y >= 0 && (bitmapPosition.y + blurSize) <= image.height private fun isBrushTouched(position: Offset): Boolean { - return position.x >= offset.x && position.x <= (offset.x + blurSize.value) && - position.y >= offset.y && position.y <= (offset.y + blurSize.value) + return position.x >= offset.x && position.x <= (offset.x + blurSize) && + position.y >= offset.y && position.y <= (offset.y + blurSize) } override fun apply() { @@ -147,8 +159,9 @@ class BlurOperation(private val editManager: EditManager) : Operation { editManager.addBlur() } editManager.keepEditedPaths() - editManager.toggleBlurMode() editManager.backgroundImage.value = image + Timber.tag("BLUR").d("${blurs.peek()}") + onApply() } override fun undo() { @@ -168,7 +181,7 @@ class BlurOperation(private val editManager: EditManager) : Operation { private fun blur(context: Context) { editManager.apply { val blurProcessor = HokoBlurBuild(context) - .radius(blurIntensity.value.toInt()) + .radius(intensity) .processor() blurredBitmap = blurProcessor.blur(brushBitmap) @@ -180,4 +193,4 @@ class BlurOperation(private val editManager: EditManager) : Operation { const val MAX_SIZE = 500f const val MIN_SIZE = 100f } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropOperation.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/crop/CropOperation.kt similarity index 76% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropOperation.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/editing/crop/CropOperation.kt index b3c9711..8b915bf 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropOperation.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/crop/CropOperation.kt @@ -1,12 +1,13 @@ -package dev.arkbuilders.arkretouch.presentation.edit.crop +package dev.arkbuilders.arkretouch.editing.crop import androidx.compose.ui.graphics.asImageBitmap -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.Operation -import dev.arkbuilders.arkretouch.presentation.utils.crop +import dev.arkbuilders.arkretouch.editing.Operation +import dev.arkbuilders.arkretouch.editing.manager.EditManager +import dev.arkbuilders.arkretouch.utils.crop class CropOperation( - private val editManager: EditManager + private val editManager: EditManager, + val onApply: () -> Unit ) : Operation { override fun apply() { @@ -18,7 +19,7 @@ class CropOperation( addCrop() saveRotationAfterOtherOperation() scaleToFit() - toggleCropMode() + onApply() } } } @@ -32,7 +33,6 @@ class CropOperation( restoreRotationAfterUndoOtherOperation() scaleToFit() redrawEditedPaths() - updateRevised() } } } @@ -46,8 +46,7 @@ class CropOperation( saveRotationAfterOtherOperation() scaleToFit() keepEditedPaths() - updateRevised() } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropWindow.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/crop/CropWindow.kt similarity index 93% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropWindow.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/editing/crop/CropWindow.kt index 3bf9a14..0aa59a5 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropWindow.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/crop/CropWindow.kt @@ -1,6 +1,5 @@ -package dev.arkbuilders.arkretouch.presentation.edit.crop +package dev.arkbuilders.arkretouch.editing.crop -import android.graphics.Bitmap import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -9,17 +8,18 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.PaintingStyle import androidx.compose.ui.unit.IntSize -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.CROP_2_3 -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.CROP_4_5 -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.CROP_9_16 -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.CROP_SQUARE -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCropFree -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCropSquare -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCrop_2_3 -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCrop_4_5 -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCrop_9_16 -import dev.arkbuilders.arkretouch.presentation.edit.resize.ResizeOperation +import android.graphics.Bitmap +import dev.arkbuilders.arkretouch.editing.manager.EditManager +import dev.arkbuilders.arkretouch.editing.resize.ResizeOperation +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.CROP_2_3 +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.CROP_4_5 +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.CROP_9_16 +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.CROP_SQUARE +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCropFree +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCropSquare +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCrop_2_3 +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCrop_4_5 +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCrop_9_16 import timber.log.Timber class CropWindow(private val editManager: EditManager) { @@ -85,7 +85,7 @@ class CropWindow(private val editManager: EditManager) { offset = Offset(x, y) } - fun updateOnDrawAreaSizeChange(newSize: IntSize) { + fun updateOnDrawAreaSizeChange() { reInit() updateOnOffsetChange() } @@ -165,6 +165,8 @@ class CropWindow(private val editManager: EditManager) { this.delta = delta } + fun resetDelta() { delta = Offset.Zero } + private fun isAspectRatioFixed() = isCropSquare.value || isCrop_4_5.value || isCrop_9_16.value || isCrop_2_3.value @@ -221,6 +223,7 @@ class CropWindow(private val editManager: EditManager) { } } } else { + Timber.tag("crop-window").d("offset $delta") left = if (isTouchedLeft.value) rect.left + delta.x else rect.left @@ -440,4 +443,4 @@ class CropWindow(private val editManager: EditManager) { ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/draw/DrawOperation.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/draw/DrawOperation.kt similarity index 50% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/draw/DrawOperation.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/editing/draw/DrawOperation.kt index 4f91250..2b75514 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/draw/DrawOperation.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/draw/DrawOperation.kt @@ -1,21 +1,15 @@ -package dev.arkbuilders.arkretouch.presentation.edit.draw +package dev.arkbuilders.arkretouch.editing.draw -import androidx.compose.ui.graphics.Path -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.Operation +import dev.arkbuilders.arkretouch.editing.Operation +import dev.arkbuilders.arkretouch.editing.manager.EditManager class DrawOperation(private val editManager: EditManager) : Operation { - private var path = Path() - - override fun apply() { - editManager.addDrawPath(path) - } + override fun apply() {} override fun undo() { editManager.apply { if (drawPaths.isNotEmpty()) { redoPaths.push(drawPaths.pop()) - updateRevised() return } } @@ -25,13 +19,8 @@ class DrawOperation(private val editManager: EditManager) : Operation { editManager.apply { if (redoPaths.isNotEmpty()) { drawPaths.push(redoPaths.pop()) - updateRevised() return } } } - - fun draw(path: Path) { - this.path = path - } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/editing/manager/EditManager.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/manager/EditManager.kt new file mode 100644 index 0000000..6398619 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/manager/EditManager.kt @@ -0,0 +1,380 @@ +package dev.arkbuilders.arkretouch.editing.manager + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntSize +import android.graphics.Matrix +import dev.arkbuilders.arkretouch.data.model.DrawPath +import dev.arkbuilders.arkretouch.data.model.ImageDefaults +import dev.arkbuilders.arkretouch.data.model.ImageViewParams +import dev.arkbuilders.arkretouch.data.model.Resolution +import dev.arkbuilders.arkretouch.editing.Operation +import dev.arkbuilders.arkretouch.editing.crop.CropWindow +import dev.arkbuilders.arkretouch.editing.resize.ResizeOperation +import dev.arkbuilders.arkretouch.editing.rotate.RotateOperation +import dev.arkbuilders.arkretouch.presentation.viewmodels.fitBackground +import dev.arkbuilders.arkretouch.presentation.viewmodels.fitImage +import timber.log.Timber +import java.util.Stack + +class EditManager { + + var imageSize: IntSize = IntSize.Zero + private set + + private val _backgroundColor = mutableStateOf(Color.Transparent) + + val cropWindow = CropWindow(this) + + val drawPaths = Stack() + + val redoPaths = Stack() + + val backgroundImage = mutableStateOf(null) + val backgroundImage2 = mutableStateOf(null) + val originalBackgroundImage = mutableStateOf(null) + + val matrix = Matrix() + val editMatrix = Matrix() + val backgroundMatrix = Matrix() + + private var matrixScale = 1f + var zoomScale = 1f + lateinit var bitmapScale: ResizeOperation.Scale + private set + + private val _resolution = mutableStateOf(null) + val resolution: State = _resolution + var drawAreaSize = mutableStateOf(IntSize.Zero) + val availableDrawAreaSize = mutableStateOf(IntSize.Zero) + + var invalidatorTick = mutableIntStateOf(0) + private set + + val rotationAngle = mutableFloatStateOf(0F) + var prevRotationAngle = 0f + + private val editedPaths = Stack>() + + val redoResize = Stack() + val resizes = Stack() + val rotationAngles = Stack() + val redoRotationAngles = Stack() + + private val undoStack = Stack() + private val redoStack = Stack() + + val cropStack = Stack() + val redoCropStack = Stack() + + fun setImageSize(size: IntSize) { + if (size != IntSize.Zero) { + imageSize = size + } + } + + fun scaleToFit() { + val viewParams = backgroundImage.value?.let { + fitImage( + it, + drawAreaSize.value.width, + drawAreaSize.value.height + ) + } ?: run { + fitBackground( + imageSize, + drawAreaSize.value.width, + drawAreaSize.value.height + ) + } + matrixScale = viewParams.scale.x + scaleMatrix(viewParams) + updateAvailableDrawArea(viewParams.drawArea) + val bitmapXScale = + imageSize.width.toFloat() / viewParams.drawArea.width.toFloat() + val bitmapYScale = + imageSize.height.toFloat() / viewParams.drawArea.height.toFloat() + bitmapScale = ResizeOperation.Scale( + bitmapXScale, + bitmapYScale + ) + } + + fun scaleToFitOnEdit( + maxWidth: Int = drawAreaSize.value.width, + maxHeight: Int = drawAreaSize.value.height, + isRotating: Boolean = false + ): ImageViewParams { + val viewParams = backgroundImage.value?.let { + fitImage(it, maxWidth, maxHeight) + } ?: run { + fitBackground( + imageSize, + maxWidth, + maxHeight + ) + } + scaleEditMatrix(viewParams, isRotating) + updateAvailableDrawArea(viewParams.drawArea) + return viewParams + } + + fun setImageResolution(value: Resolution) { + _resolution.value = value + } + + fun initDefaults(defaults: ImageDefaults, maxResolution: Resolution) { + defaults.resolution?.let { + _resolution.value = it + } + if (resolution.value == null) + _resolution.value = maxResolution + _backgroundColor.value = Color(defaults.colorValue) + } + + fun updateAvailableDrawArea(area: IntSize) { + availableDrawAreaSize.value = area + } + + fun updateRevised(updateState: (Boolean, Boolean) -> Unit) { + updateState(undoStack.isNotEmpty(), redoStack.isNotEmpty()) + } + + fun RotateOperation.onRotate(angle: Float) { + val centerX = availableDrawAreaSize.value.width / 2 + val centerY = availableDrawAreaSize.value.height / 2 + rotationAngle.floatValue += angle + rotate( + editMatrix, + angle, + centerX.toFloat(), + centerY.toFloat() + ) + } + + fun addRotation() { + rotationAngles.add(prevRotationAngle) + undoStack.add(ROTATE) + prevRotationAngle = rotationAngle.floatValue + } + + fun addResize() { + resizes.add(backgroundImage2.value) + undoStack.add(RESIZE) + keepEditedPaths() + } + + fun keepEditedPaths() { + val stack = Stack() + if (drawPaths.isNotEmpty()) { + val size = drawPaths.size + for (i in 1..size) { + stack.push(drawPaths.pop()) + } + } + editedPaths.add(stack) + } + + fun redrawEditedPaths() { + if (editedPaths.isNotEmpty()) { + val paths = editedPaths.pop() + if (paths.isNotEmpty()) { + val size = paths.size + for (i in 1..size) { + drawPaths.push(paths.pop()) + } + } + } + } + + fun addCrop() { + cropStack.add(backgroundImage2.value) + undoStack.add(CROP) + } + + fun addBlur() { + undoStack.add(BLUR) + } + + fun undo(operationByTask: (String) -> Operation) { + val undoTask = undoStack.pop() + redoStack.push(undoTask) + Timber.tag("edit-manager").d("undoing $undoTask") + undoOperation(operationByTask(undoTask)) + } + + fun redo(operationByTask: (String) -> Operation) { + val redoTask = redoStack.pop() + undoStack.push(redoTask) + Timber.tag("edit-manager").d("redoing $redoTask") + redoOperation(operationByTask(redoTask)) + } + + fun invalidate() { + invalidatorTick.intValue++ + } + + fun saveRotationAfterOtherOperation() { + addAngle() + resetRotation() + } + + fun restoreRotationAfterUndoOtherOperation() { + if (rotationAngles.isNotEmpty()) { + prevRotationAngle = rotationAngles.pop() + rotationAngle.floatValue = prevRotationAngle + } + } + + fun addDrawPath(path: DrawPath) { + drawPaths.add(path) + undoStack.add(DRAW) + } + + fun clearEdits() { + clearPaths() + clearResizes() + clearRotations() + clearCrop() + undoStack.clear() + redoStack.clear() + restoreOriginalBackgroundImage() + scaleToFit() + } + + fun clearRedo() { + redoPaths.clear() + redoCropStack.clear() + redoRotationAngles.clear() + redoResize.clear() + redoStack.clear() + } + + fun setBackgroundImage2() { + backgroundImage2.value = backgroundImage.value + } + + fun redrawBackgroundImage2() { + backgroundImage.value = backgroundImage2.value + } + + fun setOriginalBackgroundImage(imgBitmap: ImageBitmap?) { + originalBackgroundImage.value = imgBitmap + } + + fun cancelCropMode() { + backgroundImage.value = backgroundImage2.value + editMatrix.reset() + } + + fun cancelRotateMode() { + rotationAngle.floatValue = prevRotationAngle + editMatrix.reset() + } + + fun cancelResizeMode() { + backgroundImage.value = backgroundImage2.value + editMatrix.reset() + } + + fun calcCenter() = Offset( + availableDrawAreaSize.value.width / 2f, + availableDrawAreaSize.value.height / 2f + ) + + internal fun clearRedoPath() { + redoPaths.clear() + } + + private fun addAngle() { + rotationAngles.add(prevRotationAngle) + } + + private fun clearPaths() { + drawPaths.clear() + redoPaths.clear() + invalidate() + } + + private fun clearResizes() { + resizes.clear() + redoResize.clear() + } + + private fun resetRotation() { + rotationAngle.floatValue = 0f + prevRotationAngle = 0f + } + + private fun clearRotations() { + rotationAngles.clear() + redoRotationAngles.clear() + resetRotation() + } + + private fun updateAvailableDrawArea(bitmap: ImageBitmap? = backgroundImage.value) { + if (bitmap == null) { + resolution.value?.let { + availableDrawAreaSize.value = it.toIntSize() + } + return + } + availableDrawAreaSize.value = IntSize( + bitmap.width, + bitmap.height + ) + } + + private fun clearCrop() { + cropStack.clear() + redoCropStack.clear() + } + + private fun restoreOriginalBackgroundImage() { + backgroundImage.value = originalBackgroundImage.value + updateAvailableDrawArea() + } + + private fun scaleMatrix(viewParams: ImageViewParams) { + matrix.setScale(viewParams.scale.x, viewParams.scale.y) + backgroundMatrix.setScale(viewParams.scale.x, viewParams.scale.y) + if (prevRotationAngle != 0f) { + val centerX = viewParams.drawArea.width / 2f + val centerY = viewParams.drawArea.height / 2f + matrix.postRotate(prevRotationAngle, centerX, centerY) + } + } + + private fun scaleEditMatrix(viewParams: ImageViewParams, isRotating: Boolean) { + editMatrix.setScale(viewParams.scale.x, viewParams.scale.y) + backgroundMatrix.setScale(viewParams.scale.x, viewParams.scale.y) + if (rotationAngle.floatValue != 0f && isRotating) { + val centerX = viewParams.drawArea.width / 2f + val centerY = viewParams.drawArea.height / 2f + editMatrix.postRotate(rotationAngle.floatValue, centerX, centerY) + } + } + + private fun undoOperation(operation: Operation) { + operation.undo() + } + + private fun redoOperation(operation: Operation) { + operation.redo() + } + + companion object { + const val DRAW = "draw" + const val CROP = "crop" + const val RESIZE = "resize" + const val ROTATE = "rotate" + const val BLUR = "blur" + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/editing/manager/EditingMode.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/manager/EditingMode.kt new file mode 100644 index 0000000..038be3b --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/manager/EditingMode.kt @@ -0,0 +1,13 @@ +package dev.arkbuilders.arkretouch.editing.manager + +enum class EditingMode { + DRAW, + ERASE, + CROP, + RESIZE, + ROTATE, + ZOOM, + PAN, + BLUR, + EYEDROPPER +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/resize/ResizeOperation.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/resize/ResizeOperation.kt similarity index 83% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/resize/ResizeOperation.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/editing/resize/ResizeOperation.kt index f7388ff..5fb6087 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/resize/ResizeOperation.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/resize/ResizeOperation.kt @@ -1,30 +1,30 @@ -package dev.arkbuilders.arkretouch.presentation.edit.resize +package dev.arkbuilders.arkretouch.editing.resize -import android.graphics.Bitmap -import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.unit.IntSize -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.Operation -import dev.arkbuilders.arkretouch.presentation.utils.resize +import android.graphics.Bitmap +import dev.arkbuilders.arkretouch.editing.Operation +import dev.arkbuilders.arkretouch.editing.manager.EditManager +import dev.arkbuilders.arkretouch.utils.resize import java.lang.NullPointerException -class ResizeOperation(private val editManager: EditManager) : Operation { +class ResizeOperation( + private val editManager: EditManager, + private val onApply: () -> Unit = {} +) : Operation { private lateinit var bitmap: Bitmap private var aspectRatio = 1f private lateinit var editMatrixScale: Scale - private val isApplied = mutableStateOf(false) override fun apply() { editManager.apply { addResize() saveRotationAfterOtherOperation() scaleToFit() - toggleResizeMode() editMatrix.reset() - isApplied.value = true + onApply() } } @@ -56,17 +56,12 @@ class ResizeOperation(private val editManager: EditManager) : Operation { this.bitmap = bitmap aspectRatio = bitmap.width.toFloat() / bitmap.height.toFloat() editMatrixScale = editManager.scaleToFitOnEdit().scale - isApplied.value = false } fun updateEditMatrixScale(scale: Scale) { editMatrixScale = scale } - fun isApplied() = isApplied.value - - fun resetApply() { isApplied.value = false } - fun resizeDown( width: Int, height: Int, @@ -111,4 +106,4 @@ class ResizeOperation(private val editManager: EditManager) : Operation { val x: Float = 1f, val y: Float = 1f ) -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/rotate/RotateOperation.kt b/app/src/main/java/dev/arkbuilders/arkretouch/editing/rotate/RotateOperation.kt similarity index 72% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/rotate/RotateOperation.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/editing/rotate/RotateOperation.kt index 55aa5eb..bcfcdc6 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/rotate/RotateOperation.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/editing/rotate/RotateOperation.kt @@ -1,18 +1,21 @@ -package dev.arkbuilders.arkretouch.presentation.edit.rotate +package dev.arkbuilders.arkretouch.editing.rotate import android.graphics.Matrix -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.Operation -import dev.arkbuilders.arkretouch.presentation.utils.rotate +import dev.arkbuilders.arkretouch.editing.Operation +import dev.arkbuilders.arkretouch.editing.manager.EditManager +import dev.arkbuilders.arkretouch.utils.rotate -class RotateOperation(private val editManager: EditManager) : Operation { +class RotateOperation( + private val editManager: EditManager, + private val onApply: () -> Unit +) : Operation { override fun apply() { editManager.apply { - toggleRotateMode() matrix.set(editMatrix) editMatrix.reset() addRotation() + onApply() } } @@ -44,4 +47,4 @@ class RotateOperation(private val editManager: EditManager) : Operation { val x: Float, val y: Float ) -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/BackgroundCanvas.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/BackgroundCanvas.kt new file mode 100644 index 0000000..b6c2eed --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/BackgroundCanvas.kt @@ -0,0 +1,60 @@ +package dev.arkbuilders.arkretouch.presentation.canvas + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import dev.arkbuilders.arkretouch.editing.manager.EditManager + +@Composable +fun BackgroundCanvas( + modifier: Modifier, + isCropping: Boolean, + isRotating: Boolean, + isResizing: Boolean, + isBlurring: Boolean, + imageSize: IntSize, + backgroundPaint: Paint, + editManager: EditManager, + observeInvalidator: State +) { + Canvas(modifier) { + editManager.apply { + var matrix = matrix + if ( + isCropping || isRotating || + isResizing || isBlurring + ) { + matrix = editMatrix + } + drawIntoCanvas { canvas -> + // force recomposition on invalidatorTick change + observeInvalidator.value + backgroundImage.value?.let { + canvas.nativeCanvas.drawBitmap( + it.asAndroidBitmap(), + matrix, + null + ) + } ?: run { + val rect = Rect( + Offset.Zero, + imageSize.toSize() + ) + canvas.nativeCanvas.setMatrix(matrix) + canvas.drawRect(rect, backgroundPaint) + canvas.clipRect(rect, ClipOp.Intersect) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/DrawCanvas.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/DrawCanvas.kt new file mode 100644 index 0000000..8675231 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/DrawCanvas.kt @@ -0,0 +1,203 @@ +package dev.arkbuilders.arkretouch.presentation.canvas + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ClipOp +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.input.pointer.pointerInteropFilter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.toSize +import android.graphics.Matrix +import android.graphics.PointF +import android.view.MotionEvent +import dev.arkbuilders.arkretouch.editing.crop.CropWindow.Companion.computeDeltaX +import dev.arkbuilders.arkretouch.editing.crop.CropWindow.Companion.computeDeltaY +import dev.arkbuilders.arkretouch.presentation.viewmodels.EditViewModel + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun DrawCanvas(modifier: Modifier, viewModel: EditViewModel, observeInvalidator: State) { + val context = LocalContext.current + val editManager = viewModel.editManager + var path = Path() + val currentPoint = PointF(0f, 0f) + val drawModifier = if (viewModel.isCropping()) Modifier.fillMaxSize() + else modifier + + fun handleDrawEvent(action: Int, eventX: Float, eventY: Float) { + when (action) { + MotionEvent.ACTION_DOWN -> { + path.reset() + path.moveTo(eventX, eventY) + currentPoint.x = eventX + currentPoint.y = eventY + viewModel.onDrawPath(path) + } + MotionEvent.ACTION_MOVE -> { + path.quadraticBezierTo( + currentPoint.x, + currentPoint.y, + (eventX + currentPoint.x) / 2, + (eventY + currentPoint.y) / 2 + ) + currentPoint.x = eventX + currentPoint.y = eventY + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + // draw a dot + if (eventX == currentPoint.x && + eventY == currentPoint.y + ) { + path.lineTo(currentPoint.x, currentPoint.y) + } + editManager.clearRedoPath() + viewModel.updateUndoRedoState() + path = Path() + } + else -> {} + } + } + + fun handleCropEvent(action: Int, eventX: Float, eventY: Float) { + when (action) { + MotionEvent.ACTION_DOWN -> { + currentPoint.x = eventX + currentPoint.y = eventY + editManager.cropWindow.detectTouchedSide( + Offset(eventX, eventY) + ) + } + MotionEvent.ACTION_MOVE -> { + val deltaX = + computeDeltaX(currentPoint.x, eventX) + val deltaY = + computeDeltaY(currentPoint.y, eventY) + + editManager.cropWindow.setDelta( + Offset( + deltaX, + deltaY + ) + ) + currentPoint.x = eventX + currentPoint.y = eventY + } + MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { + editManager.cropWindow.resetDelta() + } + } + } + + fun handleEyeDropEvent(action: Int, eventX: Float, eventY: Float) { + viewModel.applyEyeDropper(action, eventX.toInt(), eventY.toInt()) + } + + fun handleBlurEvent(action: Int, eventX: Float, eventY: Float) { + when (action) { + MotionEvent.ACTION_DOWN -> { + currentPoint.x = eventX + currentPoint.y = eventY + } + MotionEvent.ACTION_MOVE -> { + val position = Offset( + currentPoint.x, + currentPoint.y + ) + val delta = Offset( + computeDeltaX(currentPoint.x, eventX), + computeDeltaY(currentPoint.y, eventY) + ) + viewModel.onBlurMove(position, delta) + currentPoint.x = eventX + currentPoint.y = eventY + } + else -> {} + } + } + + Canvas( + modifier = drawModifier.pointerInteropFilter { event -> + val eventX = event.x + val eventY = event.y + val tmpMatrix = Matrix() + editManager.matrix.invert(tmpMatrix) + val mappedXY = floatArrayOf( + event.x / editManager.zoomScale, + event.y / editManager.zoomScale + ) + tmpMatrix.mapPoints(mappedXY) + val mappedX = mappedXY[0] + val mappedY = mappedXY[1] + + when (true) { + viewModel.isResizing() -> {} + viewModel.isBlurring() -> handleBlurEvent( + event.action, + eventX, + eventY + ) + + viewModel.isCropping() -> handleCropEvent( + event.action, + eventX, + eventY + ) + + viewModel.isEyeDropping() -> handleEyeDropEvent( + event.action, + event.x, + event.y + ) + + else -> handleDrawEvent(event.action, mappedX, mappedY) + } + viewModel.invalidateCanvas() + true + } + ) { + drawIntoCanvas { canvas -> + // force recomposition on invalidatorTick change + observeInvalidator.value + editManager.apply { + var matrix = this.matrix + if (viewModel.isRotating() || viewModel.isResizing() || viewModel.isBlurring()) + matrix = editMatrix + if (viewModel.isCropping()) matrix = Matrix() + canvas.nativeCanvas.setMatrix(matrix) + if (viewModel.isResizing()) return@drawIntoCanvas + if (viewModel.isBlurring()) { + viewModel.onDrawBlur(context, canvas) + return@drawIntoCanvas + } + if (viewModel.isCropping()) { + editManager.cropWindow.show(canvas) + return@drawIntoCanvas + } + val rect = Rect( + Offset.Zero, + viewModel.imageSize.toSize() + ) + canvas.drawRect( + rect, + Paint().also { it.color = Color.Transparent } + ) + canvas.clipRect(rect, ClipOp.Intersect) + if (drawPaths.isNotEmpty()) { + drawPaths.forEach { + canvas.drawPath(it.path, it.paint) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/EditCanvas.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/EditCanvas.kt new file mode 100644 index 0000000..ecf1811 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/canvas/EditCanvas.kt @@ -0,0 +1,124 @@ +package dev.arkbuilders.arkretouch.presentation.canvas + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +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.geometry.Offset +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import dev.arkbuilders.arkretouch.presentation.picker.toDp +import dev.arkbuilders.arkretouch.presentation.viewmodels.EditViewModel +import dev.arkbuilders.arkretouch.presentation.views.TransparencyChessBoardCanvas +import dev.arkbuilders.arkretouch.utils.calculateRotationFromOneFingerGesture + +@Composable +fun EditCanvasScreen(viewModel: EditViewModel) { + val editManager = viewModel.editManager + var scale by remember { mutableFloatStateOf(1f) } + var offset by remember { mutableStateOf(Offset.Zero) } + + fun resetScaleAndTranslate() { + editManager.apply { + if ( + viewModel.isRotating() || viewModel.isCropping() || viewModel.isResizing() || + viewModel.isBlurring() + ) { + scale = 1f; zoomScale = scale; offset = Offset.Zero + } + } + } + + Box(contentAlignment = Alignment.Center) { + val modifier = Modifier + .size( + editManager.availableDrawAreaSize.value.width.toDp(), + editManager.availableDrawAreaSize.value.height.toDp() + ) + .graphicsLayer { + resetScaleAndTranslate() + + // Eraser leaves black line instead of erasing without this hack, it uses BlendMode.SrcOut + // https://stackoverflow.com/questions/65653560/jetpack-compose-applying-porterduffmode-to-image + // Provide a slight opacity to for compositing into an + // offscreen buffer to ensure blend modes are applied to empty pixel information + // By default any alpha != 1.0f will use a compositing layer by default + alpha = 0.99f + + scaleX = scale + scaleY = scale + translationX = offset.x + translationY = offset.y + } + TransparencyChessBoardCanvas( + modifier, + viewModel.imageSize, + editManager.backgroundMatrix, + viewModel.observeCanvasInvalidator() + ) + BackgroundCanvas( + modifier, + viewModel.isCropping(), + viewModel.isRotating(), + viewModel.isResizing(), + viewModel.isBlurring(), + viewModel.imageSize, + viewModel.drawingState.backgroundPaint, + editManager, + viewModel.observeCanvasInvalidator() + ) + DrawCanvas(modifier, viewModel, viewModel.observeCanvasInvalidator()) + } + if ( + viewModel.isRotating() || viewModel.isZooming() || + viewModel.isPanning() + ) { + Canvas( + Modifier + .fillMaxSize() + .pointerInput(Any()) { + awaitEachGesture { + awaitFirstDown() + do { + val event = awaitPointerEvent() + when (true) { + (viewModel.isRotating()) -> { + val angle = event + .calculateRotationFromOneFingerGesture( + editManager.calcCenter() + ) + viewModel.onRotate(angle) + viewModel.invalidateCanvas() + } + else -> { + if (viewModel.isZooming()) { + scale *= event.calculateZoom() + editManager.zoomScale = scale + } + if (viewModel.isPanning()) { + val pan = event.calculatePan() + offset = Offset( + offset.x + pan.x, + offset.y + pan.y + ) + } + } + } + } while (event.changes.any { it.pressed }) + } + } + ) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/ColorPickerDialog.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/ColorPickerDialog.kt similarity index 71% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/ColorPickerDialog.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/ColorPickerDialog.kt index d5b1aae..2d10ac7 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/ColorPickerDialog.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/ColorPickerDialog.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.arkretouch.presentation.edit +package dev.arkbuilders.arkretouch.presentation.dialogs import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -7,15 +7,15 @@ import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -26,40 +26,41 @@ import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment 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.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import com.godaddy.android.colorpicker.ClassicColorPicker import com.godaddy.android.colorpicker.HsvColor +import dev.arkbuilders.arkretouch.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch -import dev.arkbuilders.arkretouch.R @Composable fun ColorPickerDialog( - isVisible: MutableState, + isVisible: Boolean, initialColor: Color, usedColors: List = listOf(), enableEyeDropper: Boolean, onToggleEyeDropper: () -> Unit, onColorChanged: (Color) -> Unit, + onDismiss: () -> Unit ) { - if (!isVisible.value) return + if (!isVisible) { return } var currentColor by remember { mutableStateOf(HsvColor.from(initialColor)) @@ -67,12 +68,12 @@ fun ColorPickerDialog( val finish = { onColorChanged(currentColor.toColor()) - isVisible.value = false + onDismiss() } Dialog( onDismissRequest = { - isVisible.value = false + onDismiss() } ) { Column( @@ -84,17 +85,10 @@ fun ColorPickerDialog( horizontalAlignment = Alignment.CenterHorizontally ) { if (usedColors.isNotEmpty()) { - Box( - Modifier - .fillMaxWidth() - ) { + Box(Modifier.fillMaxWidth()) { val state = rememberLazyListState() - LazyRow( - Modifier - .align(Alignment.Center), - state = state - ) { + LazyRow(Modifier.align(Alignment.Center), state = state) { items(usedColors) { color -> Box( Modifier @@ -114,34 +108,22 @@ fun ColorPickerDialog( ) } } - LaunchedEffect(state) { - scrollToEnd(state, this) - } - UsedColorsFlowHint( - { enableScroll(state) }, - { checkScroll(state).first }, - { checkScroll(state).second } - ) + LaunchedEffect(state) { scrollToEnd(state, this) } + UsedColorsFlowHint({ enableScroll(state) }, { checkScroll(state).first }, { checkScroll(state).second }) } } ClassicColorPicker( - modifier = Modifier - .fillMaxWidth() - .height(250.dp), - color = currentColor.toColor(), - onColorChanged = { - currentColor = it - } + modifier = Modifier.fillMaxWidth().height(250.dp), + color = currentColor, + onColorChanged = { currentColor = it } ) if (enableEyeDropper) { Box(Modifier.padding(8.dp)) { Box( - Modifier - .size(50.dp) - .clip(CircleShape) + Modifier.size(50.dp).clip(CircleShape) .clickable { onToggleEyeDropper() - isVisible.value = false + onDismiss() }, contentAlignment = Alignment.Center ) { @@ -154,65 +136,22 @@ fun ColorPickerDialog( } } TextButton( - modifier = Modifier - .padding(top = 8.dp) - .fillMaxWidth(), + modifier = Modifier.padding(top = 8.dp).fillMaxWidth(), onClick = finish ) { Row(verticalAlignment = Alignment.CenterVertically) { Box( - modifier = Modifier - .padding(12.dp) - .size(50.dp) - .border( - 2.dp, - Color.LightGray, - CircleShape - ) - .padding(6.dp) + modifier = Modifier.padding(12.dp).size(50.dp).border(2.dp, Color.LightGray, CircleShape).padding(6.dp) .clip(CircleShape) .background(color = currentColor.toColor()) ) - Text(text = "Pick", fontSize = 18.sp) + Text(text = stringResource(R.string.ark_retouch_pick), fontSize = 18.sp) } } } } } -fun scrollToEnd(state: LazyListState, scope: CoroutineScope) { - scope.launch { - if (enableScroll(state)) { - val lastIndex = state.layoutInfo.totalItemsCount - 1 - state.scrollToItem(lastIndex, 0) - } - } -} - -fun enableScroll(state: LazyListState): Boolean { - return state.layoutInfo.totalItemsCount != - state.layoutInfo.visibleItemsInfo.size -} - -fun checkScroll(state: LazyListState): Pair { - var scrollIsAtStart = true - var scrollIsAtEnd = false - if (enableScroll(state)) { - val totalItems = state.layoutInfo.totalItemsCount - val visibleItems = state.layoutInfo.visibleItemsInfo.size - val itemSize = - state.layoutInfo.visibleItemsInfo.firstOrNull()?.size - ?: 0 - val rowSize = itemSize * totalItems - val visibleRowSize = itemSize * visibleItems - val scrollValue = state.firstVisibleItemIndex * itemSize - val maxScrollValue = rowSize - visibleRowSize - scrollIsAtStart = scrollValue == 0 - scrollIsAtEnd = scrollValue == maxScrollValue - } - return scrollIsAtStart to scrollIsAtEnd -} - @Composable fun BoxScope.UsedColorsFlowHint( scrollIsEnabled: () -> Boolean, @@ -220,9 +159,7 @@ fun BoxScope.UsedColorsFlowHint( scrollIsAtEnd: () -> Boolean ) { AnimatedVisibility( - visible = scrollIsEnabled() && ( - scrollIsAtEnd() || (!scrollIsAtStart() && !scrollIsAtEnd()) - ), + visible = scrollIsEnabled() && (scrollIsAtEnd() || (!scrollIsAtStart() && !scrollIsAtEnd())), enter = fadeIn(tween(500)), exit = fadeOut(tween(500)), modifier = Modifier @@ -230,15 +167,13 @@ fun BoxScope.UsedColorsFlowHint( .align(Alignment.CenterStart) ) { Icon( - Icons.Filled.KeyboardArrowLeft, + Icons.AutoMirrored.Filled.KeyboardArrowLeft, contentDescription = null, Modifier.size(32.dp) ) } AnimatedVisibility( - visible = scrollIsEnabled() && ( - scrollIsAtStart() || (!scrollIsAtStart() && !scrollIsAtEnd()) - ), + visible = scrollIsEnabled() && (scrollIsAtStart() || (!scrollIsAtStart() && !scrollIsAtEnd())), enter = fadeIn(tween(500)), exit = fadeOut(tween(500)), modifier = Modifier @@ -246,9 +181,39 @@ fun BoxScope.UsedColorsFlowHint( .align(Alignment.CenterEnd) ) { Icon( - Icons.Filled.KeyboardArrowRight, + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, - Modifier.size(32.dp) + modifier = Modifier.size(32.dp) ) } } + +fun scrollToEnd(state: LazyListState, scope: CoroutineScope) { + scope.launch { + if (enableScroll(state)) { + val lastIndex = state.layoutInfo.totalItemsCount - 1 + state.scrollToItem(lastIndex, 0) + } + } +} + +fun enableScroll(state: LazyListState): Boolean { + return state.layoutInfo.totalItemsCount != state.layoutInfo.visibleItemsInfo.size +} + +fun checkScroll(state: LazyListState): Pair { + var scrollIsAtStart = true + var scrollIsAtEnd = false + if (enableScroll(state)) { + val totalItems = state.layoutInfo.totalItemsCount + val visibleItems = state.layoutInfo.visibleItemsInfo.size + val itemSize = state.layoutInfo.visibleItemsInfo.firstOrNull()?.size ?: 0 + val rowSize = itemSize * totalItems + val visibleRowSize = itemSize * visibleItems + val scrollValue = state.firstVisibleItemIndex * itemSize + val maxScrollValue = rowSize - visibleRowSize + scrollIsAtStart = scrollValue == 0 + scrollIsAtEnd = scrollValue == maxScrollValue + } + return scrollIsAtStart to scrollIsAtEnd +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/ConfirmClearDialog.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/ConfirmClearDialog.kt similarity index 80% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/ConfirmClearDialog.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/ConfirmClearDialog.kt index 2fe0177..408c1ff 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/ConfirmClearDialog.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/ConfirmClearDialog.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.arkretouch.presentation.edit +package dev.arkbuilders.arkretouch.presentation.dialogs import androidx.compose.foundation.layout.padding import androidx.compose.material.AlertDialog @@ -6,21 +6,21 @@ import androidx.compose.material.Button import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @Composable fun ConfirmClearDialog( - show: MutableState, + show: Boolean, + onDismiss: () -> Unit, onConfirm: () -> Unit ) { - if (!show.value) return + if (!show) return AlertDialog( onDismissRequest = { - show.value = false + onDismiss() }, title = { Text( @@ -32,8 +32,8 @@ fun ConfirmClearDialog( confirmButton = { Button( onClick = { - show.value = false onConfirm() + onDismiss() } ) { Text("Clear") @@ -42,11 +42,11 @@ fun ConfirmClearDialog( dismissButton = { TextButton( onClick = { - show.value = false + onDismiss() } ) { Text("Cancel") } } ) -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/NewImageOptionsDialog.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/NewImageOptionsDialog.kt similarity index 94% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/NewImageOptionsDialog.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/NewImageOptionsDialog.kt index 27f9b00..befa420 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/NewImageOptionsDialog.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/NewImageOptionsDialog.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.arkretouch.presentation.edit +package dev.arkbuilders.arkretouch.presentation.dialogs import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -35,11 +35,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.core.text.isDigitsOnly import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.data.Resolution -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.resize.Hint -import dev.arkbuilders.arkretouch.presentation.edit.resize.delayHidingHint +import dev.arkbuilders.arkretouch.data.model.Resolution +import dev.arkbuilders.arkretouch.editing.manager.EditManager import dev.arkbuilders.arkretouch.presentation.theme.Gray +import dev.arkbuilders.arkretouch.presentation.viewmodels.EditViewModel +import dev.arkbuilders.arkretouch.presentation.views.Hint +import dev.arkbuilders.arkretouch.presentation.views.delayHidingHint @Composable fun NewImageOptionsDialog( @@ -47,10 +48,11 @@ fun NewImageOptionsDialog( maxResolution: Resolution, _backgroundColor: Color, navigateBack: () -> Unit, - editManager: EditManager, + viewModel: EditViewModel, persistDefaults: (Color, Resolution) -> Unit, onConfirm: () -> Unit ) { + val editManager: EditManager = viewModel.editManager var isVisible by remember { mutableStateOf(true) } var backgroundColor by remember { mutableStateOf(_backgroundColor) @@ -58,12 +60,15 @@ fun NewImageOptionsDialog( val showColorDialog = remember { mutableStateOf(false) } ColorPickerDialog( - isVisible = showColorDialog, + isVisible = showColorDialog.value, initialColor = backgroundColor, enableEyeDropper = false, onToggleEyeDropper = {}, onColorChanged = { backgroundColor = it + }, + onDismiss = { + showColorDialog.value = false } ) @@ -301,7 +306,7 @@ fun NewImageOptionsDialog( height.toInt() ) editManager.setImageResolution(resolution) - editManager.setBackgroundColor(backgroundColor) + viewModel.onSetBackgroundColor(backgroundColor) if (rememberDefaults) persistDefaults(backgroundColor, resolution) onConfirm() @@ -323,4 +328,4 @@ fun NewImageOptionsDialog( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/SavePathDialog.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/SavePathDialog.kt similarity index 90% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/SavePathDialog.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/SavePathDialog.kt index b280a3e..d46b76e 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/SavePathDialog.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/dialogs/SavePathDialog.kt @@ -1,55 +1,52 @@ -package dev.arkbuilders.arkretouch.presentation.edit +package dev.arkbuilders.arkretouch.presentation.dialogs import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Button import androidx.compose.material.Checkbox +import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.OutlinedTextField import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.window.Dialog -import androidx.fragment.app.FragmentManager -import java.nio.file.Path import androidx.compose.runtime.getValue +import androidx.compose.runtime.key 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.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.fragment.app.FragmentManager import dev.arkbuilders.arkfilepicker.ArkFilePickerConfig import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerFragment import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerMode import dev.arkbuilders.arkfilepicker.presentation.onArkPathPicked import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.presentation.utils.findNotExistCopyName -import kotlin.io.path.name -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.runtime.key -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.platform.LocalContext -import dev.arkbuilders.arkretouch.presentation.utils.toast +import dev.arkbuilders.arkretouch.utils.findNotExistCopyName +import dev.arkbuilders.arkretouch.utils.toast import java.nio.file.Files -import kotlin.streams.toList +import java.nio.file.Path +import kotlin.io.path.name -@OptIn(ExperimentalComposeUiApi::class) @Composable fun SavePathDialog( initialImagePath: Path?, @@ -102,22 +99,13 @@ fun SavePathDialog( ) TextButton( onClick = { - ArkFilePickerFragment - .newInstance( - folderFilePickerConfig(currentPath) - ) - .show(fragmentManager, null) + ArkFilePickerFragment.newInstance(folderFilePickerConfig(currentPath)).show(fragmentManager, null) fragmentManager.onArkPathPicked(lifecycleOwner) { path -> currentPath = path currentPath?.let { imagePath = it.resolve(name) - showOverwriteCheckbox.value = Files.list(it).toList() - .contains(imagePath) - if (showOverwriteCheckbox.value) { - name = it.findNotExistCopyName( - imagePath?.fileName!! - ).name - } + showOverwriteCheckbox.value = Files.list(it).anyMatch { anyPath -> anyPath == imagePath } + if (showOverwriteCheckbox.value) { name = it.findNotExistCopyName(imagePath?.fileName!!).name } } } } @@ -140,8 +128,7 @@ fun SavePathDialog( } currentPath?.let { path -> imagePath = path.resolve(name) - showOverwriteCheckbox.value = Files.list(path).toList() - .contains(imagePath) + showOverwriteCheckbox.value = Files.list(path).anyMatch { anyPath -> anyPath == imagePath } if (showOverwriteCheckbox.value) { name = path.findNotExistCopyName( imagePath?.fileName!! @@ -228,4 +215,4 @@ fun folderFilePickerConfig(initialPath: Path?) = ArkFilePickerConfig( initialPath = initialPath, showRoots = true, rootsFirstPage = true -) +) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/drawing/EditCanvas.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/drawing/EditCanvas.kt deleted file mode 100644 index 3c52585..0000000 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/drawing/EditCanvas.kt +++ /dev/null @@ -1,336 +0,0 @@ -@file:OptIn(ExperimentalComposeUiApi::class) - -package dev.arkbuilders.arkretouch.presentation.drawing - -import android.graphics.Matrix -import android.graphics.PointF -import android.view.MotionEvent -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.calculatePan -import androidx.compose.foundation.gestures.calculateZoom -import androidx.compose.foundation.gestures.forEachGesture -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size -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.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.ClipOp -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.graphics.drawscope.drawIntoCanvas -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.input.pointer.pointerInteropFilter -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.toSize -import dev.arkbuilders.arkretouch.presentation.edit.EditViewModel -import dev.arkbuilders.arkretouch.presentation.edit.TransparencyChessBoardCanvas -import dev.arkbuilders.arkretouch.presentation.edit.crop.CropWindow.Companion.computeDeltaX -import dev.arkbuilders.arkretouch.presentation.edit.crop.CropWindow.Companion.computeDeltaY -import dev.arkbuilders.arkretouch.presentation.picker.toDp -import dev.arkbuilders.arkretouch.presentation.utils.calculateRotationFromOneFingerGesture - -@Composable -fun EditCanvas(viewModel: EditViewModel) { - val editManager = viewModel.editManager - var scale by remember { mutableStateOf(1f) } - var offset by remember { mutableStateOf(Offset.Zero) } - - fun resetScaleAndTranslate() { - editManager.apply { - if ( - isRotateMode.value || isCropMode.value || isResizeMode.value || - isBlurMode.value - ) { - scale = 1f; zoomScale = scale; offset = Offset.Zero - } - } - } - - Box(contentAlignment = Alignment.Center) { - val modifier = Modifier.size( - editManager.availableDrawAreaSize.value.width.toDp(), - editManager.availableDrawAreaSize.value.height.toDp() - ).graphicsLayer { - resetScaleAndTranslate() - - // Eraser leaves black line instead of erasing without this hack, it uses BlendMode.SrcOut - // https://stackoverflow.com/questions/65653560/jetpack-compose-applying-porterduffmode-to-image - // Provide a slight opacity to for compositing into an - // offscreen buffer to ensure blend modes are applied to empty pixel information - // By default any alpha != 1.0f will use a compositing layer by default - alpha = 0.99f - - scaleX = scale - scaleY = scale - translationX = offset.x - translationY = offset.y - } - TransparencyChessBoardCanvas(modifier, editManager) - BackgroundCanvas(modifier, editManager) - DrawCanvas(modifier, viewModel) - } - if ( - editManager.isRotateMode.value || editManager.isZoomMode.value || - editManager.isPanMode.value - ) { - Canvas( - Modifier.fillMaxSize() - .pointerInput(Any()) { - forEachGesture { - awaitPointerEventScope { - awaitFirstDown() - do { - val event = awaitPointerEvent() - when (true) { - (editManager.isRotateMode.value) -> { - val angle = event - .calculateRotationFromOneFingerGesture( - editManager.calcCenter() - ) - editManager.rotate(angle) - editManager.invalidatorTick.value++ - } - else -> { - if (editManager.isZoomMode.value) { - scale *= event.calculateZoom() - editManager.zoomScale = scale - } - if (editManager.isPanMode.value) { - val pan = event.calculatePan() - offset = Offset( - offset.x + pan.x, - offset.y + pan.y - ) - } - } - } - } while (event.changes.any { it.pressed }) - } - } - } - ) {} - } -} - -@Composable -fun BackgroundCanvas(modifier: Modifier, editManager: EditManager) { - Canvas(modifier) { - editManager.apply { - invalidatorTick.value - var matrix = matrix - if ( - isCropMode.value || isRotateMode.value || - isResizeMode.value || isBlurMode.value - ) - matrix = editMatrix - drawIntoCanvas { canvas -> - backgroundImage.value?.let { - canvas.nativeCanvas.drawBitmap( - it.asAndroidBitmap(), - matrix, - null - ) - } ?: run { - val rect = Rect( - Offset.Zero, - imageSize.toSize() - ) - canvas.nativeCanvas.setMatrix(matrix) - canvas.drawRect(rect, backgroundPaint) - canvas.clipRect(rect, ClipOp.Intersect) - } - } - } - } -} - -@Composable -fun DrawCanvas(modifier: Modifier, viewModel: EditViewModel) { - val context = LocalContext.current - val editManager = viewModel.editManager - var path = Path() - val currentPoint = PointF(0f, 0f) - val drawModifier = if (editManager.isCropMode.value) Modifier.fillMaxSize() - else modifier - - fun handleDrawEvent(action: Int, eventX: Float, eventY: Float) { - when (action) { - MotionEvent.ACTION_DOWN -> { - path.reset() - path.moveTo(eventX, eventY) - currentPoint.x = eventX - currentPoint.y = eventY - editManager.apply { - drawOperation.draw(path) - applyOperation() - } - } - MotionEvent.ACTION_MOVE -> { - path.quadraticBezierTo( - currentPoint.x, - currentPoint.y, - (eventX + currentPoint.x) / 2, - (eventY + currentPoint.y) / 2 - ) - currentPoint.x = eventX - currentPoint.y = eventY - } - MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> { - // draw a dot - if (eventX == currentPoint.x && - eventY == currentPoint.y - ) { - path.lineTo(currentPoint.x, currentPoint.y) - } - - editManager.clearRedoPath() - editManager.updateRevised() - path = Path() - } - else -> {} - } - } - - fun handleCropEvent(action: Int, eventX: Float, eventY: Float) { - when (action) { - MotionEvent.ACTION_DOWN -> { - currentPoint.x = eventX - currentPoint.y = eventY - editManager.cropWindow.detectTouchedSide( - Offset(eventX, eventY) - ) - } - MotionEvent.ACTION_MOVE -> { - val deltaX = - computeDeltaX(currentPoint.x, eventX) - val deltaY = - computeDeltaY(currentPoint.y, eventY) - - editManager.cropWindow.setDelta( - Offset( - deltaX, - deltaY - ) - ) - currentPoint.x = eventX - currentPoint.y = eventY - } - } - } - - fun handleEyeDropEvent(action: Int, eventX: Float, eventY: Float) { - viewModel.applyEyeDropper(action, eventX.toInt(), eventY.toInt()) - } - - fun handleBlurEvent(action: Int, eventX: Float, eventY: Float) { - when (action) { - MotionEvent.ACTION_DOWN -> { - currentPoint.x = eventX - currentPoint.y = eventY - } - MotionEvent.ACTION_MOVE -> { - val position = Offset( - currentPoint.x, - currentPoint.y - ) - val delta = Offset( - computeDeltaX(currentPoint.x, eventX), - computeDeltaY(currentPoint.y, eventY) - ) - editManager.blurOperation.move(position, delta) - currentPoint.x = eventX - currentPoint.y = eventY - } - else -> {} - } - } - - Canvas( - modifier = drawModifier.pointerInteropFilter { event -> - val eventX = event.x - val eventY = event.y - val tmpMatrix = Matrix() - editManager.matrix.invert(tmpMatrix) - val mappedXY = floatArrayOf( - event.x / editManager.zoomScale, - event.y / editManager.zoomScale - ) - tmpMatrix.mapPoints(mappedXY) - val mappedX = mappedXY[0] - val mappedY = mappedXY[1] - - when (true) { - editManager.isResizeMode.value -> {} - editManager.isBlurMode.value -> handleBlurEvent( - event.action, - eventX, - eventY - ) - - editManager.isCropMode.value -> handleCropEvent( - event.action, - eventX, - eventY - ) - - editManager.isEyeDropperMode.value -> handleEyeDropEvent( - event.action, - event.x, - event.y - ) - - else -> handleDrawEvent(event.action, mappedX, mappedY) - } - editManager.invalidatorTick.value++ - true - } - ) { - // force recomposition on invalidatorTick change - editManager.invalidatorTick.value - drawIntoCanvas { canvas -> - editManager.apply { - var matrix = this.matrix - if (isRotateMode.value || isResizeMode.value || isBlurMode.value) - matrix = editMatrix - if (isCropMode.value) matrix = Matrix() - canvas.nativeCanvas.setMatrix(matrix) - if (isResizeMode.value) return@drawIntoCanvas - if (isBlurMode.value) { - editManager.blurOperation.draw(context, canvas) - return@drawIntoCanvas - } - if (isCropMode.value) { - editManager.cropWindow.show(canvas) - return@drawIntoCanvas - } - val rect = Rect( - Offset.Zero, - imageSize.toSize() - ) - canvas.drawRect( - rect, - Paint().also { it.color = Color.Transparent } - ) - canvas.clipRect(rect, ClipOp.Intersect) - if (drawPaths.isNotEmpty()) { - drawPaths.forEach { - canvas.drawPath(it.path, it.paint) - } - } - } - } - } -} diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/drawing/EditManager.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/drawing/EditManager.kt deleted file mode 100644 index 4dd4bf3..0000000 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/drawing/EditManager.kt +++ /dev/null @@ -1,624 +0,0 @@ -package dev.arkbuilders.arkretouch.presentation.drawing - -import android.graphics.Matrix -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State -import androidx.compose.runtime.mutableStateOf -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.PaintingStyle -import androidx.compose.ui.graphics.Path -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.StrokeJoin -import androidx.compose.ui.unit.IntSize -import dev.arkbuilders.arkretouch.data.ImageDefaults -import dev.arkbuilders.arkretouch.data.Resolution -import dev.arkbuilders.arkretouch.presentation.edit.ImageViewParams -import dev.arkbuilders.arkretouch.presentation.edit.Operation -import dev.arkbuilders.arkretouch.presentation.edit.blur.BlurOperation -import dev.arkbuilders.arkretouch.presentation.edit.crop.CropOperation -import dev.arkbuilders.arkretouch.presentation.edit.crop.CropWindow -import dev.arkbuilders.arkretouch.presentation.edit.draw.DrawOperation -import dev.arkbuilders.arkretouch.presentation.edit.fitBackground -import dev.arkbuilders.arkretouch.presentation.edit.fitImage -import dev.arkbuilders.arkretouch.presentation.edit.resize.ResizeOperation -import dev.arkbuilders.arkretouch.presentation.edit.rotate.RotateOperation -import timber.log.Timber -import java.util.Stack - -class EditManager { - private val drawPaint: MutableState = mutableStateOf(defaultPaint()) - - private val _paintColor: MutableState = - mutableStateOf(drawPaint.value.color) - val paintColor: State = _paintColor - private val _backgroundColor = mutableStateOf(Color.Transparent) - val backgroundColor: State = _backgroundColor - - private val erasePaint: Paint = Paint().apply { - shader = null - color = backgroundColor.value - style = PaintingStyle.Stroke - blendMode = BlendMode.SrcOut - } - - val backgroundPaint: Paint - get() { - return Paint().apply { - color = backgroundImage.value?.let { - Color.Transparent - } ?: backgroundColor.value - } - } - - val blurIntensity = mutableStateOf(12f) - - val cropWindow = CropWindow(this) - - val drawOperation = DrawOperation(this) - val resizeOperation = ResizeOperation(this) - val rotateOperation = RotateOperation(this) - val cropOperation = CropOperation(this) - val blurOperation = BlurOperation(this) - - private val currentPaint: Paint - get() = when (true) { - isEraseMode.value -> erasePaint - else -> drawPaint.value - } - - val drawPaths = Stack() - - val redoPaths = Stack() - - val backgroundImage = mutableStateOf(null) - val backgroundImage2 = mutableStateOf(null) - private val originalBackgroundImage = mutableStateOf(null) - - val matrix = Matrix() - val editMatrix = Matrix() - val backgroundMatrix = Matrix() - val rectMatrix = Matrix() - - private val matrixScale = mutableStateOf(1f) - var zoomScale = 1f - lateinit var bitmapScale: ResizeOperation.Scale - private set - - val imageSize: IntSize - get() { - return if (isResizeMode.value) - backgroundImage2.value?.let { - IntSize(it.width, it.height) - } ?: originalBackgroundImage.value?.let { - IntSize(it.width, it.height) - } ?: resolution.value?.toIntSize()!! - else - backgroundImage.value?.let { - IntSize(it.width, it.height) - } ?: resolution.value?.toIntSize() ?: drawAreaSize.value - } - - private val _resolution = mutableStateOf(null) - val resolution: State = _resolution - var drawAreaSize = mutableStateOf(IntSize.Zero) - val availableDrawAreaSize = mutableStateOf(IntSize.Zero) - - var invalidatorTick = mutableStateOf(0) - - private val _isEraseMode: MutableState = mutableStateOf(false) - val isEraseMode: State = _isEraseMode - - private val _canUndo: MutableState = mutableStateOf(false) - val canUndo: State = _canUndo - - private val _canRedo: MutableState = mutableStateOf(false) - val canRedo: State = _canRedo - - private val _isRotateMode = mutableStateOf(false) - val isRotateMode: State = _isRotateMode - - private val _isResizeMode = mutableStateOf(false) - val isResizeMode: State = _isResizeMode - - private val _isEyeDropperMode = mutableStateOf(false) - val isEyeDropperMode: State = _isEyeDropperMode - - private val _isBlurMode = mutableStateOf(false) - val isBlurMode: State = _isBlurMode - - private val _isZoomMode = mutableStateOf(false) - val isZoomMode: State = _isZoomMode - private val _isPanMode = mutableStateOf(false) - val isPanMode: State = _isPanMode - - val rotationAngle = mutableStateOf(0F) - var prevRotationAngle = 0f - - private val editedPaths = Stack>() - - val redoResize = Stack() - val resizes = Stack() - val rotationAngles = Stack() - val redoRotationAngles = Stack() - - private val undoStack = Stack() - private val redoStack = Stack() - - private val _isCropMode = mutableStateOf(false) - val isCropMode = _isCropMode - - val cropStack = Stack() - val redoCropStack = Stack() - - fun applyOperation() { - val operation: Operation = - when (true) { - isRotateMode.value -> rotateOperation - isCropMode.value -> cropOperation - isBlurMode.value -> blurOperation - isResizeMode.value -> resizeOperation - else -> drawOperation - } - operation.apply() - } - - private fun undoOperation(operation: Operation) { - operation.undo() - } - - private fun redoOperation(operation: Operation) { - operation.redo() - } - - fun scaleToFit() { - val viewParams = backgroundImage.value?.let { - fitImage( - it, - drawAreaSize.value.width, - drawAreaSize.value.height - ) - } ?: run { - fitBackground( - imageSize, - drawAreaSize.value.width, - drawAreaSize.value.height - ) - } - matrixScale.value = viewParams.scale.x - scaleMatrix(viewParams) - updateAvailableDrawArea(viewParams.drawArea) - val bitmapXScale = - imageSize.width.toFloat() / viewParams.drawArea.width.toFloat() - val bitmapYScale = - imageSize.height.toFloat() / viewParams.drawArea.height.toFloat() - bitmapScale = ResizeOperation.Scale( - bitmapXScale, - bitmapYScale - ) - } - - fun scaleToFitOnEdit( - maxWidth: Int = drawAreaSize.value.width, - maxHeight: Int = drawAreaSize.value.height - ): ImageViewParams { - val viewParams = backgroundImage.value?.let { - fitImage(it, maxWidth, maxHeight) - } ?: run { - fitBackground( - imageSize, - maxWidth, - maxHeight - ) - } - scaleEditMatrix(viewParams) - updateAvailableDrawArea(viewParams.drawArea) - return viewParams - } - - private fun scaleMatrix(viewParams: ImageViewParams) { - matrix.setScale(viewParams.scale.x, viewParams.scale.y) - backgroundMatrix.setScale(viewParams.scale.x, viewParams.scale.y) - if (prevRotationAngle != 0f) { - val centerX = viewParams.drawArea.width / 2f - val centerY = viewParams.drawArea.height / 2f - matrix.postRotate(prevRotationAngle, centerX, centerY) - } - } - - private fun scaleEditMatrix(viewParams: ImageViewParams) { - editMatrix.setScale(viewParams.scale.x, viewParams.scale.y) - backgroundMatrix.setScale(viewParams.scale.x, viewParams.scale.y) - if (prevRotationAngle != 0f && isRotateMode.value) { - val centerX = viewParams.drawArea.width / 2f - val centerY = viewParams.drawArea.height / 2f - editMatrix.postRotate(prevRotationAngle, centerX, centerY) - } - } - - fun setBackgroundColor(color: Color) { - _backgroundColor.value = color - } - - fun setImageResolution(value: Resolution) { - _resolution.value = value - } - - fun initDefaults(defaults: ImageDefaults, maxResolution: Resolution) { - defaults.resolution?.let { - _resolution.value = it - } - if (resolution.value == null) - _resolution.value = maxResolution - _backgroundColor.value = Color(defaults.colorValue) - } - - fun updateAvailableDrawAreaByMatrix() { - val drawArea = backgroundImage.value?.let { - val drawWidth = it.width * matrixScale.value - val drawHeight = it.height * matrixScale.value - IntSize( - drawWidth.toInt(), - drawHeight.toInt() - ) - } ?: run { - val drawWidth = resolution.value?.width!! * matrixScale.value - val drawHeight = resolution.value?.height!! * matrixScale.value - IntSize( - drawWidth.toInt(), - drawHeight.toInt() - ) - } - updateAvailableDrawArea(drawArea) - } - fun updateAvailableDrawArea(bitmap: ImageBitmap? = backgroundImage.value) { - if (bitmap == null) { - resolution.value?.let { - availableDrawAreaSize.value = it.toIntSize() - } - return - } - availableDrawAreaSize.value = IntSize( - bitmap.width, - bitmap.height - ) - } - fun updateAvailableDrawArea(area: IntSize) { - availableDrawAreaSize.value = area - } - - internal fun clearRedoPath() { - redoPaths.clear() - } - - fun toggleEyeDropper() { - _isEyeDropperMode.value = !isEyeDropperMode.value - } - - fun updateRevised() { - _canUndo.value = undoStack.isNotEmpty() - _canRedo.value = redoStack.isNotEmpty() - } - - fun resizeDown(width: Int = 0, height: Int = 0) = - resizeOperation.resizeDown(width, height) { - backgroundImage.value = it - } - - fun rotate(angle: Float) { - val centerX = availableDrawAreaSize.value.width / 2 - val centerY = availableDrawAreaSize.value.height / 2 - if (isRotateMode.value) { - rotationAngle.value += angle - rotateOperation.rotate( - editMatrix, - angle, - centerX.toFloat(), - centerY.toFloat() - ) - return - } - rotateOperation.rotate( - matrix, - angle, - centerX.toFloat(), - centerY.toFloat() - ) - } - - fun addRotation() { - if (canRedo.value) clearRedo() - rotationAngles.add(prevRotationAngle) - undoStack.add(ROTATE) - prevRotationAngle = rotationAngle.value - updateRevised() - } - - private fun addAngle() { - rotationAngles.add(prevRotationAngle) - } - - fun addResize() { - if (canRedo.value) clearRedo() - resizes.add(backgroundImage2.value) - undoStack.add(RESIZE) - keepEditedPaths() - updateRevised() - } - - fun keepEditedPaths() { - val stack = Stack() - if (drawPaths.isNotEmpty()) { - val size = drawPaths.size - for (i in 1..size) { - stack.push(drawPaths.pop()) - } - } - editedPaths.add(stack) - } - - fun redrawEditedPaths() { - if (editedPaths.isNotEmpty()) { - val paths = editedPaths.pop() - if (paths.isNotEmpty()) { - val size = paths.size - for (i in 1..size) { - drawPaths.push(paths.pop()) - } - } - } - } - - fun addCrop() { - if (canRedo.value) clearRedo() - cropStack.add(backgroundImage2.value) - undoStack.add(CROP) - updateRevised() - } - - fun addBlur() { - if (canRedo.value) clearRedo() - undoStack.add(BLUR) - updateRevised() - } - - private fun operationByTask(task: String) = when (task) { - ROTATE -> rotateOperation - RESIZE -> resizeOperation - CROP -> cropOperation - BLUR -> blurOperation - else -> drawOperation - } - - fun undo() { - if (canUndo.value) { - val undoTask = undoStack.pop() - redoStack.push(undoTask) - Timber.tag("edit-manager").d("undoing $undoTask") - undoOperation(operationByTask(undoTask)) - } - invalidatorTick.value++ - updateRevised() - } - - fun redo() { - if (canRedo.value) { - val redoTask = redoStack.pop() - undoStack.push(redoTask) - Timber.tag("edit-manager").d("redoing $redoTask") - redoOperation(operationByTask(redoTask)) - invalidatorTick.value++ - updateRevised() - } - } - - fun saveRotationAfterOtherOperation() { - addAngle() - resetRotation() - } - - fun restoreRotationAfterUndoOtherOperation() { - if (rotationAngles.isNotEmpty()) { - prevRotationAngle = rotationAngles.pop() - rotationAngle.value = prevRotationAngle - } - } - - fun addDrawPath(path: Path) { - drawPaths.add( - dev.arkbuilders.arkretouch.presentation.drawing.DrawPath( - path, - currentPaint.copy().apply { - strokeWidth = drawPaint.value.strokeWidth - } - ) - ) - if (canRedo.value) clearRedo() - undoStack.add(DRAW) - } - - fun setPaintColor(color: Color) { - drawPaint.value.color = color - _paintColor.value = color - } - - private fun clearPaths() { - drawPaths.clear() - redoPaths.clear() - invalidatorTick.value++ - updateRevised() - } - - private fun clearResizes() { - resizes.clear() - redoResize.clear() - updateRevised() - } - - private fun resetRotation() { - rotationAngle.value = 0f - prevRotationAngle = 0f - } - - private fun clearRotations() { - rotationAngles.clear() - redoRotationAngles.clear() - resetRotation() - } - - fun clearEdits() { - clearPaths() - clearResizes() - clearRotations() - clearCrop() - blurOperation.clear() - undoStack.clear() - redoStack.clear() - restoreOriginalBackgroundImage() - scaleToFit() - updateRevised() - } - - private fun clearRedo() { - redoPaths.clear() - redoCropStack.clear() - redoRotationAngles.clear() - redoResize.clear() - redoStack.clear() - updateRevised() - } - - private fun clearCrop() { - cropStack.clear() - redoCropStack.clear() - updateRevised() - } - - fun setBackgroundImage2() { - backgroundImage2.value = backgroundImage.value - } - - fun redrawBackgroundImage2() { - backgroundImage.value = backgroundImage2.value - } - - fun setOriginalBackgroundImage(imgBitmap: ImageBitmap?) { - originalBackgroundImage.value = imgBitmap - } - - private fun restoreOriginalBackgroundImage() { - backgroundImage.value = originalBackgroundImage.value - updateAvailableDrawArea() - } - - fun toggleEraseMode() { - _isEraseMode.value = !isEraseMode.value - } - - fun toggleRotateMode() { - _isRotateMode.value = !isRotateMode.value - if (isRotateMode.value) editMatrix.set(matrix) - } - - fun toggleCropMode() { - _isCropMode.value = !isCropMode.value - if (!isCropMode.value) cropWindow.close() - } - - fun toggleZoomMode() { - _isZoomMode.value = !isZoomMode.value - } - - fun togglePanMode() { - _isPanMode.value = !isPanMode.value - } - - fun cancelCropMode() { - backgroundImage.value = backgroundImage2.value - editMatrix.reset() - } - - fun cancelRotateMode() { - rotationAngle.value = prevRotationAngle - editMatrix.reset() - } - - fun toggleResizeMode() { - _isResizeMode.value = !isResizeMode.value - } - - fun cancelResizeMode() { - backgroundImage.value = backgroundImage2.value - editMatrix.reset() - } - - fun toggleBlurMode() { - _isBlurMode.value = !isBlurMode.value - } - fun setPaintStrokeWidth(strokeWidth: Float) { - drawPaint.value.strokeWidth = strokeWidth - } - - fun calcImageOffset(): Offset { - val drawArea = drawAreaSize.value - val allowedArea = availableDrawAreaSize.value - val xOffset = ((drawArea.width - allowedArea.width) / 2f) - .coerceAtLeast(0f) - val yOffset = ((drawArea.height - allowedArea.height) / 2f) - .coerceAtLeast(0f) - return Offset(xOffset, yOffset) - } - - fun calcCenter() = Offset( - availableDrawAreaSize.value.width / 2f, - availableDrawAreaSize.value.height / 2f - ) - private companion object { - private const val DRAW = "draw" - private const val CROP = "crop" - private const val RESIZE = "resize" - private const val ROTATE = "rotate" - private const val BLUR = "blur" - } -} - -class DrawPath( - val path: Path, - val paint: Paint -) - -fun Paint.copy(): Paint { - val from = this - return Paint().apply { - alpha = from.alpha - isAntiAlias = from.isAntiAlias - color = from.color - blendMode = from.blendMode - style = from.style - strokeWidth = from.strokeWidth - strokeCap = from.strokeCap - strokeJoin = from.strokeJoin - strokeMiterLimit = from.strokeMiterLimit - filterQuality = from.filterQuality - shader = from.shader - colorFilter = from.colorFilter - pathEffect = from.pathEffect - asFrameworkPaint().apply { - maskFilter = from.asFrameworkPaint().maskFilter - } - } -} - -fun defaultPaint(): Paint { - return Paint().apply { - color = Color.White - strokeWidth = 14f - isAntiAlias = true - style = PaintingStyle.Stroke - strokeJoin = StrokeJoin.Round - strokeCap = StrokeCap.Round - } -} diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/EditScreen.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/EditScreen.kt deleted file mode 100644 index fafee1f..0000000 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/EditScreen.kt +++ /dev/null @@ -1,1060 +0,0 @@ -@file:OptIn(ExperimentalComposeUiApi::class) - -package dev.arkbuilders.arkretouch.presentation.edit - -import android.view.MotionEvent -import android.widget.Toast -import androidx.activity.compose.BackHandler -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.clickable -import androidx.compose.foundation.background -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.MaterialTheme -import androidx.compose.material.Icon -import androidx.compose.material.Text -import androidx.compose.material.TextButton -import androidx.compose.material.AlertDialog -import androidx.compose.material.Button -import androidx.compose.material.Slider -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material.icons.filled.KeyboardArrowLeft -import androidx.compose.material.icons.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.KeyboardArrowUp -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInteropFilter -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.viewmodel.compose.viewModel -import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.data.Resolution -import dev.arkbuilders.arkretouch.di.DIManager -import dev.arkbuilders.arkretouch.presentation.drawing.EditCanvas -import dev.arkbuilders.arkretouch.presentation.edit.blur.BlurIntensityPopup -import dev.arkbuilders.arkretouch.presentation.edit.crop.CropAspectRatiosMenu -import dev.arkbuilders.arkretouch.presentation.edit.resize.Hint -import dev.arkbuilders.arkretouch.presentation.edit.resize.ResizeInput -import dev.arkbuilders.arkretouch.presentation.edit.resize.delayHidingHint -import dev.arkbuilders.arkretouch.presentation.picker.toPx -import dev.arkbuilders.arkretouch.presentation.theme.Gray -import dev.arkbuilders.arkretouch.presentation.utils.askWritePermissions -import dev.arkbuilders.arkretouch.presentation.utils.getActivity -import dev.arkbuilders.arkretouch.presentation.utils.isWritePermGranted -import java.nio.file.Path - -@Composable -fun EditScreen( - imagePath: Path?, - imageUri: String?, - fragmentManager: FragmentManager, - navigateBack: () -> Unit, - launchedFromIntent: Boolean, - maxResolution: Resolution -) { - val primaryColor = MaterialTheme.colors.primary.value.toLong() - val viewModel: EditViewModel = - viewModel( - factory = DIManager - .component - .editVMFactory() - .create( - primaryColor, - launchedFromIntent, - imagePath, - imageUri, - maxResolution - ) - ) - val context = LocalContext.current - val showDefaultsDialog = remember { - mutableStateOf( - imagePath == null && imageUri == null && !viewModel.isLoaded - ) - } - - if (showDefaultsDialog.value) { - viewModel.editManager.apply { - resolution.value?.let { - NewImageOptionsDialog( - it, - maxResolution, - this.backgroundColor.value, - navigateBack, - this, - persistDefaults = { color, resolution -> - viewModel.persistDefaults(color, resolution) - }, - onConfirm = { - showDefaultsDialog.value = false - } - ) - } - } - } - ExitDialog( - viewModel = viewModel, - navigateBack = { - navigateBack() - viewModel.isLoaded = false - }, - launchedFromIntent = launchedFromIntent, - ) - - BackHandler { - val editManager = viewModel.editManager - if ( - editManager.isCropMode.value || editManager.isRotateMode.value || - editManager.isResizeMode.value || editManager.isEyeDropperMode.value || - editManager.isBlurMode.value - ) { - viewModel.cancelOperation() - return@BackHandler - } - if (editManager.isZoomMode.value) { - editManager.toggleZoomMode() - return@BackHandler - } - if (editManager.isPanMode.value) { - editManager.togglePanMode() - return@BackHandler - } - if (editManager.canUndo.value) { - editManager.undo() - return@BackHandler - } - if (viewModel.exitConfirmed) { - if (launchedFromIntent) - context.getActivity()?.finish() - else - navigateBack() - return@BackHandler - } - if (!viewModel.exitConfirmed) { - Toast.makeText(context, "Tap back again to exit", Toast.LENGTH_SHORT) - .show() - viewModel.confirmExit() - return@BackHandler - } - } - - HandleImageSavedEffect(viewModel, launchedFromIntent, navigateBack) - - if (!showDefaultsDialog.value) - DrawContainer( - viewModel - ) - - Menus( - imagePath, - fragmentManager, - viewModel, - launchedFromIntent, - navigateBack - ) - - if (viewModel.isSavingImage) { - SaveProgress() - } - - if (viewModel.showEyeDropperHint) { - Box( - Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter - ) { - Hint(stringResource(R.string.pick_color)) { - delayHidingHint(it) { - viewModel.showEyeDropperHint = false - } - viewModel.showEyeDropperHint - } - } - } -} - -@Composable -private fun Menus( - imagePath: Path?, - fragmentManager: FragmentManager, - viewModel: EditViewModel, - launchedFromIntent: Boolean, - navigateBack: () -> Unit, -) { - Box( - Modifier - .fillMaxSize() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .wrapContentHeight() - ) { - TopMenu( - imagePath, - fragmentManager, - viewModel, - launchedFromIntent, - navigateBack - ) - } - Column( - modifier = Modifier - .align(Alignment.BottomCenter) - .height(IntrinsicSize.Min), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (viewModel.editManager.isRotateMode.value) - Row { - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - viewModel.editManager.apply { - rotate(-90f) - invalidatorTick.value++ - } - }, - imageVector = ImageVector - .vectorResource(R.drawable.ic_rotate_left), - tint = MaterialTheme.colors.primary, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - viewModel.editManager.apply { - rotate(90f) - invalidatorTick.value++ - } - }, - imageVector = ImageVector - .vectorResource(R.drawable.ic_rotate_right), - tint = MaterialTheme.colors.primary, - contentDescription = null - ) - } - - EditMenuContainer(viewModel, navigateBack) - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun DrawContainer( - viewModel: EditViewModel -) { - Box( - modifier = Modifier - .padding(bottom = 32.dp) - .fillMaxSize() - .background( - if (viewModel.editManager.isCropMode.value) Color.White - else Color.Gray - ) - .pointerInteropFilter { event -> - if (event.action == MotionEvent.ACTION_DOWN) - viewModel.strokeSliderExpanded = false - false - } - .onSizeChanged { newSize -> - if (newSize == IntSize.Zero) return@onSizeChanged - if (viewModel.showSavePathDialog) return@onSizeChanged - viewModel.editManager.drawAreaSize.value = newSize - if (viewModel.isLoaded) { - viewModel.editManager.apply { - when (true) { - isCropMode.value -> { - cropWindow.updateOnDrawAreaSizeChange(newSize) - return@onSizeChanged - } - - isResizeMode.value -> { - if ( - backgroundImage.value?.width == - imageSize.width && - backgroundImage.value?.height == - imageSize.height - ) { - val editMatrixScale = scaleToFitOnEdit().scale - resizeOperation - .updateEditMatrixScale(editMatrixScale) - } - if ( - resizeOperation.isApplied() - ) { - resizeOperation.resetApply() - } - return@onSizeChanged - } - - isRotateMode.value -> { - scaleToFitOnEdit() - return@onSizeChanged - } - - isZoomMode.value -> { return@onSizeChanged } - - else -> { - scaleToFit() - return@onSizeChanged - } - } - } - } - viewModel.loadImage() - }, - contentAlignment = Alignment.Center - ) { - EditCanvas(viewModel) - } -} - -@Composable -private fun BoxScope.TopMenu( - imagePath: Path?, - fragmentManager: FragmentManager, - viewModel: EditViewModel, - launchedFromIntent: Boolean, - navigateBack: () -> Unit -) { - val context = LocalContext.current - - if (viewModel.showSavePathDialog) - SavePathDialog( - initialImagePath = imagePath, - fragmentManager = fragmentManager, - onDismissClick = { viewModel.showSavePathDialog = false }, - onPositiveClick = { savePath -> - viewModel.saveImage(context, savePath) - } - ) - if (viewModel.showMoreOptionsPopup) - MoreOptionsPopup( - onDismissClick = { - viewModel.showMoreOptionsPopup = false - }, - onShareClick = { - viewModel.shareImage(context) - viewModel.showMoreOptionsPopup = false - }, - onSaveClick = { - if (!context.isWritePermGranted()) { - context.askWritePermissions() - return@MoreOptionsPopup - } - viewModel.showSavePathDialog = true - }, - onClearEdits = { - viewModel.showConfirmClearDialog.value = true - viewModel.showMoreOptionsPopup = false - } - ) - - ConfirmClearDialog( - viewModel.showConfirmClearDialog, - onConfirm = { - viewModel.editManager.apply { - if ( - !isRotateMode.value && - !isResizeMode.value && - !isCropMode.value && - !isEyeDropperMode.value - ) clearEdits() - } - } - ) - - if ( - !viewModel.menusVisible && - !viewModel.editManager.isRotateMode.value && - !viewModel.editManager.isResizeMode.value && - !viewModel.editManager.isCropMode.value && - !viewModel.editManager.isEyeDropperMode.value - ) - return - Icon( - modifier = Modifier - .align(Alignment.TopStart) - .padding(8.dp) - .size(36.dp) - .clip(CircleShape) - .clickable { - viewModel.editManager.apply { - if ( - isCropMode.value || isRotateMode.value || - isResizeMode.value || isEyeDropperMode.value || - isBlurMode.value - ) { - viewModel.cancelOperation() - return@clickable - } - if (isZoomMode.value) { - toggleZoomMode() - return@clickable - } - if (isPanMode.value) { - togglePanMode() - return@clickable - } - if ( - !viewModel.editManager.canUndo.value - ) { - if (launchedFromIntent) { - context - .getActivity() - ?.finish() - } else { - navigateBack() - } - } else { - viewModel.showExitDialog = true - } - } - }, - imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back), - tint = MaterialTheme.colors.primary, - contentDescription = null - ) - - Row( - Modifier - .align(Alignment.TopEnd) - ) { - Icon( - modifier = Modifier - .padding(8.dp) - .size(36.dp) - .clip(CircleShape) - .clickable { - viewModel.editManager.apply { - if ( - isCropMode.value || isRotateMode.value || - isResizeMode.value || isBlurMode.value - ) { - viewModel.applyOperation() - return@clickable - } - } - viewModel.showMoreOptionsPopup = true - }, - imageVector = if ( - viewModel.editManager.isCropMode.value || - viewModel.editManager.isRotateMode.value || - viewModel.editManager.isResizeMode.value || - viewModel.editManager.isBlurMode.value - ) - ImageVector.vectorResource(R.drawable.ic_check) - else ImageVector.vectorResource(R.drawable.ic_more_vert), - tint = MaterialTheme.colors.primary, - contentDescription = null - ) - } -} - -@Composable -private fun StrokeWidthPopup( - modifier: Modifier, - viewModel: EditViewModel -) { - val editManager = viewModel.editManager - editManager.setPaintStrokeWidth(viewModel.strokeWidth.dp.toPx()) - if (viewModel.strokeSliderExpanded) { - Column( - modifier = modifier - .fillMaxWidth() - .height(150.dp) - .padding(8.dp) - ) { - Box( - modifier = Modifier - .weight(1f) - ) { - Box( - modifier = Modifier - .padding( - horizontal = 10.dp, - vertical = 5.dp - ) - .align(Alignment.Center) - .fillMaxWidth() - .height(viewModel.strokeWidth.dp) - .clip(RoundedCornerShape(30)) - .background(editManager.paintColor.value) - ) - } - - Slider( - modifier = Modifier - .fillMaxWidth(), - value = viewModel.strokeWidth, - onValueChange = { - viewModel.strokeWidth = it - }, - valueRange = 0.5f..50f, - ) - } - } -} - -@Composable -private fun EditMenuContainer(viewModel: EditViewModel, navigateBack: () -> Unit) { - Column( - Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.Bottom - ) { - CropAspectRatiosMenu( - isVisible = viewModel.editManager.isCropMode.value, - viewModel.editManager.cropWindow - ) - ResizeInput( - isVisible = viewModel.editManager.isResizeMode.value, - viewModel.editManager - ) - - Box( - Modifier - .fillMaxWidth() - .wrapContentHeight() - .clip(RoundedCornerShape(topStartPercent = 30, topEndPercent = 30)) - .background(Gray) - .clickable { - viewModel.menusVisible = !viewModel.menusVisible - }, - contentAlignment = Alignment.Center - ) { - Icon( - if (viewModel.menusVisible) Icons.Filled.KeyboardArrowDown - else Icons.Filled.KeyboardArrowUp, - contentDescription = "", - modifier = Modifier.size(32.dp), - ) - } - AnimatedVisibility( - visible = viewModel.menusVisible, - enter = expandVertically(expandFrom = Alignment.Bottom), - exit = shrinkVertically(shrinkTowards = Alignment.Top) - ) { - EditMenuContent(viewModel, navigateBack) - EditMenuFlowHint( - viewModel.bottomButtonsScrollIsAtStart.value, - viewModel.bottomButtonsScrollIsAtEnd.value - ) - } - } -} - -@Composable -private fun EditMenuContent( - viewModel: EditViewModel, - navigateBack: () -> Unit -) { - val colorDialogExpanded = remember { mutableStateOf(false) } - val scrollState = rememberScrollState() - val editManager = viewModel.editManager - Column( - Modifier - .fillMaxWidth() - .background(Gray) - ) { - StrokeWidthPopup(Modifier, viewModel) - - BlurIntensityPopup(editManager) - - Row( - Modifier - .wrapContentHeight() - .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp) - .horizontalScroll(scrollState) - ) { - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - if ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value - ) { - editManager.undo() - } - }, - imageVector = ImageVector.vectorResource(R.drawable.ic_undo), - tint = if ( - editManager.canUndo.value && ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value - ) - ) MaterialTheme.colors.primary else Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - if ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value - ) editManager.redo() - }, - imageVector = ImageVector.vectorResource(R.drawable.ic_redo), - tint = if ( - editManager.canRedo.value && - ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value - ) - ) MaterialTheme.colors.primary else Color.Black, - contentDescription = null - ) - Box( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .background(color = editManager.paintColor.value) - .clickable { - if (editManager.isEyeDropperMode.value) { - viewModel.toggleEyeDropper() - viewModel.cancelEyeDropper() - colorDialogExpanded.value = true - return@clickable - } - if ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEraseMode.value && - !editManager.isBlurMode.value - ) - colorDialogExpanded.value = true - } - ) - ColorPickerDialog( - isVisible = colorDialogExpanded, - initialColor = editManager.paintColor.value, - usedColors = viewModel.usedColors, - enableEyeDropper = true, - onToggleEyeDropper = { - viewModel.toggleEyeDropper() - }, - onColorChanged = { - editManager.setPaintColor(it) - viewModel.trackColor(it) - } - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - if ( - !editManager.isRotateMode.value && - !editManager.isCropMode.value && - !editManager.isResizeMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value - ) - viewModel.strokeSliderExpanded = - !viewModel.strokeSliderExpanded - }, - imageVector = - ImageVector.vectorResource(R.drawable.ic_line_weight), - tint = if ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value - ) editManager.paintColor.value - else Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - if ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value - ) - editManager.toggleEraseMode() - }, - imageVector = ImageVector.vectorResource(R.drawable.ic_eraser), - tint = if ( - editManager.isEraseMode.value - ) - MaterialTheme.colors.primary - else - Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - if ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value && - !editManager.isEraseMode.value - ) - editManager.toggleZoomMode() - }, - imageVector = ImageVector.vectorResource(R.drawable.ic_zoom_in), - tint = if ( - editManager.isZoomMode.value - ) - MaterialTheme.colors.primary - else - Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - if ( - !editManager.isRotateMode.value && - !editManager.isResizeMode.value && - !editManager.isCropMode.value && - !editManager.isEyeDropperMode.value && - !editManager.isBlurMode.value && - !editManager.isEraseMode.value - ) - editManager.togglePanMode() - }, - imageVector = ImageVector.vectorResource(R.drawable.ic_pan_tool), - tint = if ( - editManager.isPanMode.value - ) - MaterialTheme.colors.primary - else - Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - editManager.apply { - if ( - !isRotateMode.value && - !isResizeMode.value && - !isEyeDropperMode.value && - !isEraseMode.value && - !isBlurMode.value - ) { - toggleCropMode() - viewModel.menusVisible = - !editManager.isCropMode.value - if (isCropMode.value) { - val bitmap = viewModel.getEditedImage() - setBackgroundImage2() - backgroundImage.value = bitmap - viewModel.editManager.cropWindow.init( - bitmap.asAndroidBitmap() - ) - return@clickable - } - editManager.cancelCropMode() - editManager.scaleToFit() - editManager.cropWindow.close() - } - } - }, - imageVector = ImageVector.vectorResource(R.drawable.ic_crop), - tint = if ( - editManager.isCropMode.value - ) MaterialTheme.colors.primary - else - Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - editManager.apply { - if ( - !isCropMode.value && - !isResizeMode.value && - !isEyeDropperMode.value && - !isEraseMode.value && - !isBlurMode.value - ) { - toggleRotateMode() - if (isRotateMode.value) { - setBackgroundImage2() - viewModel.menusVisible = - !editManager.isRotateMode.value - scaleToFitOnEdit() - return@clickable - } - cancelRotateMode() - scaleToFit() - } - } - }, - imageVector = ImageVector - .vectorResource(R.drawable.ic_rotate_90_degrees_ccw), - tint = if (editManager.isRotateMode.value) - MaterialTheme.colors.primary - else - Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - editManager.apply { - if ( - !isRotateMode.value && - !isCropMode.value && - !isEyeDropperMode.value && - !isEraseMode.value && - !isBlurMode.value - ) - toggleResizeMode() - else return@clickable - viewModel.menusVisible = !isResizeMode.value - if (isResizeMode.value) { - setBackgroundImage2() - val imgBitmap = viewModel.getEditedImage() - backgroundImage.value = imgBitmap - resizeOperation.init( - imgBitmap.asAndroidBitmap() - ) - return@clickable - } - cancelResizeMode() - scaleToFit() - } - }, - imageVector = ImageVector - .vectorResource(R.drawable.ic_aspect_ratio), - tint = if (editManager.isResizeMode.value) - MaterialTheme.colors.primary - else - Color.Black, - contentDescription = null - ) - Icon( - modifier = Modifier - .padding(12.dp) - .size(40.dp) - .clip(CircleShape) - .clickable { - editManager.apply { - if ( - !isRotateMode.value && - !isCropMode.value && - !isEyeDropperMode.value && - !isResizeMode.value && - !isEraseMode.value && - !viewModel.strokeSliderExpanded - ) toggleBlurMode() - if (isBlurMode.value) { - setBackgroundImage2() - backgroundImage.value = viewModel.getEditedImage() - blurOperation.init() - return@clickable - } - blurOperation.cancel() - scaleToFit() - } - }, - imageVector = ImageVector - .vectorResource(R.drawable.ic_blur_on), - tint = if (editManager.isBlurMode.value) - MaterialTheme.colors.primary - else - Color.Black, - contentDescription = null - ) - } - } - viewModel.bottomButtonsScrollIsAtStart.value = scrollState.value == 0 - viewModel.bottomButtonsScrollIsAtEnd.value = - scrollState.value == scrollState.maxValue -} - -@Composable -fun EditMenuFlowHint( - scrollIsAtStart: Boolean = true, - scrollIsAtEnd: Boolean = false -) { - Box(Modifier.fillMaxSize()) { - AnimatedVisibility( - visible = scrollIsAtEnd || (!scrollIsAtStart && !scrollIsAtEnd), - enter = fadeIn(tween(durationMillis = 1000)), - exit = fadeOut((tween(durationMillis = 1000))), - modifier = Modifier.align(Alignment.BottomStart) - ) { - Icon( - Icons.Filled.KeyboardArrowLeft, - contentDescription = null, - Modifier - .background(Gray) - .padding(top = 16.dp, bottom = 16.dp) - .size(32.dp) - ) - } - AnimatedVisibility( - visible = scrollIsAtStart || (!scrollIsAtStart && !scrollIsAtEnd), - enter = fadeIn(tween(durationMillis = 1000)), - exit = fadeOut((tween(durationMillis = 1000))), - modifier = Modifier.align(Alignment.BottomEnd) - ) { - Icon( - Icons.Filled.KeyboardArrowRight, - contentDescription = null, - Modifier - .background(Gray) - .padding(top = 16.dp, bottom = 16.dp) - .size(32.dp) - ) - } - } -} - -@Composable -private fun HandleImageSavedEffect( - viewModel: EditViewModel, - launchedFromIntent: Boolean, - navigateBack: () -> Unit, -) { - val context = LocalContext.current - LaunchedEffect(viewModel.imageSaved) { - if (!viewModel.imageSaved) - return@LaunchedEffect - if (launchedFromIntent) - context.getActivity()?.finish() - else - navigateBack() - } -} - -@Composable -private fun ExitDialog( - viewModel: EditViewModel, - navigateBack: () -> Unit, - launchedFromIntent: Boolean -) { - if (!viewModel.showExitDialog) return - - val context = LocalContext.current - - AlertDialog( - onDismissRequest = { - viewModel.showExitDialog = false - }, - title = { - Text( - modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), - text = "Do you want to save the changes?", - fontSize = 16.sp - ) - }, - confirmButton = { - Button( - onClick = { - viewModel.showExitDialog = false - viewModel.showSavePathDialog = true - } - ) { - Text("Save") - } - }, - dismissButton = { - TextButton( - onClick = { - viewModel.showExitDialog = false - if (launchedFromIntent) { - context.getActivity()?.finish() - } else { - navigateBack() - } - } - ) { - Text("Exit") - } - } - ) -} diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/EditViewModel.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/EditViewModel.kt deleted file mode 100644 index 534df35..0000000 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/EditViewModel.kt +++ /dev/null @@ -1,592 +0,0 @@ -package dev.arkbuilders.arkretouch.presentation.edit - -import android.content.Context -import android.content.Intent -import android.graphics.Bitmap -import android.graphics.Matrix -import android.graphics.drawable.Drawable -import android.media.MediaScannerConnection -import android.net.Uri -import android.view.MotionEvent -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.ImageBitmapConfig -import androidx.compose.ui.graphics.Canvas -import androidx.compose.ui.graphics.Paint -import androidx.compose.ui.graphics.asAndroidBitmap -import androidx.compose.ui.graphics.nativeCanvas -import androidx.compose.ui.graphics.asImageBitmap -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.toSize -import androidx.core.content.FileProvider -import androidx.core.net.toUri -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestBuilder -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition -import dagger.assisted.Assisted -import dagger.assisted.AssistedFactory -import dagger.assisted.AssistedInject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.data.Preferences -import dev.arkbuilders.arkretouch.data.Resolution -import dev.arkbuilders.arkretouch.di.DIManager -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager -import dev.arkbuilders.arkretouch.presentation.edit.resize.ResizeOperation -import timber.log.Timber -import java.io.File -import java.nio.file.Path -import kotlin.io.path.outputStream -import kotlin.system.measureTimeMillis - -class EditViewModel( - private val primaryColor: Long, - private val launchedFromIntent: Boolean, - private val imagePath: Path?, - private val imageUri: String?, - private val maxResolution: Resolution, - private val prefs: Preferences -) : ViewModel() { - val editManager = EditManager() - - var strokeSliderExpanded by mutableStateOf(false) - var menusVisible by mutableStateOf(true) - var strokeWidth by mutableStateOf(5f) - var showSavePathDialog by mutableStateOf(false) - val showOverwriteCheckbox = mutableStateOf(imagePath != null) - var showExitDialog by mutableStateOf(false) - var showMoreOptionsPopup by mutableStateOf(false) - var imageSaved by mutableStateOf(false) - var isSavingImage by mutableStateOf(false) - var showEyeDropperHint by mutableStateOf(false) - val showConfirmClearDialog = mutableStateOf(false) - var isLoaded by mutableStateOf(false) - var exitConfirmed = false - private set - val bottomButtonsScrollIsAtStart = mutableStateOf(true) - val bottomButtonsScrollIsAtEnd = mutableStateOf(false) - - private val _usedColors = mutableListOf() - val usedColors: List = _usedColors - - init { - if (imageUri == null && imagePath == null) { - viewModelScope.launch { - editManager.initDefaults( - prefs.readDefaults(), - maxResolution - ) - } - } - viewModelScope.launch { - _usedColors.addAll(prefs.readUsedColors()) - - val color = if (_usedColors.isNotEmpty()) { - _usedColors.last() - } else { - val defaultColor = Color(primaryColor.toULong()) - - _usedColors.add(defaultColor) - defaultColor - } - - editManager.setPaintColor(color) - } - } - - fun loadImage() { - isLoaded = true - imagePath?.let { - loadImageWithPath( - DIManager.component.app(), - imagePath, - editManager - ) - return - } - imageUri?.let { - loadImageWithUri( - DIManager.component.app(), - imageUri, - editManager - ) - return - } - editManager.scaleToFit() - } - - fun saveImage(context: Context, path: Path) { - viewModelScope.launch(Dispatchers.IO) { - isSavingImage = true - val combinedBitmap = getEditedImage() - - path.outputStream().use { out -> - combinedBitmap.asAndroidBitmap() - .compress(Bitmap.CompressFormat.PNG, 100, out) - } - MediaScannerConnection.scanFile( - context, - arrayOf(path.toString()), - arrayOf("image/*") - ) { _, _ -> } - imageSaved = true - isSavingImage = false - showSavePathDialog = false - } - } - - fun shareImage(context: Context) = - viewModelScope.launch(Dispatchers.IO) { - val intent = Intent(Intent.ACTION_SEND) - val uri = getCachedImageUri(context) - intent.type = "image/*" - intent.putExtra(Intent.EXTRA_STREAM, uri) - context.apply { - startActivity( - Intent.createChooser( - intent, - getString(R.string.share) - ) - ) - } - } - - fun getImageUri( - context: Context = DIManager.component.app(), - bitmap: Bitmap? = null, - name: String = "" - ) = getCachedImageUri(context, bitmap, name) - - private fun getCachedImageUri( - context: Context, - bitmap: Bitmap? = null, - name: String = "" - ): Uri { - var uri: Uri? = null - val imageCacheFolder = File(context.cacheDir, "images") - val imgBitmap = bitmap ?: getEditedImage().asAndroidBitmap() - try { - imageCacheFolder.mkdirs() - val file = File(imageCacheFolder, "image$name.png") - file.outputStream().use { out -> - imgBitmap - .compress(Bitmap.CompressFormat.PNG, 100, out) - } - Timber.tag("Cached image path").d(file.path.toString()) - uri = FileProvider.getUriForFile( - context, - "${context.packageName}.fileprovider", - file - ) - } catch (e: Exception) { - e.printStackTrace() - } - return uri!! - } - - fun trackColor(color: Color) { - _usedColors.remove(color) - _usedColors.add(color) - - val excess = _usedColors.size - KEEP_USED_COLORS - repeat(excess) { - _usedColors.removeFirst() - } - - viewModelScope.launch { - prefs.persistUsedColors(usedColors) - } - } - - fun toggleEyeDropper() { - editManager.toggleEyeDropper() - } - fun cancelEyeDropper() { - editManager.setPaintColor(usedColors.last()) - } - - fun applyEyeDropper(action: Int, x: Int, y: Int) { - try { - val bitmap = getEditedImage().asAndroidBitmap() - val imageX = (x * editManager.bitmapScale.x).toInt() - val imageY = (y * editManager.bitmapScale.y).toInt() - val pixel = bitmap.getPixel(imageX, imageY) - val color = Color(pixel) - if (color == Color.Transparent) { - showEyeDropperHint = true - return - } - when (action) { - MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> { - trackColor(color) - toggleEyeDropper() - menusVisible = true - } - } - editManager.setPaintColor(color) - } catch (e: Exception) { - e.printStackTrace() - } - } - fun getCombinedImageBitmap(): ImageBitmap { - val size = editManager.imageSize - val drawBitmap = ImageBitmap( - size.width, - size.height, - ImageBitmapConfig.Argb8888 - ) - val combinedBitmap = - ImageBitmap(size.width, size.height, ImageBitmapConfig.Argb8888) - - val time = measureTimeMillis { - val backgroundPaint = Paint().also { - it.color = editManager.backgroundColor.value - } - val drawCanvas = Canvas(drawBitmap) - val combinedCanvas = Canvas(combinedBitmap) - val matrix = Matrix().apply { - if (editManager.rotationAngles.isNotEmpty()) { - val centerX = size.width / 2 - val centerY = size.height / 2 - setRotate( - editManager.rotationAngle.value, - centerX.toFloat(), - centerY.toFloat() - ) - } - } - combinedCanvas.drawRect( - Rect(Offset.Zero, size.toSize()), - backgroundPaint - ) - combinedCanvas.nativeCanvas.setMatrix(matrix) - editManager.backgroundImage.value?.let { - combinedCanvas.drawImage( - it, - Offset.Zero, - Paint() - ) - } - editManager.drawPaths.forEach { - drawCanvas.drawPath(it.path, it.paint) - } - combinedCanvas.drawImage(drawBitmap, Offset.Zero, Paint()) - } - Timber.tag("edit-viewmodel: getCombinedImageBitmap").d( - "processing edits took ${time / 1000} s ${time % 1000} ms" - ) - return combinedBitmap - } - - fun getEditedImage(): ImageBitmap { - val size = editManager.imageSize - var bitmap = ImageBitmap( - size.width, - size.height, - ImageBitmapConfig.Argb8888 - ) - var pathBitmap: ImageBitmap? = null - val time = measureTimeMillis { - editManager.apply { - val matrix = Matrix() - if (editManager.drawPaths.isNotEmpty()) { - pathBitmap = ImageBitmap( - size.width, - size.height, - ImageBitmapConfig.Argb8888 - ) - val pathCanvas = Canvas(pathBitmap!!) - editManager.drawPaths.forEach { - pathCanvas.drawPath(it.path, it.paint) - } - } - backgroundImage.value?.let { - val canvas = Canvas(bitmap) - if (prevRotationAngle == 0f && drawPaths.isEmpty()) { - bitmap = it - return@let - } - if (prevRotationAngle != 0f) { - val centerX = size.width / 2f - val centerY = size.height / 2f - matrix.setRotate(prevRotationAngle, centerX, centerY) - } - canvas.nativeCanvas.drawBitmap( - it.asAndroidBitmap(), - matrix, - null - ) - if (drawPaths.isNotEmpty()) { - canvas.nativeCanvas.drawBitmap( - pathBitmap?.asAndroidBitmap()!!, - matrix, - null - ) - } - } ?: run { - val canvas = Canvas(bitmap) - if (prevRotationAngle != 0f) { - val centerX = size.width / 2 - val centerY = size.height / 2 - matrix.setRotate( - prevRotationAngle, - centerX.toFloat(), - centerY.toFloat() - ) - canvas.nativeCanvas.setMatrix(matrix) - } - canvas.drawRect( - Rect(Offset.Zero, size.toSize()), - backgroundPaint - ) - if (drawPaths.isNotEmpty()) { - canvas.drawImage( - pathBitmap!!, - Offset.Zero, - Paint() - ) - } - } - } - } - Timber.tag("edit-viewmodel: getEditedImage").d( - "processing edits took ${time / 1000} s ${time % 1000} ms" - ) - return bitmap - } - fun confirmExit() = viewModelScope.launch { - exitConfirmed = true - isLoaded = false - delay(2_000) - exitConfirmed = false - isLoaded = true - } - - fun applyOperation() { - editManager.applyOperation() - menusVisible = true - } - - fun cancelOperation() { - editManager.apply { - if (isRotateMode.value) { - toggleRotateMode() - cancelRotateMode() - menusVisible = true - } - if (isCropMode.value) { - toggleCropMode() - cancelCropMode() - menusVisible = true - } - if (isResizeMode.value) { - toggleResizeMode() - cancelResizeMode() - menusVisible = true - } - if (isEyeDropperMode.value) { - toggleEyeDropper() - cancelEyeDropper() - menusVisible = true - } - if (isBlurMode.value) { - toggleBlurMode() - blurOperation.cancel() - menusVisible = true - } - scaleToFit() - } - } - - fun persistDefaults(color: Color, resolution: Resolution) { - viewModelScope.launch { - prefs.persistDefaults(color, resolution) - } - } - - companion object { - private const val KEEP_USED_COLORS = 20 - } -} - -class EditViewModelFactory @AssistedInject constructor( - @Assisted private val primaryColor: Long, - @Assisted private val launchedFromIntent: Boolean, - @Assisted private val imagePath: Path?, - @Assisted private val imageUri: String?, - @Assisted private val maxResolution: Resolution, - private val prefs: Preferences, -) : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return EditViewModel( - primaryColor, - launchedFromIntent, - imagePath, - imageUri, - maxResolution, - prefs, - ) as T - } - - @AssistedFactory - interface Factory { - fun create( - @Assisted primaryColor: Long, - @Assisted launchedFromIntent: Boolean, - @Assisted imagePath: Path?, - @Assisted imageUri: String?, - @Assisted maxResolution: Resolution, - ): EditViewModelFactory - } -} - -private fun loadImageWithPath( - context: Context, - image: Path, - editManager: EditManager -) { - initGlideBuilder(context) - .load(image.toFile()) - .loadInto(editManager) -} - -private fun loadImageWithUri( - context: Context, - uri: String, - editManager: EditManager -) { - initGlideBuilder(context) - .load(uri.toUri()) - .loadInto(editManager) -} - -private fun initGlideBuilder(context: Context) = Glide - .with(context) - .asBitmap() - .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) - -private fun RequestBuilder.loadInto( - editManager: EditManager -) { - into(object : CustomTarget() { - override fun onResourceReady( - bitmap: Bitmap, - transition: Transition? - ) { - editManager.apply { - val image = bitmap.asImageBitmap() - backgroundImage.value = image - setOriginalBackgroundImage(image) - scaleToFit() - } - } - - override fun onLoadCleared(placeholder: Drawable?) {} - }) -} - -fun resize( - imageBitmap: ImageBitmap, - maxWidth: Int, - maxHeight: Int -): ImageBitmap { - val bitmap = imageBitmap.asAndroidBitmap() - val width = bitmap.width - val height = bitmap.height - - val bitmapRatio = width.toFloat() / height.toFloat() - val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() - - var finalWidth = maxWidth - var finalHeight = maxHeight - - if (maxRatio > bitmapRatio) { - finalWidth = (maxHeight.toFloat() * bitmapRatio).toInt() - } else { - finalHeight = (maxWidth.toFloat() / bitmapRatio).toInt() - } - return Bitmap - .createScaledBitmap(bitmap, finalWidth, finalHeight, true) - .asImageBitmap() -} - -fun fitImage( - imageBitmap: ImageBitmap, - maxWidth: Int, - maxHeight: Int -): ImageViewParams { - val bitmap = imageBitmap.asAndroidBitmap() - val width = bitmap.width - val height = bitmap.height - - val bitmapRatio = width.toFloat() / height.toFloat() - val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() - - var finalWidth = maxWidth - var finalHeight = maxHeight - - if (maxRatio > bitmapRatio) { - finalWidth = (maxHeight.toFloat() * bitmapRatio).toInt() - } else { - finalHeight = (maxWidth.toFloat() / bitmapRatio).toInt() - } - return ImageViewParams( - IntSize( - finalWidth, - finalHeight, - ), - ResizeOperation.Scale( - finalWidth.toFloat() / width.toFloat(), - finalHeight.toFloat() / height.toFloat() - ) - ) -} - -fun fitBackground( - resolution: IntSize, - maxWidth: Int, - maxHeight: Int -): ImageViewParams { - - val width = resolution.width - val height = resolution.height - - val resolutionRatio = width.toFloat() / height.toFloat() - val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() - - var finalWidth = maxWidth - var finalHeight = maxHeight - - if (maxRatio > resolutionRatio) { - finalWidth = (maxHeight.toFloat() * resolutionRatio).toInt() - } else { - finalHeight = (maxWidth.toFloat() / resolutionRatio).toInt() - } - return ImageViewParams( - IntSize( - finalWidth, - finalHeight, - ), - ResizeOperation.Scale( - finalWidth.toFloat() / width.toFloat(), - finalHeight.toFloat() / height.toFloat() - ) - ) -} -class ImageViewParams( - val drawArea: IntSize, - val scale: ResizeOperation.Scale -) diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/MainActivity.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/MainScreen.kt similarity index 78% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/MainActivity.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/MainScreen.kt index 96c36c8..84eb87e 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/MainActivity.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/MainScreen.kt @@ -1,15 +1,12 @@ package dev.arkbuilders.arkretouch.presentation.main -import android.os.Bundle import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.compose.setContent -import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.getValue -import androidx.compose.runtime.SideEffect import androidx.compose.ui.platform.LocalContext import androidx.fragment.app.FragmentManager import androidx.navigation.NavType @@ -17,33 +14,13 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import dev.arkbuilders.arkretouch.data.Resolution -import dev.arkbuilders.arkretouch.presentation.utils.PermissionsHelper -import dev.arkbuilders.arkretouch.presentation.edit.EditScreen -import dev.arkbuilders.arkretouch.presentation.utils.isWritePermGranted +import dev.arkbuilders.arkretouch.data.model.Resolution import dev.arkbuilders.arkretouch.presentation.picker.PickerScreen -import dev.arkbuilders.arkretouch.presentation.theme.ARKRetouchTheme +import dev.arkbuilders.arkretouch.presentation.screens.EditScreen +import dev.arkbuilders.arkretouch.utils.permission.PermissionsHelper +import dev.arkbuilders.arkretouch.utils.permission.PermissionsHelper.isWritePermissionGranted import kotlin.io.path.Path -private const val REAL_PATH_KEY = "real_file_path_2" - -class MainActivity : AppCompatActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - setContent { - ARKRetouchTheme { - MainScreen( - supportFragmentManager, - uri = intent.data?.toString(), - realPath = intent.getStringExtra(REAL_PATH_KEY), - launchedFromIntent = intent.data != null, - ) - } - } - } -} - @Composable fun MainScreen( fragmentManager: FragmentManager, @@ -55,7 +32,7 @@ fun MainScreen( val navController = rememberNavController() var maxResolution by remember { mutableStateOf(Resolution(0, 0)) } val startScreen = - if ((uri != null || realPath != null) && context.isWritePermGranted()) + if ((uri != null || realPath != null) && context.isWritePermissionGranted()) NavHelper.editRoute else NavHelper.pickerRoute @@ -72,7 +49,7 @@ fun MainScreen( } SideEffect { - if (!context.isWritePermGranted()) + if (!context.isWritePermissionGranted()) PermissionsHelper.launchWritePerm(launcher) } @@ -93,6 +70,7 @@ fun MainScreen( ) ) }, + onWritePermNotGranted = { PermissionsHelper.launchWritePerm(launcher) } ) } composable( @@ -118,9 +96,10 @@ fun MainScreen( entry.arguments?.getString("path")?.let { Path(it) }, entry.arguments?.getString("uri"), fragmentManager, - navigateBack = { navController.popBackStack() }, entry.arguments?.getBoolean("launchedFromIntent")!!, - maxResolution + maxResolution, + navigateBack = { navController.popBackStack() }, + onWritePermNotGranted = { PermissionsHelper.launchWritePerm(launcher) } ) } } @@ -146,4 +125,4 @@ private object NavHelper { } return screen } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/RootActivity.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/RootActivity.kt new file mode 100644 index 0000000..1eafd1f --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/main/RootActivity.kt @@ -0,0 +1,25 @@ +package dev.arkbuilders.arkretouch.presentation.main + +import androidx.activity.compose.setContent +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import dev.arkbuilders.arkretouch.presentation.theme.ARKRetouchTheme + +private const val REAL_PATH_KEY = "real_file_path_2" + +class RootActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + ARKRetouchTheme { + MainScreen( + fragmentManager = supportFragmentManager, + uri = intent.data?.toString(), + realPath = intent.getStringExtra(REAL_PATH_KEY), + launchedFromIntent = intent.data != null, + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/picker/FilePickerScreen.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/picker/FilePickerScreen.kt index 98ed2d8..b0dadd5 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/picker/FilePickerScreen.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/picker/FilePickerScreen.kt @@ -6,9 +6,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text @@ -38,17 +38,17 @@ import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerFragme import dev.arkbuilders.arkfilepicker.presentation.filepicker.ArkFilePickerMode import dev.arkbuilders.arkfilepicker.presentation.onArkPathPicked import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.data.Resolution -import dev.arkbuilders.arkretouch.presentation.utils.askWritePermissions -import dev.arkbuilders.arkretouch.presentation.utils.isWritePermGranted +import dev.arkbuilders.arkretouch.data.model.Resolution import dev.arkbuilders.arkretouch.presentation.theme.Purple500 import dev.arkbuilders.arkretouch.presentation.theme.Purple700 +import dev.arkbuilders.arkretouch.utils.permission.PermissionsHelper.isWritePermissionGranted import java.nio.file.Path @Composable fun PickerScreen( fragmentManager: FragmentManager, - onNavigateToEdit: (Path?, Resolution) -> Unit + onNavigateToEdit: (Path?, Resolution) -> Unit, + onWritePermNotGranted: () -> Unit ) { val lifecycleOwner = LocalLifecycleOwner.current val context = LocalContext.current @@ -73,8 +73,8 @@ fun PickerScreen( .clip(RoundedCornerShape(10)) .background(Purple500) .clickable { - if (!context.isWritePermGranted()) { - context.askWritePermissions() + if (!context.isWritePermissionGranted()) { + onWritePermNotGranted() return@clickable } @@ -152,4 +152,4 @@ fun Int.toDp() = with(LocalDensity.current) { @Composable fun Dp.toPx() = with(LocalDensity.current) { this@toPx.toPx() -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/blur/BlurIntensityPopup.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/popups/BlurIntensityPopup.kt similarity index 68% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/blur/BlurIntensityPopup.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/popups/BlurIntensityPopup.kt index a196c42..e288fe2 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/blur/BlurIntensityPopup.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/popups/BlurIntensityPopup.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.arkretouch.presentation.edit.blur +package dev.arkbuilders.arkretouch.presentation.popups import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth @@ -12,13 +12,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager @Composable fun BlurIntensityPopup( - editManager: EditManager + isBlurring: Boolean, + intensity: Float, + size: Float, + onIntensityChange: (Float) -> Unit, + onSizeChange: (Float) -> Unit, ) { - if (editManager.isBlurMode.value) { + if (isBlurring) { Column( Modifier .fillMaxWidth() @@ -31,9 +34,10 @@ fun BlurIntensityPopup( Slider( modifier = Modifier .fillMaxWidth(), - value = editManager.blurIntensity.value, + value = intensity, // editManager.blurIntensity.value, onValueChange = { - editManager.blurIntensity.value = it + onIntensityChange(it) + // editManager.blurIntensity.value = it }, valueRange = 0f..25f, ) @@ -43,14 +47,15 @@ fun BlurIntensityPopup( Slider( modifier = Modifier .fillMaxWidth(), - value = editManager.blurOperation.blurSize.value, + value = size, // editManager.blurOperation.blurSize.value, onValueChange = { - editManager.blurOperation.blurSize.value = it - editManager.blurOperation.resize() + onSizeChange(it) + // editManager.blurOperation.blurSize.value = it + // editManager.blurOperation.resize() }, valueRange = 100f..500f, ) } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/MoreOptionsPopup.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/popups/MoreOptionsPopup.kt similarity index 98% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/MoreOptionsPopup.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/popups/MoreOptionsPopup.kt index 3aa5e3b..9ba26bd 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/MoreOptionsPopup.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/popups/MoreOptionsPopup.kt @@ -1,11 +1,11 @@ -package dev.arkbuilders.arkretouch.presentation.edit +package dev.arkbuilders.arkretouch.presentation.popups import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.Text @@ -128,4 +128,4 @@ fun MoreOptionsPopup( } } } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/screens/EditScreen.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/screens/EditScreen.kt new file mode 100644 index 0000000..39b8ea6 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/screens/EditScreen.kt @@ -0,0 +1,886 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.arkbuilders.arkretouch.presentation.screens + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.AlertDialog +import androidx.compose.material.Button +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +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.input.pointer.pointerInteropFilter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.FileProvider +import androidx.fragment.app.FragmentManager +import android.content.Context +import android.content.Intent +import android.view.MotionEvent +import android.widget.Toast +import dev.arkbuilders.arkretouch.R +import dev.arkbuilders.arkretouch.data.model.EditingState +import dev.arkbuilders.arkretouch.data.model.Resolution +import dev.arkbuilders.arkretouch.presentation.canvas.EditCanvasScreen +import dev.arkbuilders.arkretouch.presentation.dialogs.ColorPickerDialog +import dev.arkbuilders.arkretouch.presentation.dialogs.ConfirmClearDialog +import dev.arkbuilders.arkretouch.presentation.dialogs.NewImageOptionsDialog +import dev.arkbuilders.arkretouch.presentation.dialogs.SavePathDialog +import dev.arkbuilders.arkretouch.presentation.dialogs.SaveProgress +import dev.arkbuilders.arkretouch.presentation.picker.toPx +import dev.arkbuilders.arkretouch.presentation.popups.BlurIntensityPopup +import dev.arkbuilders.arkretouch.presentation.popups.MoreOptionsPopup +import dev.arkbuilders.arkretouch.presentation.theme.Gray +import dev.arkbuilders.arkretouch.presentation.viewmodels.EditViewModel +import dev.arkbuilders.arkretouch.presentation.views.CropAspectRatiosMenu +import dev.arkbuilders.arkretouch.presentation.views.Hint +import dev.arkbuilders.arkretouch.presentation.views.ResizeInput +import dev.arkbuilders.arkretouch.presentation.views.delayHidingHint +import dev.arkbuilders.arkretouch.utils.getActivity +import dev.arkbuilders.arkretouch.utils.permission.PermissionsHelper.isWritePermissionGranted +import org.koin.androidx.compose.koinViewModel +import org.koin.core.parameter.parametersOf +import java.nio.file.Path + +@Composable +fun EditScreen( + imagePath: Path?, + imageUri: String?, + fragmentManager: FragmentManager, + launchedFromIntent: Boolean, + maxResolution: Resolution, + navigateBack: () -> Unit, + onWritePermNotGranted: () -> Unit +) { + val primaryColor = MaterialTheme.colors.primary.value.toLong() + val viewModel: EditViewModel = koinViewModel { + parametersOf(primaryColor, imagePath, imageUri, maxResolution) + } + + val editingState = viewModel.editingState + + val context = LocalContext.current + val showDefaultsDialog = remember { + mutableStateOf( + imagePath == null && imageUri == null && !editingState.isLoaded + ) + } + + if (showDefaultsDialog.value) { + viewModel.editManager.apply { + resolution.value?.let { + NewImageOptionsDialog( + it, + maxResolution, + viewModel.drawingState.backgroundPaint.color, + navigateBack, + viewModel, + persistDefaults = { color, resolution -> + viewModel.persistDefaults(color, resolution) + }, + onConfirm = { + showDefaultsDialog.value = false + } + ) + } + } + } + ExitDialog( + viewModel = viewModel, + navigateBack = { + navigateBack() + viewModel.setIsLoaded(false) + }, + launchedFromIntent = launchedFromIntent, + ) + + BackHandler { + if ( + viewModel.isCropping() || viewModel.isRotating() || + viewModel.isResizing() || viewModel.isEyeDropping() || + viewModel.isBlurring() + ) { + viewModel.cancelOperation() + return@BackHandler + } + if (viewModel.isZooming()) { + viewModel.toggleZoom() + return@BackHandler + } + if (viewModel.isPanning()) { + viewModel.togglePan() + return@BackHandler + } + if (viewModel.editingState.canUndo) { + viewModel.onUndoClick() + return@BackHandler + } + if (viewModel.editingState.exitConfirmed) { + if (launchedFromIntent) + context.getActivity()?.finish() + else + navigateBack() + return@BackHandler + } + if (!viewModel.editingState.exitConfirmed) { + Toast.makeText(context, "Tap back again to exit", Toast.LENGTH_SHORT) + .show() + viewModel.confirmExit() + return@BackHandler + } + } + + HandleImageSavedEffect(viewModel, launchedFromIntent, navigateBack) + + if (!showDefaultsDialog.value) + DrawContainer(viewModel, context) + + Menus( + imagePath, + fragmentManager, + viewModel, + launchedFromIntent, + editingState, + navigateBack, + onWritePermNotGranted + ) + + if (viewModel.editingState.isSavingImage) { + SaveProgress() + } + + if (viewModel.editingState.showEyeDropperHint) { + Box( + Modifier.fillMaxSize(), + contentAlignment = Alignment.TopCenter + ) { + Hint(stringResource(R.string.pick_color)) { + delayHidingHint(it) { + viewModel.showEyeDropperHint(false) + } + viewModel.editingState.showEyeDropperHint + } + } + } +} + +@Composable +private fun Menus( + imagePath: Path?, + fragmentManager: FragmentManager, + viewModel: EditViewModel, + launchedFromIntent: Boolean, + editingState: EditingState, + navigateBack: () -> Unit, + onWritePermNotGranted: () -> Unit +) { + Box( + Modifier + .fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + ) { + TopMenu( + imagePath, + fragmentManager, + viewModel, + launchedFromIntent, + navigateBack, + onWritePermNotGranted + ) + } + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .height(IntrinsicSize.Min), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (viewModel.isRotating()) + Row { + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + viewModel.onRotate(-90f) + viewModel.invalidateCanvas() + } + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_rotate_left), + tint = MaterialTheme.colors.primary, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + viewModel.onRotate(90f) + viewModel.invalidateCanvas() + } + }, + imageVector = ImageVector + .vectorResource(R.drawable.ic_rotate_right), + tint = MaterialTheme.colors.primary, + contentDescription = null + ) + } + + EditMenuContainer(viewModel, editingState) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun DrawContainer( + viewModel: EditViewModel, + context: Context +) { + Box( + modifier = Modifier + .padding(bottom = 32.dp) + .fillMaxSize() + .background( + if (viewModel.isCropping()) { + Color.White + } else { + Color.Gray + } + ) + .pointerInteropFilter { event -> + if (event.action == MotionEvent.ACTION_DOWN) { + viewModel.setStrokeSliderExpanded(isExpanded = false) + } + + return@pointerInteropFilter false + } + .onSizeChanged { newSize -> + viewModel.onDrawContainerSizeChanged(newSize, context) + }, + contentAlignment = Alignment.Center + ) { + EditCanvasScreen(viewModel) + } +} + +@Composable +private fun BoxScope.TopMenu( + imagePath: Path?, + fragmentManager: FragmentManager, + viewModel: EditViewModel, + launchedFromIntent: Boolean, + navigateBack: () -> Unit, + onWritePermNotGranted: () -> Unit +) { + val context = LocalContext.current + + if (viewModel.editingState.showSavePathDialog) + SavePathDialog( + initialImagePath = imagePath, + fragmentManager = fragmentManager, + onDismissClick = { viewModel.showSavePathDialog(false) }, + onPositiveClick = { savePath -> + viewModel.saveImage(context, savePath) + } + ) + if (viewModel.editingState.showMoreOptionsPopup) + MoreOptionsPopup( + onDismissClick = { + viewModel.showMoreOptions(false) + }, + onShareClick = { + viewModel.shareImage( + context.cacheDir.toPath(), + provideUri = { file -> + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + }, + startShare = { intent -> + context.apply { + startActivity( + Intent.createChooser( + intent, + getString(R.string.share) + ) + ) + } + } + ) + viewModel.showMoreOptions(false) + }, + onSaveClick = { + if (!context.isWritePermissionGranted()) { + onWritePermNotGranted() + return@MoreOptionsPopup + } + viewModel.showSavePathDialog(true) + }, + onClearEdits = { + viewModel.showConfirmClearDialog(true) + viewModel.showMoreOptions(false) + } + ) + + ConfirmClearDialog( + viewModel.editingState.showConfirmClearDialog, + onConfirm = { + viewModel.editManager.apply { + if ( + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isEyeDropping() + ) viewModel.onClearEditsConfirm() + } + }, + onDismiss = { + viewModel.showConfirmClearDialog(false) + } + ) + + if ( + !viewModel.editingState.menusVisible && + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isCropping() && + !viewModel.isEyeDropping() + ) + return + Icon( + modifier = Modifier + .align(Alignment.TopStart) + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + if ( + viewModel.isCropping() || viewModel.isRotating() || + viewModel.isResizing() || viewModel.isEyeDropping() || + viewModel.isBlurring() + ) { + viewModel.cancelOperation() + return@clickable + } + if (viewModel.isZooming()) { + viewModel.toggleZoom() + return@clickable + } + if (viewModel.isPanning()) { + viewModel.togglePan() + return@clickable + } + if ( + !viewModel.editingState.canUndo + ) { + if (launchedFromIntent) { + context + .getActivity() + ?.finish() + } else { + navigateBack() + } + } else { + viewModel.showExitDialog(true) + } + } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back), + tint = MaterialTheme.colors.primary, + contentDescription = null + ) + + Row( + Modifier + .align(Alignment.TopEnd) + ) { + Icon( + modifier = Modifier + .padding(8.dp) + .size(36.dp) + .clip(CircleShape) + .clickable { + viewModel.editManager.apply { + if ( + viewModel.isCropping() || viewModel.isRotating() || + viewModel.isResizing() || viewModel.isBlurring() + ) { + viewModel.applyOperation() + return@clickable + } + } + viewModel.showMoreOptions(true) + }, + imageVector = if ( + viewModel.isCropping() || + viewModel.isRotating() || + viewModel.isResizing() || + viewModel.isBlurring() + ) + ImageVector.vectorResource(R.drawable.ic_check) + else ImageVector.vectorResource(R.drawable.ic_more_vert), + tint = MaterialTheme.colors.primary, + contentDescription = null + ) + } +} + +@Composable +private fun StrokeWidthPopup( + modifier: Modifier, + viewModel: EditViewModel, + editionState: EditingState +) { + viewModel.onSetPaintStrokeWidth(viewModel.editingState.strokeWidth.dp.toPx()) + if (editionState.strokeSliderExpanded) { + Column( + modifier = modifier + .fillMaxWidth() + .height(150.dp) + .padding(8.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + ) { + Box( + modifier = Modifier + .padding( + horizontal = 10.dp, + vertical = 5.dp + ) + .align(Alignment.Center) + .fillMaxWidth() + .height(viewModel.editingState.strokeWidth.dp) + .clip(RoundedCornerShape(30)) + .background(viewModel.drawingState.drawPaint.color) + ) + } + + Slider( + modifier = Modifier + .fillMaxWidth(), + value = viewModel.editingState.strokeWidth, + onValueChange = { + viewModel.setStrokeWidth(it) + }, + valueRange = 0.5f..50f, + ) + } + } +} + +@Composable +private fun EditMenuContainer( + viewModel: EditViewModel, + editingState: EditingState, +) { + Column( + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Bottom + ) { + CropAspectRatiosMenu( + isVisible = viewModel.isCropping(), + viewModel.editManager.cropWindow + ) + ResizeInput( + isVisible = viewModel.isResizing(), + viewModel.imageSize, + onResizeDown = { width, height -> + viewModel.onResizeDown(width, height) + } + ) + + Box( + Modifier + .fillMaxWidth() + .wrapContentHeight() + .clip(RoundedCornerShape(topStartPercent = 30, topEndPercent = 30)) + .background(Gray) + .clickable { + viewModel.toggleMenus() + }, + contentAlignment = Alignment.Center + ) { + Icon( + if (viewModel.editingState.menusVisible) Icons.Filled.KeyboardArrowDown + else Icons.Filled.KeyboardArrowUp, + contentDescription = "", + modifier = Modifier.size(32.dp), + ) + } + AnimatedVisibility( + visible = viewModel.editingState.menusVisible, + enter = expandVertically(expandFrom = Alignment.Bottom), + exit = shrinkVertically(shrinkTowards = Alignment.Top) + ) { + EditMenuContent(viewModel, editingState) + EditMenuFlowHint( + viewModel.editingState.bottomButtonsScrollIsAtStart, + viewModel.editingState.bottomButtonsScrollIsAtEnd + ) + } + } +} + +@Composable +private fun EditMenuContent( + viewModel: EditViewModel, + editingState: EditingState +) { + val scrollState = rememberScrollState() + Column( + Modifier + .fillMaxWidth() + .background(Gray) + ) { + StrokeWidthPopup(Modifier, viewModel, editingState) + + BlurIntensityPopup( + viewModel.isBlurring(), + viewModel.drawingState.blurIntensity, + viewModel.drawingState.blurSize, + onIntensityChange = { viewModel.onBlurIntensityChange(it) }, + onSizeChange = { viewModel.onBlurSizeChange(it) } + ) + + Row( + Modifier + .wrapContentHeight() + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp) + .horizontalScroll(scrollState) + ) { + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isCropping() && + !viewModel.isEyeDropping() && + !viewModel.isBlurring() + ) viewModel.onUndoClick() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_undo), + tint = if ( + viewModel.editingState.canUndo && ( + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isCropping() && + !viewModel.isEyeDropping() && + !viewModel.isBlurring() + ) + ) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isCropping() && + !viewModel.isEyeDropping() && + !viewModel.isBlurring() + ) viewModel.onRedoClick() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_redo), + tint = if ( + viewModel.editingState.canRedo && + ( + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isCropping() && + !viewModel.isEyeDropping() && + !viewModel.isBlurring() + ) + ) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + Box( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .background(color = viewModel.drawingState.drawPaint.color) + .clickable { + viewModel.showColorDialog(true) + } + ) + ColorPickerDialog( + isVisible = viewModel.editingState.showColorDialog, + initialColor = viewModel.drawingState.drawPaint.color, + usedColors = viewModel.editingState.usedColors, + enableEyeDropper = true, + onToggleEyeDropper = { + viewModel.toggleEyeDropper() + }, + onColorChanged = { + viewModel.onSetPaintColor(it) + viewModel.trackColor(it) + }, + onDismiss = { + viewModel.showColorDialog(false) + } + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !viewModel.isRotating() && + !viewModel.isCropping() && + !viewModel.isResizing() && + !viewModel.isEyeDropping() && + !viewModel.isBlurring() + ) + viewModel.setStrokeSliderExpanded(isExpanded = !editingState.strokeSliderExpanded) + }, + imageVector = + ImageVector.vectorResource(R.drawable.ic_line_weight), + tint = if ( + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isCropping() && + !viewModel.isEyeDropping() && + !viewModel.isBlurring() + ) viewModel.drawingState.drawPaint.color + else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + if ( + !viewModel.isRotating() && + !viewModel.isResizing() && + !viewModel.isCropping() && + !viewModel.isEyeDropping() && + !viewModel.isBlurring() + ) { viewModel.toggleErase() } + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_eraser), + tint = if ( + viewModel.isErasing() + ) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier + .padding(12.dp) + .size(40.dp) + .clip(CircleShape) + .clickable { + viewModel.toggleZoom() + }, + imageVector = ImageVector.vectorResource(R.drawable.ic_zoom_in), + tint = if ( + viewModel.isZooming() + ) + MaterialTheme.colors.primary + else + Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier.padding(12.dp).size(40.dp).clip(CircleShape).clickable { viewModel.togglePan() }, + imageVector = ImageVector.vectorResource(R.drawable.ic_pan_tool), + tint = if (viewModel.isPanning()) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier.padding(12.dp).size(40.dp).clip(CircleShape).clickable { viewModel.toggleCrop() }, + imageVector = ImageVector.vectorResource(R.drawable.ic_crop), + tint = if (viewModel.isCropping()) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier.padding(12.dp).size(40.dp).clip(CircleShape).clickable { viewModel.toggleRotate() }, + imageVector = ImageVector.vectorResource(R.drawable.ic_rotate_90_degrees_ccw), + tint = if (viewModel.isRotating()) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier.padding(12.dp).size(40.dp).clip(CircleShape).clickable { viewModel.toggleResize() }, + imageVector = ImageVector.vectorResource(R.drawable.ic_aspect_ratio), + tint = if (viewModel.isResizing()) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + Icon( + modifier = Modifier.padding(12.dp).size(40.dp).clip(CircleShape).clickable { viewModel.toggleBlur() }, + imageVector = ImageVector.vectorResource(R.drawable.ic_blur_on), + tint = if (viewModel.isBlurring()) MaterialTheme.colors.primary else Color.Black, + contentDescription = null + ) + } + } + viewModel.setBottomButtonsScrollIsAtStart(scrollState.value == 0) + viewModel.setBottomButtonsScrollIsAtEnd(scrollState.value == scrollState.maxValue) +} + +@Composable +fun EditMenuFlowHint( + scrollIsAtStart: Boolean = true, + scrollIsAtEnd: Boolean = false +) { + Box(Modifier.fillMaxSize()) { + AnimatedVisibility( + visible = scrollIsAtEnd || !scrollIsAtStart, + enter = fadeIn(tween(durationMillis = 1000)), + exit = fadeOut((tween(durationMillis = 1000))), + modifier = Modifier.align(Alignment.BottomStart) + ) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = null, + Modifier.background(Gray).padding(top = 16.dp, bottom = 16.dp).size(32.dp) + ) + } + AnimatedVisibility( + visible = scrollIsAtStart || !scrollIsAtEnd, + enter = fadeIn(tween(durationMillis = 1000)), + exit = fadeOut((tween(durationMillis = 1000))), + modifier = Modifier.align(Alignment.BottomEnd) + ) { + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + Modifier.background(Gray).padding(top = 16.dp, bottom = 16.dp).size(32.dp) + ) + } + } +} + +@Composable +private fun HandleImageSavedEffect( + viewModel: EditViewModel, + launchedFromIntent: Boolean, + navigateBack: () -> Unit, +) { + val context = LocalContext.current + LaunchedEffect(viewModel.editingState.imageSaved) { + if (!viewModel.editingState.imageSaved) + return@LaunchedEffect + if (launchedFromIntent) + context.getActivity()?.finish() + else + navigateBack() + } +} + +@Composable +private fun ExitDialog( + viewModel: EditViewModel, + navigateBack: () -> Unit, + launchedFromIntent: Boolean +) { + if (!viewModel.editingState.showExitDialog) return + + val context = LocalContext.current + + AlertDialog( + onDismissRequest = { + viewModel.showExitDialog(false) + }, + title = { + Text( + modifier = Modifier.padding(top = 4.dp, bottom = 8.dp), + text = "Do you want to save the changes?", + fontSize = 16.sp + ) + }, + confirmButton = { + Button( + onClick = { + viewModel.showExitDialog(false) + viewModel.showSavePathDialog(true) + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton( + onClick = { + viewModel.showExitDialog(false) + if (launchedFromIntent) { + context.getActivity()?.finish() + } else { + navigateBack() + } + } + ) { + Text("Exit") + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Color.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Color.kt index 93c2ca7..471651c 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Color.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Color.kt @@ -6,4 +6,4 @@ val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) -val Gray = Color(0xFFD3D3D3) +val Gray = Color(0xFFD3D3D3) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Shape.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Shape.kt index d9e83e1..058f40a 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Shape.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Shape.kt @@ -8,4 +8,4 @@ val Shapes = Shapes( small = RoundedCornerShape(4.dp), medium = RoundedCornerShape(4.dp), large = RoundedCornerShape(0.dp) -) +) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Theme.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Theme.kt index e000615..0aeda0e 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Theme.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Theme.kt @@ -44,4 +44,4 @@ fun ARKRetouchTheme( shapes = Shapes, content = content ) -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Type.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Type.kt index 8d5bd33..227d495 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Type.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/theme/Type.kt @@ -25,4 +25,4 @@ val Typography = Typography( fontSize = 12.sp ) */ -) +) \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/viewmodels/EditViewModel.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/viewmodels/EditViewModel.kt new file mode 100644 index 0000000..1fe5aa6 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/viewmodels/EditViewModel.kt @@ -0,0 +1,830 @@ +package dev.arkbuilders.arkretouch.presentation.viewmodels + +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageBitmapConfig +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.asAndroidBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.toSize +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Matrix +import android.media.MediaScannerConnection +import android.net.Uri +import android.view.MotionEvent +import dev.arkbuilders.arkretouch.data.model.DrawPath +import dev.arkbuilders.arkretouch.data.model.DrawingState +import dev.arkbuilders.arkretouch.data.model.EditingState +import dev.arkbuilders.arkretouch.data.model.ImageViewParams +import dev.arkbuilders.arkretouch.data.model.Resolution +import dev.arkbuilders.arkretouch.data.repo.OldStorageRepository +import dev.arkbuilders.arkretouch.editing.Operation +import dev.arkbuilders.arkretouch.editing.blur.BlurOperation +import dev.arkbuilders.arkretouch.editing.crop.CropOperation +import dev.arkbuilders.arkretouch.editing.draw.DrawOperation +import dev.arkbuilders.arkretouch.editing.manager.EditManager +import dev.arkbuilders.arkretouch.editing.manager.EditingMode +import dev.arkbuilders.arkretouch.editing.resize.ResizeOperation +import dev.arkbuilders.arkretouch.editing.rotate.RotateOperation +import dev.arkbuilders.arkretouch.utils.copy +import dev.arkbuilders.arkretouch.utils.loadImageWithPath +import dev.arkbuilders.arkretouch.utils.loadImageWithUri +import timber.log.Timber +import java.io.File +import java.nio.file.Path +import kotlin.io.path.createDirectory +import kotlin.io.path.exists +import kotlin.io.path.outputStream +import kotlin.system.measureTimeMillis +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class EditViewModel( + private val primaryColor: Long, + private val imagePath: Path?, + private val imageUri: String?, + private val maxResolution: Resolution, + private val prefs: OldStorageRepository +) : ViewModel() { + + private val _usedColors = mutableListOf() + + var editingState: EditingState by mutableStateOf(EditingState.DEFAULT.copy(usedColors = _usedColors)) + private set + + var drawingState: DrawingState by mutableStateOf(DrawingState()) + private set + + val editManager = EditManager() + + val imageSize: IntSize + get() = with(editManager) { + val size = if (isResizing()) + backgroundImage2.value?.let { + IntSize(it.width, it.height) + } ?: originalBackgroundImage.value?.let { + IntSize(it.width, it.height) + } ?: resolution.value?.toIntSize()!! + else + backgroundImage.value?.let { + IntSize(it.width, it.height) + } ?: resolution.value?.toIntSize() ?: drawAreaSize.value + editManager.setImageSize(size) + size + } + + private val drawOperation = DrawOperation(editManager) + + private val cropOperation = CropOperation(editManager) { + clearRedo() + updateUndoRedoState() + toggleDraw() + } + + private val rotateOperation = RotateOperation(editManager) { + clearRedo() + updateUndoRedoState() + toggleDraw() + } + + private val resizeOperation = ResizeOperation(editManager) { + clearRedo() + updateUndoRedoState() + toggleDraw() + } + + private val blurOperation = BlurOperation(editManager) { + clearRedo() + updateUndoRedoState() + toggleDraw() + } + + init { + viewModelScope.launch { + if (imageUri == null && imagePath == null) { + editManager.initDefaults( + prefs.readDefaults(), + maxResolution + ) + editManager.setImageSize(imageSize) + } + loadDefaultPaintColor() + } + } + + fun toggleMenus() { + showMenus(!editingState.menusVisible) + } + + fun showMenus(bool: Boolean) { + editingState = editingState.copy(menusVisible = bool) + } + + fun setStrokeWidth(width: Float) { + editingState = editingState.copy(strokeWidth = width) + } + + fun showSavePathDialog(bool: Boolean) { + editingState = editingState.copy(showSavePathDialog = bool) + } + + fun showExitDialog(bool: Boolean) { + editingState = editingState.copy(showExitDialog = bool) + } + + fun showMoreOptions(bool: Boolean) { + editingState = editingState.copy(showMoreOptionsPopup = bool) + } + + fun showEyeDropperHint(bool: Boolean) { + editingState = editingState.copy(showEyeDropperHint = bool) + } + + fun showConfirmClearDialog(bool: Boolean) { + editingState = editingState.copy(showConfirmClearDialog = bool) + } + + fun showColorDialog(bool: Boolean) { + if (isRotating() && isResizing() && isCropping() && isErasing() && isBlurring()) return + if (isEyeDropping() && bool) { + toggleDraw() + } + editingState = editingState.copy(showColorDialog = bool) + } + + fun setIsLoaded(bool: Boolean) { + editingState = editingState.copy(isLoaded = bool) + } + + fun setBottomButtonsScrollIsAtStart(bool: Boolean) { + editingState = editingState.copy(bottomButtonsScrollIsAtStart = bool) + } + fun setBottomButtonsScrollIsAtEnd(bool: Boolean) { + editingState = editingState.copy(bottomButtonsScrollIsAtEnd = bool) + } + + fun setStrokeSliderExpanded(isExpanded: Boolean) { + editingState = editingState.copy(strokeSliderExpanded = isExpanded) + } + + fun loadImage(loadByPath: (Path, EditManager) -> Unit, loadByUri: (String, EditManager) -> Unit) { + editingState = editingState.copy(isLoaded = true) + imagePath?.let { + loadByPath(it, editManager) + return + } + imageUri?.let { + loadByUri(it, editManager) + return + } + editManager.scaleToFit() + } + + fun saveImage(context: Context, path: Path) { + viewModelScope.launch(Dispatchers.IO) { + editingState = editingState.copy(isSavingImage = true) + val combinedBitmap = getEditedImage() + + path.outputStream().use { out -> + combinedBitmap.asAndroidBitmap() + .compress(Bitmap.CompressFormat.PNG, 100, out) + } + MediaScannerConnection.scanFile( + context, + arrayOf(path.toString()), + arrayOf("image/*") + ) { _, _ -> } + editingState = editingState.copy(imageSaved = true, isSavingImage = false, showSavePathDialog = false) + } + } + + fun shareImage(root: Path, provideUri: (File) -> Uri, startShare: (Intent) -> Unit) { + viewModelScope.launch(Dispatchers.IO) { + val intent = Intent(Intent.ACTION_SEND) + val imagesPath = root.resolve("images").apply { if (!exists()) createDirectory() } + val tempPath = kotlin.io.path.createTempFile(imagesPath, suffix = ".png") + val bitmap = getEditedImage().asAndroidBitmap() + tempPath.outputStream().use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + intent.type = "image/*" + intent.putExtra(Intent.EXTRA_STREAM, provideUri(tempPath.toFile())) + startShare(intent) + } + } + + fun trackColor(color: Color) { + _usedColors.remove(color) + _usedColors.add(color) + + val excess = _usedColors.size - KEEP_USED_COLORS + repeat(excess) { + _usedColors.removeFirst() + } + + viewModelScope.launch { + prefs.persistUsedColors(editingState.usedColors) + } + } + + fun cancelEyeDropper() { + onSetPaintColor(editingState.usedColors.last()) + } + + fun applyEyeDropper(action: Int, x: Int, y: Int) { + try { + val bitmap = getEditedImage().asAndroidBitmap() + val imageX = (x * editManager.bitmapScale.x).toInt() + val imageY = (y * editManager.bitmapScale.y).toInt() + val pixel = bitmap.getPixel(imageX, imageY) + val color = Color(pixel) + if (color == Color.Transparent) { + showEyeDropperHint(true) + return + } + when (action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP -> { + trackColor(color) + toggleEyeDropper() + showMenus(true) + } + } + onSetPaintColor(color) + } catch (e: Exception) { + e.printStackTrace() + } + } + + fun getCombinedImageBitmap(): ImageBitmap { + val size = imageSize + val drawBitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + val combinedBitmap = + ImageBitmap(size.width, size.height, ImageBitmapConfig.Argb8888) + + val time = measureTimeMillis { + val backgroundPaint = Paint().also { + it.color = drawingState.backgroundPaint.color + } + val drawCanvas = Canvas(drawBitmap) + val combinedCanvas = Canvas(combinedBitmap) + val matrix = Matrix().apply { + if (editManager.rotationAngles.isNotEmpty()) { + val centerX = size.width / 2 + val centerY = size.height / 2 + setRotate( + editManager.rotationAngle.floatValue, + centerX.toFloat(), + centerY.toFloat() + ) + } + } + combinedCanvas.drawRect( + Rect(Offset.Zero, size.toSize()), + backgroundPaint + ) + combinedCanvas.nativeCanvas.setMatrix(matrix) + editManager.backgroundImage.value?.let { + combinedCanvas.drawImage( + it, + Offset.Zero, + Paint() + ) + } + editManager.drawPaths.forEach { + drawCanvas.drawPath(it.path, it.paint) + } + combinedCanvas.drawImage(drawBitmap, Offset.Zero, Paint()) + } + Timber.tag("edit-viewmodel: getCombinedImageBitmap").d("processing edits took ${time / 1000} s ${time % 1000} ms") + return combinedBitmap + } + + fun getEditedImage(): ImageBitmap { + val size = imageSize + var bitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + var pathBitmap: ImageBitmap? = null + val time = measureTimeMillis { + editManager.apply { + val matrix = Matrix() + if (editManager.drawPaths.isNotEmpty()) { + pathBitmap = ImageBitmap( + size.width, + size.height, + ImageBitmapConfig.Argb8888 + ) + val pathCanvas = Canvas(pathBitmap!!) + editManager.drawPaths.forEach { + pathCanvas.drawPath(it.path, it.paint) + } + } + backgroundImage.value?.let { + val canvas = Canvas(bitmap) + if (prevRotationAngle == 0f && drawPaths.isEmpty()) { + bitmap = it + return@let + } + if (prevRotationAngle != 0f) { + val centerX = size.width / 2f + val centerY = size.height / 2f + matrix.setRotate(prevRotationAngle, centerX, centerY) + } + canvas.nativeCanvas.drawBitmap( + it.asAndroidBitmap(), + matrix, + null + ) + if (drawPaths.isNotEmpty()) { + canvas.nativeCanvas.drawBitmap( + pathBitmap?.asAndroidBitmap()!!, + matrix, + null + ) + } + } ?: run { + val canvas = Canvas(bitmap) + if (prevRotationAngle != 0f) { + val centerX = size.width / 2 + val centerY = size.height / 2 + matrix.setRotate( + prevRotationAngle, + centerX.toFloat(), + centerY.toFloat() + ) + canvas.nativeCanvas.setMatrix(matrix) + } + canvas.drawRect( + Rect(Offset.Zero, size.toSize()), + drawingState.backgroundPaint + ) + if (drawPaths.isNotEmpty()) { + canvas.drawImage( + pathBitmap!!, + Offset.Zero, + Paint() + ) + } + } + } + } + Timber.tag("edit-viewmodel: getEditedImage").d( + "processing edits took ${time / 1000} s ${time % 1000} ms" + ) + return bitmap + } + + fun confirmExit() = viewModelScope.launch { + editingState = editingState.copy(exitConfirmed = true, isLoaded = false) + delay(2_000) + editingState = editingState.copy(exitConfirmed = false, isLoaded = true) + } + + fun applyOperation() { + applyEdit() + } + + fun cancelOperation() { + if (isRotating()) { + toggleDraw() + editManager.cancelRotateMode() + } + if (isCropping()) { + toggleDraw() + editManager.cancelCropMode() + } + if (isResizing()) { + toggleDraw() + editManager.cancelResizeMode() + } + if (isEyeDropping()) { + toggleEyeDropper() + } + if (isBlurring()) { + toggleDraw() + blurOperation.cancel() + } + + showMenus(true) + editManager.scaleToFit() + } + + fun persistDefaults(color: Color, resolution: Resolution) { + viewModelScope.launch { + prefs.persistDefaults(color, resolution) + } + } + + fun toggleDraw() { + editingState = editingState.copy(mode = EditingMode.DRAW) + } + + fun toggleErase() { + if (isErasing()) { + toggleDraw() + return + } + editingState = editingState.copy(mode = EditingMode.ERASE) + } + + fun toggleCrop() { + if (!isRotating() && !isResizing() && !isEyeDropping() && !isErasing() && !isBlurring()) { + if (isCropping()) { + cancelOperation() + return + } + editingState = editingState.copy(mode = EditingMode.CROP) + val bitmap = getEditedImage() + editManager.setBackgroundImage2() + editManager.backgroundImage.value = bitmap + editManager.cropWindow.init(bitmap.asAndroidBitmap()) + } + } + + fun toggleResize() { + if (!isRotating() && !isCropping() && !isEyeDropping() && !isErasing() && !isBlurring()) { + if (isResizing()) { + cancelOperation() + return + } + editingState = editingState.copy(mode = EditingMode.RESIZE) + editManager.setBackgroundImage2() + val imgBitmap = getEditedImage() + editManager.backgroundImage.value = imgBitmap + resizeOperation.init(imgBitmap.asAndroidBitmap()) + } + } + + fun toggleRotate() { + if (!isCropping() && !isResizing() && !isEyeDropping() && !isErasing() && !isBlurring()) { + if (isRotating()) { + cancelOperation() + return + } + editingState = editingState.copy(mode = EditingMode.ROTATE) + editManager.setBackgroundImage2() + editManager.scaleToFitOnEdit() + } + } + + fun toggleZoom() { + if (!isRotating() && !isResizing() && !isCropping() && !isEyeDropping() && !isBlurring() && !isErasing()) { + if (isZooming()) { + toggleDraw() + return + } + editingState = editingState.copy(mode = EditingMode.ZOOM) + } + } + + fun togglePan() { + if (!isRotating() && !isResizing() && !isCropping() && !isEyeDropping() && !isBlurring() && !isErasing()) { + if (isPanning()) { + toggleDraw() + return + } + editingState = editingState.copy(mode = EditingMode.PAN) + } + } + + fun toggleBlur() { + if ( + !isRotating() && !isCropping() && !isEyeDropping() && !isResizing() && !isErasing() + ) { + if (isBlurring()) { + cancelOperation() + return + } + if (editingState.strokeSliderExpanded) { setStrokeSliderExpanded(false) } + editingState = editingState.copy(mode = EditingMode.BLUR) + editManager.setBackgroundImage2() + editManager.backgroundImage.value = getEditedImage() + blurOperation.init() + } + } + + fun toggleEyeDropper() { + if (isEyeDropping()) { + cancelEyeDropper() + toggleDraw() + return + } + editingState = editingState.copy(mode = EditingMode.EYEDROPPER) + showColorDialog(false) + } + + fun isCropping(): Boolean = editingState.mode == EditingMode.CROP + + fun isRotating(): Boolean = editingState.mode == EditingMode.ROTATE + + fun isResizing(): Boolean = editingState.mode == EditingMode.RESIZE + + fun isErasing(): Boolean = editingState.mode == EditingMode.ERASE + + fun isZooming(): Boolean = editingState.mode == EditingMode.ZOOM + + fun isPanning(): Boolean = editingState.mode == EditingMode.PAN + + fun isBlurring(): Boolean = editingState.mode == EditingMode.BLUR + + fun isEyeDropping(): Boolean = editingState.mode == EditingMode.EYEDROPPER + + fun onRotate(angle: Float) { + editManager.apply { + this@EditViewModel.rotateOperation.onRotate(angle) + } + } + + fun onResizeDown(width: Int = 0, height: Int = 0): IntSize { + return resizeOperation.resizeDown(width, height) { + editManager.backgroundImage.value = it + } + } + + fun onBlurSizeChange(size: Float) { + drawingState = drawingState.copy(blurSize = size) + blurOperation.setSize(drawingState.blurSize) + blurOperation.resize() + } + + fun onBlurIntensityChange(intensity: Float) { + drawingState = drawingState.copy(blurIntensity = intensity) + blurOperation.setIntensity(drawingState.blurIntensity) + } + + fun onBlurMove(position: Offset, delta: Offset) { + blurOperation.move(position, delta) + } + + fun onDrawBlur(context: Context, canvas: Canvas) { + blurOperation.draw(context, canvas) + } + + fun onDrawContainerSizeChanged(newSize: IntSize, context: Context) { + viewModelScope.launch { + if (newSize == IntSize.Zero) return@launch + if (editingState.showSavePathDialog) return@launch + editManager.drawAreaSize.value = newSize + if (editingState.isLoaded) { + editManager.apply { + when (true) { + isCropping() -> { + cropWindow.updateOnDrawAreaSizeChange() + return@launch + } + + isResizing() -> { + if ( + backgroundImage.value?.width == + this@EditViewModel.imageSize.width && + backgroundImage.value?.height == + this@EditViewModel.imageSize.height + ) { + val editMatrixScale = scaleToFitOnEdit().scale + this@EditViewModel.resizeOperation + .updateEditMatrixScale(editMatrixScale) + } + return@launch + } + + isRotating() -> { + scaleToFitOnEdit(isRotating = true) + return@launch + } + + isZooming() -> { + return@launch + } + + else -> { + scaleToFit() + return@launch + } + } + } + } + loadImage( + loadByPath = { path, editManager -> loadImageWithPath(context, path, editManager) }, + loadByUri = { uri, editManager -> loadImageWithUri(context, uri, editManager) } + ) + } + } + + fun onSetPaintColor(color: Color) { + drawingState = drawingState.copy( + drawPaint = drawingState.drawPaint.copy().also { + it.color = color + } + ) + } + + fun onSetPaintStrokeWidth(strokeWidth: Float) { + drawingState.drawPaint.strokeWidth = strokeWidth + drawingState.erasePaint.strokeWidth = strokeWidth + } + + fun onSetBackgroundColor(color: Color) { + drawingState.backgroundPaint.color = color + } + + fun onDrawPath(path: androidx.compose.ui.graphics.Path) { + clearRedo() + editManager.addDrawPath( + DrawPath( + path, + if (isErasing()) drawingState.erasePaint.copy() else drawingState.drawPaint.copy() + ) + ) + } + + fun onUndoClick() { + if (editingState.canUndo) { + editManager.undo { task -> + operationByTask(task) + } + } + updateUndoRedoState() + editManager.invalidate() + } + + fun onRedoClick() { + if (editingState.canRedo) { + editManager.redo { task -> + operationByTask(task) + } + } + updateUndoRedoState() + editManager.invalidate() + } + + fun updateUndoRedoState() { + editManager.updateRevised { canUndo, canRedo -> + editingState = editingState.copy(canUndo = canUndo, canRedo = canRedo) + } + } + + fun onClearEditsConfirm() { + blurOperation.clear() + editManager.clearEdits() + updateUndoRedoState() + } + + private fun clearRedo() { + if (editingState.canRedo) { + editManager.clearRedo() + } + } + + private fun operationByTask(task: String): Operation = when (task) { + EditManager.ROTATE -> rotateOperation + EditManager.RESIZE -> resizeOperation + EditManager.CROP -> cropOperation + EditManager.BLUR -> blurOperation + else -> drawOperation + } + + private fun applyEdit() { + val operation: Operation = when (editingState.mode) { + EditingMode.CROP -> cropOperation + EditingMode.RESIZE -> resizeOperation + EditingMode.ROTATE -> rotateOperation + EditingMode.BLUR -> blurOperation + else -> drawOperation + } + operation.apply() + if (operation != drawOperation) { showMenus(true) } + } + + private fun loadDefaultPaintColor() { + viewModelScope.launch { + _usedColors.addAll(prefs.readUsedColors()) + + val color = if (_usedColors.isNotEmpty()) { + _usedColors.last() + } else { + Color(primaryColor.toULong()).also { _usedColors.add(it) } + } + + onSetPaintColor(color) + } + } + + fun invalidateCanvas() { + editManager.invalidate() + } + + companion object { + private const val KEEP_USED_COLORS = 20 + } + + fun observeCanvasInvalidator(): State = editManager.invalidatorTick +} + +fun resize( + imageBitmap: ImageBitmap, + maxWidth: Int, + maxHeight: Int +): ImageBitmap { + val bitmap = imageBitmap.asAndroidBitmap() + val width = bitmap.width + val height = bitmap.height + + val bitmapRatio = width.toFloat() / height.toFloat() + val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() + + var finalWidth = maxWidth + var finalHeight = maxHeight + + if (maxRatio > bitmapRatio) { + finalWidth = (maxHeight.toFloat() * bitmapRatio).toInt() + } else { + finalHeight = (maxWidth.toFloat() / bitmapRatio).toInt() + } + return Bitmap + .createScaledBitmap(bitmap, finalWidth, finalHeight, true) + .asImageBitmap() +} + +fun fitImage( + imageBitmap: ImageBitmap, + maxWidth: Int, + maxHeight: Int +): ImageViewParams { + val bitmap = imageBitmap.asAndroidBitmap() + val width = bitmap.width + val height = bitmap.height + + val bitmapRatio = width.toFloat() / height.toFloat() + val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() + + var finalWidth = maxWidth + var finalHeight = maxHeight + + if (maxRatio > bitmapRatio) { + finalWidth = (maxHeight.toFloat() * bitmapRatio).toInt() + } else { + finalHeight = (maxWidth.toFloat() / bitmapRatio).toInt() + } + return ImageViewParams( + IntSize( + finalWidth, + finalHeight, + ), + ResizeOperation.Scale( + finalWidth.toFloat() / width.toFloat(), + finalHeight.toFloat() / height.toFloat() + ) + ) +} + +fun fitBackground( + resolution: IntSize, + maxWidth: Int, + maxHeight: Int +): ImageViewParams { + + val width = resolution.width + val height = resolution.height + + val resolutionRatio = width.toFloat() / height.toFloat() + val maxRatio = maxWidth.toFloat() / maxHeight.toFloat() + + var finalWidth = maxWidth + var finalHeight = maxHeight + + if (maxRatio > resolutionRatio) { + finalWidth = (maxHeight.toFloat() * resolutionRatio).toInt() + } else { + finalHeight = (maxWidth.toFloat() / resolutionRatio).toInt() + } + return ImageViewParams( + IntSize( + finalWidth, + finalHeight, + ), + ResizeOperation.Scale( + finalWidth.toFloat() / width.toFloat(), + finalHeight.toFloat() / height.toFloat() + ) + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropAspectRatiosMenu.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/CropAspectRatiosMenu.kt similarity index 93% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropAspectRatiosMenu.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/CropAspectRatiosMenu.kt index 544a6c3..65ffbb1 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/crop/CropAspectRatiosMenu.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/CropAspectRatiosMenu.kt @@ -1,13 +1,13 @@ -package dev.arkbuilders.arkretouch.presentation.edit.crop +package dev.arkbuilders.arkretouch.presentation.views import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme @@ -26,13 +26,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.aspectRatios -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isChanged -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCropFree -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCropSquare -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCrop_9_16 -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCrop_2_3 -import dev.arkbuilders.arkretouch.presentation.edit.crop.AspectRatio.isCrop_4_5 +import dev.arkbuilders.arkretouch.editing.crop.CropWindow +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.aspectRatios +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isChanged +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCropFree +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCropSquare +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCrop_2_3 +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCrop_4_5 +import dev.arkbuilders.arkretouch.presentation.views.AspectRatio.isCrop_9_16 @Composable fun CropAspectRatiosMenu( @@ -238,4 +239,4 @@ internal object AspectRatio { val CROP_4_5 = Offset(4f, 5f) val CROP_9_16 = Offset(9f, 16f) val CROP_2_3 = Offset(2f, 3f) -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/resize/ResizeInput.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/ResizeInput.kt similarity index 90% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/resize/ResizeInput.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/ResizeInput.kt index 2642344..a38cd55 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/resize/ResizeInput.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/ResizeInput.kt @@ -1,4 +1,4 @@ -package dev.arkbuilders.arkretouch.presentation.edit.resize +package dev.arkbuilders.arkretouch.presentation.views import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween @@ -8,8 +8,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.MaterialTheme @@ -17,11 +17,11 @@ import androidx.compose.material.Text import androidx.compose.material.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -29,37 +29,37 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.core.text.isDigitsOnly +import dev.arkbuilders.arkretouch.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import dev.arkbuilders.arkretouch.R -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager @Composable -fun ResizeInput(isVisible: Boolean, editManager: EditManager) { +fun ResizeInput(isVisible: Boolean, imageSize: IntSize, onResizeDown: (Int, Int) -> IntSize) { if (isVisible) { var width by rememberSaveable { mutableStateOf( - editManager.imageSize.width.toString() + imageSize.width.toString() ) } var height by rememberSaveable { mutableStateOf( - editManager.imageSize.height.toString() + imageSize.height.toString() ) } val widthHint = stringResource( R.string.width_too_large, - editManager.imageSize.width + imageSize.width ) val digitsHint = stringResource(R.string.digits_only) val heightHint = stringResource( R.string.height_too_large, - editManager.imageSize.height + imageSize.height ) var hint by remember { mutableStateOf("") @@ -88,7 +88,7 @@ fun ResizeInput(isVisible: Boolean, editManager: EditManager) { if ( it.isNotEmpty() && it.isDigitsOnly() && - it.toInt() > editManager.imageSize.width + it.toInt() > imageSize.width ) { hint = widthHint showHint = true @@ -103,7 +103,7 @@ fun ResizeInput(isVisible: Boolean, editManager: EditManager) { showHint = false if (width.isEmpty()) height = width if (width.isNotEmpty() && width.isDigitsOnly()) { - height = editManager.resizeDown(width = width.toInt()) + height = onResizeDown(width.toInt(), 0) .height.toString() } }, @@ -132,7 +132,7 @@ fun ResizeInput(isVisible: Boolean, editManager: EditManager) { if ( it.isNotEmpty() && it.isDigitsOnly() && - it.toInt() > editManager.imageSize.height + it.toInt() > imageSize.height ) { hint = heightHint showHint = true @@ -147,7 +147,7 @@ fun ResizeInput(isVisible: Boolean, editManager: EditManager) { showHint = false if (height.isEmpty()) width = height if (height.isNotEmpty() && height.isDigitsOnly()) { - width = editManager.resizeDown(height = height.toInt()) + width = onResizeDown(0, height.toInt()) .width.toString() } }, @@ -198,4 +198,4 @@ fun Hint(text: String, isVisible: (CoroutineScope) -> Boolean) { .padding(12.dp) ) } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/TransparencyChessBoard.kt b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/TransparencyChessBoard.kt similarity index 87% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/TransparencyChessBoard.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/TransparencyChessBoard.kt index 3644e32..f255f92 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/edit/TransparencyChessBoard.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/presentation/views/TransparencyChessBoard.kt @@ -1,20 +1,21 @@ -package dev.arkbuilders.arkretouch.presentation.edit +package dev.arkbuilders.arkretouch.presentation.views -import android.graphics.Matrix +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Canvas -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Paint import androidx.compose.ui.graphics.drawscope.drawIntoCanvas import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.toSize -import dev.arkbuilders.arkretouch.presentation.drawing.EditManager +import android.graphics.Matrix private class TransparencyChessBoard { fun create(boardSize: Size, canvas: Canvas, matrix: Matrix) { @@ -77,15 +78,16 @@ private fun transparencyChessBoard( } @Composable -fun TransparencyChessBoardCanvas(modifier: Modifier, editManager: EditManager) { +fun TransparencyChessBoardCanvas(modifier: Modifier, size: IntSize, matrix: Matrix, observeInvalidator: State) { Canvas(modifier.background(Color.Transparent)) { - editManager.invalidatorTick.value drawIntoCanvas { canvas -> + // force recomposition on invalidatorTick change + observeInvalidator.value transparencyChessBoard( canvas, - editManager.imageSize.toSize(), - editManager.backgroundMatrix + size.toSize(), + matrix ) } } -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/utils/GraphicsExtensions.kt b/app/src/main/java/dev/arkbuilders/arkretouch/utils/GraphicsExtensions.kt new file mode 100644 index 0000000..34ecb38 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/utils/GraphicsExtensions.kt @@ -0,0 +1,40 @@ +package dev.arkbuilders.arkretouch.utils + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin + +fun Paint.copy(): Paint { + val from = this + return Paint().apply { + alpha = from.alpha + isAntiAlias = from.isAntiAlias + color = from.color + blendMode = from.blendMode + style = from.style + strokeWidth = from.strokeWidth + strokeCap = from.strokeCap + strokeJoin = from.strokeJoin + strokeMiterLimit = from.strokeMiterLimit + filterQuality = from.filterQuality + shader = from.shader + colorFilter = from.colorFilter + pathEffect = from.pathEffect + asFrameworkPaint().apply { + maskFilter = from.asFrameworkPaint().maskFilter + } + } +} + +fun defaultPaint(): Paint { + return Paint().apply { + color = Color.White + strokeWidth = 14f + isAntiAlias = true + style = PaintingStyle.Stroke + strokeJoin = StrokeJoin.Round + strokeCap = StrokeCap.Round + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/ImageHelper.kt b/app/src/main/java/dev/arkbuilders/arkretouch/utils/ImageHelper.kt similarity index 69% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/ImageHelper.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/utils/ImageHelper.kt index ddb3994..ce745a0 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/ImageHelper.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/utils/ImageHelper.kt @@ -1,10 +1,10 @@ -package dev.arkbuilders.arkretouch.presentation.utils +package dev.arkbuilders.arkretouch.utils import android.graphics.Bitmap import android.graphics.Matrix -import dev.arkbuilders.arkretouch.presentation.edit.crop.CropWindow -import dev.arkbuilders.arkretouch.presentation.edit.resize.ResizeOperation -import dev.arkbuilders.arkretouch.presentation.edit.rotate.RotateOperation +import dev.arkbuilders.arkretouch.editing.crop.CropWindow +import dev.arkbuilders.arkretouch.editing.resize.ResizeOperation +import dev.arkbuilders.arkretouch.editing.rotate.RotateOperation fun Bitmap.crop(cropParams: CropWindow.CropParams): Bitmap = Bitmap.createBitmap( this, @@ -30,4 +30,4 @@ fun Bitmap.resize(scale: ResizeOperation.Scale): Bitmap { fun Matrix.rotate(angle: Float, center: RotateOperation.Center) { this.postRotate(angle, center.x, center.y) -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/utils/ImageLoading.kt b/app/src/main/java/dev/arkbuilders/arkretouch/utils/ImageLoading.kt new file mode 100644 index 0000000..16b73c6 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/utils/ImageLoading.kt @@ -0,0 +1,60 @@ +package dev.arkbuilders.arkretouch.utils + +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.net.toUri +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable +import com.bumptech.glide.Glide +import com.bumptech.glide.RequestBuilder +import com.bumptech.glide.load.engine.DiskCacheStrategy +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition +import dev.arkbuilders.arkretouch.editing.manager.EditManager +import java.nio.file.Path + +fun loadImageWithPath( + context: Context, + image: Path, + editManager: EditManager +) { + initGlideBuilder(context) + .load(image.toFile()) + .loadInto(editManager) +} + +fun loadImageWithUri( + context: Context, + uri: String, + editManager: EditManager +) { + initGlideBuilder(context) + .load(uri.toUri()) + .loadInto(editManager) +} + +private fun initGlideBuilder(context: Context) = Glide + .with(context) + .asBitmap() + .skipMemoryCache(true) + .diskCacheStrategy(DiskCacheStrategy.NONE) + +private fun RequestBuilder.loadInto( + editManager: EditManager +) { + into(object : CustomTarget() { + override fun onResourceReady( + bitmap: Bitmap, + transition: Transition? + ) { + editManager.apply { + val image = bitmap.asImageBitmap() + backgroundImage.value = image + setOriginalBackgroundImage(image) + scaleToFit() + } + } + + override fun onLoadCleared(placeholder: Drawable?) {} + }) +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/Utils.kt b/app/src/main/java/dev/arkbuilders/arkretouch/utils/Utils.kt similarity index 59% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/Utils.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/utils/Utils.kt index 480f7ab..befc74f 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/Utils.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/utils/Utils.kt @@ -1,22 +1,12 @@ -package dev.arkbuilders.arkretouch.presentation.utils +package dev.arkbuilders.arkretouch.utils -import android.Manifest -import android.content.Context -import android.content.ContextWrapper -import android.content.Intent -import android.content.pm.PackageManager -import android.net.Uri -import android.os.Build -import android.os.Environment -import android.provider.Settings -import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerEvent -import androidx.core.app.ActivityCompat -import androidx.core.content.ContextCompat -import dev.arkbuilders.arkretouch.BuildConfig +import android.content.Context +import android.content.ContextWrapper +import android.widget.Toast import java.nio.file.Path import kotlin.io.path.exists import kotlin.io.path.extension @@ -39,36 +29,6 @@ fun Path.findNotExistCopyName(name: Path): Path { return newPath } -fun Context.askWritePermissions() = getActivity()?.apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val packageUri = - Uri.parse("package:${BuildConfig.APPLICATION_ID}") - val intent = - Intent( - Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - packageUri - ) - startActivityForResult(intent, 1) - } else { - ActivityCompat.requestPermissions( - this, - arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE), - 2 - ) - } -} - -fun Context.isWritePermGranted(): Boolean { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - Environment.isExternalStorageManager() - } else { - ContextCompat.checkSelfPermission( - this, - Manifest.permission.WRITE_EXTERNAL_STORAGE - ) == PackageManager.PERMISSION_GRANTED - } -} - fun Context.getActivity(): AppCompatActivity? = when (this) { is AppCompatActivity -> this is ContextWrapper -> baseContext.getActivity() @@ -103,4 +63,4 @@ fun PointerEvent.calculateRotationFromOneFingerGesture( fun Context.toast(@StringRes stringId: Int) { Toast.makeText(this, getString(stringId), Toast.LENGTH_SHORT).show() -} +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/utils/permission/AllFilesAccessContract.kt b/app/src/main/java/dev/arkbuilders/arkretouch/utils/permission/AllFilesAccessContract.kt new file mode 100644 index 0000000..53ec917 --- /dev/null +++ b/app/src/main/java/dev/arkbuilders/arkretouch/utils/permission/AllFilesAccessContract.kt @@ -0,0 +1,22 @@ +package dev.arkbuilders.arkretouch.utils.permission + +import androidx.activity.result.contract.ActivityResultContract +import android.annotation.TargetApi +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.provider.Settings + +@TargetApi(30) +class AllFilesAccessContract : ActivityResultContract() { + override fun createIntent(context: Context, input: String): Intent { + return Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse(input) + ) + } + + override fun parseResult(resultCode: Int, intent: Intent?) = + Environment.isExternalStorageManager() +} \ No newline at end of file diff --git a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/PermissionsHelper.kt b/app/src/main/java/dev/arkbuilders/arkretouch/utils/permission/PermissionsHelper.kt similarity index 62% rename from app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/PermissionsHelper.kt rename to app/src/main/java/dev/arkbuilders/arkretouch/utils/permission/PermissionsHelper.kt index f3c57d9..a878570 100644 --- a/app/src/main/java/dev/arkbuilders/arkretouch/presentation/utils/PermissionsHelper.kt +++ b/app/src/main/java/dev/arkbuilders/arkretouch/utils/permission/PermissionsHelper.kt @@ -1,16 +1,14 @@ -package dev.arkbuilders.arkretouch.presentation.utils +package dev.arkbuilders.arkretouch.utils.permission +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import android.Manifest -import android.annotation.TargetApi import android.content.Context -import android.content.Intent -import android.net.Uri +import android.content.pm.PackageManager import android.os.Build import android.os.Environment -import android.provider.Settings -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContract -import androidx.activity.result.contract.ActivityResultContracts import dev.arkbuilders.arkretouch.BuildConfig object PermissionsHelper { @@ -30,17 +28,13 @@ object PermissionsHelper { launcher.launch(Manifest.permission.WRITE_EXTERNAL_STORAGE) } } -} -@TargetApi(30) -private class AllFilesAccessContract : ActivityResultContract() { - override fun createIntent(context: Context, input: String): Intent { - return Intent( - Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, - Uri.parse(input) - ) + fun Context.isWritePermissionGranted(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Environment.isExternalStorageManager() + } else { + val checkSelfPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) + checkSelfPermission == PackageManager.PERMISSION_GRANTED + } } - - override fun parseResult(resultCode: Int, intent: Intent?) = - Environment.isExternalStorageManager() -} +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9a4036a..c912bba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,4 +38,5 @@ Size Please pick folder to save image Please provide file name + Pick diff --git a/app/src/test/java/dev/arkbuilders/arkretouch/ExampleUnitTest.kt b/app/src/test/java/dev/arkbuilders/arkretouch/ExampleUnitTest.kt deleted file mode 100644 index 43b11f8..0000000 --- a/app/src/test/java/dev/arkbuilders/arkretouch/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package dev.arkbuilders.arkretouch - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} diff --git a/build.gradle b/build.gradle index 2a49e80..e67acaf 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,8 @@ -buildscript { - ext { - compose_version = '1.3.0' - } -}// Top-level build file where you can add configuration options common to all sub-projects/modules. +// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.4.2' apply false - id 'com.android.library' version '7.4.2' apply false + id 'com.android.application' version '8.5.1' apply false + id 'com.android.library' version '8.5.1' apply false id 'org.jetbrains.kotlin.android' version '1.7.10' apply false - id 'org.jlleitschuh.gradle.ktlint' version '11.0.0' } task clean(type: Delete) { diff --git a/gradle.properties b/gradle.properties index b5987b6..dd3e782 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,3 +22,4 @@ kotlin.code.style=official # 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 +android.nonFinalResIds=false diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 15c7564..5581c64 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue Oct 18 23:46:27 ALMT 2022 +#Thu Apr 04 10:43:50 CAT 2024 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradle/wrapper/libs.versions.toml b/gradle/wrapper/libs.versions.toml new file mode 100644 index 0000000..c8171ff --- /dev/null +++ b/gradle/wrapper/libs.versions.toml @@ -0,0 +1,159 @@ +[versions] +agp = "8.1.1" +coil = "2.4.0" +gms = "4.4.0" +crashlytics = "2.9.9" +app-distribution = "4.0.0" +datastorePreferences = "1.0.0" +glide-compose = "1.0.0-beta01" +kotlin-base = "1.9.0" +kotlin-coroutines = "1.7.3" +ksp = "1.9.0-1.0.11" +composeMetrics = "1.0.0-alpha04" +ktlint = "0.45.2" +minSdk = "26" +maxSdk = "34" + +# https://developer.android.com/jetpack/androidx/releases/core +android-core = "1.12.0" +# https://developer.android.com/jetpack/androidx/releases/appcompat +android-appcompat = "1.6.1" +# https://github.com/material-components/material-components-android/tags +android-material = "1.10.0" +# https://developer.android.com/jetpack/compose/layouts/constraintlayout#get-started +android-constraintlayoutCompose = "1.0.1" +# https://developer.android.com/jetpack/androidx/releases/navigation +android-navigation = "2.7.4" +# https://developer.android.com/jetpack/androidx/releases/webkit +android-webkit = "1.8.0" +# https://developer.android.com/jetpack/androidx/releases/lifecycle +android-lifecycle-base = "2.6.2" +android-activity-compose = "1.8.0" +android-compose-accompanist-navigation-material = "0.33.2-alpha" +android-compose-bom = "2023.10.00" +# https://developer.android.com/jetpack/androidx/releases/preference +android-preferences = "1.2.1" +# https://developer.android.com/jetpack/androidx/releases/security +android-security-base = "1.1.0-alpha06" +android-datastore = "1.0.0" + +log-timber = "5.0.1" + +firebase-bom = "32.4.0" + +network-okhttp = "4.11.0" +network-retrofit = "2.9.0" + +serializers-moshi = "1.15.0" + +di-koin = "3.4.3" + +wire = "4.8.1" +web3j = "4.9.6" +qr-zxing = "3.5.1" +ui-reorderable = "0.9.6" + +test-junit = "4.13.2" +test-espresso-core = "3.5.1" +test-ext-junit = "1.1.5" + +[libraries] +# Android section +android-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "android-core" } +android-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "android-appcompat" } +android-material = { group = "com.google.android.material", name = "material", version.ref = "android-material" } +android-webkit = { "module" = "androidx.webkit:webkit", version.ref = "android-webkit" } +android-preferences = { "module" = "androidx.preference:preference-ktx", version.ref = "android-preferences" } +android-security-crypto = { module = "androidx.security:security-crypto-ktx", version.ref = "android-security-base" } +android-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "android-datastore" } + +android-lifecycle-livedata = { module = "androidx.lifecycle:lifecycle-livedata-ktx", version.ref = "android-lifecycle-base" } +android-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "android-lifecycle-base" } +android-lifecycle-viewmodelSavedState = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "android-lifecycle-base" } +android-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "android-lifecycle-base" } + +coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" } +coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } +kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } +kotlin-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlin-coroutines" } +kotlin-coroutines-rx2 = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-rx2", version.ref = "kotlin-coroutines" } + +android-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "android-compose-bom" } +android-compose-activity = { group = "androidx.activity", name = "activity-compose", version.ref = "android-activity-compose" } +android-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } +android-compose-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "android-lifecycle-base" } +android-compose-navigation = { module = "androidx.navigation:navigation-compose", version.ref = "android-navigation" } +android-compose-constraintlayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "android-constraintlayoutCompose" } +android-compose-accompanist-navigation-material = { module = "com.google.accompanist:accompanist-navigation-material", version.ref = "android-compose-accompanist-navigation-material" } + +android-compose-ui = { group = "androidx.compose.ui", name = "ui" } +android-compose-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } +android-compose-tooling-ui = { group = "androidx.compose.ui", name = "ui-tooling" } +android-compose-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } +android-compose-material3 = { group = "androidx.compose.material3", name = "material3" } +android-compose-material3WindowSizeClass = { module = "androidx.compose.material3:material3-window-size-class" } +android-compose-materialIcons-core = { group = "androidx.compose.material", name = "material-icons-core" } +android-compose-materialIcons-extended = { group = "androidx.compose.material", name = "material-icons-extended" } +android-compose-metrics = { module = "androidx.metrics:metrics-performance", version.ref = "composeMetrics" } + +# Log section +ktlint = { module = "com.pinterest:ktlint", version.ref = "ktlint" } +log-timber = { module = "com.jakewharton.timber:timber", version.ref = "log-timber" } + +# Network section +network-okhttp-base = { module = "com.squareup.okhttp3:okhttp", version.ref = "network-okhttp" } +network-okhttp-loggingInterceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "network-okhttp" } +network-retrofit-base = { module = "com.squareup.retrofit2:retrofit", version.ref = "network-retrofit" } +network-retrofit-converterScalars = { module = "com.squareup.retrofit2:converter-scalars", version.ref = "network-retrofit" } +network-retrofit-converterMoshi = { module = "com.squareup.retrofit2:converter-moshi", version.ref = "network-retrofit" } +network-wire-grpcClient = { module = "com.squareup.wire:wire-grpc-client", version.ref = "wire" } + +# Serializers +serializers-json-moshi-processor = { module = "com.squareup.moshi:moshi-kotlin-codegen", version.ref = "serializers-moshi" } +serializers-json-moshi-base = { module = "com.squareup.moshi:moshi-kotlin", version.ref = "serializers-moshi" } +serializers-json-moshi-adapters = { module = "com.squareup.moshi:moshi-adapters", version.ref = "serializers-moshi" } + +# UI +glide-compose = { module = "com.github.bumptech.glide:compose", version.ref = "glide-compose" } +qr-core = { module = "com.google.zxing:core", version.ref = "qr-zxing" } +ui-reorderable = { module = "org.burnoutcrew.composereorderable:reorderable", version.ref = "ui-reorderable" } + +# Firebase +firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } +firebase-analytics-ktx = { module = "com.google.firebase:firebase-analytics" } +firebase-crashlytics-ktx = { module = "com.google.firebase:firebase-crashlytics" } +firebase-appdistribution-gradle = { group = "com.google.firebase", name = "firebase-appdistribution-gradle", version = "4.0.0" } + +# DI section +di-koin-core = { group = "io.insert-koin", name = "koin-core", version.ref = "di-koin" } +di-koin-coroutines = { group = "io.insert-koin", name = "koin-core-coroutines", version.ref = "di-koin" } +di-koin-android-base = { group = "io.insert-koin", name = "koin-android", version.ref = "di-koin" } +di-koin-android-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "di-koin" } +di-koin-android-navigation = { group = "io.insert-koin", name = "koin-androidx-compose-navigation", version.ref = "di-koin" } + +# Crypto +crypto-web3j = { group = "org.web3j", name = "core", version.ref = "web3j" } + +# Test section +test-di-koin = { group = "io.insert-koin", name = "koin-test", version.ref = "di-koin" } +test-kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlin-coroutines" } +test-junit-core = { group = "junit", name = "junit", version.ref = "test-junit" } +test-junit-androidext = { group = "androidx.test.ext", name = "junit", version.ref = "test-ext-junit" } +test-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "test-espresso-core" } +test-android-compose-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } +test-android-compose-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } + +[plugins] +android-application = { id = "com.android.application", version.ref = "agp" } +android-library = { id = "com.android.library", version.ref = "agp" } + +kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin-base" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin-base" } +kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin-base" } +gms-google-services = { id = "com.google.gms.google-services", version.ref = "gms" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "crashlytics" } +firebase-appdistribution = { id = "com.google.firebase.appdistribution", version.ref = "app-distribution" } + +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } + +wire = { id = "com.squareup.wire", version.ref = "wire" } diff --git a/scripts/pre-commit-linux b/scripts/pre-commit-linux deleted file mode 100644 index f803e3a..0000000 --- a/scripts/pre-commit-linux +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/bash - -echo "Changed Kotlin source files:" - -if ! git diff --name-only --cached --relative | grep '\.kt[s"]\?$' -then - echo "(no Kotlin code changed)" - echo - exit 0 -fi - -echo -echo "Running ktlintCheck..." - -if ! ./gradlew ktlintCheck -then - echo "Run ./gradlew ktlintFormat to attempt automatic fixure" - exit 1 -fi diff --git a/scripts/pre-commit-windows b/scripts/pre-commit-windows deleted file mode 100644 index d8ceebb..0000000 --- a/scripts/pre-commit-windows +++ /dev/null @@ -1,19 +0,0 @@ -#!C:/Program\ Files/Git/usr/bin/sh.exe - -echo "Changed Kotlin source files:" - -if ! git diff --name-only --cached --relative | grep '\.kt[s"]\?$' -then - echo "(no Kotlin code changed)" - echo - exit 0 -fi - -echo -echo "Running ktlintCheck..." - -if ! ./gradlew ktlintCheck -then - echo "Run ./gradlew ktlintFormat to attempt automatic fixure" - exit 1 -fi