diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/Contents.json new file mode 100644 index 000000000..eb54967ab --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_creation_camera.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_creation_camera@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_creation_camera@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera.png new file mode 100644 index 000000000..65264a038 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@2x.png new file mode 100644 index 000000000..f87bc29e8 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@3x.png new file mode 100644 index 000000000..c593c5bf5 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_camera.imageset/space_creation_camera@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/Contents.json new file mode 100644 index 000000000..bb2b6cc6f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_creation_private.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_creation_private@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_creation_private@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private.png new file mode 100644 index 000000000..a77ac3c62 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@2x.png new file mode 100644 index 000000000..581625eaf Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@3x.png new file mode 100644 index 000000000..9441bbbbc Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_private.imageset/space_creation_private@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/Contents.json new file mode 100644 index 000000000..d5cfa2de7 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_creation_public.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_creation_public@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_creation_public@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public.png new file mode 100644 index 000000000..dce0fb56a Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@2x.png new file mode 100644 index 000000000..38caf7d4e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@3x.png new file mode 100644 index 000000000..3ff76fca5 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_creation_public.imageset/space_creation_public@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png deleted file mode 100644 index 75af27227..000000000 Binary files a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png deleted file mode 100644 index 7c4243805..000000000 Binary files a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@2x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png deleted file mode 100644 index 1076124e2..000000000 Binary files a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/space_home_icon@3x.png and /dev/null differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/Contents.json new file mode 100644 index 000000000..4513c28c2 --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_home_icon_dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_home_icon_dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_home_icon_dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark.png new file mode 100644 index 000000000..8f122d2c9 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@2x.png new file mode 100644 index 000000000..cf8c23942 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@3x.png new file mode 100644 index 000000000..5960d13a5 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_dark.imageset/space_home_icon_dark@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/Contents.json new file mode 100644 index 000000000..97d17d9bb --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "space_home_icon_light.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "space_home_icon_light@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "space_home_icon_light@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light.png new file mode 100644 index 000000000..fb671b141 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@2x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@2x.png new file mode 100644 index 000000000..d6396bb1a Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@3x.png b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@3x.png new file mode 100644 index 000000000..2d527378c Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/space_home_icon_light.imageset/space_home_icon_light@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/Contents.json new file mode 100644 index 000000000..f5861fe3f --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_add_space_dark.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_add_space_dark@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_add_space_dark@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark.png new file mode 100644 index 000000000..2e8b66fd0 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@2x.png new file mode 100644 index 000000000..dfc11fc8f Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@3x.png new file mode 100644 index 000000000..9711a05a4 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_dark.imageset/spaces_add_space_dark@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/Contents.json new file mode 100644 index 000000000..3003074fa --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_add_space_light.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_add_space_light@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_add_space_light@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light.png new file mode 100644 index 000000000..948a2b3bd Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@2x.png new file mode 100644 index 000000000..55ce9ba21 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@3x.png new file mode 100644 index 000000000..1db00fbb1 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_add_space_light.imageset/spaces_add_space_light@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/Contents.json new file mode 100644 index 000000000..d6068239b --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_invite_users.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_invite_users@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_invite_users@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users.png b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users.png new file mode 100644 index 000000000..5c6b4401b Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@2x.png new file mode 100644 index 000000000..4a36ffbe2 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@3x.png new file mode 100644 index 000000000..2568a3280 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_invite_users.imageset/spaces_invite_users@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/Contents.json similarity index 66% rename from Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/Contents.json rename to Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/Contents.json index 9b395447b..75f10b9f4 100644 --- a/Riot/Assets/Images.xcassets/Spaces/space_home_icon.imageset/Contents.json +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "filename" : "space_home_icon.png", + "filename" : "spaces_modal_back.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "space_home_icon@2x.png", + "filename" : "spaces_modal_back@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "space_home_icon@3x.png", + "filename" : "spaces_modal_back@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back.png b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back.png new file mode 100644 index 000000000..b9c92417e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@2x.png new file mode 100644 index 000000000..d299195d2 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@3x.png new file mode 100644 index 000000000..3a869e88e Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_back.imageset/spaces_modal_back@3x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/Contents.json b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/Contents.json new file mode 100644 index 000000000..93d85da8a --- /dev/null +++ b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "spaces_modal_close.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "spaces_modal_close@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "spaces_modal_close@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close.png b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close.png new file mode 100644 index 000000000..751a9bd59 Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@2x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@2x.png new file mode 100644 index 000000000..365ac0fac Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@2x.png differ diff --git a/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@3x.png b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@3x.png new file mode 100644 index 000000000..33d3f90da Binary files /dev/null and b/Riot/Assets/Images.xcassets/Spaces/spaces_modal_close.imageset/spaces_modal_close@3x.png differ diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings index e28693c92..438d53936 100644 --- a/Riot/Assets/en.lproj/Vector.strings +++ b/Riot/Assets/en.lproj/Vector.strings @@ -66,6 +66,9 @@ "less" = "Less"; "open" = "Open"; "done" = "Done"; +"private" = "Private"; +"public" = "Public"; +"stop" = "Stop"; "ok" = "OK"; // Call Bar @@ -1749,6 +1752,8 @@ Tap the + to start adding people."; "space_beta_announce_information" = "Spaces are a new way to group rooms and people. They’re not on iOS yet, but you can use them now on Web and Desktop."; "spaces_home_space_title" = "Home"; +"spaces_add_space_title" = "Create space"; +"spaces_create_space_title" = "Create a space"; "spaces_left_panel_title" = "Spaces"; "leave_space_title" = "Leave %@"; "leave_space_message" = "Are you sure you want to leave %@? Do you also want to leave all rooms and spaces of this space?"; @@ -1772,7 +1777,66 @@ Tap the + to start adding people."; "space_home_show_all_rooms" = "Show all rooms"; "space_private_join_rule" = "Private space"; +"space_private_join_rule_detail" = "Invite only, best for yourself or teams"; "space_public_join_rule" = "Public space"; +"space_public_join_rule_detail" = "Open to anyone, best for communities"; + +"space_topic" = "description"; + +// Mark: - Space Creation + +"spaces_creation_hint" = "Spaces are a new way to group rooms and people."; +"spaces_creation_visibility_title" = "What type of space do you want to create?"; +"spaces_creation_visibility_message" = "To join an existing space, you need an invite."; +"spaces_creation_footer" = "You can change this later"; +"spaces_creation_settings_message" = "Add some details to help it stand out. You can change these at any point."; +"spaces_creation_address" = "Address"; +"spaces_creation_empty_room_name_error" = "Name required"; +"spaces_creation_address_default_message" = "Your space will be viewable at\n%@"; +"spaces_creation_address_invalid_characters" = "%@\nhas invalid characters"; +"spaces_creation_address_already_exists" = "%@\nalready exists"; +"spaces_creation_public_space_title" = "Your public space"; +"spaces_creation_private_space_title" = "Your private space"; +"spaces_creation_cancel_title" = "Stop creating a space?"; +"spaces_creation_cancel_message" = "Your progress will be lost."; + +"spaces_creation_new_rooms_title" = "What are some discussions you’ll have?"; +"spaces_creation_new_rooms_message" = "We’ll create a room for each one."; +"spaces_creation_new_rooms_room_name_title" = "Room name"; +"spaces_creation_new_rooms_general" = "General"; +"spaces_creation_new_rooms_random" = "Random"; +"spaces_creation_new_rooms_support" = "Support"; + +"spaces_creation_email_invites_title" = "Invite your team"; +"spaces_creation_email_invites_message" = "You can invite them later too."; +"spaces_creation_email_invites_email_title" = "Email"; + +"spaces_creation_sharing_type_title" = "Who are you working with?"; +"spaces_creation_sharing_type_message" = "Make sure the right people have access %@. You can change this later."; +"spaces_creation_sharing_type_just_me_title" = "Just me"; +"spaces_creation_sharing_type_just_me_detail" = "A private space to organise your rooms"; +"spaces_creation_sharing_type_me_and_teammates_title" = "Me and teammates"; +"spaces_creation_sharing_type_me_and_teammates_detail" = "A private space for you & your teammates"; + +"spaces_creation_add_rooms_title" = "What do you want to add?"; +"spaces_creation_add_rooms_message" = "As this space is just for you, no one will be informed. You can add more later."; + +"spaces_creation_invite_by_username" = "Invite by username"; +"spaces_creation_invite_by_username_title" = "Invite your team"; +"spaces_creation_invite_by_username_message" = "You can invite them later too."; + +"spaces_creation_post_process_creating_space" = "Creating space"; +"spaces_creation_post_process_creating_space_task" = "Creating %@"; +"spaces_creation_post_process_uploading_avatar" = "Uploading avatar"; +"spaces_creation_post_process_creating_room" = "Creating %@"; +"spaces_creation_post_process_adding_rooms" = "Adding %@ rooms"; +"spaces_creation_post_process_inviting_users" = "Inviting %@ users"; + +"spaces_creation_in_spacename" = "in %@"; +"spaces_creation_in_spacename_plus_one" = "in %@ + 1 space"; +"spaces_creation_in_spacename_plus_many" = "in %@ + %@ spaces"; +"spaces_creation_in_many_spaces" = "in %@ spaces"; +"spaces_creation_in_one_space" = "in 1 space"; // Mark: Avatar diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift index 86ed4017a..d5cd25d24 100644 --- a/Riot/Generated/Images.swift +++ b/Riot/Generated/Images.swift @@ -206,7 +206,11 @@ internal enum Asset { internal static let sideMenuNotifIcon = ImageAsset(name: "side_menu_notif_icon") internal static let featureUnavaibleArtwork = ImageAsset(name: "feature_unavaible_artwork") internal static let featureUnavaibleArtworkDark = ImageAsset(name: "feature_unavaible_artwork_dark") - internal static let spaceHomeIcon = ImageAsset(name: "space_home_icon") + internal static let spaceCreationCamera = ImageAsset(name: "space_creation_camera") + internal static let spaceCreationPrivate = ImageAsset(name: "space_creation_private") + internal static let spaceCreationPublic = ImageAsset(name: "space_creation_public") + internal static let spaceHomeIconDark = ImageAsset(name: "space_home_icon_dark") + internal static let spaceHomeIconLight = ImageAsset(name: "space_home_icon_light") internal static let spaceMenuClose = ImageAsset(name: "space_menu_close") internal static let spaceMenuLeave = ImageAsset(name: "space_menu_leave") internal static let spaceMenuMembers = ImageAsset(name: "space_menu_members") @@ -215,6 +219,11 @@ internal enum Asset { internal static let spaceRoomIcon = ImageAsset(name: "space_room_icon") internal static let spaceTypeIcon = ImageAsset(name: "space_type_icon") internal static let spaceUserIcon = ImageAsset(name: "space_user_icon") + internal static let spacesAddSpaceDark = ImageAsset(name: "spaces_add_space_dark") + internal static let spacesAddSpaceLight = ImageAsset(name: "spaces_add_space_light") + internal static let spacesInviteUsers = ImageAsset(name: "spaces_invite_users") + internal static let spacesModalBack = ImageAsset(name: "spaces_modal_back") + internal static let spacesModalClose = ImageAsset(name: "spaces_modal_close") internal static let spacesMore = ImageAsset(name: "spaces_more") internal static let tabFavourites = ImageAsset(name: "tab_favourites") internal static let tabGroups = ImageAsset(name: "tab_groups") diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift index bb758fd51..bda182632 100644 --- a/Riot/Generated/Strings.swift +++ b/Riot/Generated/Strings.swift @@ -2631,6 +2631,14 @@ public class VectorL10n: NSObject { public static var preview: String { return VectorL10n.tr("Vector", "preview") } + /// Private + public static var `private`: String { + return VectorL10n.tr("Vector", "private") + } + /// Public + public static var `public`: String { + return VectorL10n.tr("Vector", "public") + } /// Public Rooms (at %@): public static func publicRoomSectionTitle(_ p1: String) -> String { return VectorL10n.tr("Vector", "public_room_section_title", p1) @@ -5215,18 +5223,34 @@ public class VectorL10n: NSObject { public static var spacePrivateJoinRule: String { return VectorL10n.tr("Vector", "space_private_join_rule") } + /// Invite only, best for yourself or teams + public static var spacePrivateJoinRuleDetail: String { + return VectorL10n.tr("Vector", "space_private_join_rule_detail") + } /// Public space public static var spacePublicJoinRule: String { return VectorL10n.tr("Vector", "space_public_join_rule") } + /// Open to anyone, best for communities + public static var spacePublicJoinRuleDetail: String { + return VectorL10n.tr("Vector", "space_public_join_rule_detail") + } /// space public static var spaceTag: String { return VectorL10n.tr("Vector", "space_tag") } + /// description + public static var spaceTopic: String { + return VectorL10n.tr("Vector", "space_topic") + } /// Adding rooms coming soon public static var spacesAddRoomsComingSoonTitle: String { return VectorL10n.tr("Vector", "spaces_add_rooms_coming_soon_title") } + /// Create space + public static var spacesAddSpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_add_space_title") + } /// This feature hasn’t been implemented here, but it’s on the way. For now, you can do that with %@ on your computer. public static func spacesComingSoonDetail(_ p1: String) -> String { return VectorL10n.tr("Vector", "spaces_coming_soon_detail", p1) @@ -5235,6 +5259,190 @@ public class VectorL10n: NSObject { public static var spacesComingSoonTitle: String { return VectorL10n.tr("Vector", "spaces_coming_soon_title") } + /// Create a space + public static var spacesCreateSpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_create_space_title") + } + /// As this space is just for you, no one will be informed. You can add more later. + public static var spacesCreationAddRoomsMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_add_rooms_message") + } + /// What do you want to add? + public static var spacesCreationAddRoomsTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_add_rooms_title") + } + /// Address + public static var spacesCreationAddress: String { + return VectorL10n.tr("Vector", "spaces_creation_address") + } + /// %@\nalready exists + public static func spacesCreationAddressAlreadyExists(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_address_already_exists", p1) + } + /// Your space will be viewable at\n%@ + public static func spacesCreationAddressDefaultMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_address_default_message", p1) + } + /// %@\nhas invalid characters + public static func spacesCreationAddressInvalidCharacters(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_address_invalid_characters", p1) + } + /// Your progress will be lost. + public static var spacesCreationCancelMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_cancel_message") + } + /// Stop creating a space? + public static var spacesCreationCancelTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_cancel_title") + } + /// Email + public static var spacesCreationEmailInvitesEmailTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_email_invites_email_title") + } + /// You can invite them later too. + public static var spacesCreationEmailInvitesMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_email_invites_message") + } + /// Invite your team + public static var spacesCreationEmailInvitesTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_email_invites_title") + } + /// Name required + public static var spacesCreationEmptyRoomNameError: String { + return VectorL10n.tr("Vector", "spaces_creation_empty_room_name_error") + } + /// You can change this later + public static var spacesCreationFooter: String { + return VectorL10n.tr("Vector", "spaces_creation_footer") + } + /// Spaces are a new way to group rooms and people. + public static var spacesCreationHint: String { + return VectorL10n.tr("Vector", "spaces_creation_hint") + } + /// in %@ spaces + public static func spacesCreationInManySpaces(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_many_spaces", p1) + } + /// in 1 space + public static var spacesCreationInOneSpace: String { + return VectorL10n.tr("Vector", "spaces_creation_in_one_space") + } + /// in %@ + public static func spacesCreationInSpacename(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_spacename", p1) + } + /// in %@ + %@ spaces + public static func spacesCreationInSpacenamePlusMany(_ p1: String, _ p2: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_spacename_plus_many", p1, p2) + } + /// in %@ + 1 space + public static func spacesCreationInSpacenamePlusOne(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_in_spacename_plus_one", p1) + } + /// Invite by username + public static var spacesCreationInviteByUsername: String { + return VectorL10n.tr("Vector", "spaces_creation_invite_by_username") + } + /// You can invite them later too. + public static var spacesCreationInviteByUsernameMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_invite_by_username_message") + } + /// Invite your team + public static var spacesCreationInviteByUsernameTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_invite_by_username_title") + } + /// General + public static var spacesCreationNewRoomsGeneral: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_general") + } + /// We’ll create a room for each one. + public static var spacesCreationNewRoomsMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_message") + } + /// Random + public static var spacesCreationNewRoomsRandom: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_random") + } + /// Room name + public static var spacesCreationNewRoomsRoomNameTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_room_name_title") + } + /// Support + public static var spacesCreationNewRoomsSupport: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_support") + } + /// What are some discussions you’ll have? + public static var spacesCreationNewRoomsTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_new_rooms_title") + } + /// Adding %@ rooms + public static func spacesCreationPostProcessAddingRooms(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_adding_rooms", p1) + } + /// Creating %@ + public static func spacesCreationPostProcessCreatingRoom(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_room", p1) + } + /// Creating space + public static var spacesCreationPostProcessCreatingSpace: String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_space") + } + /// Creating %@ + public static func spacesCreationPostProcessCreatingSpaceTask(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_creating_space_task", p1) + } + /// Inviting %@ users + public static func spacesCreationPostProcessInvitingUsers(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_inviting_users", p1) + } + /// Uploading avatar + public static var spacesCreationPostProcessUploadingAvatar: String { + return VectorL10n.tr("Vector", "spaces_creation_post_process_uploading_avatar") + } + /// Your private space + public static var spacesCreationPrivateSpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_private_space_title") + } + /// Your public space + public static var spacesCreationPublicSpaceTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_public_space_title") + } + /// Add some details to help it stand out. You can change these at any point. + public static var spacesCreationSettingsMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_settings_message") + } + /// A private space to organise your rooms + public static var spacesCreationSharingTypeJustMeDetail: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_just_me_detail") + } + /// Just me + public static var spacesCreationSharingTypeJustMeTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_just_me_title") + } + /// A private space for you & your teammates + public static var spacesCreationSharingTypeMeAndTeammatesDetail: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_me_and_teammates_detail") + } + /// Me and teammates + public static var spacesCreationSharingTypeMeAndTeammatesTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_me_and_teammates_title") + } + /// Make sure the right people have access %@. You can change this later. + public static func spacesCreationSharingTypeMessage(_ p1: String) -> String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_message", p1) + } + /// Who are you working with? + public static var spacesCreationSharingTypeTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_sharing_type_title") + } + /// To join an existing space, you need an invite. + public static var spacesCreationVisibilityMessage: String { + return VectorL10n.tr("Vector", "spaces_creation_visibility_message") + } + /// What type of space do you want to create? + public static var spacesCreationVisibilityTitle: String { + return VectorL10n.tr("Vector", "spaces_creation_visibility_title") + } /// Some rooms may be hidden because they’re private and you need an invite. public static var spacesEmptySpaceDetail: String { return VectorL10n.tr("Vector", "spaces_empty_space_detail") @@ -5279,6 +5487,10 @@ public class VectorL10n: NSObject { public static var start: String { return VectorL10n.tr("Vector", "start") } + /// Stop + public static var stop: String { + return VectorL10n.tr("Vector", "stop") + } /// Element is a new type of messenger and collaboration app that:\n\n1. Puts you in control to preserve your privacy\n2. Lets you communicate with anyone in the Matrix network, and even beyond by integrating with apps such as Slack\n3. Protects you from advertising, datamining, backdoors and walled gardens\n4. Secures you through end-to-end encryption, with cross-signing to verify others\n\nElement is completely different from other messaging and collaboration apps because it is decentralised and open source.\n\nElement lets you self-host - or choose a host - so that you have privacy, ownership and control of your data and conversations. It gives you access to an open network; so you’re not just stuck speaking to other Element users only. And it is very secure.\n\nElement is able to do all this because it operates on Matrix - the standard for open, decentralised communication. \n\nElement puts you in control by letting you choose who hosts your conversations. From the Element app, you can choose to host in different ways:\n\n1. Get a free account on the matrix.org public server\n2. Self-host your account by running a server on your own hardware\n3. Sign up for an account on a custom server by simply subscribing to the Element Matrix Services hosting platform\n\nWhy choose Element?\n\nOWN YOUR DATA: You decide where to keep your data and messages. You own it and control it, not some MEGACORP that mines your data or gives access to third parties.\n\nOPEN MESSAGING AND COLLABORATION: You can chat with anyone else in the Matrix network, whether they’re using Element or another Matrix app, and even if they are using a different messaging system of the likes of Slack, IRC or XMPP.\n\nSUPER-SECURE: Real end-to-end encryption (only those in the conversation can decrypt messages), and cross-signing to verify the devices of conversation participants.\n\nCOMPLETE COMMUNICATION: Messaging, voice and video calls, file sharing, screen sharing and a whole bunch of integrations, bots and widgets. Build rooms, communities, stay in touch and get things done.\n\nEVERYWHERE YOU ARE: Stay in touch wherever you are with fully synchronised message history across all your devices and on the web at https://element.io/app. public static var storeFullDescription: String { return VectorL10n.tr("Vector", "store_full_description") diff --git a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift index 3e9ad98b4..5d36aa97a 100644 --- a/Riot/Modules/Common/SwiftUI/VectorHostingController.swift +++ b/Riot/Modules/Common/SwiftUI/VectorHostingController.swift @@ -26,6 +26,8 @@ class VectorHostingController: UIHostingController { // MARK: Private + var isNavigationBarHidden: Bool = false + var hidesBackTitleWhenPushed: Bool = false private var theme: Theme init(rootView: Content) where Content: View { @@ -48,6 +50,26 @@ class VectorHostingController: UIHostingController { self.update(theme: self.theme) } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if isNavigationBarHidden { + self.navigationController?.isNavigationBarHidden = true + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if hidesBackTitleWhenPushed { + vc_removeBackTitle() + } + + if navigationController?.isNavigationBarHidden ?? false { + navigationController?.interactivePopGestureRecognizer?.delegate = nil + } + } + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() diff --git a/Riot/Modules/SideMenu/SideMenuCoordinator.swift b/Riot/Modules/SideMenu/SideMenuCoordinator.swift index 797e20818..59eb8ca91 100644 --- a/Riot/Modules/SideMenu/SideMenuCoordinator.swift +++ b/Riot/Modules/SideMenu/SideMenuCoordinator.swift @@ -64,6 +64,7 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { private var exploreRoomCoordinator: ExploreRoomCoordinator? private var membersCoordinator: SpaceMembersCoordinator? + private var createSpaceCoordinator: SpaceCreationCoordinator? // MARK: Public @@ -257,6 +258,36 @@ final class SideMenuCoordinator: NSObject, SideMenuCoordinatorType { self.spaceDetailPresenter.present(forSpaceWithId: spaceId, from: self.sideMenuViewController, sourceView: sourceView, session: session, animated: true) } + @available(iOS 14.0, *) + private func showCreateSpace() { + guard let session = self.parameters.userSessionsService.mainUserSession?.matrixSession else { + return + } + + let coordinator = SpaceCreationCoordinator(parameters: SpaceCreationCoordinatorParameters(session: session)) + let presentable = coordinator.toPresentable() + presentable.presentationController?.delegate = self + self.sideMenuViewController.present(presentable, animated: true, completion: nil) + coordinator.callback = { [weak self] result in + guard let self = self else { + return + } + + self.createSpaceCoordinator?.toPresentable().dismiss(animated: true) { + self.createSpaceCoordinator = nil + switch result { + case .cancel: + break + case .done(let spaceId): + self.select(spaceWithId: spaceId) + } + } + } + coordinator.start() + + self.createSpaceCoordinator = coordinator + } + // MARK: UserSessions management private func registerUserSessionsServiceNotifications() { @@ -310,7 +341,7 @@ extension SideMenuCoordinator: SideMenuNavigationControllerDelegate { // MARK: - SideMenuNavigationControllerDelegate extension SideMenuCoordinator: SpaceListCoordinatorDelegate { - func spaceListCoordinatorDidSelectHomeSpace(_ coordinator: SpaceListCoordinatorType) { + func spaceListCoordinatorDidSelectHomeSpace(_ coordinator: SpaceListCoordinatorType) { self.parameters.appNavigator.sideMenu.dismiss(animated: true) { } @@ -331,6 +362,12 @@ extension SideMenuCoordinator: SpaceListCoordinatorDelegate { func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) { self.showMenu(forSpaceWithId: spaceId, from: sourceView) } + + func spaceListCoordinatorDidSelectCreateSpace(_ coordinator: SpaceListCoordinatorType) { + if #available(iOS 14.0, *) { + self.showCreateSpace() + } + } } // MARK: - SpaceMenuPresenterDelegate @@ -386,5 +423,6 @@ extension SideMenuCoordinator: UIAdaptivePresentationControllerDelegate { func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { self.exploreRoomCoordinator = nil self.membersCoordinator = nil + self.createSpaceCoordinator = nil } } diff --git a/Riot/Modules/SideMenu/SideMenuViewModel.swift b/Riot/Modules/SideMenu/SideMenuViewModel.swift index 7c9d128ee..d1b013f97 100644 --- a/Riot/Modules/SideMenu/SideMenuViewModel.swift +++ b/Riot/Modules/SideMenu/SideMenuViewModel.swift @@ -105,7 +105,6 @@ final class SideMenuViewModel: SideMenuViewModelType { sideMenuItems += [ .settings, - .help, .feedback ] diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift index 5d6924347..e8cfc5db5 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinator.swift @@ -85,4 +85,9 @@ extension SpaceListCoordinator: SpaceListViewModelCoordinatorDelegate { func spaceListViewModel(_ viewModel: SpaceListViewModelType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) { self.delegate?.spaceListCoordinator(self, didPressMoreForSpaceWithId: spaceId, from: sourceView) } + + func spaceListViewModelDidSelectCreateSpace(_ viewModel: SpaceListViewModelType) { + self.delegate?.spaceListCoordinatorDidSelectCreateSpace(self) + } + } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift index 8ab4815bc..03383216e 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListCoordinatorType.swift @@ -23,6 +23,7 @@ protocol SpaceListCoordinatorDelegate: AnyObject { func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectSpaceWithId spaceId: String) func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) func spaceListCoordinator(_ coordinator: SpaceListCoordinatorType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) + func spaceListCoordinatorDidSelectCreateSpace(_ coordinator: SpaceListCoordinatorType) } /// `SpaceListCoordinatorType` is a protocol describing a Coordinator that handle key backup setup passphrase navigation flow. diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift b/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift index f4cc0c399..8877dbac2 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListSection.swift @@ -20,4 +20,5 @@ import Foundation enum SpaceListSection { case home(_ viewData: SpaceListItemViewData) case spaces(_ viewDataList: [SpaceListItemViewData]) + case addSpace(_ viewData: SpaceListItemViewData) } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift index eaf3a5e09..b952b336c 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewCell.swift @@ -58,7 +58,7 @@ final class SpaceListViewCell: UITableViewCell, Themable, NibReusable { func fill(with viewData: SpaceListItemViewData) { self.avatarView.fill(with: viewData.avatarViewData) self.titleLabel.text = viewData.title - self.moreButton.isHidden = viewData.isInvite + self.moreButton.isHidden = viewData.spaceId == SpaceListViewModel.Constants.addSpaceId || viewData.isInvite if viewData.isInvite { self.isBadgeAlert = true self.badgeLabel.isHidden = false @@ -68,7 +68,7 @@ final class SpaceListViewCell: UITableViewCell, Themable, NibReusable { self.badgeLabel.text = "!" } else { self.isBadgeAlert = viewData.highlightedNotificationCount > 0 - let notificationCount = viewData.notificationCount + viewData.highlightedNotificationCount + let notificationCount = viewData.notificationCount self.badgeLabel.isHidden = notificationCount == 0 if let theme = self.theme { self.badgeLabel.badgeColor = viewData.highlightedNotificationCount == 0 ? theme.colors.tertiaryContent : theme.colors.alert diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift index 79f5f2dba..f0f254e18 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewController.swift @@ -172,8 +172,10 @@ extension SpaceListViewController: UITableViewDataSource { numberOfRows = 1 case .spaces(let viewDataList): numberOfRows = viewDataList.count + case .addSpace: + numberOfRows = 1 } - + return numberOfRows } @@ -189,6 +191,8 @@ extension SpaceListViewController: UITableViewDataSource { viewData = spaceViewData case .spaces(let viewDataList): viewData = viewDataList[indexPath.row] + case .addSpace(let spaceViewData): + viewData = spaceViewData } cell.update(theme: self.theme) diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift index 62770e05e..f1127657b 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewModel.swift @@ -24,6 +24,7 @@ final class SpaceListViewModel: SpaceListViewModelType { enum Constants { static let homeSpaceId: String = "home" + static let addSpaceId: String = "add_space" } // MARK: - Properties @@ -55,6 +56,8 @@ final class SpaceListViewModel: SpaceListViewModelType { NotificationCenter.default.addObserver(self, selector: #selector(self.sessionDidSync(notification:)), name: MXSpaceService.didBuildSpaceGraph, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(self.counterDidUpdateNotificationCount(notification:)), name: MXSpaceNotificationCounter.didUpdateNotificationCount, object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(self.loadData), name: NSNotification.Name.themeServiceDidChangeTheme, object: nil) } @@ -87,12 +90,16 @@ final class SpaceListViewModel: SpaceListViewModelType { self.selectedIndexPath = indexPath self.update(viewState: .selectionChanged(indexPath)) } + case .addSpace: + self.update(viewState: .selectionChanged(self.selectedIndexPath)) + addSpace() } case .moreAction(at: let indexPath, from: let sourceView): let section = self.sections[indexPath.section] switch section { case .home: self.coordinatorDelegate?.spaceListViewModel(self, didPressMoreForSpaceWithId: Constants.homeSpaceId, from: sourceView) + case .addSpace: break case .spaces(let viewDataList): let spaceViewData = viewDataList[indexPath.row] self.coordinatorDelegate?.spaceListViewModel(self, didPressMoreForSpaceWithId: spaceViewData.spaceId, from: sourceView) @@ -108,6 +115,7 @@ final class SpaceListViewModel: SpaceListViewModelType { for (sectionIndex, section) in self.sections.enumerated() { switch section { case .home: break + case .addSpace: break case .spaces(let viewDataList): for (row, itemViewData) in viewDataList.enumerated() where itemViewData.spaceId == spaceId { let indexPath = IndexPath(row: row, section: sectionIndex) @@ -129,7 +137,7 @@ final class SpaceListViewModel: SpaceListViewModelType { loadData() } - private func loadData() { + @objc private func loadData() { guard let session = self.userSessionsService.mainUserSession?.matrixSession else { // If there is no main session, reset current selection and give an empty section list // It can happen when the user make a clear cache or logout @@ -142,7 +150,7 @@ final class SpaceListViewModel: SpaceListViewModelType { let homeViewData = self.createHomeViewData(session: session) let viewDataList = getSpacesViewData(session: session) - let sections: [SpaceListSection] = viewDataList.invites.isEmpty ? [ + var sections: [SpaceListSection] = viewDataList.invites.isEmpty ? [ .home(homeViewData), .spaces(viewDataList.spaces) ] @@ -152,6 +160,12 @@ final class SpaceListViewModel: SpaceListViewModelType { .home(homeViewData), .spaces(viewDataList.spaces) ] + + let spacesSectionIndex = sections.count - 1 + if #available(iOS 14.0, *) { + let addSpaceViewData = self.createAddSpaceViewData(session: session) + sections.append(.addSpace(addSpaceViewData)) + } self.sections = sections let homeIndexPath = viewDataList.invites.isEmpty ? IndexPath(row: 0, section: 0) : IndexPath(row: 0, section: 1) @@ -159,10 +173,9 @@ final class SpaceListViewModel: SpaceListViewModelType { self.selectedIndexPath = homeIndexPath } else if self.selectedItemId != self.itemId(with: self.selectedIndexPath) { var newSelection: IndexPath? - let section = sections.last + let section = sections[spacesSectionIndex] switch section { - case .home: - break + case .home, .addSpace: break case .spaces(let viewDataList): var index = 0 for itemViewData in viewDataList { @@ -171,8 +184,6 @@ final class SpaceListViewModel: SpaceListViewModelType { } index += 1 } - case .none: - break } if let selection = newSelection { @@ -191,6 +202,10 @@ final class SpaceListViewModel: SpaceListViewModelType { self.coordinatorDelegate?.spaceListViewModelDidSelectHomeSpace(self) } + private func addSpace() { + self.coordinatorDelegate?.spaceListViewModelDidSelectCreateSpace(self) + } + private func selectSpace(with spaceId: String) { self.coordinatorDelegate?.spaceListViewModel(self, didSelectSpaceWithId: spaceId) } @@ -200,21 +215,44 @@ final class SpaceListViewModel: SpaceListViewModelType { } private func createHomeViewData(session: MXSession) -> SpaceListItemViewData { - let avatarViewData = AvatarViewData(matrixItemId: Constants.homeSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(Asset.Images.spaceHomeIcon.image, .center)) + let defaultAsset = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.spaceHomeIconDark : Asset.Images.spaceHomeIconLight + let avatarViewData = AvatarViewData(matrixItemId: Constants.homeSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(defaultAsset.image, .center)) let homeNotificationState = session.spaceService.notificationCounter.homeNotificationState let homeViewData = SpaceListItemViewData(spaceId: Constants.homeSpaceId, - title: VectorL10n.spacesHomeSpaceTitle, avatarViewData: avatarViewData, isInvite: false, notificationCount: homeNotificationState.allCount, highlightedNotificationCount: homeNotificationState.allHighlightCount) + title: VectorL10n.spacesHomeSpaceTitle, + avatarViewData: avatarViewData, + isInvite: false, + notificationCount: homeNotificationState.allCount, + highlightedNotificationCount: homeNotificationState.allHighlightCount) return homeViewData } + private func createAddSpaceViewData(session: MXSession) -> SpaceListItemViewData { + let defaultAsset = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.spacesAddSpaceDark : Asset.Images.spacesAddSpaceLight + let avatarViewData = AvatarViewData(matrixItemId: Constants.addSpaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(defaultAsset.image, .center)) + + let homeViewData = SpaceListItemViewData(spaceId: Constants.addSpaceId, + title: VectorL10n.spacesAddSpaceTitle, + avatarViewData: avatarViewData, + isInvite: false, + notificationCount: 0, + highlightedNotificationCount: 0) + return homeViewData + } + private func getSpacesViewData(session: MXSession) -> (invites: [SpaceListItemViewData], spaces: [SpaceListItemViewData]) { var invites: [SpaceListItemViewData] = [] var spaces: [SpaceListItemViewData] = [] session.spaceService.rootSpaceSummaries.forEach { summary in let avatarViewData = AvatarViewData(matrixItemId: summary.roomId, displayName: summary.displayname, avatarUrl: summary.avatar, mediaManager: session.mediaManager, fallbackImage: .matrixItem(summary.roomId, summary.displayname)) let notificationState = session.spaceService.notificationCounter.notificationState(forSpaceWithId: summary.roomId) - let viewData = SpaceListItemViewData(spaceId: summary.roomId, title: summary.displayname, avatarViewData: avatarViewData, isInvite: summary.membership == .invite, notificationCount: notificationState?.groupMissedDiscussionsCount ?? 0, highlightedNotificationCount: notificationState?.groupMissedDiscussionsHighlightedCount ?? 0) + let viewData = SpaceListItemViewData(spaceId: summary.roomId, + title: summary.displayname, + avatarViewData: avatarViewData, + isInvite: summary.membership == .invite, + notificationCount: notificationState?.groupMissedDiscussionsCount ?? 0, + highlightedNotificationCount: notificationState?.groupMissedDiscussionsHighlightedCount ?? 0) if viewData.isInvite { invites.append(viewData) } else { @@ -244,6 +282,8 @@ final class SpaceListViewModel: SpaceListViewModelType { case .spaces(let viewDataList): let spaceViewData = viewDataList[self.selectedIndexPath.row] return spaceViewData.spaceId + case .addSpace: + return Constants.addSpaceId } } diff --git a/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift b/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift index 00066a70d..cb706a8c2 100644 --- a/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift +++ b/Riot/Modules/Spaces/SpaceList/SpaceListViewModelType.swift @@ -27,6 +27,7 @@ protocol SpaceListViewModelCoordinatorDelegate: AnyObject { func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectSpaceWithId spaceId: String) func spaceListViewModel(_ viewModel: SpaceListViewModelType, didSelectInviteWithId spaceId: String, from sourceView: UIView?) func spaceListViewModel(_ viewModel: SpaceListViewModelType, didPressMoreForSpaceWithId spaceId: String, from sourceView: UIView) + func spaceListViewModelDidSelectCreateSpace(_ viewModel: SpaceListViewModelType) } /// Protocol describing the view model used by `SpaceListViewController` diff --git a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift index 7b9bebea4..600b677b3 100644 --- a/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift +++ b/Riot/Modules/Spaces/SpaceMenu/SpaceMenuViewController.swift @@ -103,6 +103,12 @@ class SpaceMenuViewController: UIViewController { self.subtitleLabel.font = theme.fonts.caption1 self.closeButton.backgroundColor = theme.roomInputTextBorder self.closeButton.tintColor = theme.noticeSecondaryColor + + if self.spaceId == SpaceListViewModel.Constants.homeSpaceId { + let defaultAsset = ThemeService.shared().isCurrentThemeDark() ? Asset.Images.spaceHomeIconDark : Asset.Images.spaceHomeIconLight + let avatarViewData = AvatarViewData(matrixItemId: self.spaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(defaultAsset.image, .center)) + self.avatarView.fill(with: avatarViewData) + } } private func registerThemeServiceDidChangeThemeNotification() { @@ -117,8 +123,6 @@ class SpaceMenuViewController: UIViewController { setupTableView() if self.spaceId == SpaceListViewModel.Constants.homeSpaceId { - let avatarViewData = AvatarViewData(matrixItemId: self.spaceId, displayName: nil, avatarUrl: nil, mediaManager: session.mediaManager, fallbackImage: .image(Asset.Images.spaceHomeIcon.image, .center)) - self.avatarView.fill(with: avatarViewData) self.titleLabel.text = VectorL10n.titleHome self.subtitleLabel.text = VectorL10n.settingsTitle diff --git a/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift new file mode 100644 index 000000000..c33bc3c73 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Avatar/View/SpaceAvatarImage.swift @@ -0,0 +1,109 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import DesignKit + +@available(iOS 14.0, *) +struct SpaceAvatarImage: View { + + @Environment(\.theme) var theme: ThemeSwiftUI + @Environment(\.dependencies) var dependencies: DependencyContainer + @StateObject var viewModel = AvatarViewModel() + + var mxContentUri: String? + var matrixItemId: String + var displayName: String? + var size: AvatarSize + + var body: some View { + Group { + switch viewModel.viewState { + case .empty: + ProgressView() + case .placeholder(let firstCharacter, let colorIndex): + Text(firstCharacter) + .padding(10) + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .foregroundColor(.white) + .background(theme.colors.namesAndAvatars[colorIndex]) + .clipShape(RoundedRectangle(cornerRadius: 8)) + // Make the text resizable (i.e. Make it large and then allow it to scale down) + .font(.system(size: 200)) + .minimumScaleFactor(0.001) + case .avatar(let image): + Image(uiImage: image) + .resizable() + .frame(width: CGFloat(size.rawValue), height: CGFloat(size.rawValue)) + .clipShape(Circle()) + } + } + .onChange(of: displayName, perform: { value in + viewModel.loadAvatar( + mxContentUri: mxContentUri, + matrixItemId: matrixItemId, + displayName: value, + colorCount: theme.colors.namesAndAvatars.count, + avatarSize: size + ) + }) + .onAppear { + viewModel.inject(dependencies: dependencies) + viewModel.loadAvatar( + mxContentUri: mxContentUri, + matrixItemId: matrixItemId, + displayName: displayName, + colorCount: theme.colors.namesAndAvatars.count, + avatarSize: size + ) + } + } +} + +@available(iOS 14.0, *) +extension SpaceAvatarImage { + init(avatarData: AvatarInputProtocol, size: AvatarSize) { + self.init( + mxContentUri: avatarData.mxContentUri, + matrixItemId: avatarData.matrixItemId, + displayName: avatarData.displayName, + size: size + ) + } +} + +@available(iOS 14.0, *) +struct LiveAvatarImage_Previews: PreviewProvider { + static let mxContentUri = "fakeUri" + static let name = "Alice" + static var previews: some View { + Group { + HStack { + VStack(alignment: .center, spacing: 20) { + SpaceAvatarImage(avatarData: MockAvatarInput.example, size: .xSmall) + SpaceAvatarImage(avatarData: MockAvatarInput.example, size: .medium) + SpaceAvatarImage(avatarData: MockAvatarInput.example, size: .xLarge) + } + VStack(alignment: .center, spacing: 20) { + SpaceAvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .xSmall) + SpaceAvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .medium) + SpaceAvatarImage(mxContentUri: nil, matrixItemId: name, displayName: name, size: .xLarge) + } + } + .addDependency(MockAvatarService.example) + } + } +} diff --git a/RiotSwiftUI/Modules/Common/Extensions/MatrixSDK/MXUserAvatarable.swift b/RiotSwiftUI/Modules/Common/Extensions/MatrixSDK/MXUserAvatarable.swift new file mode 100644 index 000000000..701c2f75f --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Extensions/MatrixSDK/MXUserAvatarable.swift @@ -0,0 +1,31 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +extension MXUser: Avatarable { + var mxContentUri: String? { + avatarUrl + } + + var matrixItemId: String { + userId + } + + var displayName: String? { + displayname + } + +} diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift index cedc46d0f..3fb7b554a 100644 --- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift +++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift @@ -24,6 +24,12 @@ enum MockAppScreens { MockAnalyticsPromptScreenState.self, MockUserSuggestionScreenState.self, MockPollEditFormScreenState.self, + MockSpaceCreationEmailInvitesScreenState.self, + MockSpaceCreationMatrixItemChooserScreenState.self, + MockSpaceCreationMenuScreenState.self, + MockSpaceCreationRoomsScreenState.self, + MockSpaceCreationSettingsScreenState.self, + MockSpaceCreationPostProcessScreenState.self, MockTimelinePollScreenState.self, MockTemplateUserProfileScreenState.self, MockTemplateRoomListScreenState.self, diff --git a/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift new file mode 100644 index 000000000..116c5d610 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ClearViewModifier.swift @@ -0,0 +1,53 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// `ClearViewModifier` aims to add a clear button (e.g. `x` button) on the right side of any text editing view +@available(iOS 14.0, *) +struct ClearViewModifier: ViewModifier +{ + // MARK: - Properties + + let alignment: VerticalAlignment + + // MARK: - Bindings + + @Binding var text: String + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: - Public + + public func body(content: Content) -> some View + { + HStack(alignment: alignment) { + content + if !text.isEmpty { + Button(action: { + self.text = "" + }) { + Image(systemName: "xmark.circle.fill") + .renderingMode(.template) + .foregroundColor(theme.colors.quarterlyContent) + } + .padding(EdgeInsets(top: alignment == .top ? 8 : 0, leading: 0, bottom: alignment == .bottom ? 8 : 0, trailing: 8)) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/OptionButton.swift b/RiotSwiftUI/Modules/Common/Util/OptionButton.swift new file mode 100644 index 000000000..9a4ca7b5f --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/OptionButton.swift @@ -0,0 +1,90 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct OptionButton: View { + + // MARK: - Style + + private struct Style: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeOut(duration: 0.2), value: configuration.isPressed) + } + } + + // MARK: - Properties + + let icon: UIImage? + let title: String + let detailMessage: String? + let action: () -> Void + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + Button(action: action, label: { + HStack { + if let image = icon { + Image(uiImage: image).renderingMode(.template).resizable().frame(width: 24, height: 24).foregroundColor(theme.colors.secondaryContent) + } + VStack(alignment: .leading, spacing: nil) { + Text(title).font(theme.fonts.bodySB).foregroundColor(theme.colors.primaryContent) + if let detail = detailMessage { + Text(detail).font(theme.fonts.caption1).foregroundColor(theme.colors.secondaryContent) + } + } + Spacer() + Image(systemName: "chevron.right").font(.system(size: 16, weight: .regular)).foregroundColor(theme.colors.quarterlyContent) + } + .padding(EdgeInsets(top: 15, leading: 16, bottom: 15, trailing: 16)) + .background(theme.colors.quinaryContent) + .foregroundColor(theme.colors.secondaryContent) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + ) + .buttonStyle(Style()) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct OptionButton_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack { + OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.light) + OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.light) + OptionButton(icon: nil, title: "A title", detailMessage: nil, action: {}).theme(.light) + } + VStack { + OptionButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.dark) + OptionButton(icon: nil, title: "A title", detailMessage: "Some details for this option", action: {}).theme(.dark) + OptionButton(icon: nil, title: "A title", detailMessage: nil, action: {}).theme(.dark) + }.preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift new file mode 100644 index 000000000..21738bae9 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ResponderManager.swift @@ -0,0 +1,93 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import UIKit + +/// `ResponderManager` is used to chain `SwiftUI` text editing views that embed `UIKit` text editing views using `UIViewRepresentable` +class ResponderManager { + + private static var tagIndex: Int = 1000 + private static var registeredResponders = NSMapTable(keyOptions: .strongMemory, valueOptions: .weakMemory) + + private static var nextIndex: Int { + tagIndex += 1 + return tagIndex + } + + private static var firstResponder: UIView? { + guard let enumerator = registeredResponders.objectEnumerator() else { + return nil + } + + while let view: UIView = enumerator.nextObject() as? UIView { + if view.isFirstResponder { + return view + } + } + + return nil + } + + /// register the `UIKit` view as a responder + /// + /// - Parameters: + /// - view: view to be registered + static func register(view: UIView) { + if registeredResponders.object(forKey: NSNumber(value: view.tag)) == nil { + view.tag = nextIndex + registeredResponders.setObject(view, forKey: NSNumber(value: view.tag)) + } + } + + /// Unregister the `UIKit` view from this manager. The view won't be considered as potential next responder anymore + /// + /// - Parameters: + /// - view: view to be unregistered + static func unregister(view: UIView) { + registeredResponders.removeObject(forKey: NSNumber(value: view.tag)) + } + + /// Tries to get the focused registered responder and give the focus to it's next responder + /// - Returns: `True` if the next responder has been found and is successfully focused. `False` otherwise. + static func makeActiveNextResponder() -> Bool { + guard let firstResponder = self.firstResponder else { + return false + } + + return makeActiveNextResponder(of: firstResponder) + } + + /// Give the focus to the next responder f the given `UIKit` view + /// + /// - Parameters: + /// - view: base view + /// + /// - Returns: `True` if the next responder has been found and is successfully focused. `False` otherwise. + static func makeActiveNextResponder(of view: UIView) -> Bool { + let nextTag = view.tag + 1 + guard let nextResponder = registeredResponders.object(forKey: NSNumber(value: nextTag)) else { + return false + } + + nextResponder.becomeFirstResponder() + return true + } + + /// Unfocus any focused registered view. + static func resignFirstResponder() { + firstResponder?.resignFirstResponder() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift new file mode 100644 index 000000000..3ec7141c8 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextEditor.swift @@ -0,0 +1,125 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct RoundedBorderTextEditor: View { + + // MARK: - Properties + + var title: String? + var placeHolder: String + @Binding var text: String + var textMaxHeight: CGFloat? + @Binding var error: String? + + var onTextChanged: ((String) -> Void)? + var onEditingChanged: ((Bool) -> Void)? + + @State private var editing = false + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Setup + + init(title: String? = nil, + placeHolder: String, + text: Binding, + textMaxHeight: CGFloat? = nil, + error: Binding = .constant(nil), + onTextChanged: ((String) -> Void)? = nil, + onEditingChanged: ((Bool) -> Void)? = nil) { + self.title = title + self.placeHolder = placeHolder + self._text = text + self.textMaxHeight = textMaxHeight + self._error = error + self.onTextChanged = onTextChanged + self.onEditingChanged = onEditingChanged + } + + // MARK: Public + + var body: some View { + VStack(alignment: .leading, spacing: -1) { + if let title = self.title { + Text(title) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) + } + ZStack(alignment: .topLeading) { + if text.isEmpty { + Text(placeHolder) + .padding(EdgeInsets(top: 10, leading: 10, bottom: 0, trailing: 0)) + .font(theme.fonts.callout) + .foregroundColor(theme.colors.tertiaryContent) + .allowsHitTesting(false) + } + ThemableTextEditor(text: $text, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .modifier(ClearViewModifier(alignment: .top, text: $text)) + // Found no good solution here. Hidding next button for the moment +// .modifier(NextViewModifier(alignment: .bottomTrailing, isEditing: $editing)) + .padding(EdgeInsets(top: 2, leading: 6, bottom: 0, trailing: 0)) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + } + .overlay(RoundedRectangle(cornerRadius: 8) + .stroke(editing ? theme.colors.accent : (error == nil ? theme.colors.quinaryContent : theme.colors.alert), lineWidth: editing || error != nil ? 2 : 1)) + .frame(height: textMaxHeight) + if let error = self.error { + Text(error) + .foregroundColor(theme.colors.alert) + .font(theme.fonts.footnote) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.2), value: error) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct ThemableTextEditor_Previews: PreviewProvider { + static var previews: some View { + + Group { + VStack(alignment: .center, spacing: 40) { + RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil)) + RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil)) + RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), error: .constant("Some error text")) + } + VStack(alignment: .center, spacing: 40) { + RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant(""), error: .constant(nil)) + RoundedBorderTextEditor(placeHolder: "A placeholder", text: .constant("Some text"), error: .constant(nil)) + RoundedBorderTextEditor(title: "A title", placeHolder: "A placeholder", text: .constant("Some text"), error: .constant("Some error text")) + } + .theme(.dark).preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift new file mode 100644 index 000000000..eaf9c8ba0 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/RoundedBorderTextField.swift @@ -0,0 +1,126 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + + +@available(iOS 14.0, *) +struct RoundedBorderTextField: View { + + // MARK: - Properties + + var title: String? + var placeHolder: String + @Binding var text: String + @Binding var footerText: String? + @Binding var isError: Bool + var isFirstResponder = false + + var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() + + var onTextChanged: ((String) -> Void)? + var onEditingChanged: ((Bool) -> Void)? + + // MARK: Private + + @State private var editing = false + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Setup + + init(title: String? = nil, placeHolder: String, text: Binding, footerText: Binding = .constant(nil), isError: Binding = .constant(false), isFirstResponder: Bool = false, configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), onTextChanged: ((String) -> Void)? = nil, onEditingChanged: ((Bool) -> Void)? = nil) { + self.title = title + self.placeHolder = placeHolder + self._text = text + self._footerText = footerText + self._isError = isError + self.isFirstResponder = isFirstResponder + self.configuration = configuration + self.onTextChanged = onTextChanged + self.onEditingChanged = onEditingChanged + } + + // MARK: Public + + var body: some View { + VStack(alignment: .leading, spacing: -1) { + if let title = self.title { + Text(title) + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.subheadline) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0)) + } + ZStack(alignment: .leading) { + if text.isEmpty { + Text(placeHolder) + .font(theme.fonts.callout) + .foregroundColor(theme.colors.tertiaryContent) + .lineLimit(1) + } + ThemableTextField(placeholder: "", text: $text, configuration: configuration, onEditingChanged: { edit in + self.editing = edit + onEditingChanged?(edit) + }) + .makeFirstResponder(isFirstResponder) + .onChange(of: text, perform: { newText in + onTextChanged?(newText) + }) + .frame(height: 30) + .modifier(ClearViewModifier(alignment: .center, text: $text)) + } + .padding(EdgeInsets(top: 8, leading: 8, bottom: 8, trailing: text.isEmpty ? 8 : 0)) + .overlay(RoundedRectangle(cornerRadius: 8) + .stroke(editing ? theme.colors.accent : (footerText != nil && isError ? theme.colors.alert : theme.colors.quinaryContent), lineWidth: editing || (footerText != nil && isError) ? 2 : 1)) + + if let footerText = self.footerText { + Text(footerText) + .foregroundColor(isError ? theme.colors.alert : theme.colors.tertiaryContent) + .font(theme.fonts.footnote) + .multilineTextAlignment(.leading) + .padding(EdgeInsets(top: 8, leading: 0, bottom: 0, trailing: 0)) + .transition(.opacity) + } + } + .animation(.easeOut(duration: 0.2)) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct TextFieldWithError_Previews: PreviewProvider { + static var previews: some View { + + Group { + VStack(alignment: .center, spacing: 40) { + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false)) + RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false)) + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true)) + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false)) + } + + VStack(alignment: .center, spacing: 20) { + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant(""), footerText: .constant(nil), isError: .constant(false)) + RoundedBorderTextField(placeHolder: "A placeholder", text: .constant("Some text"), footerText: .constant(nil), isError: .constant(false)) + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some error text"), isError: .constant(true)) + RoundedBorderTextField(title: "A title", placeHolder: "A placeholder", text: .constant("Some very long text used to check overlapping with the delete button"), footerText: .constant("Some normal text"), isError: .constant(false)) + }.theme(.dark).preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/SearchBar.swift b/RiotSwiftUI/Modules/Common/Util/SearchBar.swift new file mode 100644 index 000000000..f498c24e6 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/SearchBar.swift @@ -0,0 +1,81 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SearchBar: View { + + // MARK: - Properties + + var placeholder: String + @Binding var text: String + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + @State private var isEditing = false + var onTextChanged: ((String) -> Void)? + + // MARK: - Public + + var body: some View { + HStack { + TextField(placeholder, text: $text) { isEditing in + self.isEditing = isEditing + } + .padding(8) + .padding(.horizontal, 25) + .background(theme.colors.navigation) + .cornerRadius(8) + .padding(.leading) + .padding(.trailing, isEditing ? 8 : 16) + .overlay( + HStack { + Image(systemName: "magnifyingglass") + .renderingMode(.template) + .foregroundColor(theme.colors.quarterlyContent) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if isEditing && !text.isEmpty { + Button(action: { + self.text = "" + }) { + Image(systemName: "multiply.circle.fill") + .renderingMode(.template) + .foregroundColor(theme.colors.quarterlyContent) + } + } + } + .padding(.horizontal, 22) + ) + if isEditing { + Button(action: { + self.isEditing = false + self.text = "" + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + }) { + Text(VectorL10n.cancel) + .font(theme.fonts.body) + } + .foregroundColor(theme.colors.accent) + .padding(.trailing) + .transition(.move(edge: .trailing)) + } + } + .animation(.default) + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift b/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift new file mode 100644 index 000000000..13169ffc9 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableButton.swift @@ -0,0 +1,82 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct ThemableButton: View { + + // MARK: - Style + + private struct Style: ButtonStyle { + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.easeOut(duration: 0.2), value: configuration.isPressed) + } + } + + // MARK: - Properties + + let icon: UIImage? + let title: String + let action: () -> Void + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + Button(action: action, label: { + HStack { + Spacer() + if let icon = self.icon { + Image(uiImage: icon).renderingMode(.template).resizable().frame(width: 24, height: 24).foregroundColor(theme.colors.background) + } + Text(title).font(theme.fonts.bodySB).foregroundColor(theme.colors.background) + Spacer() + } + .padding() + .background(theme.colors.accent) + .foregroundColor(theme.colors.background) + .clipShape(RoundedRectangle(cornerRadius: 8)) + }) + .buttonStyle(Style()) + .frame(height: 44) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct ThemableButton_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack(alignment: .center, spacing: 20) { + ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: {}).theme(.light).preferredColorScheme(.light) + ThemableButton(icon: nil, title: "A title", action: {}).theme(.light).preferredColorScheme(.light) + } + VStack(alignment: .center, spacing: 20) { + ThemableButton(icon: Asset.Images.spaceTypeIcon.image, title: "A title", action: {}).theme(.dark).preferredColorScheme(.dark) + ThemableButton(icon: nil, title: "A title", action: {}).theme(.dark).preferredColorScheme(.dark) + } + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift b/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift new file mode 100644 index 000000000..010034d18 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableNavigationBar.swift @@ -0,0 +1,87 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct ThemableNavigationBar: View { + + // MARK: - Style + + // MARK: - Properties + + let title: String? + let showBackButton: Bool + let backAction: () -> Void + let closeAction: () -> Void + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ViewBuilder + var body: some View { + HStack { + Button(action: {backAction()}) + { + Image(uiImage: Asset.Images.spacesModalBack.image) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + } + .isHidden(!showBackButton) + Spacer() + if let title = title { + Text(title).font(theme.fonts.headline) + .foregroundColor(theme.colors.primaryContent) + } + Spacer() + Button(action: {closeAction()}) + { + Image(uiImage: Asset.Images.spacesModalClose.image) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + } + } + .padding(.horizontal) + .frame(height: 44) + .background(theme.colors.background) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct NavigationBar_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}) + } + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + }.preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift b/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift new file mode 100644 index 000000000..e57b08d68 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableTextEditor.swift @@ -0,0 +1,162 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + + +@available(iOS 14.0, *) +struct ThemableTextEditor: UIViewRepresentable { + + // MARK: Properties + + @Binding var text: String + @State var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() + var onEditingChanged: ((_ edit: Bool) -> Void)? + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + private let textView: UITextView = UITextView() + private let internalParams = InternalParams() + + // MARK: Setup + + init(text: Binding, + configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), + onEditingChanged: ((_ edit: Bool) -> Void)? = nil) { + self._text = text + self._configuration = State(initialValue: configuration) + self.onEditingChanged = onEditingChanged + + ResponderManager.register(view: textView) + } + + // MARK: UIViewRepresentable + + func makeUIView(context: Context) -> UITextView { + textView.delegate = context.coordinator + textView.text = text + + if internalParams.isFirstResponder { + textView.becomeFirstResponder() + } + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.backgroundColor = .clear + uiView.font = UIFont.preferredFont(forTextStyle: .callout) + uiView.textColor = UIColor(theme.colors.primaryContent) + uiView.tintColor = UIColor(theme.colors.accent) + + if uiView.text != self.text { + uiView.text = self.text + } + + uiView.keyboardType = configuration.keyboardType + uiView.returnKeyType = configuration.returnKeyType + uiView.isSecureTextEntry = configuration.isSecureTextEntry + uiView.autocapitalizationType = configuration.autocapitalizationType + uiView.autocorrectionType = configuration.autocorrectionType + } + + static func dismantleUIView(_ uiView: UITextView, coordinator: Coordinator) { + ResponderManager.unregister(view: uiView) + } + + // MARK: - Private + + private func replaceText(with newText: String) { + self.text = newText + } + + private class InternalParams { + var isFirstResponder = false + } + + // MARK: - Coordinator + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, UITextViewDelegate { + var parent: ThemableTextEditor + + init(_ parent: ThemableTextEditor) { + self.parent = parent + } + + func textViewDidBeginEditing(_ textView: UITextView) { + parent.onEditingChanged?(true) + } + + func textViewDidEndEditing(_ textView: UITextView) { + parent.onEditingChanged?(false) + } + + func textViewDidChange(_ textView: UITextView) { + guard let text = textView.text else { + return + } + + parent.replaceText(with: text) + } + + @objc func wakeUpNextResponder() { + if !ResponderManager.makeActiveNextResponder(of: parent.textView) { + parent.textView.resignFirstResponder() + } + } + } +} + +// MARK: - modifiers + +@available(iOS 14.0, *) +extension ThemableTextEditor { + func keyboardType(_ type: UIKeyboardType) -> ThemableTextEditor { + textView.keyboardType = type + return self + } + + func isSecureTextEntry(_ isSecure: Bool) -> ThemableTextEditor { + textView.isSecureTextEntry = isSecure + return self + } + + func returnKeyType(_ type: UIReturnKeyType) -> ThemableTextEditor { + textView.returnKeyType = type + return self + } + + func autocapitalizationType(_ type: UITextAutocapitalizationType) -> ThemableTextEditor { + textView.autocapitalizationType = type + return self + } + + func autocorrectionType(_ type: UITextAutocorrectionType) -> ThemableTextEditor { + textView.autocorrectionType = type + return self + } + + func makeFirstResponder() -> ThemableTextEditor { + internalParams.isFirstResponder = true + return self + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift new file mode 100644 index 000000000..ef469a43e --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/ThemableTextField.swift @@ -0,0 +1,162 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct UIKitTextInputConfiguration { + var keyboardType: UIKeyboardType = .default + var returnKeyType: UIReturnKeyType = .default + var isSecureTextEntry: Bool = false + var autocapitalizationType: UITextAutocapitalizationType = .sentences + var autocorrectionType: UITextAutocorrectionType = .default +} + +@available(iOS 14.0, *) +struct ThemableTextField: UIViewRepresentable { + + // MARK: Properties + + @State var placeholder: String? + @Binding var text: String + @State var configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration() + var onEditingChanged: ((_ edit: Bool) -> Void)? + var onCommit: (() -> Void)? + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + private let textField: UITextField = UITextField() + private let internalParams = InternalParams() + + // MARK: Setup + + init(placeholder: String? = nil, + text: Binding, + configuration: UIKitTextInputConfiguration = UIKitTextInputConfiguration(), + onEditingChanged: ((_ edit: Bool) -> Void)? = nil, + onCommit: (() -> Void)? = nil) { + self._text = text + self._placeholder = State(initialValue: placeholder) + self._configuration = State(initialValue: configuration) + self.onEditingChanged = onEditingChanged + self.onCommit = onCommit + + ResponderManager.register(view: textField) + } + + // MARK: UIViewRepresentable + + func makeUIView(context: Context) -> UITextField { + textField.delegate = context.coordinator + textField.setContentHuggingPriority(.defaultHigh, for: .vertical) + textField.setContentHuggingPriority(.defaultLow, for: .horizontal) + textField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textField.text = text + + textField.addTarget(context.coordinator, action: #selector(Coordinator.textFieldEditingChanged(sender:)), for: .editingChanged) + + if internalParams.isFirstResponder { + textField.becomeFirstResponder() + } + + return textField + } + + func updateUIView(_ uiView: UITextField, context: Context) { + uiView.backgroundColor = .clear + uiView.font = UIFont.preferredFont(forTextStyle: .callout) + uiView.textColor = UIColor(theme.colors.primaryContent) + uiView.tintColor = UIColor(theme.colors.accent) + + if uiView.text != self.text { + uiView.text = self.text + } + uiView.placeholder = placeholder + + uiView.keyboardType = configuration.keyboardType + uiView.returnKeyType = configuration.returnKeyType + uiView.isSecureTextEntry = configuration.isSecureTextEntry + uiView.autocapitalizationType = configuration.autocapitalizationType + uiView.autocorrectionType = configuration.autocorrectionType + } + + static func dismantleUIView(_ uiView: UITextField, coordinator: Coordinator) { + ResponderManager.unregister(view: uiView) + } + + // MARK: - Private + + private func replaceText(with newText: String) { + self.text = newText + } + + // MARK: - Coordinator + + func makeCoordinator() -> Coordinator { + return Coordinator(self) + } + + class Coordinator: NSObject, UITextFieldDelegate { + + var parent: ThemableTextField + + init(_ parent: ThemableTextField) { + self.parent = parent + } + + func textFieldDidBeginEditing(_ textField: UITextField) { + parent.onEditingChanged?(true) + } + + func textFieldDidEndEditing(_ textField: UITextField) { + parent.onEditingChanged?(false) + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if parent.configuration.returnKeyType != .next || !ResponderManager.makeActiveNextResponder(of: textField) { + textField.resignFirstResponder() + } + + parent.onCommit?() + + return true + } + + @objc func textFieldEditingChanged(sender: UITextField) { + parent.replaceText(with: sender.text ?? "") + } + } + + private class InternalParams { + var isFirstResponder = false + } + +} + +// MARK: - modifiers + +@available(iOS 14.0, *) +extension ThemableTextField { + func makeFirstResponder() -> ThemableTextField { + return makeFirstResponder(true) + } + + func makeFirstResponder(_ isFirstResponder: Bool) -> ThemableTextField { + internalParams.isFirstResponder = isFirstResponder + return self + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/View+Riot.swift b/RiotSwiftUI/Modules/Common/Util/View+Riot.swift new file mode 100644 index 000000000..d126ec8d0 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/View+Riot.swift @@ -0,0 +1,28 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 13.0, *) +extension View { + @ViewBuilder func isHidden(_ isHidden: Bool) -> some View { + if isHidden { + self.hidden() + } else { + self + } + } +} diff --git a/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift b/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift new file mode 100644 index 000000000..b5d341e63 --- /dev/null +++ b/RiotSwiftUI/Modules/Common/Util/WaitOverlay.swift @@ -0,0 +1,81 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +/// `WaitOverlay` allows to easily add an overlay that covers the entire with an `ActivityIndicator` at the center +@available(iOS 14.0, *) +struct WaitOverlay: ViewModifier { + // MARK: - Properties + + var alignment: Alignment = .center + @Binding var isLoading: Bool + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: - Public + + public func body(content: Content) -> some View + { + ZStack(alignment: alignment) { + content + if isLoading { + ZStack { + Color.clear + ActivityIndicator() + } + .frame(width: .infinity, height: .infinity) + .transition(.opacity) + .allowsHitTesting(true) + } + } + .animation(.easeIn(duration: 0.2)) + } +} + +@available(iOS 14.0, *) +struct WaitView_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}) + } + .modifier(WaitOverlay(isLoading: .constant(true))) + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}) + } + .modifier(WaitOverlay(alignment:.topLeading, isLoading: .constant(true))) + VStack { + ThemableNavigationBar(title: nil, showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: true, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: nil, showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + ThemableNavigationBar(title: "Some Title", showBackButton: false, backAction: {}, closeAction: {}).theme(.dark) + } + + .modifier(WaitOverlay(isLoading: .constant(true))).theme(.dark) + .preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift new file mode 100644 index 000000000..c5fa17cca --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinator.swift @@ -0,0 +1,275 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// File created from FlowTemplate +// $ createRootCoordinator.sh SpaceCreationCoordinator SpaceCreation +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import UIKit + +@objcMembers +final class SpaceCreationCoordinator: Coordinator { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationCoordinatorParameters + + private var navigationRouter: NavigationRouterType { + return self.parameters.navigationRouter + } + + private let spaceVisibilityMenuParameters: SpaceCreationMenuCoordinatorParameters + private let spaceSharingTypeMenuParameters: SpaceCreationMenuCoordinatorParameters + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + + var callback: ((SpaceCreationCoordinatorAction) -> Void)? + + // MARK: - Setup + + init(parameters: SpaceCreationCoordinatorParameters) { + self.parameters = parameters + self.spaceVisibilityMenuParameters = SpaceCreationMenuCoordinatorParameters( + session: parameters.session, + creationParams: parameters.creationParameters, + navTitle: VectorL10n.spacesCreateSpaceTitle, + showBackButton: false, + title: VectorL10n.spacesCreationVisibilityTitle, + detail: VectorL10n.spacesCreationVisibilityMessage, + options: [ + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceCreationPublic.image, title: VectorL10n.public, detail: VectorL10n.spacePublicJoinRuleDetail), + SpaceCreationMenuRoomOption(id: .privateSpace, icon: Asset.Images.spaceCreationPrivate.image, title: VectorL10n.private, detail: VectorL10n.spacePrivateJoinRuleDetail) + ] + ) + + self.spaceSharingTypeMenuParameters = SpaceCreationMenuCoordinatorParameters( + session: parameters.session, + creationParams: parameters.creationParameters, + navTitle: nil, + showBackButton: true, + title: VectorL10n.spacesCreationSharingTypeTitle, + detail: VectorL10n.spacesCreationSharingTypeMessage(parameters.creationParameters.name ?? ""), + options: [ + SpaceCreationMenuRoomOption(id: .ownedPrivateSpace, icon: Asset.Images.tabPeople.image, title: VectorL10n.spacesCreationSharingTypeJustMeTitle, detail: VectorL10n.spacesCreationSharingTypeJustMeDetail), + SpaceCreationMenuRoomOption(id: .sharedPrivateSpace, icon: Asset.Images.tabGroups.image, title: VectorL10n.spacesCreationSharingTypeMeAndTeammatesTitle, detail: VectorL10n.spacesCreationSharingTypeMeAndTeammatesDetail) + ] + ) + } + + // MARK: - Public + + func start() { + if #available(iOS 14.0, *) { + MXLog.debug("[SpaceCreationCoordinator] did start.") + + let rootCoordinator = self.createMenuCoordinator(with: spaceVisibilityMenuParameters) + rootCoordinator.start() + + self.add(childCoordinator: rootCoordinator) + + self.toPresentable().isModalInPresentation = true + + if self.navigationRouter.modules.isEmpty == false { + self.navigationRouter.push(rootCoordinator, animated: true, popCompletion: { [weak self] in + self?.remove(childCoordinator: rootCoordinator) + }) + } else { + self.navigationRouter.setRootModule(rootCoordinator) { [weak self] in + self?.remove(childCoordinator: rootCoordinator) + } + } + } + } + + func toPresentable() -> UIViewController { + return self.navigationRouter.toPresentable() + } + + // MARK: - Private + + @available(iOS 14.0, *) + func pushScreen(with coordinator: Coordinator & Presentable) { + add(childCoordinator: coordinator) + + self.navigationRouter.push(coordinator, animated: true, popCompletion: { [weak self] in + self?.remove(childCoordinator: coordinator) + }) + + coordinator.start() + } + + @available(iOS 14.0, *) + private func createMenuCoordinator(with parameters: SpaceCreationMenuCoordinatorParameters) -> SpaceCreationMenuCoordinator { + let coordinator: SpaceCreationMenuCoordinator = SpaceCreationMenuCoordinator(parameters: parameters) + + coordinator.callback = { [weak self] result in + MXLog.debug("[SpaceCreationCoordinator] SpaceCreationMenuCoordinator did complete with result \(result).") + guard let self = self else { return } + switch result { + case .didSelectOption(let optionId): + switch optionId { + case .privateSpace, .publicSpace: + self.pushScreen(with: self.createSettingsCoordinator()) + case .ownedPrivateSpace: + self.pushScreen(with: self.createRoomChooserCoordinator()) + case .sharedPrivateSpace: + self.pushScreen(with: self.createRoomsCoordinator()) + } + case .cancel: + self.cancel() + case .back: + self.back() + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createSettingsCoordinator() -> SpaceCreationSettingsCoordinator { + let coordinator = SpaceCreationSettingsCoordinator(parameters: SpaceCreationSettingsCoordinatorParameters(session: parameters.session, creationParameters: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .didSetupParameters: + if self.parameters.creationParameters.isPublic { + self.pushScreen(with: self.createRoomsCoordinator()) + } else { + self.pushScreen(with: self.createMenuCoordinator(with: self.spaceSharingTypeMenuParameters)) + } + case .cancel: + self.cancel() + case .back: + self.back() + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createRoomsCoordinator() -> SpaceCreationRoomsCoordinator { + let coordinator = SpaceCreationRoomsCoordinator(parameters: SpaceCreationRoomsCoordinatorParameters(session: parameters.session, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .didSetupRooms: + if self.parameters.creationParameters.isPublic { + self.pushScreen(with: self.createPostProcessCoordinator()) + } else if self.parameters.creationParameters.isShared { + self.pushScreen(with: self.createEmailInvitesCoordinator()) + } else { + UILog.error("[SpaceCreationCoordinator] createRoomsCoordinator: should be public space or shared private space") + } + case .cancel: + self.cancel() + case .back: + self.back() + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createEmailInvitesCoordinator() -> SpaceCreationEmailInvitesCoordinator { + let coordinator = SpaceCreationEmailInvitesCoordinator(parameters: SpaceCreationEmailInvitesCoordinatorParameters(session: parameters.session, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .cancel: + self.cancel() + case .back: + self.back() + case .done: + self.pushScreen(with: self.createPostProcessCoordinator()) + case .inviteByUsername: + self.pushScreen(with: self.createPeopleChooserCoordinator()) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createPeopleChooserCoordinator() -> SpaceCreationMatrixItemChooserCoordinator { + let coordinator = SpaceCreationMatrixItemChooserCoordinator(parameters: SpaceCreationMatrixItemChooserCoordinatorParameters(session: parameters.session, type: .people, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .cancel: + self.cancel() + case .back: + self.back() + case .done: + self.pushScreen(with: self.createPostProcessCoordinator()) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createRoomChooserCoordinator() -> SpaceCreationMatrixItemChooserCoordinator { + let coordinator = SpaceCreationMatrixItemChooserCoordinator(parameters: SpaceCreationMatrixItemChooserCoordinatorParameters(session: parameters.session, type: .room, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .cancel: + self.cancel() + case .back: + self.back() + case .done: + self.pushScreen(with: self.createPostProcessCoordinator()) + } + } + return coordinator + } + + @available(iOS 14.0, *) + private func createPostProcessCoordinator() -> SpaceCreationPostProcessCoordinator { + let coordinator = SpaceCreationPostProcessCoordinator(parameters: SpaceCreationPostProcessCoordinatorParameters(session: parameters.session, creationParams: parameters.creationParameters)) + coordinator.callback = { [weak self] result in + guard let self = self else { return } + switch result { + case .done(let spaceId): + self.callback?(.done(spaceId)) + case .cancel: + self.cancel() + } + } + return coordinator + } + + private func cancel() { + if parameters.creationParameters.isModified { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + let alert = UIAlertController(title: VectorL10n.spacesCreationCancelTitle, message: VectorL10n.spacesCreationCancelMessage, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: VectorL10n.stop, style: .destructive, handler: { action in + self.callback?(.cancel) + })) + alert.addAction(UIAlertAction(title: VectorL10n.continue, style: .cancel, handler: nil)) + navigationRouter.present(alert, animated: true) + } else { + self.callback?(.cancel) + } + } + + private func back() { + navigationRouter.popModule(animated: true) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorAction.swift new file mode 100644 index 000000000..879d8e0f8 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorAction.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationCoordinatorAction { + case cancel + case done(_ spaceId: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift new file mode 100644 index 000000000..0106cef34 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/Coordinator/SpaceCreationCoordinatorParameters.swift @@ -0,0 +1,40 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// File created from FlowTemplate +// $ createRootCoordinator.sh SpaceCreationCoordinator SpaceCreation +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation + +/// SpaceCreationCoordinator input parameters +struct SpaceCreationCoordinatorParameters { + + /// The Matrix session + let session: MXSession + + /// Parameters needed to create the new space + let creationParameters: SpaceCreationParameters = SpaceCreationParameters() + + /// The navigation router that manage physical navigation + let navigationRouter: NavigationRouterType + + init(session: MXSession, + navigationRouter: NavigationRouterType? = nil) { + self.session = session + self.navigationRouter = navigationRouter ?? NavigationRouter(navigationController: RiotNavigationController()) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift new file mode 100644 index 000000000..f3a7779fc --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinator.swift @@ -0,0 +1,124 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import SwiftUI + +final class SpaceCreationEmailInvitesCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationEmailInvitesCoordinatorParameters + private let spaceCreationEmailInvitesHostingController: UIViewController + private var spaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationEmailInvitesCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationEmailInvitesCoordinatorParameters) { + self.parameters = parameters + let service = SpaceCreationEmailInvitesService(session: parameters.session) + let viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: parameters.creationParams, service: service) + let view = SpaceCreationEmailInvites(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationEmailInvitesViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.isNavigationBarHidden = true + spaceCreationEmailInvitesHostingController = hostingController + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceCreationEmailInvitesCoordinator] did start.") + spaceCreationEmailInvitesViewModel.completion = { [weak self] result in + MXLog.debug("[SpaceCreationEmailInvitesCoordinator] SpaceCreationEmailInvitesViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .back: + self.callback?(.back) + case .done: + self.callback?(.done) + case .inviteByUsername: + self.callback?(.inviteByUsername) + case .needIdentityServiceTerms(let baseUrl, let accessToken): + self.presentIdentityServerTerms(with: baseUrl, accessToken: accessToken) + case .identityServiceFailure(let error): + self.showIdentityServiceFailure(error) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationEmailInvitesHostingController + } + + // MARK: - Identity service + + private var serviceTermsModalCoordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter? + + private func presentIdentityServerTerms(with baseUrl: String?, accessToken: String?) { + guard let baseUrl = baseUrl, let accessToken = accessToken else { + showIdentityServiceFailure(nil) + return + } + + let presenter = ServiceTermsModalCoordinatorBridgePresenter(session: parameters.session, baseUrl: baseUrl, serviceType: MXServiceTypeIdentityService, accessToken: accessToken) + presenter.delegate = self + presenter.present(from: self.toPresentable(), animated: true) + serviceTermsModalCoordinatorBridgePresenter = presenter + } + + private func showIdentityServiceFailure(_ error: Error?) { + let alertController = UIAlertController(title: VectorL10n.findYourContactsIdentityServiceError, message: nil, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: MatrixKitL10n.ok, style: .default, handler: nil)) + self.toPresentable().present(alertController, animated: true, completion: nil); + } +} + +extension SpaceCreationEmailInvitesCoordinator: ServiceTermsModalCoordinatorBridgePresenterDelegate { + func serviceTermsModalCoordinatorBridgePresenterDelegateDidAccept(_ coordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.serviceTermsModalCoordinatorBridgePresenter = nil; + self.callback?(.done) + } + } + + func serviceTermsModalCoordinatorBridgePresenterDelegateDidDecline(_ coordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter, session: MXSession) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.serviceTermsModalCoordinatorBridgePresenter = nil; + } + } + + func serviceTermsModalCoordinatorBridgePresenterDelegateDidClose(_ coordinatorBridgePresenter: ServiceTermsModalCoordinatorBridgePresenter) { + coordinatorBridgePresenter.dismiss(animated: true) { + self.serviceTermsModalCoordinatorBridgePresenter = nil; + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinatorParameters.swift new file mode 100644 index 000000000..af2ac696c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Coordinator/SpaceCreationEmailInvitesCoordinatorParameters.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationEmailInvitesCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift new file mode 100644 index 000000000..cec48fc61 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesCoordinatorAction.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationEmailInvitesCoordinatorAction { + case done + case cancel + case back + case inviteByUsername +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift new file mode 100644 index 000000000..ead48ebf2 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesStateAction.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationEmailInvitesStateAction { + case updateEmailValidity(_ validity: [Bool]) + case updateLoading(_ loading: Bool) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift new file mode 100644 index 000000000..0f08acea8 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewAction.swift @@ -0,0 +1,26 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationEmailInvitesViewAction { + case cancel + case back + case done + case inviteByUsername +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelBindings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelBindings.swift new file mode 100644 index 000000000..531689fa1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelBindings.swift @@ -0,0 +1,21 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationEmailInvitesViewModelBindings { + var emailInvites: [String] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift new file mode 100644 index 000000000..d4f55e4a3 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewModelResult.swift @@ -0,0 +1,28 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationEmailInvitesViewModelResult { + case cancel + case back + case done + case needIdentityServiceTerms(_ baseUrl: String?, _ accessToken: String?) + case identityServiceFailure(_ error: Error?) + case inviteByUsername +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift new file mode 100644 index 000000000..e3028134f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Model/SpaceCreationEmailInvitesViewState.swift @@ -0,0 +1,26 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationEmailInvitesViewState: BindableState { + var title: String + var emailAddressesValid: [Bool] + var loading: Bool + var bindings: SpaceCreationEmailInvitesViewModelBindings +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift new file mode 100644 index 000000000..45833bf09 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/MatrixSDK/SpaceCreationEmailInvitesService.swift @@ -0,0 +1,54 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class SpaceCreationEmailInvitesService: SpaceCreationEmailInvitesServiceProtocol { + + private let session: MXSession + private(set) var isLoadingSubject: CurrentValueSubject + + var isIdentityServiceReady: Bool { + if let identityService = session.identityService { + return identityService.areAllTermsAgreed + } + return false + } + + init(session: MXSession) { + self.session = session + isLoadingSubject = CurrentValueSubject(false) + } + + func validate(_ emailAddresses: [String]) -> [Bool] { + return emailAddresses.map { $0.isEmpty || MXTools.isEmailAddress($0) } + } + + func prepareIdentityService(prepared: ((String?, String?) -> Void)?, failure: ((Error?) -> Void)?) { + isLoadingSubject.send(true) + session.prepareIdentityServiceForTerms(withDefault: RiotSettings.shared.identityServerUrlString) { [weak self] session, baseURL, accessToken in + self?.isLoadingSubject.send(false) + prepared?(baseURL, accessToken) + } failure: { [weak self] error in + self?.isLoadingSubject.send(false) + failure?(error) + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift new file mode 100644 index 000000000..2019deb0c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesScreenState.swift @@ -0,0 +1,71 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockSpaceCreationEmailInvitesScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case defaultEmailValues + case emailEntered + case emailValidationFailed + case loading + + /// The associated screen + var screenType: Any.Type { + SpaceCreationEmailInvites.self + } + + /// A list of screen state definitions + static var allCases: [MockSpaceCreationEmailInvitesScreenState] { + [.defaultEmailValues, .emailEntered, .emailValidationFailed, .loading] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let creationParams = SpaceCreationParameters() + let service: MockSpaceCreationEmailInvitesService + switch self { + case .defaultEmailValues: + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: false) + case .emailEntered: + creationParams.emailInvites = ["test1@element.io", "test2@element.io"] + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: false) + case .emailValidationFailed: + creationParams.emailInvites = ["test1@element.io", "test2@element.io"] + service = MockSpaceCreationEmailInvitesService(defaultValidation: false, isLoading: false) + case .loading: + creationParams.emailInvites = ["test1@element.io", "test2@element.io"] + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: true) + } + let viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: creationParams, service: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(SpaceCreationEmailInvites(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift new file mode 100644 index 000000000..a9ecbe15b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/Mock/MockSpaceCreationEmailInvitesService.swift @@ -0,0 +1,44 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MockSpaceCreationEmailInvitesService: SpaceCreationEmailInvitesServiceProtocol { + var isLoadingSubject: CurrentValueSubject + + private let defaultValidation: Bool + + var isIdentityServiceReady: Bool { + return true + } + + init(defaultValidation: Bool, isLoading: Bool) { + self.defaultValidation = defaultValidation + self.isLoadingSubject = CurrentValueSubject(isLoading) + } + + func validate(_ emailAddresses: [String]) -> [Bool] { + return emailAddresses.map { _ in defaultValidation } + } + + func prepareIdentityService(prepared: ((String?, String?) -> Void)?, failure: ((Error?) -> Void)?) { + failure?(nil) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift new file mode 100644 index 000000000..b460da054 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Service/SpaceCreationEmailInvitesServiceProtocol.swift @@ -0,0 +1,28 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +protocol SpaceCreationEmailInvitesServiceProtocol { + var isIdentityServiceReady: Bool { get } + var isLoadingSubject: CurrentValueSubject { get } + func validate(_ emailAddresses: [String]) -> [Bool] + func prepareIdentityService(prepared: ((String?, String?) -> Void)?, failure: ((Error?) -> Void)?) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift new file mode 100644 index 000000000..c6f1f8426 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/UI/SpaceCreationEmailInvitesUITests.swift @@ -0,0 +1,51 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationEmailInvitesUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationEmailInvitesScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationEmailInvitesUITests(selector: #selector(verifySpaceCreationEmailInvitesScreen)) + } + + func verifySpaceCreationEmailInvitesScreen() throws { + guard let screenState = screenState as? MockSpaceCreationEmailInvitesScreenState else { fatalError("no screen") } + switch screenState { + case .defaultEmailValues: + verifyEmailValues() + case .emailEntered: + verifyEmailValues() + case .emailValidationFailed: + verifyEmailValues() + case .loading: + verifyEmailValues() + } + } + + func verifyEmailValues() { + let emailTextFieldsCount = app.textFields.matching(identifier: "emailTextField").count + XCTAssertEqual(emailTextFieldsCount, 2) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift new file mode 100644 index 000000000..8a53ff89a --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/Test/Unit/SpaceCreationEmailInvitesViewModelTests.swift @@ -0,0 +1,41 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationEmailInvitesViewModelTests: XCTestCase { + var creationParameters = SpaceCreationParameters() + var service: MockSpaceCreationEmailInvitesService! + var viewModel: SpaceCreationEmailInvitesViewModelProtocol! + var context: SpaceCreationEmailInvitesViewModelType.Context! + + override func setUpWithError() throws { + service = MockSpaceCreationEmailInvitesService(defaultValidation: true, isLoading: false) + viewModel = SpaceCreationEmailInvitesViewModel(creationParameters: creationParameters, service: service) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.title, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.emailInvites, creationParameters.emailInvites) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift new file mode 100644 index 000000000..b8e36720c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/View/SpaceCreationEmailInvites.swift @@ -0,0 +1,124 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceCreationEmailInvites: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationEmailInvitesViewModel.Context + + // MARK: - Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: - Public + + @ViewBuilder + var body: some View { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView + .animation(.easeInOut(duration: 0.2), value: viewModel.viewState.loading) + .modifier(WaitOverlay(isLoading: .constant(viewModel.viewState.loading))) + } + .background(theme.colors.background) + .navigationBarHidden(true) + } + + // MARK: - Private + + @ViewBuilder + private var mainView: some View { + VStack { + GeometryReader { reader in + ScrollView { + VStack { + headerView + Spacer() + formView + } + .frame(minHeight: reader.size.height - 2) + } + } + footerView + } + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) + } + + @ViewBuilder + private var headerView: some View { + VStack { + Text(VectorL10n.spacesCreationEmailInvitesTitle) + .multilineTextAlignment(.center) + .font(theme.fonts.title3SB) + .foregroundColor(theme.colors.primaryContent) + Spacer().frame(height: 20) + Text(VectorL10n.spacesCreationEmailInvitesMessage) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + } + } + + @ViewBuilder + private var formView: some View { + VStack { + VStack(spacing: 20) { + ForEach(viewModel.emailInvites.indices) { index in + RoundedBorderTextField(title: VectorL10n.spacesCreationEmailInvitesEmailTitle, placeHolder: VectorL10n.spacesCreationEmailInvitesEmailTitle, text: $viewModel.emailInvites[index], footerText: .constant(viewModel.viewState.emailAddressesValid[index] ? nil : VectorL10n.authInvalidEmail), isError: .constant(!viewModel.viewState.emailAddressesValid[index]), configuration: UIKitTextInputConfiguration(keyboardType: .emailAddress, returnKeyType: index < viewModel.emailInvites.endIndex - 1 ? .next : .done, autocapitalizationType: .none, autocorrectionType: .no)) + .accessibility(identifier: "emailTextField") + } + } + .padding(.horizontal, 2) + .padding(.bottom) + Text(VectorL10n.or) + .font(theme.fonts.caption1) + .foregroundColor(theme.colors.secondaryContent) + .padding(.bottom) + OptionButton(icon: Asset.Images.spacesInviteUsers.image, title: VectorL10n.spacesCreationInviteByUsername, detailMessage: nil) { + viewModel.send(viewAction: .inviteByUsername) + } + .padding(.bottom) + } + } + + @ViewBuilder + private var footerView: some View { + ThemableButton(icon: nil, title: VectorL10n.next) { + viewModel.send(viewAction: .done) + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationEmailInvites_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationEmailInvitesScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift new file mode 100644 index 000000000..a6ab659bc --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModel.swift @@ -0,0 +1,122 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + +@available(iOS 14, *) +typealias SpaceCreationEmailInvitesViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationEmailInvitesViewModel: SpaceCreationEmailInvitesViewModelType, SpaceCreationEmailInvitesViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let creationParameters: SpaceCreationParameters + private let service: SpaceCreationEmailInvitesServiceProtocol + + // MARK: Public + + var completion: ((SpaceCreationEmailInvitesViewModelResult) -> Void)? + + // MARK: - Setup + + init(creationParameters: SpaceCreationParameters, service: SpaceCreationEmailInvitesServiceProtocol) { + self.creationParameters = creationParameters + self.service = service + super.init(initialViewState: SpaceCreationEmailInvitesViewModel.defaultState(creationParameters: creationParameters, service: service)) + } + + private func setupServiceObserving() { + let publisher = service.isLoadingSubject + .map(SpaceCreationEmailInvitesStateAction.updateLoading) + .eraseToAnyPublisher() + dispatch(actionPublisher: publisher) + } + + private static func defaultState(creationParameters: SpaceCreationParameters, service: SpaceCreationEmailInvitesServiceProtocol) -> SpaceCreationEmailInvitesViewState { + let emailValidation = service.validate(creationParameters.emailInvites) + let bindings = SpaceCreationEmailInvitesViewModelBindings(emailInvites: creationParameters.emailInvites) + return SpaceCreationEmailInvitesViewState( + title: creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle, + emailAddressesValid: emailValidation, + loading: service.isLoadingSubject.value, + bindings: bindings + ) + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationEmailInvitesViewAction) { + switch viewAction { + case .cancel: + cancel() + case .back: + back() + case .done: + done() + case .inviteByUsername: + inviteByUsername() + } + } + + override class func reducer(state: inout SpaceCreationEmailInvitesViewState, action: SpaceCreationEmailInvitesStateAction) { + switch action { + case .updateEmailValidity(let emailValidity): + state.emailAddressesValid = emailValidity + case .updateLoading(let isLoading): + state.loading = isLoading + } + } + + private func done() { + self.creationParameters.emailInvites = self.context.emailInvites + self.creationParameters.inviteType = .email + let emailAddressesValidity = service.validate(self.context.emailInvites) + + dispatch(action: .updateEmailValidity(emailAddressesValidity)) + if self.context.emailInvites.reduce(true, { $0 && $1.isEmpty }) { + completion?(.done) + } else if emailAddressesValidity.reduce(true, { $0 && $1}) { + if service.isIdentityServiceReady { + completion?(.done) + } else { + service.prepareIdentityService { [weak self] baseURL, accessToken in + self?.completion?(.needIdentityServiceTerms(baseURL, accessToken)) + } failure: { [weak self] error in + self?.completion?(.identityServiceFailure(error)) + } + } + } + } + + private func cancel() { + completion?(.cancel) + } + + private func back() { + completion?(.back) + } + + private func inviteByUsername() { + completion?(.inviteByUsername) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModelProtocol.swift new file mode 100644 index 000000000..a34305d9b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationEmailInvites/ViewModel/SpaceCreationEmailInvitesViewModelProtocol.swift @@ -0,0 +1,26 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationEmailInvites SpaceCreationEmailInvites +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol SpaceCreationEmailInvitesViewModelProtocol { + + var completion: ((SpaceCreationEmailInvitesViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: SpaceCreationEmailInvitesViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift new file mode 100644 index 000000000..c3399db0c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinator.swift @@ -0,0 +1,74 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import SwiftUI + +final class SpaceCreationMatrixItemChooserCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationMatrixItemChooserCoordinatorParameters + private let spaceCreationMatrixItemChooserHostingController: UIViewController + private var spaceCreationMatrixItemChooserViewModel: SpaceCreationMatrixItemChooserViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationMatrixItemChooserCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationMatrixItemChooserCoordinatorParameters) { + self.parameters = parameters + let service = SpaceCreationMatrixItemChooserService(session: parameters.session, type: parameters.type, selectedItemIds: []) + let viewModel = SpaceCreationMatrixItemChooserViewModel.makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: service, creationParams: parameters.creationParams) + let view = SpaceCreationMatrixItemChooser(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationMatrixItemChooserViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.isNavigationBarHidden = true + spaceCreationMatrixItemChooserHostingController = hostingController + } + + // MARK: - Public + func start() { + MXLog.debug("[SpaceCreationMatrixItemChooserCoordinator] did start.") + spaceCreationMatrixItemChooserViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationMatrixItemChooserCoordinator] SpaceCreationMatrixItemChooserViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .back: + self.callback?(.back) + case .done: + self.callback?(.done) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationMatrixItemChooserHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinatorParameters.swift new file mode 100644 index 000000000..638c48ed1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Coordinator/SpaceCreationMatrixItemChooserCoordinatorParameters.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationMatrixItemChooserCoordinatorParameters { + let session: MXSession + let type: SpaceCreationMatrixItemType + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItem.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItem.swift new file mode 100644 index 000000000..6562518d5 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItem.swift @@ -0,0 +1,26 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationMatrixItem { + let id: String + let avatar: AvatarInput + let displayName: String? + let detailText: String? +} + +extension SpaceCreationMatrixItem: Identifiable, Equatable {} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift new file mode 100644 index 000000000..c431b000c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemChooserCoordinatorAction.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions returned by the coordinator callback +enum SpaceCreationMatrixItemChooserCoordinatorAction { + case done + case cancel + case back +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateAction.swift new file mode 100644 index 000000000..daa989ccf --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateAction.swift @@ -0,0 +1,23 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions to be performed on the `ViewModel` State +enum SpaceCreationMatrixItemListStateAction { + case updateItems([SpaceCreationMatrixItem]) + case updateSelection(Set) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift new file mode 100644 index 000000000..3a8382c15 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewAction.swift @@ -0,0 +1,26 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions send from the `View` to the `ViewModel`. +enum SpaceCreationMatrixItemListStateActionListViewAction { + case searchTextChanged(String) + case itemTapped(_ itemId: String) + case done + case cancel + case back +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift new file mode 100644 index 000000000..490d7dc52 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewModelAction.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions sent by the`ViewModel` to the `Coordinator`. +enum SpaceCreationMatrixItemListStateActionListViewModelAction { + case done + case cancel + case back +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewState.swift new file mode 100644 index 000000000..adf7e24cf --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemListStateActionListViewState.swift @@ -0,0 +1,27 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// State managed by the `ViewModel` delivered to the `View`. +struct SpaceCreationMatrixItemListStateActionListViewState: BindableState { + var navTitle: String + var title: String + var message: String + var emptyListMessage: String + var items: [SpaceCreationMatrixItem] + var selectedItemIds: Set +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemType.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemType.swift new file mode 100644 index 000000000..63b7068e2 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Model/SpaceCreationMatrixItemType.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationMatrixItemType { + case room + case people +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift new file mode 100644 index 000000000..cda1b485d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/MatrixSDK/SpaceCreationMatrixItemChooserService.swift @@ -0,0 +1,133 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class SpaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let processingQueue = DispatchQueue(label: "io.element.SpaceCreationMatrixItemChooserService.processingQueue") + private let completionQueue = DispatchQueue.main + + private let session: MXSession + private let items: [SpaceCreationMatrixItem] + private var filteredItems: [SpaceCreationMatrixItem] { + didSet { + itemsSubject.send(filteredItems) + } + } + private var selectedItemIds: Set + + // MARK: Public + + private(set) var type: SpaceCreationMatrixItemType + private(set) var itemsSubject: CurrentValueSubject<[SpaceCreationMatrixItem], Never> + private(set) var selectedItemIdsSubject: CurrentValueSubject, Never> + var searchText: String = "" { + didSet { + if searchText.isEmpty { + filteredItems = items + } else { + self.processingQueue.async { + let lowercasedSearchText = self.searchText.lowercased() + let filteredItems = self.items.filter { $0.id.lowercased().contains(lowercasedSearchText) || ($0.displayName ?? "").lowercased().contains(lowercasedSearchText) } + + self.completionQueue.async { + self.filteredItems = filteredItems + } + } + } + } + } + + // MARK: - Setup + + init(session: MXSession, type: SpaceCreationMatrixItemType, selectedItemIds: [String]) { + self.session = session + self.type = type + switch type { + case .people: + self.items = session.users().map { user in + SpaceCreationMatrixItem(mxUser: user) + } + case .room: + self.items = session.rooms.compactMap { room in + if room.summary.roomType == .space { + return nil + } + + return SpaceCreationMatrixItem(mxRoom: room, spaceService: session.spaceService) + } + } + self.itemsSubject = CurrentValueSubject(self.items) + self.filteredItems = self.items + + self.selectedItemIds = Set(selectedItemIds) + self.selectedItemIdsSubject = CurrentValueSubject(self.selectedItemIds) + } + + // MARK: - Public + + func reverseSelectionForItem(withId itemId: String) { + if selectedItemIds.contains(itemId) { + selectedItemIds.remove(itemId) + } else { + selectedItemIds.insert(itemId) + } + selectedItemIdsSubject.send(selectedItemIds) + } + +} + +fileprivate extension SpaceCreationMatrixItem { + + init(mxUser: MXUser) { + self.init(id: mxUser.userId, avatar: mxUser.avatarData, displayName: mxUser.displayname, detailText: mxUser.userId) + } + + init(mxRoom: MXRoom, spaceService: MXSpaceService) { + let parentSapceIds = mxRoom.summary.parentSpaceIds ?? Set() + let detailText: String? + if parentSapceIds.isEmpty { + detailText = nil + } else { + if let spaceName = spaceService.getSpace(withId: parentSapceIds.first ?? "")?.summary?.displayname { + let count = parentSapceIds.count - 1 + switch count { + case 0: + detailText = VectorL10n.spacesCreationInSpacename(spaceName) + case 1: + detailText = VectorL10n.spacesCreationInSpacenamePlusOne(spaceName) + default: + detailText = VectorL10n.spacesCreationInSpacenamePlusMany(spaceName, "\(count)") + } + } else { + if parentSapceIds.count > 1 { + detailText = VectorL10n.spacesCreationInManySpaces("\(parentSapceIds.count)") + } else { + detailText = VectorL10n.spacesCreationInOneSpace + } + } + } + self.init(id: mxRoom.roomId, avatar: mxRoom.avatarData, displayName: mxRoom.summary.displayname, detailText: detailText) + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserScreenState.swift new file mode 100644 index 000000000..db48f6350 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserScreenState.swift @@ -0,0 +1,59 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockSpaceCreationMatrixItemChooserScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case noItems + case items + case selectedItems + + /// The associated screen + var screenType: Any.Type { + SpaceCreationMatrixItem.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: MockSpaceCreationMatrixItemChooserService + switch self { + case .noItems: + service = MockSpaceCreationMatrixItemChooserService(type: .room, items: []) + case .items: + service = MockSpaceCreationMatrixItemChooserService() + case .selectedItems: + service = MockSpaceCreationMatrixItemChooserService(type: .room, items: MockSpaceCreationMatrixItemChooserService.mockItems, selectedItemIndexes: [0, 2]) + } + let viewModel = SpaceCreationMatrixItemChooserViewModel.makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: service, creationParams: SpaceCreationParameters()) + + // can simulate service and viewModel actions here if needs be. + + return ( + [service, viewModel], + AnyView(SpaceCreationMatrixItemChooser(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserService.swift new file mode 100644 index 000000000..7974f1304 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/Mock/MockSpaceCreationMatrixItemChooserService.swift @@ -0,0 +1,66 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MockSpaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol { + + static let mockItems = [ + SpaceCreationMatrixItem(id: "!aaabaa:matrix.org", avatar: MockAvatarInput.example, displayName: "Matrix Discussion", detailText: "Descripton of this room"), + SpaceCreationMatrixItem(id: "!zzasds:matrix.org", avatar: MockAvatarInput.example, displayName: "Element Mobile", detailText: "Descripton of this room"), + SpaceCreationMatrixItem(id: "!scthve:matrix.org", avatar: MockAvatarInput.example, displayName: "Alice Personal", detailText: "Descripton of this room") + ] + var itemsSubject: CurrentValueSubject<[SpaceCreationMatrixItem], Never> + var selectedItemIdsSubject: CurrentValueSubject, Never> + var searchText: String = "" + var type: SpaceCreationMatrixItemType = .room + var selectedItemIds: Set = Set() + + init(type: SpaceCreationMatrixItemType = .room, items: [SpaceCreationMatrixItem] = mockItems, selectedItemIndexes: [Int] = []) { + itemsSubject = CurrentValueSubject(items) + var selectedItemIds = Set() + for index in selectedItemIndexes { + if index >= items.count { + continue + } + + selectedItemIds.insert(items[index].id) + } + selectedItemIdsSubject = CurrentValueSubject(selectedItemIds) + self.selectedItemIds = selectedItemIds + } + + func simulateSelectionForItem(at index: Int) { + guard index < itemsSubject.value.count else { + return + } + + reverseSelectionForItem(withId: itemsSubject.value[index].id) + } + + func reverseSelectionForItem(withId itemId: String) { + if selectedItemIds.contains(itemId) { + selectedItemIds.remove(itemId) + } else { + selectedItemIds.insert(itemId) + } + selectedItemIdsSubject.send(selectedItemIds) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/SpaceCreationMatrixItemChooserServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/SpaceCreationMatrixItemChooserServiceProtocol.swift new file mode 100644 index 000000000..66c533325 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Service/SpaceCreationMatrixItemChooserServiceProtocol.swift @@ -0,0 +1,30 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +protocol SpaceCreationMatrixItemChooserServiceProtocol { + var type: SpaceCreationMatrixItemType { get } + var itemsSubject: CurrentValueSubject<[SpaceCreationMatrixItem], Never> { get } + var selectedItemIdsSubject: CurrentValueSubject, Never> { get } + var searchText: String { get set } + + func reverseSelectionForItem(withId itemId: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift new file mode 100644 index 000000000..e6368d86f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/UI/SpaceCreationMatrixItemChooserUITests.swift @@ -0,0 +1,67 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationMatrixItemChooserUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationMatrixItemChooserScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationMatrixItemChooserUITests(selector: #selector(verifySpaceCreationMatrixItemChooserScreen)) + } + + func verifySpaceCreationMatrixItemChooserScreen() throws { + guard let screenState = screenState as? MockSpaceCreationMatrixItemChooserScreenState else { fatalError("no screen") } + switch screenState { + case .noItems: + verifyEmptyScreen() + case .items: + verifyPopulatedScreen() + case .selectedItems: + verifyPopulatedWithSelectionScreen() + } + } + + func verifyEmptyScreen() { + XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, true) + XCTAssertEqual(app.staticTexts["emptyListMessage"].label, VectorL10n.spacesNoResultFoundTitle) + XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip) + } + + func verifyPopulatedScreen() { + XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false) + XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.skip) + } + + func verifyPopulatedWithSelectionScreen() { + XCTAssertEqual(app.staticTexts["titleText"].label, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(app.staticTexts["messageText"].label, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(app.staticTexts["emptyListMessage"].exists, false) + XCTAssertEqual(app.buttons["doneButton"].label, VectorL10n.next) + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/Unit/SpaceCreationMatrixItemChooserViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/Unit/SpaceCreationMatrixItemChooserViewModelTests.swift new file mode 100644 index 000000000..483833579 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/Test/Unit/SpaceCreationMatrixItemChooserViewModelTests.swift @@ -0,0 +1,53 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationMatrixItemChooserViewModelTests: XCTestCase { + + var creationParameters = SpaceCreationParameters() + var service: MockSpaceCreationMatrixItemChooserService! + var viewModel: SpaceCreationMatrixItemChooserViewModelProtocol! + var context: SpaceCreationMatrixItemChooserViewModel.Context! + + override func setUpWithError() throws { + service = MockSpaceCreationMatrixItemChooserService(type: .room) + viewModel = SpaceCreationMatrixItemChooserViewModel.makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: service, creationParams: creationParameters) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.navTitle, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.viewState.emptyListMessage, VectorL10n.spacesNoResultFoundTitle) + XCTAssertEqual(context.viewState.title, VectorL10n.spacesCreationAddRoomsTitle) + XCTAssertEqual(context.viewState.message, VectorL10n.spacesCreationAddRoomsMessage) + XCTAssertEqual(context.viewState.items, MockSpaceCreationMatrixItemChooserService.mockItems) + XCTAssertEqual(context.viewState.selectedItemIds.count, 0) + } + + func testItemSelection() throws { + XCTAssertEqual(context.viewState.selectedItemIds.count, 0) + service.simulateSelectionForItem(at: 0) + XCTAssertEqual(context.viewState.selectedItemIds.count, 1) + XCTAssertEqual(context.viewState.selectedItemIds.first, MockSpaceCreationMatrixItemChooserService.mockItems[0].id) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift new file mode 100644 index 000000000..b4a4d7d98 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooser.swift @@ -0,0 +1,134 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceCreationMatrixItemChooser: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationMatrixItemChooserViewModel.Context + @State var searchText: String = "" + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ViewBuilder + var body: some View { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView + } + .background(theme.colors.background) + .navigationBarHidden(true) + } + + @ViewBuilder + private var mainView: some View { + ZStack(alignment: .bottom) { + listContent + footerView + } + } + + @ViewBuilder + private var headerView: some View { + VStack { + Text(viewModel.viewState.title) + .font(theme.fonts.bodySB) + .foregroundColor(theme.colors.primaryContent) + .padding(.horizontal) + .padding(.vertical, 8) + .accessibility(identifier: "titleText") + Text(viewModel.viewState.message) + .font(theme.fonts.callout) + .foregroundColor(theme.colors.secondaryContent) + .multilineTextAlignment(.center) + .padding(.horizontal) + .accessibility(identifier: "messageText") + Spacer().frame(height: 24) + SearchBar(placeholder: VectorL10n.searchDefaultPlaceholder, text: $searchText) + .onChange(of: searchText, perform: { value in + viewModel.send(viewAction: .searchTextChanged(searchText)) + }) + } + } + + @ViewBuilder + private var listContent: some View { + ScrollView{ + headerView + if viewModel.viewState.items.isEmpty { + Text(viewModel.viewState.emptyListMessage) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + .accessibility(identifier: "emptyListMessage") + Spacer() + } else { + LazyVStack(spacing: 0) { + ForEach(viewModel.viewState.items) { item in + SpaceCreationMatrixItemChooserListRow( + avatar: item.avatar, + displayName: item.displayName, + detailText: item.detailText, + isSelected: viewModel.viewState.selectedItemIds.contains(item.id) + ) + .onTapGesture { + viewModel.send(viewAction: .itemTapped(item.id)) + } + } + } + .padding(.bottom, 76) + .accessibility(identifier: "itemsList") + .frame(maxHeight: .infinity, alignment: .top) + } + } + } + + @ViewBuilder + private var footerView: some View { + ThemableButton(icon: nil, title: viewModel.viewState.selectedItemIds.isEmpty ? VectorL10n.skip : VectorL10n.next) { + viewModel.send(viewAction: .done) + } + .accessibility(identifier: "doneButton") + .padding(.horizontal, 24) + .padding(.bottom) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationMatrixItemChooser_Previews: PreviewProvider { + + static let stateRenderer = MockSpaceCreationMatrixItemChooserScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift new file mode 100644 index 000000000..0144fce20 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/View/SpaceCreationMatrixItemChooserListRow.swift @@ -0,0 +1,73 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceCreationMatrixItemChooserListRow: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + let avatar: AvatarInputProtocol + let displayName: String? + let detailText: String? + let isSelected: Bool + + @ViewBuilder + var body: some View { + HStack{ + AvatarImage(avatarData: avatar, size: .small) + VStack(alignment: .leading) { + Text(displayName ?? "") + .foregroundColor(theme.colors.primaryContent) + .font(theme.fonts.callout) + .accessibility(identifier: "itemNameText") + if let detailText = self.detailText { + Text(detailText) + .foregroundColor(theme.colors.secondaryContent) + .font(theme.fonts.footnote) + .accessibility(identifier: "itemDetailText") + } + } + Spacer() + if isSelected { + Image(systemName: "checkmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.accent) + } else { + Image(systemName: "circle").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent) + } + } + .contentShape(Rectangle()) + .padding(.horizontal) + .padding(.vertical, 12) + .frame(maxWidth: .infinity) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationMatrixItemChooserListRow_Previews: PreviewProvider { + static var previews: some View { + TemplateRoomListRow(avatar: MockAvatarInput.example, displayName: "Alice") + .addDependency(MockAvatarService.example) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift new file mode 100644 index 000000000..549bd8f6e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModel.swift @@ -0,0 +1,121 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + + +@available(iOS 14, *) +typealias SpaceCreationMatrixItemChooserViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationMatrixItemChooserViewModel: SpaceCreationMatrixItemChooserViewModelType, SpaceCreationMatrixItemChooserViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private var spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol + private var creationParams: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationMatrixItemListStateActionListViewModelAction) -> Void)? + + // MARK: - Setup + + static func makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) -> SpaceCreationMatrixItemChooserViewModelProtocol { + return SpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: spaceCreationMatrixItemChooserService, creationParams: creationParams) + } + + private init(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) { + self.spaceCreationMatrixItemChooserService = spaceCreationMatrixItemChooserService + self.creationParams = creationParams + super.init(initialViewState: Self.defaultState(spaceCreationMatrixItemChooserService: spaceCreationMatrixItemChooserService, creationParams: creationParams)) + startObservingItems() + } + + private static func defaultState(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) -> SpaceCreationMatrixItemListStateActionListViewState { + let navTitle = creationParams.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle + let title = spaceCreationMatrixItemChooserService.type == .people ? VectorL10n.spacesCreationInviteByUsernameTitle : VectorL10n.spacesCreationAddRoomsTitle + let message = spaceCreationMatrixItemChooserService.type == .people ? VectorL10n.spacesCreationInviteByUsernameMessage : VectorL10n.spacesCreationAddRoomsMessage + let emptyListMessage = VectorL10n.spacesNoResultFoundTitle + + return SpaceCreationMatrixItemListStateActionListViewState(navTitle: navTitle, title: title, message: message, emptyListMessage: emptyListMessage, items: spaceCreationMatrixItemChooserService.itemsSubject.value, selectedItemIds: spaceCreationMatrixItemChooserService.selectedItemIdsSubject.value) + } + + private func startObservingItems() { + let itemsUpdatePublisher = spaceCreationMatrixItemChooserService.itemsSubject + .map(SpaceCreationMatrixItemListStateAction.updateItems) + .eraseToAnyPublisher() + dispatch(actionPublisher: itemsUpdatePublisher) + + let selectionPublisher = spaceCreationMatrixItemChooserService.selectedItemIdsSubject + .map(SpaceCreationMatrixItemListStateAction.updateSelection) + .eraseToAnyPublisher() + dispatch(actionPublisher: selectionPublisher) + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationMatrixItemListStateActionListViewAction) { + switch viewAction { + case .cancel: + cancel() + case .back: + back() + case .done: + let selectedItemIds = Array(spaceCreationMatrixItemChooserService.selectedItemIdsSubject.value) + switch spaceCreationMatrixItemChooserService.type { + case .people: + creationParams.inviteType = .userId + creationParams.userIdInvites = selectedItemIds + default: + creationParams.addedRoomIds = selectedItemIds + } + done() + case .searchTextChanged(let searchText): + self.spaceCreationMatrixItemChooserService.searchText = searchText + case .itemTapped(let itemId): + self.spaceCreationMatrixItemChooserService.reverseSelectionForItem(withId: itemId) + } + } + + override class func reducer(state: inout SpaceCreationMatrixItemListStateActionListViewState, action: SpaceCreationMatrixItemListStateAction) { + switch action { + case .updateItems(let items): + state.items = items + case .updateSelection(let selectedItemIds): + state.selectedItemIds = selectedItemIds + } + UILog.debug("[SpaceCreationMatrixItemChooserViewModel] reducer with action \(action) produced state: \(state)") + } + + private func done() { + callback?(.done) + } + + private func cancel() { + callback?(.cancel) + } + + private func back() { + callback?(.back) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModelProtocol.swift new file mode 100644 index 000000000..3009daf32 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMatrixItemChooser/ViewModel/SpaceCreationMatrixItemChooserViewModelProtocol.swift @@ -0,0 +1,28 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationMatrixItemChooser SpaceCreationMatrixItemChooser +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol SpaceCreationMatrixItemChooserViewModelProtocol { + + var callback: ((SpaceCreationMatrixItemListStateActionListViewModelAction) -> Void)? { get set } + @available(iOS 14, *) + static func makeSpaceCreationMatrixItemChooserViewModel(spaceCreationMatrixItemChooserService: SpaceCreationMatrixItemChooserServiceProtocol, creationParams: SpaceCreationParameters) -> SpaceCreationMatrixItemChooserViewModelProtocol + @available(iOS 14, *) + var context: SpaceCreationMatrixItemChooserViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift new file mode 100644 index 000000000..32e7031b7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinator.swift @@ -0,0 +1,75 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import SwiftUI + +final class SpaceCreationMenuCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationMenuCoordinatorParameters + private let spaceCreationMenuHostingController: UIViewController + private var spaceCreationMenuViewModel: SpaceCreationMenuViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationMenuCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationMenuCoordinatorParameters) { + self.parameters = parameters + let viewModel = SpaceCreationMenuViewModel(navTitle: parameters.navTitle, creationParams: parameters.creationParams, title: parameters.title, detail: parameters.detail, options: parameters.options) + let view = SpaceCreationMenu(viewModel: viewModel.context, showBackButton: parameters.showBackButton) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationMenuViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.isNavigationBarHidden = true + spaceCreationMenuHostingController = hostingController + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceCreationMenuCoordinator] did start.") + spaceCreationMenuViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationMenuCoordinator] SpaceCreationMenuViewModel did complete with result \(result).") + guard let self = self else { return } + switch result { + case .didSelectOption(let optionId): + self.callback?(.didSelectOption(optionId)) + case .cancel: + self.callback?(.cancel) + case .back: + self.callback?(.back) + break + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationMenuHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift new file mode 100644 index 000000000..099c12b68 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Coordinator/SpaceCreationMenuCoordinatorParamaters.swift @@ -0,0 +1,29 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationMenuCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters + let navTitle: String? + let showBackButton: Bool + let title: String + let detail: String + let options: [SpaceCreationMenuRoomOption] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift new file mode 100644 index 000000000..c3715ee52 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuCoordinatorAction.swift @@ -0,0 +1,26 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions returned by the coordinator callback +enum SpaceCreationMenuCoordinatorAction { + case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) + case cancel + case back +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift new file mode 100644 index 000000000..27c95e48c --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuRoom.swift @@ -0,0 +1,41 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +/// list of IDs for the items displayed in the different menu views +enum SpaceCreationMenuRoomOptionId { + /// Public space option + case publicSpace + /// Private space option + case privateSpace + /// Private space for internal use option + case ownedPrivateSpace + /// Private space shared with members option + case sharedPrivateSpace +} + +struct SpaceCreationMenuRoomOption { + let id: SpaceCreationMenuRoomOptionId + let icon: UIImage + let title: String + let detail: String +} + +extension SpaceCreationMenuRoomOption: Identifiable, Equatable {} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuStateAction.swift new file mode 100644 index 000000000..3109b348b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuStateAction.swift @@ -0,0 +1,23 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions to be performed on the `ViewModel` State +enum SpaceCreationMenuStateAction { +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift new file mode 100644 index 000000000..7d0afb05e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewAction.swift @@ -0,0 +1,26 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions send from the `View` to the `ViewModel`. +enum SpaceCreationMenuViewAction { + case back + case cancel + case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift new file mode 100644 index 000000000..079ddab25 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewModelAction.swift @@ -0,0 +1,26 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// Actions sent by the`ViewModel` to the `Coordinator`. +enum SpaceCreationMenuViewModelAction { + case didSelectOption(_ optionId: SpaceCreationMenuRoomOptionId) + case cancel + case back +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewState.swift new file mode 100644 index 000000000..d25f2bf1a --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationMenuViewState.swift @@ -0,0 +1,27 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// State managed by the `ViewModel` delivered to the `View`. +struct SpaceCreationMenuViewState: BindableState { + var navTitle: String + var title: String + var detail: String + var options: [SpaceCreationMenuRoomOption] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift new file mode 100644 index 000000000..a9c2036f4 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Model/SpaceCreationParameters.swift @@ -0,0 +1,112 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +enum SpaceCreationInviteType { + case email + case userId +} + +class SpaceCreationParameters { + var name: String? { + didSet { + isModified = true + } + } + var topic: String? { + didSet { + isModified = true + } + } + var address: String? { + didSet { + isModified = true + } + } + var userDefinedAddress: String? { + didSet { + isModified = true + } + } + var isPublic: Bool = false { + didSet { + isModified = true + } + } + var showAddress: Bool { + isPublic + } + + var userSelectedAvatar: UIImage? { + didSet { + isModified = true + } + } + var isShared: Bool = false { + didSet { + isModified = true + } + } + + var newRooms: [SpaceCreationNewRoom] = [ + SpaceCreationNewRoom(name: VectorL10n.spacesCreationNewRoomsGeneral, defaultName: VectorL10n.spacesCreationNewRoomsGeneral), + SpaceCreationNewRoom(name: VectorL10n.spacesCreationNewRoomsRandom, defaultName: VectorL10n.spacesCreationNewRoomsRandom), + SpaceCreationNewRoom(name: "", defaultName: VectorL10n.spacesCreationNewRoomsSupport) + ] { + didSet { + isModified = true + } + } + + var addedRoomIds: [String]? { + didSet { + isModified = true + } + } + + var emailInvites: [String] = ["", ""] { + didSet { + isModified = true + } + } + var userDefinedEmailInvites: [String] { + return emailInvites.filter { address in + return !address.isEmpty + } + } + var userIdInvites: [String] = [] { + didSet { + isModified = true + } + } + var inviteType: SpaceCreationInviteType = .email { + didSet { + isModified = true + } + } + var isModified: Bool = false +} + +struct SpaceCreationNewRoom: Equatable { + var name: String + var defaultName: String + + static func == (lhs: Self, rhs: Self) -> Bool { + return lhs.defaultName == rhs.defaultName && lhs.name == rhs.name + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift new file mode 100644 index 000000000..77e6826d5 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/UI/SpaceCreationMenuUITests.swift @@ -0,0 +1,54 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationMenuUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationMenuScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationMenuUITests(selector: #selector(verifySpaceCreationMenuScreen)) + } + + func verifySpaceCreationMenuScreen() throws { + guard let screenState = screenState as? MockSpaceCreationMenuScreenState else { fatalError("no screen") } + switch screenState { + case .options: + verifySpaceCreationMenuOptions() + } + } + + func verifySpaceCreationMenuOptions() { + let optionButtonCount = app.buttons.matching(identifier:"optionButton").count + XCTAssertEqual(optionButtonCount, 2) + + let titleText = app.staticTexts["titleText"] + XCTAssert(titleText.exists) + XCTAssert(titleText.label == "Some title") + + let detailText = app.staticTexts["detailText"] + XCTAssert(detailText.exists) + XCTAssertEqual(detailText.label, "Some detail text") + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/Unit/SpaceCreationMenuViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/Unit/SpaceCreationMenuViewModelTests.swift new file mode 100644 index 000000000..50f4b099b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/Test/Unit/SpaceCreationMenuViewModelTests.swift @@ -0,0 +1,59 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationMenuViewModelTests: XCTestCase { + private enum Constants { + } + + let navTitle = VectorL10n.spacesCreateSpaceTitle + var creationParams = SpaceCreationParameters() + let title = VectorL10n.spacesCreateSpaceTitle + let detail = VectorL10n.spacesCreationVisibilityMessage + let options = [ + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: VectorL10n.spacePublicJoinRule, detail: VectorL10n.spacePublicJoinRuleDetail), + SpaceCreationMenuRoomOption(id: .privateSpace, icon: Asset.Images.spacePrivateIcon.image, title: VectorL10n.spacePrivateJoinRule, detail: VectorL10n.spacePrivateJoinRuleDetail) + ] + + var viewModel: SpaceCreationMenuViewModel! + var context: SpaceCreationMenuViewModel.Context! + var cancellables = Set() + + override func setUpWithError() throws { + viewModel = SpaceCreationMenuViewModel( + navTitle: navTitle, + creationParams: creationParams, + title: title, + detail: detail, + options: options + ) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.navTitle, navTitle) + XCTAssertEqual(context.viewState.title, title) + XCTAssertEqual(context.viewState.detail, detail) + XCTAssertEqual(context.viewState.options, options) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift new file mode 100644 index 000000000..68bc776c7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/View/SpaceCreationMenu.swift @@ -0,0 +1,146 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceCreationMenu: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationMenuViewModelType.Context + let showBackButton: Bool + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + var body: some View { + mainScreen + .navigationBarHidden(true) + } + + // MARK: - Private + + @ViewBuilder + private var mainScreen: some View { + VStack { + ThemableNavigationBar(title: nil, showBackButton: showBackButton) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + GeometryReader { reader in + ScrollView { + VStack { + headerView + Spacer() + optionsView + } + .frame(minHeight: reader.size.height - 2) + } + } + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) + } + .background(theme.colors.background) + } + + @ViewBuilder + private var headerView: some View { + VStack { + Text(viewModel.viewState.title) + .multilineTextAlignment(.center) + .font(theme.fonts.title3SB) + .foregroundColor(theme.colors.primaryContent) + .accessibility(identifier: "titleText") + .padding(.bottom, 20) + Text(viewModel.viewState.detail) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + .accessibility(identifier: "detailText") + } + } + + @ViewBuilder + private var optionsView: some View { + VStack(spacing: 24) { + VStack(spacing: 16) { + ForEach(viewModel.viewState.options) { option in + OptionButton(icon: option.icon, title: option.title, detailMessage: option.detail) { + viewModel.send(viewAction: .didSelectOption(option.id)) + } + .accessibility(identifier: "optionButton") + } + } + Text(VectorL10n.spacesCreationFooter) + .multilineTextAlignment(.center) + .font(theme.fonts.footnote) + .foregroundColor(theme.colors.secondaryContent) + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationMenu_Previews: PreviewProvider { + + static let stateRenderer = MockSpaceCreationMenuScreenState.stateRenderer + + static var previews: some View { + Group { + stateRenderer.screenGroup() + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup() + .theme(.dark).preferredColorScheme(.dark) + } + } +} + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockSpaceCreationMenuScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case options + + /// The associated screen + var screenType: Any.Type { + SpaceCreationMenu.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let viewModel = SpaceCreationMenuViewModel(navTitle: VectorL10n.spacesCreateSpaceTitle, creationParams: SpaceCreationParameters(), title: "Some title", detail: "Some detail text", options: [ + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: "Title of option 1", detail: "Detail of option 1"), + SpaceCreationMenuRoomOption(id: .publicSpace, icon: Asset.Images.spaceTypeIcon.image, title: "Title of option 2", detail: "Detail of option 2") + ]) + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(SpaceCreationMenu(viewModel: viewModel.context, showBackButton: true)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift new file mode 100644 index 000000000..0d315e0f7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModel.swift @@ -0,0 +1,95 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + +@available(iOS 14, *) +typealias SpaceCreationMenuViewModelType = StateStoreViewModel +@available(iOS 14.0, *) +class SpaceCreationMenuViewModel: SpaceCreationMenuViewModelType, SpaceCreationMenuViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + let creationParams: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationMenuViewModelAction) -> Void)? + + // MARK: - Setup + + init(navTitle: String?, creationParams: SpaceCreationParameters, title: String, detail: String, options: [SpaceCreationMenuRoomOption]) { + self.creationParams = creationParams + + super.init(initialViewState: SpaceCreationMenuViewModel.defaultState(navTitle: navTitle, creationParams: creationParams, title: title, detail: detail, options: options)) + } + + private static func defaultState(navTitle: String?, creationParams: SpaceCreationParameters, title: String, detail: String, options: [SpaceCreationMenuRoomOption]) -> SpaceCreationMenuViewState { + var navigationTitle: String = "" + if let navTitle = navTitle { + navigationTitle = navTitle + } else { + navigationTitle = creationParams.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle + } + + return SpaceCreationMenuViewState(navTitle: navigationTitle, title: title, detail: detail, options: options) + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationMenuViewAction) { + switch viewAction { + case .didSelectOption(let optionId): + switch optionId { + case .publicSpace: + self.creationParams.isPublic = true + case .privateSpace: + self.creationParams.isPublic = false + case .ownedPrivateSpace: + self.creationParams.isShared = false + case .sharedPrivateSpace: + self.creationParams.isShared = true + } + + didSelectOption(withId: optionId) + case .cancel: + done() + case .back: + back() + } + } + + // MARK: - Private + + private func done() { + callback?(.cancel) + } + + private func back() { + callback?(.back) + } + + private func didSelectOption(withId optionId: SpaceCreationMenuRoomOptionId) { + callback?(.didSelectOption(optionId)) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModelProtocol.swift new file mode 100644 index 000000000..4012e8d57 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationMenu/ViewModel/SpaceCreationMenuViewModelProtocol.swift @@ -0,0 +1,25 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol SpaceCreationMenuViewModelProtocol { + var callback: ((SpaceCreationMenuViewModelAction) -> Void)? { get set } + @available(iOS 14, *) + var context: SpaceCreationMenuViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift new file mode 100644 index 000000000..bbcc56a13 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinator.swift @@ -0,0 +1,71 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import SwiftUI + +final class SpaceCreationPostProcessCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationPostProcessCoordinatorParameters + private let spaceCreationPostProcessHostingController: UIViewController + private var spaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationPostProcessCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationPostProcessCoordinatorParameters) { + self.parameters = parameters + let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessService(session: parameters.session, creationParams: parameters.creationParams)) + let view = SpaceCreationPostProcess(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationPostProcessViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.isNavigationBarHidden = true + spaceCreationPostProcessHostingController = hostingController + } + + // MARK: - Public + func start() { + MXLog.debug("[SpaceCreationPostProcessCoordinator] did start.") + spaceCreationPostProcessViewModel.completion = { [weak self] result in + MXLog.debug("[SpaceCreationPostProcessCoordinator] SpaceCreationPostProcessViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .done(let spaceId): + self.callback?(.done(spaceId)) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationPostProcessHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift new file mode 100644 index 000000000..be2857b70 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Coordinator/SpaceCreationPostProcessCoordinatorParameters.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationPostProcessCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessCoordinatorAction.swift new file mode 100644 index 000000000..d384c3a5d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessCoordinatorAction.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationPostProcessCoordinatorAction { + case done(_ spaceId: String) + case cancel +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessStateAction.swift new file mode 100644 index 000000000..31003ed4e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessStateAction.swift @@ -0,0 +1,23 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationPostProcessStateAction { + case updateTasks([SpaceCreationPostProcessTask]) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift new file mode 100644 index 000000000..6c2d8baf1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessTask.swift @@ -0,0 +1,48 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationPostProcessTaskState: CaseIterable, Equatable { + static var allCases: [SpaceCreationPostProcessTaskState] = [.none, .started, .success, .failure] + + case none + case started + case success + case failure +} + +enum SpaceCreationPostProcessTaskType: Equatable { + case createSpace + case uploadAvatar + case createRoom(_ roomName: String) + case addRooms + case inviteUsersByEmail +} + +struct SpaceCreationPostProcessTask: Equatable { + let type: SpaceCreationPostProcessTaskType + let title: String + var state: SpaceCreationPostProcessTaskState + var isFinished: Bool { + return state == .failure || state == .success + } + var subTasks: [SpaceCreationPostProcessTask] = [] + + static func == (lhs: SpaceCreationPostProcessTask, rhs: SpaceCreationPostProcessTask) -> Bool { + return lhs.type == rhs.type && lhs.title == rhs.title && lhs.state == rhs.state && lhs.subTasks == lhs.subTasks + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewAction.swift new file mode 100644 index 000000000..dc7397f46 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewAction.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationPostProcessViewAction { + case cancel + case runTasks + case retry +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewModelResult.swift new file mode 100644 index 000000000..38ee7179d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewModelResult.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationPostProcessViewModelResult { + case cancel + case done(_ spaceId: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift new file mode 100644 index 000000000..311b2fcba --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Model/SpaceCreationPostProcessViewState.swift @@ -0,0 +1,28 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +struct SpaceCreationPostProcessViewState: BindableState { + var avatar: AvatarInput + var avatarImage: UIImage? + var tasks: [SpaceCreationPostProcessTask] + var isFinished: Bool + var errorCount: Int +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift new file mode 100644 index 000000000..2c455bcf6 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/MatrixSDK/SpaceCreationPostProcessService.swift @@ -0,0 +1,343 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import MatrixSDK + +@available(iOS 14.0, *) +class SpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { + + // MARK: - Properties + + // MARK: Private + + private let session: MXSession + private let creationParams: SpaceCreationParameters + + private var tasks: [SpaceCreationPostProcessTask] = [] + private var currentTaskIndex = 0 + private var isRetry = false + + private(set) var createdSpace: MXSpace? { + didSet { + createdSpaceId = createdSpace?.spaceId + } + } + private var createdRoomsByName: [String: MXRoom] = [:] + + private var currentSubTaskIndex = 0 + + private var processingQueue = DispatchQueue(label: "io.element.MXSpace.processingQueue", attributes: .concurrent) + + private lazy var stateEventBuilder: MXRoomInitialStateEventBuilder = { + return MXRoomInitialStateEventBuilder() + }() + + private lazy var mediaUploader: MXMediaLoader = { + return MXMediaManager.prepareUploader(withMatrixSession: session, initialRange: 0, andRange: 1.0) + }() + + // MARK: Public + + private(set) var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> + private(set) var createdSpaceId: String? + var avatar: AvatarInput { + let alias = creationParams.userDefinedAddress.isEmptyOrNil ? creationParams.address : creationParams.userDefinedAddress + return AvatarInput(mxContentUri: alias, matrixItemId: "", displayName: creationParams.name) + } + var avatarImage: UIImage? { + return creationParams.userSelectedAvatar + } + + // MARK: - Setup + + init(session: MXSession, creationParams: SpaceCreationParameters) { + self.session = session + self.creationParams = creationParams + self.tasks = Self.tasks(with: creationParams) + self.tasksSubject = CurrentValueSubject(tasks) + } + + deinit { + } + + // MARK: - Public + + func run() { + self.isRetry = self.currentTaskIndex > 0 + self.currentTaskIndex = -1 + runNextTask() + } + + // MARK: - Private + + private static func tasks(with creationParams: SpaceCreationParameters) -> [SpaceCreationPostProcessTask] { + guard let spaceName = creationParams.name else { + MXLog.error("[SpaceCreationPostProcessService] setupTasks: space name shouldn't be nil") + return [] + } + + var tasks = [SpaceCreationPostProcessTask(type: .createSpace, title: VectorL10n.spacesCreationPostProcessCreatingSpaceTask(spaceName), state: .none)] + if creationParams.userSelectedAvatar != nil { + tasks.append(SpaceCreationPostProcessTask(type: .uploadAvatar, title: VectorL10n.spacesCreationPostProcessUploadingAvatar, state: .none)) + } + if let addedRoomIds = creationParams.addedRoomIds { + if !addedRoomIds.isEmpty { + let subTasks = addedRoomIds.map { roomId in + SpaceCreationPostProcessTask(type: .addRooms, title: roomId, state: .none) + } + tasks.append(SpaceCreationPostProcessTask(type: .addRooms, title: VectorL10n.spacesCreationPostProcessAddingRooms("\(addedRoomIds.count)"), state: .none, subTasks: subTasks)) + } + } else { + tasks.append(contentsOf: creationParams.newRooms.compactMap({ room in + guard !room.name.isEmpty else { + return nil + } + + return SpaceCreationPostProcessTask(type: .createRoom(room.name), title: VectorL10n.spacesCreationPostProcessCreatingRoom(room.name), state: .none) + })) + } + + if creationParams.inviteType == .email { + let emailInviteCount = creationParams.userDefinedEmailInvites.count + if emailInviteCount > 0 { + let subTasks = creationParams.userDefinedEmailInvites.map { emailAddress in + SpaceCreationPostProcessTask(type: .inviteUsersByEmail, title: emailAddress, state: .none) + } + + tasks.append(SpaceCreationPostProcessTask(type: .inviteUsersByEmail, title: VectorL10n.spacesCreationPostProcessInvitingUsers("\(creationParams.userDefinedEmailInvites.count)"), state: .none, subTasks: subTasks)) + } + } + + return tasks + } + + private func runNextTask() { + currentTaskIndex += 1 + guard currentTaskIndex < tasks.count else { + return + } + + let task = tasks[currentTaskIndex] + + guard !task.isFinished || task.state == .failure else { + runNextTask() + return + } + + switch task.type { + case .createSpace: + createSpace(andUpdate: task) + case .uploadAvatar: + uploadAvatar(andUpdate: task) + case .addRooms: + addRooms(andUpdate: task) + case .createRoom(let roomName): + if let room = createdRoomsByName[roomName] { + addToSpace(room: room) + } else { + createRoom(withName: roomName, andUpdate: task) + } + case .inviteUsersByEmail: + inviteUsersByEmail(andUpdate: task) + } + } + + private func createSpace(andUpdate task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + + var alias = creationParams.address + if let userDefinedAlias = creationParams.userDefinedAddress, !userDefinedAlias.isEmpty { + alias = userDefinedAlias + } + let userIdInvites = creationParams.inviteType == .userId ? creationParams.userIdInvites : [] + session.spaceService.createSpace(withName: creationParams.name, topic: creationParams.topic, isPublic: creationParams.isPublic, aliasLocalPart: alias, inviteArray: userIdInvites) { [weak self] response in + guard let self = self else { return } + if response.isFailure { + self.updateCurrentTask(with: .failure) + } else { + self.creationParams.isModified = false + self.createdSpace = response.value + self.updateCurrentTask(with: .success) + self.runNextTask() + } + } + } + + private func uploadAvatar(andUpdate task: SpaceCreationPostProcessTask) { + self.updateCurrentTask(with: .started) + + guard let avatar = creationParams.userSelectedAvatar, let spaceRoom = self.createdSpace?.room else { + self.updateCurrentTask(with: .success) + self.runNextTask() + return + } + + let avatarUp = MXKTools.forceImageOrientationUp(avatar) + + mediaUploader.uploadData(avatarUp?.jpegData(compressionQuality: 0.5), filename: nil, mimeType: "image/jpeg", + success: { [weak self] (urlString) in + guard let self = self else { return } + guard let urlString = urlString else { return } + guard let url = URL(string: urlString) else { return } + + self.setAvatar(ofRoom: spaceRoom, withURL: url, andUpdate: task) + }, + failure: { [weak self] (error) in + guard let self = self else { return } + + self.updateCurrentTask(with: .failure) + self.runNextTask() + }) + } + + private func setAvatar(ofRoom room: MXRoom, withURL url: URL, andUpdate task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + + room.setAvatar(url: url) { [weak self] (response) in + guard let self = self else { return } + + self.updateCurrentTask(with: response.isSuccess ? .success: .failure) + self.runNextTask() + } + } + + private func createRoom(withName roomName: String, andUpdate task: SpaceCreationPostProcessTask) { + guard let createdSpace = self.createdSpace else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + updateCurrentTask(with: .started) + + let joinRule: MXRoomJoinRule = creationParams.isPublic ? .public : .restricted + let parentRoomId = creationParams.isPublic ? nil : createdSpace.spaceId + session.createRoom(withName: roomName, joinRule: joinRule, topic: nil, parentRoomId: parentRoomId, aliasLocalPart: nil) { [weak self] response in + guard let self = self else { return } + + guard response.isSuccess, let createdRoom = response.value else { + self.updateCurrentTask(with: .failure) + self.runNextTask() + return + } + + self.createdRoomsByName[roomName] = createdRoom + self.addToSpace(room: createdRoom) + } + } + + private func addToSpace(room: MXRoom) { + guard let createdSpace = self.createdSpace else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + createdSpace.addChild(roomId: room.matrixItemId, completion: { response in + self.updateCurrentTask(with: response.isFailure ? .failure : .success) + self.runNextTask() + }) + } + + private func addRooms(andUpdate task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + currentSubTaskIndex = -1 + addNextExistingRoom() + } + + private func inviteUsersByEmail(andUpdate task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + currentSubTaskIndex = -1 + inviteNextUserByEmail() + } + + private func inviteNextUserByEmail() { + guard let createdSpace = self.createdSpace, let room = createdSpace.room else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + currentSubTaskIndex += 1 + + guard currentSubTaskIndex < tasks[currentTaskIndex].subTasks.count else { + let isSuccess = tasks[currentTaskIndex].subTasks.reduce(true, { $0 && $1.state == .success }) + updateCurrentTask(with: isSuccess ? .success : .failure) + runNextTask() + return + } + + room.invite(.email(creationParams.emailInvites[currentSubTaskIndex])) { [weak self] response in + guard let self = self else { return } + + self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure + self.inviteNextUserByEmail() + } + } + + private func addNextExistingRoom() { + guard let createdSpace = self.createdSpace else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + currentSubTaskIndex += 1 + + guard currentSubTaskIndex < tasks[currentTaskIndex].subTasks.count else { + let isSuccess = tasks[currentTaskIndex].subTasks.reduce(true, { $0 && $1.state == .success }) + updateCurrentTask(with: isSuccess ? .success : .failure) + runNextTask() + return + } + + guard let roomId = creationParams.addedRoomIds?[currentSubTaskIndex] else { + updateCurrentTask(with: .failure) + runNextTask() + return + } + + createdSpace.addChild(roomId: roomId, completion: { [weak self] response in + guard let self = self else { return } + + self.tasks[self.currentTaskIndex].subTasks[self.currentSubTaskIndex].state = response.isSuccess ? .success : .failure + self.addNextExistingRoom() + }) + } + + private func fakeTaskExecution(task: SpaceCreationPostProcessTask) { + updateCurrentTask(with: .started) + processingQueue.async { + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.updateCurrentTask(with: .success) + self.runNextTask() + } + } + } + + private func updateCurrentTask(with state: SpaceCreationPostProcessTaskState) { + guard currentTaskIndex < tasks.count else { + return + } + + tasks[currentTaskIndex].state = state + self.tasksSubject.send(tasks) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift new file mode 100644 index 000000000..785f4cf90 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessScreenState.swift @@ -0,0 +1,62 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockSpaceCreationPostProcessScreenState: MockScreenState { + static var screenStates: [MockScreenState] = [MockSpaceCreationPostProcessScreenState.running, MockSpaceCreationPostProcessScreenState.done, MockSpaceCreationPostProcessScreenState.doneWithError] + + + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case running + case done + case doneWithError + + /// The associated screen + var screenType: Any.Type { + SpaceCreationPostProcess.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let service: MockSpaceCreationPostProcessService + switch self { + case .running: + service = MockSpaceCreationPostProcessService() + case .done: + service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.lastTaskDoneSuccesfully) + case .doneWithError: + service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.lastTaskDoneWithError) + } + let viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service) + + // can simulate service and viewModel actions here if needs be. + + return ( + [service, viewModel], + AnyView(SpaceCreationPostProcess(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift new file mode 100644 index 000000000..949ff78ea --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/Mock/MockSpaceCreationPostProcessService.swift @@ -0,0 +1,75 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import UIKit + +@available(iOS 14.0, *) +class MockSpaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol { + + static let defaultTasks: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .started), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .none) + ] + + static let nextStepTasks: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .started) + ] + + static let lastTaskDoneWithError: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .failure), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .success) + ] + + static let lastTaskDoneSuccesfully: [SpaceCreationPostProcessTask] = [ + SpaceCreationPostProcessTask(type: .createSpace, title: "Space creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#1"), title: "Room#1 creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#2"), title: "Room#2 creation", state: .success), + SpaceCreationPostProcessTask(type: .createRoom("Room#3"), title: "Room#3 creation", state: .success) + ] + + var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> + private(set) var createdSpaceId: String? + var avatar: AvatarInput { + return AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: "Some space") + } + var avatarImage: UIImage? { + return nil + } + + init( + tasks: [SpaceCreationPostProcessTask] = defaultTasks + ) { + self.tasksSubject = CurrentValueSubject<[SpaceCreationPostProcessTask], Never>(tasks) + } + + func simulateUpdate(tasks: [SpaceCreationPostProcessTask]) { + self.tasksSubject.send(tasks) + } + + func run() { + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift new file mode 100644 index 000000000..49c81d3d8 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Service/SpaceCreationPostProcessServiceProtocol.swift @@ -0,0 +1,30 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import UIKit + +@available(iOS 14.0, *) +protocol SpaceCreationPostProcessServiceProtocol: AnyObject { + var tasksSubject: CurrentValueSubject<[SpaceCreationPostProcessTask], Never> { get } + var createdSpaceId: String? { get } + var avatar: AvatarInput { get } + var avatarImage: UIImage? { get } + func run() +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift new file mode 100644 index 000000000..2f42eacf2 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/UI/SpaceCreationPostProcessUITests.swift @@ -0,0 +1,40 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationPostProcessUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationPostProcessScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationPostProcessUITests(selector: #selector(verifySpaceCreationPostProcessScreen)) + } + + func verifySpaceCreationPostProcessScreen() throws { + guard let screenState = screenState as? MockSpaceCreationPostProcessScreenState else { fatalError("no screen") } + } + + func verifyTasksList() { + } + +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift new file mode 100644 index 000000000..76c1a53f4 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/Test/Unit/SpaceCreationPostProcessViewModelTests.swift @@ -0,0 +1,56 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationPostProcessViewModelTests: XCTestCase { + + var service: MockSpaceCreationPostProcessService! + var viewModel: SpaceCreationPostProcessViewModelProtocol! + var context: SpaceCreationPostProcessViewModelType.Context! + + override func setUpWithError() throws { + service = MockSpaceCreationPostProcessService(tasks: MockSpaceCreationPostProcessService.defaultTasks) + viewModel = SpaceCreationPostProcessViewModel.makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: service) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.tasks, MockSpaceCreationPostProcessService.defaultTasks) + XCTAssertEqual(context.viewState.errorCount, 1) + XCTAssertEqual(context.viewState.isFinished, false) + } + + func testUpateToNextTask() { + service.simulateUpdate(tasks: MockSpaceCreationPostProcessService.nextStepTasks) + XCTAssertEqual(context.viewState.tasks, MockSpaceCreationPostProcessService.nextStepTasks) + XCTAssertEqual(context.viewState.errorCount, 2) + XCTAssertEqual(context.viewState.isFinished, false) + } + + func testLastTaskDone() { + service.simulateUpdate(tasks: MockSpaceCreationPostProcessService.lastTaskDoneWithError) + XCTAssertEqual(context.viewState.tasks, MockSpaceCreationPostProcessService.lastTaskDoneWithError) + XCTAssertEqual(context.viewState.errorCount, 2) + XCTAssertEqual(context.viewState.isFinished, true) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift new file mode 100644 index 000000000..47bc0bf7f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcess.swift @@ -0,0 +1,111 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceCreationPostProcess: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: SpaceCreationPostProcessViewModel.Context + + var body: some View { + VStack { + Spacer() + headerView + Spacer() + tasksList + Spacer() + buttonsPanel + } + .animation(.easeIn(duration: 0.2), value: viewModel.viewState.errorCount) + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) + .navigationBarHidden(true) + .background(theme.colors.background) + .frame(maxHeight: .infinity) + .onAppear() { + viewModel.send(viewAction: .runTasks) + } + } + + @ViewBuilder + private var headerView: some View { + VStack(spacing: 13) { + avatarView + Text(VectorL10n.spacesCreationPostProcessCreatingSpace) + .font(theme.fonts.calloutSB) + .foregroundColor(theme.colors.secondaryContent) + } + } + + @ViewBuilder + private var tasksList: some View { + VStack(alignment: .leading, spacing: 11) { + ForEach(viewModel.viewState.tasks.indices) { index in + SpaceCreationPostProcessItem(title: viewModel.viewState.tasks[index].title, state: viewModel.viewState.tasks[index].state) + } + } + } + + @ViewBuilder + private var buttonsPanel: some View { + HStack { + ThemableButton(icon: nil, title: VectorL10n.cancel) { + viewModel.send(viewAction: .cancel) + } + ThemableButton(icon: nil, title: VectorL10n.retry) { + viewModel.send(viewAction: .retry) + } + } + .isHidden(!viewModel.viewState.isFinished || viewModel.viewState.errorCount == 0) + } + + @ViewBuilder + private var avatarView: some View { + ZStack { + SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xLarge) + .padding(6) + if let image = viewModel.viewState.avatarImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 52, height: 52, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } + +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationPostProcess_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationPostProcessScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcessItem.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcessItem.swift new file mode 100644 index 000000000..c14d95696 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/View/SpaceCreationPostProcessItem.swift @@ -0,0 +1,87 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceCreationPostProcessItem: View { + // MARK: - Properties + + let title: String + let state: SpaceCreationPostProcessTaskState + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + private var tintColor: Color { + switch state { + case .none: + return theme.colors.quinaryContent + case .started: + return theme.colors.primaryContent + case .success: + return theme.colors.tertiaryContent + case .failure: + return theme.colors.alert + } + } + + // MARK: Public + + var body: some View { + HStack { + switch state { + case .none: + Image(systemName: "circle").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent) + case .started: + ProgressView().progressViewStyle(CircularProgressViewStyle(tint: theme.colors.secondaryContent)).scaleEffect(0.9, anchor: .center) + Spacer().frame(width: 6) + case .success: + Image(systemName: "checkmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.tertiaryContent) + case .failure: + Image(systemName: "exclamationmark.circle.fill").renderingMode(.template).foregroundColor(theme.colors.alert) + } + Text(title) + .font(theme.fonts.callout) + .foregroundColor(state == .started ? theme.colors.primaryContent : theme.colors.tertiaryContent) + } + .opacity(state == .none ? 0.5 : 1) + .animation(.easeOut(duration: 0.2), value: state) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationPostProcessItem_Previews: PreviewProvider { + static var previews: some View { + Group { + VStack(alignment: .leading, spacing: 20) { + SpaceCreationPostProcessItem(title: "failed task", state: .failure) + SpaceCreationPostProcessItem(title: "not started", state: .none) + SpaceCreationPostProcessItem(title: "on going task ", state: .started) + SpaceCreationPostProcessItem(title: "succesful task", state: .success) + } + VStack(alignment: .leading, spacing: 20) { + SpaceCreationPostProcessItem(title: "failed task", state: .failure) + SpaceCreationPostProcessItem(title: "not started", state: .none) + SpaceCreationPostProcessItem(title: "on going task ", state: .started) + SpaceCreationPostProcessItem(title: "succesful task", state: .success) + }.theme(.dark).preferredColorScheme(.dark) + } + .padding() + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift new file mode 100644 index 000000000..d17c5c93b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModel.swift @@ -0,0 +1,142 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + + + +@available(iOS 14, *) +typealias SpaceCreationPostProcessViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationPostProcessViewModel: SpaceCreationPostProcessViewModelType, SpaceCreationPostProcessViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol + private var updateNotificationObserver: Any? + + // MARK: Public + + var completion: ((SpaceCreationPostProcessViewModelResult) -> Void)? + + // MARK: - Setup + + static func makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewModelProtocol { + return SpaceCreationPostProcessViewModel(spaceCreationPostProcessService: spaceCreationPostProcessService) + } + + private init(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) { + self.spaceCreationPostProcessService = spaceCreationPostProcessService + super.init(initialViewState: Self.defaultState(spaceCreationPostProcessService: spaceCreationPostProcessService)) + setupTasksObserving() + } + + private static func defaultState(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewState { + let tasks = spaceCreationPostProcessService.tasksSubject.value + return SpaceCreationPostProcessViewState( + avatar: spaceCreationPostProcessService.avatar, + avatarImage: spaceCreationPostProcessService.avatarImage, + tasks: tasks, + isFinished: tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished }), + errorCount: tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) }) + ) + } + + private func setupTasksObserving() { + let tasksUpdatePublisher = spaceCreationPostProcessService.tasksSubject + .map(SpaceCreationPostProcessStateAction.updateTasks) + .eraseToAnyPublisher() + dispatch(actionPublisher: tasksUpdatePublisher) + updateNotificationObserver = NotificationCenter.default.addObserver(forName: SpaceCreationPostProcessViewModel.didUpdate, object: nil, queue: OperationQueue.main) { [weak self] notification in + guard let self = self else { + return + } + + guard let state = notification.userInfo?[SpaceCreationPostProcessViewModel.newStateKey] as? SpaceCreationPostProcessViewState else { + return + } + + if state.isFinished && state.errorCount == 0 { + guard let spaceId = self.spaceCreationPostProcessService.createdSpaceId else { + self.cancel() + return + } + + self.done(spaceId: spaceId) + } + } + } + + deinit { + if let updateNotificationObserver = self.updateNotificationObserver { + NotificationCenter.default.removeObserver(updateNotificationObserver) + } + } + + // MARK: - Public + + override func process(viewAction: SpaceCreationPostProcessViewAction) { + switch viewAction { + case .cancel: + cancel() + case .runTasks: + runTasks() + case .retry: + runTasks() + } + } + + override class func reducer(state: inout SpaceCreationPostProcessViewState, action: SpaceCreationPostProcessStateAction) { + switch action { + case .updateTasks(let tasks): + state.tasks = tasks + state.isFinished = tasks.first?.state == .failure || tasks.reduce(true, { result, task in result && task.isFinished }) + state.errorCount = tasks.reduce(0, { result, task in result + (task.state == .failure ? 1 : 0) }) + } + + NotificationCenter.default.post(name: SpaceCreationPostProcessViewModel.didUpdate, object: nil, userInfo: [SpaceCreationPostProcessViewModel.newStateKey : state]) + + UILog.debug("[SpaceCreationPostProcessViewModel] reducer with action \(action) produced state: \(state)") + } + + private func done(spaceId: String) { + completion?(.done(spaceId)) + } + + private func cancel() { + completion?(.cancel) + } + + private func runTasks() { + spaceCreationPostProcessService.run() + } +} + +// MARK: - MXSpaceService notification constants +@available(iOS 14, *) +extension SpaceCreationPostProcessViewModel { + /// Posted once the process is finished + public static let didUpdate = Notification.Name("SpaceCreationPostProcessViewModelDidUpdate") + + public static let newStateKey = "newState" +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModelProtocol.swift new file mode 100644 index 000000000..13420e655 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationPostProcess/ViewModel/SpaceCreationPostProcessViewModelProtocol.swift @@ -0,0 +1,28 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationPostProcess SpaceCreationPostProcess +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol SpaceCreationPostProcessViewModelProtocol { + + var completion: ((SpaceCreationPostProcessViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + static func makeSpaceCreationPostProcessViewModel(spaceCreationPostProcessService: SpaceCreationPostProcessServiceProtocol) -> SpaceCreationPostProcessViewModelProtocol + @available(iOS 14, *) + var context: SpaceCreationPostProcessViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift new file mode 100644 index 000000000..6826dd2bc --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinator.swift @@ -0,0 +1,74 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import SwiftUI + +final class SpaceCreationRoomsCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationRoomsCoordinatorParameters + private let spaceCreationRoomsHostingController: UIViewController + private var spaceCreationRoomsViewModel: SpaceCreationRoomsViewModelProtocol + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationRoomsCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationRoomsCoordinatorParameters) { + self.parameters = parameters + let viewModel = SpaceCreationRoomsViewModel(creationParameters: parameters.creationParams) + let view = SpaceCreationRooms(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationRoomsViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.isNavigationBarHidden = true + spaceCreationRoomsHostingController = hostingController + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceCreationRoomsCoordinator] did start.") + spaceCreationRoomsViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationRoomsCoordinator] SpaceCreationRoomsViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .cancel: + self.callback?(.cancel) + case .back: + self.callback?(.back) + case .done: + self.callback?(.didSetupRooms) + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationRoomsHostingController + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinatorParameters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinatorParameters.swift new file mode 100644 index 000000000..6e095f71b --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Coordinator/SpaceCreationRoomsCoordinatorParameters.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationRoomsCoordinatorParameters { + let session: MXSession + let creationParams: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift new file mode 100644 index 000000000..749d87bd1 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsCoordinatorAction.swift @@ -0,0 +1,23 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationRoomsCoordinatorAction { + case cancel + case back + case didSetupRooms +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsPresence.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsPresence.swift new file mode 100644 index 000000000..3790e6207 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsPresence.swift @@ -0,0 +1,44 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationRoomsPresence { + case online + case idle + case offline +} + +extension SpaceCreationRoomsPresence { + var title: String { + switch self { + case .online: + return VectorL10n.roomParticipantsOnline + case .idle: + return VectorL10n.roomParticipantsIdle + case .offline: + return VectorL10n.roomParticipantsOffline + } + } +} + +extension SpaceCreationRoomsPresence: CaseIterable { } + +extension SpaceCreationRoomsPresence: Identifiable { + var id: Self { self } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsStateAction.swift new file mode 100644 index 000000000..3515e1250 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsStateAction.swift @@ -0,0 +1,22 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationRoomsStateAction { +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift new file mode 100644 index 000000000..c7ed6ce24 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewAction.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationRoomsViewAction { + case cancel + case back + case done +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelBindings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelBindings.swift new file mode 100644 index 000000000..f9d2ad6e7 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelBindings.swift @@ -0,0 +1,22 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// State bound directly to SwiftUI elements. +struct SpaceCreationRoomsViewModelBindings { + var rooms: [SpaceCreationNewRoom] +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift new file mode 100644 index 000000000..8368baacc --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewModelResult.swift @@ -0,0 +1,25 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationRoomsViewModelResult { + case cancel + case back + case done +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewState.swift new file mode 100644 index 000000000..81bf820ec --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Model/SpaceCreationRoomsViewState.swift @@ -0,0 +1,24 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationRoomsViewState: BindableState { + let title: String + var bindings: SpaceCreationRoomsViewModelBindings +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Service/Mock/MockSpaceCreationRoomsScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Service/Mock/MockSpaceCreationRoomsScreenState.swift new file mode 100644 index 000000000..110e4a6f0 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Service/Mock/MockSpaceCreationRoomsScreenState.swift @@ -0,0 +1,62 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockSpaceCreationRoomsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case defaultValues + case valuesEntered + + /// The associated screen + var screenType: Any.Type { + SpaceCreationRooms.self + } + + /// A list of screen state definitions + static var allCases: [MockSpaceCreationRoomsScreenState] { + [.defaultValues, .valuesEntered] + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let creationParams = SpaceCreationParameters() + switch self { + case .defaultValues: break + case .valuesEntered: + for (index, room) in creationParams.newRooms.enumerated() { + creationParams.newRooms[index] = SpaceCreationNewRoom(name: "Room \(index + 1)", defaultName: room.defaultName) + } + } + let viewModel = SpaceCreationRoomsViewModel(creationParameters: creationParams) + + // can simulate service and viewModel actions here if needs be. + + return ( + [viewModel], + AnyView(SpaceCreationRooms(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/UI/SpaceCreationRoomsUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/UI/SpaceCreationRoomsUITests.swift new file mode 100644 index 000000000..7cb20666e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/UI/SpaceCreationRoomsUITests.swift @@ -0,0 +1,47 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationRoomsUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationRoomsScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationRoomsUITests(selector: #selector(verifySpaceCreationRoomsScreen)) + } + + func verifySpaceCreationRoomsScreen() throws { + guard let screenState = screenState as? MockSpaceCreationRoomsScreenState else { fatalError("no screen") } + switch screenState { + case .defaultValues: + verifyValueTextFields() + case .valuesEntered: + verifyValueTextFields() + } + } + + func verifyValueTextFields() { + let emailTextFieldsCount = app.textFields.matching(identifier: "roomTextField").count + XCTAssertEqual(emailTextFieldsCount, 3) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/Unit/SpaceCreationRoomsViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/Unit/SpaceCreationRoomsViewModelTests.swift new file mode 100644 index 000000000..2c53401ec --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/Test/Unit/SpaceCreationRoomsViewModelTests.swift @@ -0,0 +1,39 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationRoomsViewModelTests: XCTestCase { + var creationParameters = SpaceCreationParameters() + var viewModel: SpaceCreationRoomsViewModelProtocol! + var context: SpaceCreationRoomsViewModelType.Context! + + override func setUpWithError() throws { + viewModel = SpaceCreationRoomsViewModel(creationParameters: creationParameters) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.title, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.rooms, creationParameters.newRooms) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift new file mode 100644 index 000000000..bb31ce770 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/View/SpaceCreationRooms.swift @@ -0,0 +1,94 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +@available(iOS 14.0, *) +struct SpaceCreationRooms: View { + + // MARK: - Properties + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ObservedObject var viewModel: SpaceCreationRoomsViewModel.Context + + var body: some View { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView + } + .background(theme.colors.background) + .navigationBarHidden(true) + } + + // MARK: - Private + + @ViewBuilder + private var mainView: some View { + VStack { + GeometryReader { reader in + ScrollView { + ScrollViewReader { scrollViewReader in + VStack(spacing: 20) { + Text(VectorL10n.spacesCreationNewRoomsTitle) + .multilineTextAlignment(.center) + .font(theme.fonts.title3SB) + .foregroundColor(theme.colors.primaryContent) + Text(VectorL10n.spacesCreationNewRoomsMessage) + .multilineTextAlignment(.center) + .font(theme.fonts.body) + .foregroundColor(theme.colors.secondaryContent) + Spacer() + ForEach(viewModel.rooms.indices) { index in + RoundedBorderTextField(title: VectorL10n.spacesCreationNewRoomsRoomNameTitle, placeHolder: viewModel.rooms[index].defaultName, text: $viewModel.rooms[index].name, configuration: UIKitTextInputConfiguration( returnKeyType: index < viewModel.rooms.endIndex - 1 ? .next : .done)) + .accessibility(identifier: "roomTextField") + } + } + .padding(.horizontal, 2) + .padding(.bottom) + .frame(minHeight: reader.size.height - 2) + } + } + } + ThemableButton(icon: nil, title: VectorL10n.next) { + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .done) + } + } + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationRooms_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationRoomsScreenState.stateRenderer + static var previews: some View { + stateRenderer.screenGroup(addNavigation: true).theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true).theme(.dark).preferredColorScheme(.dark) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift new file mode 100644 index 000000000..269e12a28 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModel.swift @@ -0,0 +1,87 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + + + +@available(iOS 14, *) +typealias SpaceCreationRoomsViewModelType = StateStoreViewModel +@available(iOS 14, *) +class SpaceCreationRoomsViewModel: SpaceCreationRoomsViewModelType, SpaceCreationRoomsViewModelProtocol { + + // MARK: - Setup + + // MARK: Private + + private let creationParameters: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationRoomsViewModelResult) -> Void)? + + // MARK: - Setup + + init(creationParameters: SpaceCreationParameters) { + self.creationParameters = creationParameters + super.init(initialViewState: SpaceCreationRoomsViewModel.defaultState(creationParameters: creationParameters)) + } + + private static func defaultState(creationParameters: SpaceCreationParameters) -> SpaceCreationRoomsViewState { + let bindings = SpaceCreationRoomsViewModelBindings(rooms: creationParameters.newRooms) + return SpaceCreationRoomsViewState( + title: creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle, + bindings: bindings + ) + } + + + // MARK: - Public + + override func process(viewAction: SpaceCreationRoomsViewAction) { + switch viewAction { + case .cancel: + cancel() + case .back: + back() + case .done: + done() + } + } + + override class func reducer(state: inout SpaceCreationRoomsViewState, action: SpaceCreationRoomsStateAction) { + } + + // MARK: - Private + + private func done() { + self.creationParameters.newRooms = self.context.rooms + callback?(.done) + } + + private func back() { + callback?(.back) + } + + private func cancel() { + callback?(.cancel) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModelProtocol.swift new file mode 100644 index 000000000..8406bd68e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationRooms/ViewModel/SpaceCreationRoomsViewModelProtocol.swift @@ -0,0 +1,26 @@ +// File created from SimpleUserProfileExample +// $ createScreen.sh Spaces/SpaceCreation/SpaceCreationRooms SpaceCreationRooms +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +protocol SpaceCreationRoomsViewModelProtocol { + + var callback: ((SpaceCreationRoomsViewModelResult) -> Void)? { get set } + @available(iOS 14, *) + var context: SpaceCreationRoomsViewModelType.Context { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift new file mode 100644 index 000000000..ab620514e --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinator.swift @@ -0,0 +1,104 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +/* + Copyright 2021 New Vector Ltd + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + */ + +import Foundation +import UIKit +import SwiftUI + +final class SpaceCreationSettingsCoordinator: Coordinator, Presentable { + + // MARK: - Properties + + // MARK: Private + + private let parameters: SpaceCreationSettingsCoordinatorParameters + private let spaceCreationSettingsHostingController: UIViewController + private var spaceCreationSettingsViewModel: SpaceCreationSettingsViewModelProtocol + + private lazy var singleImagePickerPresenter: SingleImagePickerPresenter = { + let presenter = SingleImagePickerPresenter(session: parameters.session) + presenter.delegate = self + return presenter + }() + + // MARK: Public + + // Must be used only internally + var childCoordinators: [Coordinator] = [] + var callback: ((SpaceCreationSettingsCoordinatorAction) -> Void)? + + // MARK: - Setup + + @available(iOS 14.0, *) + init(parameters: SpaceCreationSettingsCoordinatorParameters) { + self.parameters = parameters + let service = SpaceCreationSettingsService(roomName: parameters.creationParameters.name ?? "", userDefinedAddress: parameters.creationParameters.userDefinedAddress, session: parameters.session) + let viewModel = SpaceCreationSettingsViewModel(spaceCreationSettingsService: service, creationParameters: parameters.creationParameters) + let view = SpaceCreationSettings(viewModel: viewModel.context) + .addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager)) + spaceCreationSettingsViewModel = viewModel + let hostingController = VectorHostingController(rootView: view) + hostingController.isNavigationBarHidden = true + spaceCreationSettingsHostingController = hostingController + } + + // MARK: - Public + + func start() { + MXLog.debug("[SpaceCreationSettingsCoordinator] did start.") + spaceCreationSettingsViewModel.callback = { [weak self] result in + MXLog.debug("[SpaceCreationSettingsCoordinator] SpaceCreationSettingsViewModel did complete with result: \(result).") + guard let self = self else { return } + switch result { + case .done: + self.callback?(.didSetupParameters) + case .cancel: + self.callback?(.cancel) + case .back: + self.callback?(.back) + case .pickImage(let sourceRect): + self.pickImage(from: sourceRect) + break + } + } + } + + func toPresentable() -> UIViewController { + return self.spaceCreationSettingsHostingController + } + + // MARK: - Private + + private func pickImage(from sourceRect: CGRect) { + let controller = toPresentable() + let adjustedRect = controller.view.convert(sourceRect, from: nil) + singleImagePickerPresenter.present(from: controller, sourceView: controller.view, sourceRect: adjustedRect, animated: true) + } +} + +// MARK: - SingleImagePickerPresenterDelegate +extension SpaceCreationSettingsCoordinator: SingleImagePickerPresenterDelegate { + func singleImagePickerPresenter(_ presenter: SingleImagePickerPresenter, didSelectImageData imageData: Data, withUTI uti: MXKUTI?) { + spaceCreationSettingsViewModel.updateAvatarImage(with: UIImage(data: imageData)) + presenter.dismiss(animated: true, completion: nil) + } + + func singleImagePickerPresenterDidCancel(_ presenter: SingleImagePickerPresenter) { + presenter.dismiss(animated: true, completion: nil) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinatorParamaters.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinatorParamaters.swift new file mode 100644 index 000000000..f79a0c749 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Coordinator/SpaceCreationSettingsCoordinatorParamaters.swift @@ -0,0 +1,24 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct SpaceCreationSettingsCoordinatorParameters { + let session: MXSession + let creationParameters: SpaceCreationParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift new file mode 100644 index 000000000..329c151e3 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsAddressValidationStatus.swift @@ -0,0 +1,24 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationSettingsAddressValidationStatus { + case none(_ address: String) + case valid(_ address: String) + case alreadyExists(_ address: String) + case invalidCharacters(_ address: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift new file mode 100644 index 000000000..b94d0d3c2 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsCoordinatorAction.swift @@ -0,0 +1,23 @@ +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +enum SpaceCreationSettingsCoordinatorAction { + case cancel + case back + case didSetupParameters +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsStateAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsStateAction.swift new file mode 100644 index 000000000..670015eae --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsStateAction.swift @@ -0,0 +1,29 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +/// Actions to be performed on the `ViewModel` State +enum SpaceCreationSettingsStateAction { + case updateRoomNameError(String?) + case updateRoomDefaultAddress(String) + case updateAddressValidationStatus(SpaceCreationSettingsAddressValidationStatus) + case updateAvatar(AvatarInputProtocol) + case updateAvatarImage(UIImage?) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift new file mode 100644 index 000000000..de2350fc9 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewAction.swift @@ -0,0 +1,31 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +/// Actions send from the `View` to the `ViewModel`. +enum SpaceCreationSettingsViewAction { + case cancel + case back + case done + case pickImage(_ sourceRect: CGRect) + case nameChanged(_ newValue: String) + case addressChanged(_ newValue: String) + case topicChanged(_ newValue: String) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift new file mode 100644 index 000000000..94b838e38 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelAction.swift @@ -0,0 +1,28 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +/// Actions sent by the `ViewModel` to the `Coordinator` +enum SpaceCreationSettingsViewModelAction { + case done + case cancel + case back + case pickImage(_ sourceRect: CGRect) +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelBindings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelBindings.swift new file mode 100644 index 000000000..0a1757979 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewModelBindings.swift @@ -0,0 +1,26 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +/// State bound directly to SwiftUI elements. +struct SpaceCreationSettingsViewModelBindings { + var roomName: String + var topic: String + var address: String +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewState.swift new file mode 100644 index 000000000..663d968df --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Model/SpaceCreationSettingsViewState.swift @@ -0,0 +1,34 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +/// State managed by the `ViewModel` delivered to the `View`. +struct SpaceCreationSettingsViewState: BindableState { + let title: String + let showRoomAddress: Bool + var defaultAddress: String + var roomNameError: String? + var addressMessage: String? + var isAddressValid: Bool + var avatar: AvatarInputProtocol + var avatarImage: UIImage? + var bindings: SpaceCreationSettingsViewModelBindings +} + diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift new file mode 100644 index 000000000..98e7b573f --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/MatrixSDK/SpaceCreationSettingsService.swift @@ -0,0 +1,136 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine +import MatrixSDK + +@available(iOS 14.0, *) +class SpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol { + + // MARK: - Properties + + var roomName: String { + didSet { + updateDefaultAddress() + updateAvatar() + } + } + var userDefinedAddress: String? { + didSet { + validateAddress() + } + } + + // MARK: Private + + private let session: MXSession + private var defaultAddress: String { + didSet { + defaultAddressSubject.send(defaultAddress) + validateAddress() + } + } + private var lastValidatedAddress: String = "" + private var currentAddress: String? { + return self.userDefinedAddress?.count ?? 0 > 0 ? self.userDefinedAddress : defaultAddress + } + private var currentOperation: MXHTTPOperation? + + // MARK: Public + + private(set) var addressValidationSubject: CurrentValueSubject + private(set) var defaultAddressSubject: CurrentValueSubject + private(set) var avatarViewDataSubject: CurrentValueSubject + var isAddressValid: Bool { + switch addressValidationSubject.value { + case .none, .valid: + return true + default: + return false + } + } + + // MARK: - Setup + + init(roomName: String, userDefinedAddress: String?, session: MXSession) { + self.session = session + self.defaultAddress = "" + self.defaultAddressSubject = CurrentValueSubject(defaultAddress) + self.roomName = roomName + self.addressValidationSubject = CurrentValueSubject(.none("#")) + self.avatarViewDataSubject = CurrentValueSubject(AvatarInput(mxContentUri: userDefinedAddress, matrixItemId: "", displayName: roomName)) + + self.updateDefaultAddress() + self.validateAddress() + } + + deinit { + currentOperation?.cancel() + currentOperation = nil + } + + // MARK: Public + + // MARK: Private + + private func updateAvatar() { + self.avatarViewDataSubject.send(AvatarInput(mxContentUri: currentAddress, matrixItemId: "", displayName: roomName)) + } + + private func updateDefaultAddress() { + defaultAddress = roomName.toValidAliasLocalPart() + } + + private func validateAddress() { + currentOperation?.cancel() + currentOperation = nil + + guard let userDefinedAddress = self.userDefinedAddress, !userDefinedAddress.isEmpty else { + let fullAddress = defaultAddress.fullLocalAlias(with: session) + + if defaultAddress.isEmpty { + addressValidationSubject.send(.none(fullAddress)) + } else { + validate(defaultAddress) + } + return + } + + validate(userDefinedAddress) + } + + private func validate(_ aliasLocalPart: String) { + let fullAddress = aliasLocalPart.fullLocalAlias(with: session) + + currentOperation = MXRoomAliasAvailabilityChecker.validate(aliasLocalPart: aliasLocalPart, with: session) { [weak self] result in + guard let self = self else { return } + + switch result { + case .available: + self.addressValidationSubject.send(.valid(fullAddress)) + case .invalid: + self.addressValidationSubject.send(.invalidCharacters(fullAddress)) + case .notAvailable: + self.addressValidationSubject.send(.alreadyExists(fullAddress)) + case .serverError: + self.addressValidationSubject.send(.none(fullAddress)) + } + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsScreenState.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsScreenState.swift new file mode 100644 index 000000000..7a4ec66be --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsScreenState.swift @@ -0,0 +1,67 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import SwiftUI + + +/// Using an enum for the screen allows you define the different state cases with +/// the relevant associated data for each case. +@available(iOS 14.0, *) +enum MockSpaceCreationSettingsScreenState: MockScreenState, CaseIterable { + // A case for each state you want to represent + // with specific, minimal associated data that will allow you + // mock that screen. + case privateSpace + case validated + case validationFailed + + /// The associated screen + var screenType: Any.Type { + SpaceCreationSettings.self + } + + /// Generate the view struct for the screen state. + var screenView: ([Any], AnyView) { + let creationParameters = SpaceCreationParameters() + creationParameters.name = "Fake" + + let service: MockSpaceCreationSettingsService = MockSpaceCreationSettingsService() + switch self { + case .privateSpace: + creationParameters.isPublic = false + case .validated: + creationParameters.isPublic = true + service.simulateUpdate(addressValidationStatus: .valid("#fake:fake-domain.org")) + case .validationFailed: + creationParameters.isPublic = true + creationParameters.topic = "Some short description" + creationParameters.userDefinedAddress = "fake-uri" + service.simulateUpdate(addressValidationStatus: .alreadyExists("#fake-uri:fake-domain.org")) + creationParameters.userSelectedAvatar = Asset.Images.appSymbol.image + } + + let viewModel = SpaceCreationSettingsViewModel(spaceCreationSettingsService: service, creationParameters: creationParameters) + + return ( + [service, viewModel], + AnyView(SpaceCreationSettings(viewModel: viewModel.context) + .addDependency(MockAvatarService.example)) + ) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsService.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsService.swift new file mode 100644 index 000000000..0590f95ca --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/Mock/MockSpaceCreationSettingsService.swift @@ -0,0 +1,46 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +class MockSpaceCreationSettingsService: SpaceCreationSettingsServiceProtocol { + + + var addressValidationSubject: CurrentValueSubject + var avatarViewDataSubject: CurrentValueSubject + var defaultAddressSubject: CurrentValueSubject + var spaceAddress: String? + var roomName: String + var userDefinedAddress: String? + var isAddressValid: Bool = true + + init() { + roomName = "Fake" + defaultAddressSubject = CurrentValueSubject("fake-uri") + addressValidationSubject = CurrentValueSubject(.none("#fake-uri:fake-domain.org")) + avatarViewDataSubject = CurrentValueSubject(AvatarInput(mxContentUri: defaultAddressSubject.value, matrixItemId: "", displayName: roomName)) + } + + func simulateUpdate(addressValidationStatus: SpaceCreationSettingsAddressValidationStatus) { + self.addressValidationSubject.value = addressValidationStatus + } + +// func simulateUpdate() +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/SpaceCreationSettingsServiceProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/SpaceCreationSettingsServiceProtocol.swift new file mode 100644 index 000000000..bbc47c10d --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Service/SpaceCreationSettingsServiceProtocol.swift @@ -0,0 +1,30 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import Combine + +@available(iOS 14.0, *) +protocol SpaceCreationSettingsServiceProtocol: AnyObject { + var defaultAddressSubject: CurrentValueSubject { get } + var addressValidationSubject: CurrentValueSubject { get } + var avatarViewDataSubject: CurrentValueSubject { get } + var roomName: String { get set } + var userDefinedAddress: String? { get set } + var isAddressValid: Bool { get } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/UI/SpaceCreationSettingsUITests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/UI/SpaceCreationSettingsUITests.swift new file mode 100644 index 000000000..66e5cc3bd --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/UI/SpaceCreationSettingsUITests.swift @@ -0,0 +1,51 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationSettingsUITests: MockScreenTest { + + override class var screenType: MockScreenState.Type { + return MockSpaceCreationSettingsScreenState.self + } + + override class func createTest() -> MockScreenTest { + return SpaceCreationSettingsUITests(selector: #selector(verifySpaceCreationSettingsScreen)) + } + + func verifySpaceCreationSettingsScreen() throws { + guard let screenState = screenState as? MockSpaceCreationSettingsScreenState else { fatalError("no screen") } + switch screenState { + case .privateSpace: break + case .validated: break + case .validationFailed: break + } + } + + func verifyPrivateSpace() { + let addressTextField = app.groups["addressTextField"] + XCTAssertEqual(addressTextField.exists, false) + } + + func verifyPublicValidated() { + let addressTextField = app.groups["addressTextField"] + XCTAssertEqual(addressTextField.exists, true) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift new file mode 100644 index 000000000..135ac6fd0 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/Test/Unit/SpaceCreationSettingsViewModelTests.swift @@ -0,0 +1,66 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +import Combine + +@testable import RiotSwiftUI + +@available(iOS 14.0, *) +class SpaceCreationSettingsViewModelTests: XCTestCase { + + let creationParameters = SpaceCreationParameters() + var service: MockSpaceCreationSettingsService! + var viewModel: SpaceCreationSettingsViewModel! + var context: SpaceCreationSettingsViewModel.Context! + var cancellables = Set() + + + override func setUpWithError() throws { + creationParameters.name = "Fake" + creationParameters.isPublic = true + creationParameters.topic = "Some short description" + creationParameters.userSelectedAvatar = Asset.Images.appSymbol.image + + service = MockSpaceCreationSettingsService() + viewModel = SpaceCreationSettingsViewModel(spaceCreationSettingsService: service, creationParameters: creationParameters) + context = viewModel.context + } + + func testInitialState() { + XCTAssertEqual(context.viewState.title, creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle) + XCTAssertEqual(context.viewState.isAddressValid, true) + XCTAssertEqual(context.viewState.defaultAddress, "fake-uri") + XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressDefaultMessage("#fake-uri:fake-domain.org")) + XCTAssertEqual(context.viewState.avatarImage, Asset.Images.appSymbol.image) + XCTAssertEqual(context.roomName, creationParameters.name) + XCTAssertEqual(context.topic, creationParameters.topic) + } + + func testAddressAlready() throws { + service.simulateUpdate(addressValidationStatus: .alreadyExists("#fake:matrix.org")) + XCTAssertEqual(context.viewState.isAddressValid, false) + XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressAlreadyExists("#fake:matrix.org")) + } + + func testInvalidAddress() throws { + service.simulateUpdate(addressValidationStatus: .invalidCharacters("#fake:matrix.org")) + XCTAssertEqual(context.viewState.isAddressValid, false) + XCTAssertEqual(context.viewState.addressMessage, VectorL10n.spacesCreationAddressInvalidCharacters("#fake:matrix.org")) + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift new file mode 100644 index 000000000..bca8671ed --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/View/SpaceCreationSettings.swift @@ -0,0 +1,173 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + +@available(iOS 14.0, *) +struct SpaceCreationSettings: View { + + // MARK: - Properties + + @ObservedObject var viewModel: SpaceCreationSettingsViewModel.Context + + // MARK: Private + + @Environment(\.theme) private var theme: ThemeSwiftUI + + // MARK: Public + + @ViewBuilder + var body: some View { + VStack { + ThemableNavigationBar(title: nil, showBackButton: true) { + viewModel.send(viewAction: .back) + } closeAction: { + viewModel.send(viewAction: .cancel) + } + mainView + } + .background(theme.colors.background) + .navigationBarHidden(true) + } + + // MARK: - Private + + @ViewBuilder + private var mainView: some View { + VStack(alignment: .center) { + formView + footerView + } + .padding(EdgeInsets(top: 0, leading: 16, bottom: 24, trailing: 16)) + } + + @ViewBuilder + private var headerView: some View { + VStack(alignment: .center, spacing: nil) { + Text(VectorL10n.spacesCreationSettingsMessage).multilineTextAlignment(.center) + Spacer().frame(height: 22) + } + } + + @ViewBuilder + private var avatarView: some View { + ZStack(alignment: .bottomTrailing) { + GeometryReader { reader in + ZStack { + SpaceAvatarImage(mxContentUri: viewModel.viewState.avatar.mxContentUri, matrixItemId: viewModel.viewState.avatar.matrixItemId, displayName: viewModel.viewState.avatar.displayName, size: .xxLarge) + .padding(6) + if let image = viewModel.viewState.avatarImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: 80, height: 80, alignment: .center) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + }.padding(10) + .gesture(TapGesture().onEnded { _ in + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .pickImage(reader.frame(in: .global))) + }) + } + Image(uiImage: Asset.Images.spaceCreationCamera.image) + .renderingMode(.template) + .foregroundColor(theme.colors.secondaryContent) + .frame(width: 32, height: 32, alignment: .center) + .background(theme.colors.background) + .clipShape(Circle()) + }.frame(width: 104, height: 104) + } + + @ViewBuilder + private var formView: some View { + GeometryReader { geometryReader in + ScrollView { + ScrollViewReader { scrollViewReader in + VStack { + headerView + Spacer() + avatarView + Spacer().frame(height:40) + RoundedBorderTextField(title: VectorL10n.createRoomPlaceholderName, placeHolder: "", text: $viewModel.roomName, footerText: .constant(viewModel.viewState.roomNameError), isError: .constant(true), isFirstResponder: false, configuration: UIKitTextInputConfiguration( returnKeyType: .next), onTextChanged: { newText in + viewModel.send(viewAction: .nameChanged(newText)) + }) + .id("nameTextField") + .padding(.horizontal, 2) + .padding(.bottom, 20) + RoundedBorderTextEditor(title: nil, placeHolder: VectorL10n.spaceTopic, text: $viewModel.topic, textMaxHeight: 72, error: .constant(nil), onTextChanged: { + newText in + viewModel.send(viewAction: .topicChanged(newText)) + }, onEditingChanged: { editing in + if editing { + scrollDown(reader: scrollViewReader) + } + }) + .id("topicTextEditor") + .padding(.horizontal, 2) + .padding(.bottom, viewModel.viewState.showRoomAddress ? 20 : 3) + if viewModel.viewState.showRoomAddress { + RoundedBorderTextField(title: VectorL10n.spacesCreationAddress, placeHolder: "# \(viewModel.viewState.defaultAddress)", text: $viewModel.address, footerText: .constant(viewModel.viewState.addressMessage), isError: .constant(!viewModel.viewState.isAddressValid), configuration: UIKitTextInputConfiguration(keyboardType: .URL, returnKeyType: .done, autocapitalizationType: .none), onTextChanged: { + newText in + viewModel.send(viewAction: .addressChanged(newText)) + }) + .id("addressTextField") + .accessibility(identifier: "addressTextField") + .padding(.horizontal, 2) + .padding(.bottom, 3) + } + Spacer() + } + .animation(.easeOut(duration: 0.2)) + } + } + } + } + + @ViewBuilder + private var footerView: some View { + ThemableButton(icon: nil, title: VectorL10n.next) { + ResponderManager.resignFirstResponder() + viewModel.send(viewAction: .done) + } + } + + private func scrollDown(reader: ScrollViewProxy) { + let identifier = viewModel.viewState.showRoomAddress ? "addressTextField" : "topicTextEditor" + DispatchQueue.main.async { + withAnimation { + reader.scrollTo(identifier, anchor: .bottom) + } + } + } +} + +// MARK: - Previews + +@available(iOS 14.0, *) +struct SpaceCreationSettings_Previews: PreviewProvider { + static let stateRenderer = MockSpaceCreationSettingsScreenState.stateRenderer + static var previews: some View { + Group { + stateRenderer.screenGroup(addNavigation: true) + .theme(.light).preferredColorScheme(.light) + stateRenderer.screenGroup(addNavigation: true) + .theme(.dark).preferredColorScheme(.dark) + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift new file mode 100644 index 000000000..c05e5d24a --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModel.swift @@ -0,0 +1,186 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI +import Combine + +@available(iOS 14, *) +typealias SpaceCreationSettingsViewModelType = StateStoreViewModel + +@available(iOS 14, *) +class SpaceCreationSettingsViewModel: SpaceCreationSettingsViewModelType, SpaceCreationSettingsViewModelProtocol { + + // MARK: - Properties + + // MARK: Private + + private let spaceCreationSettingsService: SpaceCreationSettingsServiceProtocol + private let creationParameters: SpaceCreationParameters + + // MARK: Public + + var callback: ((SpaceCreationSettingsViewModelAction) -> Void)? + + // MARK: - Setup + + init(spaceCreationSettingsService: SpaceCreationSettingsServiceProtocol, creationParameters: SpaceCreationParameters) { + self.spaceCreationSettingsService = spaceCreationSettingsService + self.creationParameters = creationParameters + let defaultState = Self.defaultState(creationParameters: creationParameters, validationStatus: spaceCreationSettingsService.addressValidationSubject.value) + super.init(initialViewState: defaultState) + setupServiceObserving() + } + + private func setupServiceObserving() { + let defaultAddressUpdatePublisher = spaceCreationSettingsService.defaultAddressSubject + .map(SpaceCreationSettingsStateAction.updateRoomDefaultAddress) + .eraseToAnyPublisher() + dispatch(actionPublisher: defaultAddressUpdatePublisher) + + let addressValidationUpdatePublisher = spaceCreationSettingsService.addressValidationSubject + .map(SpaceCreationSettingsStateAction.updateAddressValidationStatus) + .eraseToAnyPublisher() + dispatch(actionPublisher: addressValidationUpdatePublisher) + + let avatarUpdatePublisher = spaceCreationSettingsService.avatarViewDataSubject + .map(SpaceCreationSettingsStateAction.updateAvatar) + .eraseToAnyPublisher() + dispatch(actionPublisher: avatarUpdatePublisher) + } + + private static func defaultState(creationParameters: SpaceCreationParameters, validationStatus: SpaceCreationSettingsAddressValidationStatus) -> SpaceCreationSettingsViewState { + let bindings = SpaceCreationSettingsViewModelBindings( + roomName: creationParameters.name ?? "", + topic: creationParameters.topic ?? "", + address: creationParameters.userDefinedAddress ?? "") + + return SpaceCreationSettingsViewState( + title: creationParameters.isPublic ? VectorL10n.spacesCreationPublicSpaceTitle : VectorL10n.spacesCreationPrivateSpaceTitle, + showRoomAddress: creationParameters.showAddress, + defaultAddress: creationParameters.address ?? "", + roomNameError: nil, + addressMessage: addressMessage(with: validationStatus), + isAddressValid: isAddressValid(with: validationStatus), + avatar: AvatarInput(mxContentUri: nil, matrixItemId: "", displayName: nil), + avatarImage: creationParameters.userSelectedAvatar, + bindings: bindings) + } + + // MARK: - Public + + func updateAvatarImage(with image: UIImage?) { + creationParameters.userSelectedAvatar = image + dispatch(action: .updateAvatarImage(image)) + } + + override func process(viewAction: SpaceCreationSettingsViewAction) { + switch viewAction { + case .done: + done() + case .back: + back() + case .cancel: + cancel() + case .pickImage(let sourceRect): + pickImage(from: sourceRect) + case .nameChanged(let newValue): + spaceCreationSettingsService.roomName = newValue + creationParameters.address = spaceCreationSettingsService.defaultAddressSubject.value + creationParameters.name = newValue + dispatch(action: .updateRoomNameError(newValue.isEmpty ? VectorL10n.spacesCreationEmptyRoomNameError : nil)) + case .addressChanged(let newValue): + spaceCreationSettingsService.userDefinedAddress = newValue + creationParameters.userDefinedAddress = newValue + case .topicChanged(let newValue): + creationParameters.topic = newValue + } + } + + override class func reducer(state: inout SpaceCreationSettingsViewState, action: SpaceCreationSettingsStateAction) { + switch action { + case .updateRoomNameError(let error): + state.roomNameError = error + case .updateRoomDefaultAddress(let defaultAddress): + state.defaultAddress = defaultAddress + case .updateAddressValidationStatus(let validationStatus): + state.addressMessage = Self.addressMessage(with: validationStatus) + state.isAddressValid = Self.isAddressValid(with: validationStatus) + case .updateAvatar(let avatar): + state.avatar = avatar + case .updateAvatarImage(let image): + state.avatarImage = image + } + } + + // MARK: - Private + + private func done() { + guard !context.roomName.isEmpty else { + dispatch(action: .updateRoomNameError(VectorL10n.spacesCreationEmptyRoomNameError)) + return + } + + guard !creationParameters.isPublic || spaceCreationSettingsService.isAddressValid else { + return + } + + creationParameters.name = context.roomName + creationParameters.topic = context.topic + creationParameters.userDefinedAddress = context.address + creationParameters.address = spaceCreationSettingsService.defaultAddressSubject.value + + dispatch(action: .updateRoomNameError(nil)) + callback?(.done) + } + + private func cancel() { + callback?(.cancel) + } + + private func back() { + callback?(.back) + } + + private func pickImage(from sourceRect: CGRect) { + callback?(.pickImage(sourceRect)) + } + + private static func addressMessage(with validationStatus: SpaceCreationSettingsAddressValidationStatus) -> String { + switch validationStatus { + case .none(let fullAddress): + return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress) + case .valid(let fullAddress): + return VectorL10n.spacesCreationAddressDefaultMessage(fullAddress) + case .alreadyExists(let fullAddress): + return VectorL10n.spacesCreationAddressAlreadyExists(fullAddress) + case .invalidCharacters(let fullAddress): + return VectorL10n.spacesCreationAddressInvalidCharacters(fullAddress) + } + } + + private static func isAddressValid(with validationStatus: SpaceCreationSettingsAddressValidationStatus) -> Bool { + switch validationStatus { + case .none, .valid: + return true + default: + return false + } + } +} diff --git a/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModelProtocol.swift b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModelProtocol.swift new file mode 100644 index 000000000..9802bca02 --- /dev/null +++ b/RiotSwiftUI/Modules/Spaces/SpaceCreation/SpaceCreationSettings/ViewModel/SpaceCreationSettingsViewModelProtocol.swift @@ -0,0 +1,25 @@ +// File created from TemplateAdvancedRoomsExample +// $ createSwiftUITwoScreen.sh Spaces/SpaceCreation SpaceCreation SpaceCreationMenu SpaceCreationSettings +// +// Copyright 2021 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation +import UIKit + +protocol SpaceCreationSettingsViewModelProtocol { + var callback: ((SpaceCreationSettingsViewModelAction) -> Void)? { get set } + func updateAvatarImage(with image: UIImage?) +} diff --git a/changelog.d/5224.change b/changelog.d/5224.change new file mode 100644 index 000000000..4a5b7afec --- /dev/null +++ b/changelog.d/5224.change @@ -0,0 +1 @@ +Space creation: Added entire space creation flow. \ No newline at end of file